[ty] Fix attribute access on TypedDicts (#19758)

## Summary

This PR fixes a few inaccuracies in attribute access on `TypedDict`s. It
also changes the return type of `type(person)` to `type[dict[str,
object]]` if `person: Person` is an inhabitant of a `TypedDict`
`Person`. We still use `type[Person]` as the *meta type* of Person,
however (see reasoning
[here](https://github.com/astral-sh/ruff/pull/19733#discussion_r2253297926)).

## Test Plan

Updated Markdown tests.
This commit is contained in:
David Peter 2025-08-05 13:59:10 +02:00 committed by GitHub
parent 3af0b31de3
commit 948f3f856c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 139 additions and 60 deletions

View file

@ -669,6 +669,10 @@ impl<'db> Type<'db> {
matches!(self, Type::Dynamic(_))
}
pub(crate) const fn is_typed_dict(&self) -> bool {
matches!(self, Type::TypedDict(..))
}
/// Returns the top materialization (or upper bound materialization) of this type, which is the
/// most general form of the type that is fully static.
#[must_use]
@ -3108,7 +3112,7 @@ impl<'db> Type<'db> {
) -> PlaceAndQualifiers<'db> {
tracing::trace!("member_lookup_with_policy: {}.{}", self.display(db), name);
if name == "__class__" {
return Place::bound(self.to_meta_type(db)).into();
return Place::bound(self.dunder_class(db)).into();
}
let name_str = name.as_str();
@ -3325,6 +3329,12 @@ impl<'db> Type<'db> {
.into()
};
if result.is_class_var() && self.is_typed_dict() {
// `ClassVar`s on `TypedDictFallback` can not be accessed on inhabitants of `SomeTypedDict`.
// They can only be accessed on `SomeTypedDict` directly.
return Place::Unbound.into();
}
match result {
member @ PlaceAndQualifiers {
place: Place::Type(_, Boundness::Bound),
@ -5533,6 +5543,9 @@ impl<'db> Type<'db> {
/// Given a type that is assumed to represent an instance of a class,
/// return a type that represents that class itself.
///
/// Note: the return type of `type(obj)` is subtly different from this.
/// See `Self::dunder_class` for more details.
#[must_use]
pub fn to_meta_type(&self, db: &'db dyn Db) -> Type<'db> {
match self {
@ -5595,6 +5608,23 @@ impl<'db> Type<'db> {
}
}
/// Get the type of the `__class__` attribute of this type.
///
/// For most types, this is equivalent to the meta type of this type. For `TypedDict` types,
/// this returns `type[dict[str, object]]` instead, because inhabitants of a `TypedDict` are
/// instances of `dict` at runtime.
#[must_use]
pub fn dunder_class(self, db: &'db dyn Db) -> Type<'db> {
if self.is_typed_dict() {
return KnownClass::Dict
.to_specialized_class_type(db, [KnownClass::Str.to_instance(db), Type::object(db)])
.map(Type::from)
.unwrap_or_else(Type::unknown);
}
self.to_meta_type(db)
}
#[must_use]
pub fn apply_optional_specialization(
self,

View file

@ -1010,7 +1010,7 @@ impl<'db> Bindings<'db> {
Some(KnownClass::Type) if overload_index == 0 => {
if let [Some(arg)] = overload.parameter_types() {
overload.set_return_type(arg.to_meta_type(db));
overload.set_return_type(arg.dunder_class(db));
}
}

View file

@ -95,7 +95,7 @@ impl<'db> AllMembers<'db> {
Type::NominalInstance(instance) => {
let (class_literal, _specialization) = instance.class.class_literal(db);
self.extend_with_instance_members(db, class_literal);
self.extend_with_instance_members(db, ty, class_literal);
}
Type::ClassLiteral(class_literal) if class_literal.is_typed_dict(db) => {
@ -106,6 +106,10 @@ impl<'db> AllMembers<'db> {
self.extend_with_type(db, KnownClass::TypedDictFallback.to_class_literal(db));
}
Type::SubclassOf(subclass_of_type) if subclass_of_type.is_typed_dict(db) => {
self.extend_with_type(db, KnownClass::TypedDictFallback.to_class_literal(db));
}
Type::ClassLiteral(class_literal) => {
self.extend_with_class_members(db, ty, class_literal);
@ -168,7 +172,11 @@ impl<'db> AllMembers<'db> {
self.extend_with_class_members(db, ty, class_literal);
}
self.extend_with_type(db, KnownClass::TypedDictFallback.to_instance(db));
if let Type::ClassLiteral(class) =
KnownClass::TypedDictFallback.to_class_literal(db)
{
self.extend_with_instance_members(db, ty, class);
}
}
Type::ModuleLiteral(literal) => {
@ -281,13 +289,17 @@ impl<'db> AllMembers<'db> {
}
}
fn extend_with_instance_members(&mut self, db: &'db dyn Db, class_literal: ClassLiteral<'db>) {
fn extend_with_instance_members(
&mut self,
db: &'db dyn Db,
ty: Type<'db>,
class_literal: ClassLiteral<'db>,
) {
for parent in class_literal
.iter_mro(db, None)
.filter_map(ClassBase::into_class)
.map(|class| class.class_literal(db).0)
{
let parent_instance = Type::instance(db, parent.default_specialization(db));
let class_body_scope = parent.body_scope(db);
let file = class_body_scope.file(db);
let index = semantic_index(db, file);
@ -297,7 +309,7 @@ impl<'db> AllMembers<'db> {
let Some(name) = place_expr.as_instance_attribute() else {
continue;
};
let result = parent_instance.member(db, name.as_str());
let result = ty.member(db, name.as_str());
let Some(ty) = result.place.ignore_possibly_unbound() else {
continue;
};
@ -314,7 +326,7 @@ impl<'db> AllMembers<'db> {
// member, e.g., `SomeClass.__delattr__` is not a bound
// method, but `instance_of_SomeClass.__delattr__` is.
for Member { name, .. } in all_declarations_and_bindings(db, class_body_scope) {
let result = parent_instance.member(db, name.as_str());
let result = ty.member(db, name.as_str());
let Some(ty) = result.place.ignore_possibly_unbound() else {
continue;
};

View file

@ -196,6 +196,12 @@ impl<'db> SubclassOfType<'db> {
SubclassOfInner::Dynamic(dynamic_type) => Type::Dynamic(dynamic_type),
}
}
pub(crate) fn is_typed_dict(self, db: &'db dyn Db) -> bool {
self.subclass_of
.into_class()
.is_some_and(|class| class.class_literal(db).0.is_typed_dict(db))
}
}
/// An enumeration of the different kinds of `type[]` types that a [`SubclassOfType`] can represent: