Skip to content

Commit 96ded77

Browse files
authored
[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](astral-sh/ruff#19733 (comment))). ## Test Plan Updated Markdown tests.
1 parent 2f6c0de commit 96ded77

7 files changed

Lines changed: 139 additions & 60 deletions

File tree

crates/ty_ide/src/completion.rs

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,28 +1233,28 @@ quux.<CURSOR>
12331233
baz :: Unknown | Literal[3]
12341234
foo :: Unknown | Literal[1]
12351235
__annotations__ :: dict[str, Any]
1236-
__class__ :: type
1237-
__delattr__ :: bound method object.__delattr__(name: str, /) -> None
1236+
__class__ :: type[Quux]
1237+
__delattr__ :: bound method Quux.__delattr__(name: str, /) -> None
12381238
__dict__ :: dict[str, Any]
1239-
__dir__ :: bound method object.__dir__() -> Iterable[str]
1239+
__dir__ :: bound method Quux.__dir__() -> Iterable[str]
12401240
__doc__ :: str | None
1241-
__eq__ :: bound method object.__eq__(value: object, /) -> bool
1242-
__format__ :: bound method object.__format__(format_spec: str, /) -> str
1243-
__getattribute__ :: bound method object.__getattribute__(name: str, /) -> Any
1244-
__getstate__ :: bound method object.__getstate__() -> object
1245-
__hash__ :: bound method object.__hash__() -> int
1241+
__eq__ :: bound method Quux.__eq__(value: object, /) -> bool
1242+
__format__ :: bound method Quux.__format__(format_spec: str, /) -> str
1243+
__getattribute__ :: bound method Quux.__getattribute__(name: str, /) -> Any
1244+
__getstate__ :: bound method Quux.__getstate__() -> object
1245+
__hash__ :: bound method Quux.__hash__() -> int
12461246
__init__ :: bound method Quux.__init__() -> Unknown
1247-
__init_subclass__ :: bound method object.__init_subclass__() -> None
1247+
__init_subclass__ :: bound method Quux.__init_subclass__() -> None
12481248
__module__ :: str
1249-
__ne__ :: bound method object.__ne__(value: object, /) -> bool
1250-
__new__ :: bound method object.__new__() -> Self@object
1251-
__reduce__ :: bound method object.__reduce__() -> str | tuple[Any, ...]
1252-
__reduce_ex__ :: bound method object.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...]
1253-
__repr__ :: bound method object.__repr__() -> str
1254-
__setattr__ :: bound method object.__setattr__(name: str, value: Any, /) -> None
1255-
__sizeof__ :: bound method object.__sizeof__() -> int
1256-
__str__ :: bound method object.__str__() -> str
1257-
__subclasshook__ :: bound method type.__subclasshook__(subclass: type, /) -> bool
1249+
__ne__ :: bound method Quux.__ne__(value: object, /) -> bool
1250+
__new__ :: bound method Quux.__new__() -> Self@object
1251+
__reduce__ :: bound method Quux.__reduce__() -> str | tuple[Any, ...]
1252+
__reduce_ex__ :: bound method Quux.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...]
1253+
__repr__ :: bound method Quux.__repr__() -> str
1254+
__setattr__ :: bound method Quux.__setattr__(name: str, value: Any, /) -> None
1255+
__sizeof__ :: bound method Quux.__sizeof__() -> int
1256+
__str__ :: bound method Quux.__str__() -> str
1257+
__subclasshook__ :: bound method type[Quux].__subclasshook__(subclass: type, /) -> bool
12581258
");
12591259
}
12601260

@@ -1278,28 +1278,28 @@ quux.b<CURSOR>
12781278
baz :: Unknown | Literal[3]
12791279
foo :: Unknown | Literal[1]
12801280
__annotations__ :: dict[str, Any]
1281-
__class__ :: type
1282-
__delattr__ :: bound method object.__delattr__(name: str, /) -> None
1281+
__class__ :: type[Quux]
1282+
__delattr__ :: bound method Quux.__delattr__(name: str, /) -> None
12831283
__dict__ :: dict[str, Any]
1284-
__dir__ :: bound method object.__dir__() -> Iterable[str]
1284+
__dir__ :: bound method Quux.__dir__() -> Iterable[str]
12851285
__doc__ :: str | None
1286-
__eq__ :: bound method object.__eq__(value: object, /) -> bool
1287-
__format__ :: bound method object.__format__(format_spec: str, /) -> str
1288-
__getattribute__ :: bound method object.__getattribute__(name: str, /) -> Any
1289-
__getstate__ :: bound method object.__getstate__() -> object
1290-
__hash__ :: bound method object.__hash__() -> int
1286+
__eq__ :: bound method Quux.__eq__(value: object, /) -> bool
1287+
__format__ :: bound method Quux.__format__(format_spec: str, /) -> str
1288+
__getattribute__ :: bound method Quux.__getattribute__(name: str, /) -> Any
1289+
__getstate__ :: bound method Quux.__getstate__() -> object
1290+
__hash__ :: bound method Quux.__hash__() -> int
12911291
__init__ :: bound method Quux.__init__() -> Unknown
1292-
__init_subclass__ :: bound method object.__init_subclass__() -> None
1292+
__init_subclass__ :: bound method Quux.__init_subclass__() -> None
12931293
__module__ :: str
1294-
__ne__ :: bound method object.__ne__(value: object, /) -> bool
1295-
__new__ :: bound method object.__new__() -> Self@object
1296-
__reduce__ :: bound method object.__reduce__() -> str | tuple[Any, ...]
1297-
__reduce_ex__ :: bound method object.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...]
1298-
__repr__ :: bound method object.__repr__() -> str
1299-
__setattr__ :: bound method object.__setattr__(name: str, value: Any, /) -> None
1300-
__sizeof__ :: bound method object.__sizeof__() -> int
1301-
__str__ :: bound method object.__str__() -> str
1302-
__subclasshook__ :: bound method type.__subclasshook__(subclass: type, /) -> bool
1294+
__ne__ :: bound method Quux.__ne__(value: object, /) -> bool
1295+
__new__ :: bound method Quux.__new__() -> Self@object
1296+
__reduce__ :: bound method Quux.__reduce__() -> str | tuple[Any, ...]
1297+
__reduce_ex__ :: bound method Quux.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...]
1298+
__repr__ :: bound method Quux.__repr__() -> str
1299+
__setattr__ :: bound method Quux.__setattr__(name: str, value: Any, /) -> None
1300+
__sizeof__ :: bound method Quux.__sizeof__() -> int
1301+
__str__ :: bound method Quux.__str__() -> str
1302+
__subclasshook__ :: bound method type[Quux].__subclasshook__(subclass: type, /) -> bool
13031303
");
13041304
}
13051305

crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,18 +219,27 @@ class Person(TypedDict):
219219
age: int | None
220220

221221
static_assert(not has_member(Person, "name"))
222-
static_assert(not has_member(Person, "age"))
223-
222+
static_assert(has_member(Person, "keys"))
224223
static_assert(has_member(Person, "__total__"))
225-
static_assert(has_member(Person, "__required_keys__"))
226224

227225
def _(person: Person):
228226
static_assert(not has_member(person, "name"))
229-
static_assert(not has_member(person, "age"))
230-
227+
static_assert(not has_member(person, "__total__"))
231228
static_assert(has_member(person, "keys"))
229+
230+
# type(person) is `dict` at runtime, so `__total__` is not available:
231+
static_assert(not has_member(type(person), "name"))
232+
static_assert(not has_member(type(person), "__total__"))
233+
static_assert(has_member(type(person), "keys"))
234+
235+
def _(t_person: type[Person]):
236+
static_assert(not has_member(t_person, "name"))
237+
static_assert(has_member(t_person, "__total__"))
238+
static_assert(has_member(t_person, "keys"))
232239
```
233240

241+
### Unions
242+
234243
For unions, `ide_support::all_members` only returns members that are available on all elements of
235244
the union.
236245

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ def _(p: Person) -> None:
148148

149149
## Unlike normal classes
150150

151-
`TypedDict` types are not like normal classes. The "attributes" can not be accessed. Neither on the
152-
class itself, nor on inhabitants of the type defined by the class:
151+
`TypedDict` types do not act like normal classes. For example, calling `type(..)` on an inhabitant
152+
of a `TypedDict` type will return `dict`:
153153

154154
```py
155155
from typing import TypedDict
@@ -158,6 +158,16 @@ class Person(TypedDict):
158158
name: str
159159
age: int | None
160160

161+
def _(p: Person) -> None:
162+
reveal_type(type(p)) # revealed: <class 'dict[str, object]'>
163+
164+
reveal_type(p.__class__) # revealed: <class 'dict[str, object]'>
165+
```
166+
167+
Also, the "attributes" on the class definition can not be accessed. Neither on the class itself, nor
168+
on inhabitants of the type defined by the class:
169+
170+
```py
161171
# error: [unresolved-attribute] "Type `<class 'Person'>` has no attribute `name`"
162172
Person.name
163173

