[ty] Prefer declared base class attribute over inferred attribute on subclass (#20764)

## Summary

When accessing an (instance) attribute on a given class, we were
previously traversing its MRO, and building a union of types (if the
attribute was available on multiple classes in the MRO) until we found a
*definitely bound* symbol. The idea was that possibly unbound symbols in
a subclass might only partially shadow the underlying base class
attribute.

This behavior was problematic for two reasons:
* if the attribute was definitely bound on a class (e.g. `self.x =
None`), we would have stopped iterating, even if there might be a `x:
str | None` declaration in a base class (the bug reported in
https://github.com/astral-sh/ty/issues/1067).
* if the attribute originated from an implicit instance attribute
assignment (e.g. `self.x = 1` in method `Sub.foo`), we might stop
looking and miss another implicit instance attribute assignment in a
base class method (e.g. `self.x = 2` in method `Base.bar`).

With this fix, we still iterate the MRO of the class, but we only stop
iterating if we find a *definitely declared* symbol. In this case, we
only return the declared attribute type. Otherwise, we keep building a
union of inferred attribute types.

The implementation here seemed to be the easiest fix for
https://github.com/astral-sh/ty/issues/1067 that also kept the ecosystem
impact low (the changes that I see all look correct). However, as the
Markdown tests show, there are other things to fix in this area. For
example, we should do a similar thing for *class attributes*. This is
more involved, though (affects many different areas and probably
involves a change to our descriptor protocol implementation), so I'd
like to postpone this to a follow-up.

closes https://github.com/astral-sh/ty/issues/1067

## Test Plan

Updated Markdown tests, including a regression test for
https://github.com/astral-sh/ty/issues/1067.
This commit is contained in:
David Peter 2025-10-13 09:28:57 +02:00 committed by GitHub
parent c80ee1a50b
commit 9b9c9ae092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 169 additions and 86 deletions

View file

@ -1,6 +1,7 @@
use ruff_db::files::File;
use crate::dunder_all::dunder_all_names;
use crate::member::Member;
use crate::module_resolver::{KnownModule, file_to_module};
use crate::semantic_index::definition::{Definition, DefinitionState};
use crate::semantic_index::place::{PlaceExprRef, ScopedPlaceId};
@ -232,13 +233,9 @@ pub(crate) fn place<'db>(
)
}
/// Infer the public type of a class symbol (its type as seen from outside its scope) in the given
/// Infer the public type of a class member/symbol (its type as seen from outside its scope) in the given
/// `scope`.
pub(crate) fn class_symbol<'db>(
db: &'db dyn Db,
scope: ScopeId<'db>,
name: &str,
) -> PlaceAndQualifiers<'db> {
pub(crate) fn class_member<'db>(db: &'db dyn Db, scope: ScopeId<'db>, name: &str) -> Member<'db> {
place_table(db, scope)
.symbol_id(name)
.map(|symbol_id| {
@ -252,7 +249,7 @@ pub(crate) fn class_symbol<'db>(
if !place_and_quals.place.is_unbound() && !place_and_quals.is_init_var() {
// Trust the declared type if we see a class-level declaration
return place_and_quals;
return Member::declared(place_and_quals);
}
if let PlaceAndQualifiers {
@ -267,14 +264,14 @@ pub(crate) fn class_symbol<'db>(
// TODO: we should not need to calculate inferred type second time. This is a temporary
// solution until the notion of Boundness and Declaredness is split. See #16036, #16264
match inferred {
Member::inferred(match inferred {
Place::Unbound => Place::Unbound.with_qualifiers(qualifiers),
Place::Type(_, boundness) => {
Place::Type(ty, boundness).with_qualifiers(qualifiers)
}
}
})
} else {
Place::Unbound.into()
Member::unbound()
}
})
.unwrap_or_default()