@@ -168,6 +178,8 @@ def _(P: type[Person]):
168178
def _(p: Person) -> None:
169179
# error: [unresolved-attribute] "Type `Person` has no attribute `name`"
170180
p.name
181+
182+
type(p).name # error: [unresolved-attribute] "Type `<class 'dict[str, object]'>` has no attribute `name`"
171183
```
172184

173185
## Special properties
@@ -190,20 +202,30 @@ These attributes can not be accessed on inhabitants:
190202

191203
```py
192204
def _(person: Person) -> None:
193-
# TODO: these should be errors
194-
person.__total__
195-
person.__required_keys__
196-
person.__optional_keys__
205+
person.__total__ # error: [unresolved-attribute]
206+
person.__required_keys__ # error: [unresolved-attribute]
207+
person.__optional_keys__ # error: [unresolved-attribute]
197208
```
198209

199210
Also, they can not be accessed on `type(person)`, as that would be `dict` at runtime:
200211

201212
```py
202-
def _(t_person: type[Person]) -> None:
203-
# TODO: these should be errors
204-
t_person.__total__
205-
t_person.__required_keys__
206-
t_person.__optional_keys__
213+
def _(person: Person) -> None:
214+
type(person).__total__ # error: [unresolved-attribute]
215+
type(person).__required_keys__ # error: [unresolved-attribute]
216+
type(person).__optional_keys__ # error: [unresolved-attribute]
217+
```
218+
219+
But they *can* be accessed on `type[Person]`, because this function would accept the class object
220+
`Person` as an argument:
221+
222+
```py
223+
def accepts_typed_dict_class(t_person: type[Person]) -> None:
224+
reveal_type(t_person.__total__) # revealed: bool
225+
reveal_type(t_person.__required_keys__) # revealed: frozenset[str]
226+
reveal_type(t_person.__optional_keys__) # revealed: frozenset[str]
227+
228+
accepts_typed_dict_class(Person)
207229
```
208230

209231
## Subclassing

crates/ty_python_semantic/src/types.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,10 @@ impl<'db> Type<'db> {
669669
matches!(self, Type::Dynamic(_))
670670
}
671671

672+
pub(crate) const fn is_typed_dict(&self) -> bool {
673+
matches!(self, Type::TypedDict(..))
674+
}
675+
672676
/// Returns the top materialization (or upper bound materialization) of this type, which is the
673677
/// most general form of the type that is fully static.
674678
#[must_use]
@@ -3108,7 +3112,7 @@ impl<'db> Type<'db> {
31083112
) -> PlaceAndQualifiers<'db> {
31093113
tracing::trace!("member_lookup_with_policy: {}.{}", self.display(db), name);
31103114
if name == "__class__" {
3111-
return Place::bound(self.to_meta_type(db)).into();
3115+
return Place::bound(self.dunder_class(db)).into();
31123116
}
31133117

31143118
let name_str = name.as_str();
@@ -3325,6 +3329,12 @@ impl<'db> Type<'db> {
33253329
.into()
33263330
};
33273331

3332+
if result.is_class_var() && self.is_typed_dict() {
3333+
// `ClassVar`s on `TypedDictFallback` can not be accessed on inhabitants of `SomeTypedDict`.
3334+
// They can only be accessed on `SomeTypedDict` directly.
3335+
return Place::Unbound.into();
3336+
}
3337+
33283338
match result {
33293339
member @ PlaceAndQualifiers {
33303340
place: Place::Type(_, Boundness::Bound),
@@ -5533,6 +5543,9 @@ impl<'db> Type<'db> {
55335543

55345544
/// Given a type that is assumed to represent an instance of a class,
55355545
/// return a type that represents that class itself.
5546+
///
5547+
/// Note: the return type of `type(obj)` is subtly different from this.
5548+
/// See `Self::dunder_class` for more details.
55365549
#[must_use]
55375550
pub fn to_meta_type(&self, db: &'db dyn Db) -> Type<'db> {
55385551
match self {
@@ -5595,6 +5608,23 @@ impl<'db> Type<'db> {
55955608
}
55965609
}
55975610

5611+
/// Get the type of the `__class__` attribute of this type.
5612+
///
5613+
/// For most types, this is equivalent to the meta type of this type. For `TypedDict` types,
5614+
/// this returns `type[dict[str, object]]` instead, because inhabitants of a `TypedDict` are
5615+
/// instances of `dict` at runtime.
5616+
#[must_use]
5617+
pub fn dunder_class(self, db: &'db dyn Db) -> Type<'db> {
5618+
if self.is_typed_dict() {
5619+
return KnownClass::Dict
5620+
.to_specialized_class_type(db, [KnownClass::Str.to_instance(db), Type::object(db)])
5621+
.map(Type::from)
5622+
.unwrap_or_else(Type::unknown);
5623+
}
5624+
5625+
self.to_meta_type(db)
5626+
}
5627+
55985628
#[must_use]
55995629
pub fn apply_optional_specialization(
56005630
self,

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,7 +1010,7 @@ impl<'db> Bindings<'db> {
10101010

10111011
Some(KnownClass::Type) if overload_index == 0 => {
10121012
if let [Some(arg)] = overload.parameter_types() {
1013-
overload.set_return_type(arg.to_meta_type(db));
1013+
overload.set_return_type(arg.dunder_class(db));
10141014
}
10151015
}
10161016

crates/ty_python_semantic/src/types/ide_support.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ impl<'db> AllMembers<'db> {
9595

9696
Type::NominalInstance(instance) => {
9797
let (class_literal, _specialization) = instance.class.class_literal(db);
98-
self.extend_with_instance_members(db, class_literal);
98+
self.extend_with_instance_members(db, ty, class_literal);
9999
}
100100

101101
Type::ClassLiteral(class_literal) if class_literal.is_typed_dict(db) => {
@@ -106,6 +106,10 @@ impl<'db> AllMembers<'db> {
106106
self.extend_with_type(db, KnownClass::TypedDictFallback.to_class_literal(db));
107107
}
108108

109+
Type::SubclassOf(subclass_of_type) if subclass_of_type.is_typed_dict(db) => {
110+
self.extend_with_type(db, KnownClass::TypedDictFallback.to_class_literal(db));
111+
}
112+
109113
Type::ClassLiteral(class_literal) => {
110114
self.extend_with_class_members(db, ty, class_literal);
111115

@@ -168,7 +172,11 @@ impl<'db> AllMembers<'db> {
168172
self.extend_with_class_members(db, ty, class_literal);
169173
}
170174

171-
self.extend_with_type(db, KnownClass::TypedDictFallback.to_instance(db));
175+
if let Type::ClassLiteral(class) =
176+
KnownClass::TypedDictFallback.to_class_literal(db)
177+
{
178+
self.extend_with_instance_members(db, ty, class);
179+
}
172180
}
173181

174182
Type::ModuleLiteral(literal) => {
@@ -281,13 +289,17 @@ impl<'db> AllMembers<'db> {
281289
}
282290
}
283291

284-
fn extend_with_instance_members(&mut self, db: &'db dyn Db, class_literal: ClassLiteral<'db>) {
292+
fn extend_with_instance_members(
293+
&mut self,
294+
db: &'db dyn Db,
295+
ty: Type<'db>,
296+
class_literal: ClassLiteral<'db>,
297+
) {
285298
for parent in class_literal
286299
.iter_mro(db, None)
287300
.filter_map(ClassBase::into_class)
288301
.map(|class| class.class_literal(db).0)
289302
{
290-
let parent_instance = Type::instance(db, parent.default_specialization(db));
291303
let class_body_scope = parent.body_scope(db);
292304
let file = class_body_scope.file(db);
293305
let index = semantic_index(db, file);
@@ -297,7 +309,7 @@ impl<'db> AllMembers<'db> {
297309
let Some(name) = place_expr.as_instance_attribute() else {
298310
continue;
299311
};
300-
let result = parent_instance.member(db, name.as_str());
312+
let result = ty.member(db, name.as_str());
301313
let Some(ty) = result.place.ignore_possibly_unbound() else {
302314
continue;
303315
};
@@ -314,7 +326,7 @@ impl<'db> AllMembers<'db> {
314326
// member, e.g., `SomeClass.__delattr__` is not a bound
315327
// method, but `instance_of_SomeClass.__delattr__` is.
316328
for Member { name, .. } in all_declarations_and_bindings(db, class_body_scope) {
317-
let result = parent_instance.member(db, name.as_str());
329+
let result = ty.member(db, name.as_str());
318330
let Some(ty) = result.place.ignore_possibly_unbound() else {
319331
continue;
320332
};

crates/ty_python_semantic/src/types/subclass_of.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ impl<'db> SubclassOfType<'db> {
196196
SubclassOfInner::Dynamic(dynamic_type) => Type::Dynamic(dynamic_type),
197197
}
198198
}
199+
200+
pub(crate) fn is_typed_dict(self, db: &'db dyn Db) -> bool {
201+
self.subclass_of
202+
.into_class()
203+
.is_some_and(|class| class.class_literal(db).0.is_typed_dict(db))
204+
}
199205
}
200206

201207
/// An enumeration of the different kinds of `type[]` types that a [`SubclassOfType`] can represent:

0 commit comments

Comments
 (0)