Skip to content

Commit 67334a5

Browse files
committed
New tests for meta class descriptors
1 parent 0bbc699 commit 67334a5

File tree

3 files changed

+201
-61
lines changed

3 files changed

+201
-61
lines changed

crates/red_knot_python_semantic/resources/mdtest/descriptor_protocol.md

Lines changed: 176 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class DataDescriptor:
9595
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
9696
return "data"
9797

98-
def __set__(self, instance: int, value) -> None:
98+
def __set__(self, instance: object, value: int) -> None:
9999
pass
100100

101101
class NonDataDescriptor:
@@ -133,42 +133,6 @@ reveal_type(C.non_data_descriptor) # revealed: Unknown | Literal["non-data"]
133133
C.data_descriptor = "something else" # This is okay
134134
```
135135

136-
## Descriptor protocol for class objects
137-
138-
When a descriptor is accessed on a class object, the following precedence chain is used:
139-
140-
- Data descriptor on the metaclass
141-
- Data or non-data descriptor on the class
142-
- Class attribute
143-
- Non-data descriptor on the metaclass
144-
145-
```py
146-
from typing import Literal
147-
148-
class DataDescriptor:
149-
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
150-
return "data"
151-
152-
def __set__(self, instance: int, value) -> None:
153-
pass
154-
155-
class NonDataDescriptor:
156-
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
157-
return "non-data"
158-
159-
class Meta(type):
160-
meta_data_descriptor: DataDescriptor = DataDescriptor()
161-
meta_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
162-
163-
class DescriptorsOnMetaClass(metaclass=Meta):
164-
# meta_data_descriptor = "shadowed?"
165-
# meta_non_data_descriptor = "shadowed?"
166-
...
167-
168-
reveal_type(DescriptorsOnMetaClass.meta_data_descriptor) # revealed: Literal["data"]
169-
reveal_type(DescriptorsOnMetaClass.meta_non_data_descriptor) # revealed: Literal["non-data"]
170-
```
171-
172136
## Possibly unbound descriptors, unions with other attributes
173137

174138
```py
@@ -230,6 +194,180 @@ def _(flag: bool):
230194
reveal_type(UnionWithInstanceAttribute().data) # revealed: int | str
231195
```
232196

197+
## Descriptor protocol for class objects
198+
199+
When attributes are accessed on a class object, the following [precedence chain] is used:
200+
201+
- Data descriptor on the metaclass
202+
- Data or non-data descriptor on the class
203+
- Class attribute
204+
- Non-data descriptor on the metaclass
205+
- Metaclass attribute
206+
207+
To verify this, we define a data and a non-data descriptor:
208+
209+
```py
210+
from typing import Literal, Any
211+
212+
class DataDescriptor:
213+
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
214+
return "data"
215+
216+
def __set__(self, instance: object, value: str) -> None:
217+
pass
218+
219+
class NonDataDescriptor:
220+
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
221+
return "non-data"
222+
```
223+
224+
First, we make sure that the descriptors are correctly accessed when defined on the metaclass or the
225+
class:
226+
227+
```py
228+
class Meta1(type):
229+
meta_data_descriptor: DataDescriptor = DataDescriptor()
230+
meta_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
231+
232+
class C1(metaclass=Meta1):
233+
class_data_descriptor: DataDescriptor = DataDescriptor()
234+
class_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
235+
236+
reveal_type(C1.meta_data_descriptor) # revealed: Literal["data"]
237+
reveal_type(C1.meta_non_data_descriptor) # revealed: Literal["non-data"]
238+
239+
reveal_type(C1.class_data_descriptor) # revealed: Literal["data"]
240+
reveal_type(C1.class_non_data_descriptor) # revealed: Literal["non-data"]
241+
```
242+
243+
Next, we demonstrate that a *metaclass data descriptor* takes precedence over all class-level
244+
attributes:
245+
246+
```py
247+
class Meta2(type):
248+
meta_data_descriptor1: DataDescriptor = DataDescriptor()
249+
meta_data_descriptor2: DataDescriptor = DataDescriptor()
250+
251+
class ClassLevelDataDescriptor:
252+
def __get__(self, instance: object, owner: type | None = None) -> Literal["class level data descriptor"]:
253+
return "class level data descriptor"
254+
255+
def __set__(self, instance: object, value: str) -> None:
256+
pass
257+
258+
class C2(metaclass=Meta2):
259+
meta_data_descriptor1: Literal["value on class"] = "value on class"
260+
meta_data_descriptor2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
261+
262+
reveal_type(C2.meta_data_descriptor1) # revealed: Literal["data"]
263+
reveal_type(C2.meta_data_descriptor2) # revealed: Literal["data"]
264+
```
265+
266+
On the other hand, normal metaclass attributes and metaclass non-data descriptors are shadowed by
267+
class-level attributes (descriptor or not):
268+
269+
```py
270+
class Meta3(type):
271+
meta_attribute1: Literal["value on metaclass"] = "value on metaclass"
272+
meta_attribute2: Literal["value on metaclass"] = "value on metaclass"
273+
meta_non_data_descriptor1: NonDataDescriptor = NonDataDescriptor()
274+
meta_non_data_descriptor2: NonDataDescriptor = NonDataDescriptor()
275+
276+
class C3(metaclass=Meta3):
277+
meta_attribute1: Literal["value on class"] = "value on class"
278+
meta_attribute2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
279+
meta_non_data_descriptor1: Literal["value on class"] = "value on class"
280+
meta_non_data_descriptor2: ClassLevelDataDescriptor = ClassLevelDataDescriptor()
281+
282+
reveal_type(C3.meta_attribute1) # revealed: Literal["value on class"]
283+
reveal_type(C3.meta_attribute2) # revealed: Literal["class level data descriptor"]
284+
reveal_type(C3.meta_non_data_descriptor1) # revealed: Literal["value on class"]
285+
reveal_type(C3.meta_non_data_descriptor2) # revealed: Literal["class level data descriptor"]
286+
```
287+
288+
Finally, metaclass attributes and metaclass non-data descriptors are only accessible when they are
289+
not shadowed by class-level attributes:
290+
291+
```py
292+
class Meta4(type):
293+
meta_attribute: Literal["value on metaclass"] = "value on metaclass"
294+
meta_non_data_descriptor: NonDataDescriptor = NonDataDescriptor()
295+
296+
class C4(metaclass=Meta4): ...
297+
298+
reveal_type(C4.meta_attribute) # revealed: Literal["value on metaclass"]
299+
reveal_type(C4.meta_non_data_descriptor) # revealed: Literal["non-data"]
300+
```
301+
302+
When a metaclass data descriptor is possibly unbound, we union the result type of its `__get__`
303+
method with an underlying class level attribute, if present:
304+
305+
```py
306+
def _(flag: bool):
307+
class Meta5(type):
308+
if flag:
309+
meta_data_descriptor1: DataDescriptor = DataDescriptor()
310+
meta_data_descriptor2: DataDescriptor = DataDescriptor()
311+
312+
class C5(metaclass=Meta5):
313+
meta_data_descriptor1: Literal["value on class"] = "value on class"
314+
315+
reveal_type(C5.meta_data_descriptor1) # revealed: Literal["value on class", "data"]
316+
# error: [possibly-unbound-attribute]
317+
reveal_type(C5.meta_data_descriptor2) # revealed: Literal["data"]
318+
```
319+
320+
When a class-level attribute is possibly unbound, we union its (descriptor protocol) type with the
321+
metaclass attribute (unless it's a data descriptor, which always takes precedence):
322+
323+
```py
324+
def _(flag: bool):
325+
class Meta6(type):
326+
attribute1: DataDescriptor = DataDescriptor()
327+
attribute2: NonDataDescriptor = NonDataDescriptor()
328+
attribute3: Literal["value on metaclass"] = "value on metaclass"
329+
330+
class C6(metaclass=Meta6):
331+
if flag:
332+
attribute1: Literal["value on class"] = "value on class"
333+
attribute2: Literal["value on class"] = "value on class"
334+
attribute3: Literal["value on class"] = "value on class"
335+
attribute4: Literal["value on class"] = "value on class"
336+
337+
reveal_type(C6.attribute1) # revealed: Literal["data"]
338+
reveal_type(C6.attribute2) # revealed: Literal["value on class", "non-data"]
339+
reveal_type(C6.attribute3) # revealed: Literal["value on class", "value on metaclass"]
340+
# error: [possibly-unbound-attribute]
341+
reveal_type(C6.attribute4) # revealed: Literal["value on class"]
342+
```
343+
344+
Finally, we can also have unions of various types of attributes:
345+
346+
```py
347+
def _(flag: bool):
348+
class Meta7(type):
349+
if flag:
350+
union_of_metaclass_attributes: Literal[1] = 1
351+
union_of_metaclass_data_descriptor_and_attribute: DataDescriptor = DataDescriptor()
352+
else:
353+
union_of_metaclass_attributes: Literal[2] = 2
354+
union_of_metaclass_data_descriptor_and_attribute: Literal[2] = 2
355+
356+
class C7(metaclass=Meta7):
357+
if flag:
358+
union_of_class_attributes: Literal[1] = 1
359+
union_of_class_data_descriptor_and_attribute: DataDescriptor = DataDescriptor()
360+
else:
361+
union_of_class_attributes: Literal[2] = 2
362+
union_of_class_data_descriptor_and_attribute: Literal[2] = 2
363+
364+
reveal_type(C7.union_of_metaclass_attributes) # revealed: Literal[1, 2]
365+
# TODO: should be `Literal["data", 2]`
366+
reveal_type(C7.union_of_metaclass_data_descriptor_and_attribute) # revealed: Literal["data"]
367+
reveal_type(C7.union_of_class_attributes) # revealed: Literal[1, 2]
368+
reveal_type(C7.union_of_class_data_descriptor_and_attribute) # revealed: Literal["data", 2]
369+
```
370+
233371
## Built-in `property` descriptor
234372

235373
The built-in `property` decorator creates a descriptor. The names for attribute reads/writes are
@@ -533,4 +671,5 @@ wrapper_descriptor(f, None, type(f), "one too many")
533671
```
534672

535673
[descriptors]: https://docs.python.org/3/howto/descriptor.html
674+
[precedence chain]: https://github.com/python/cpython/blob/3.13/Objects/typeobject.c#L5393-L5481
536675
[simple example]: https://docs.python.org/3/howto/descriptor.html#simple-example-a-descriptor-that-returns-a-constant

crates/red_knot_python_semantic/src/types.rs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1836,7 +1836,17 @@ impl<'db> Type<'db> {
18361836
{
18371837
let meta_descr_get =
18381838
meta_attribute_ty.class_member(db, "__get__").0;
1839+
18391840
if let Symbol::Type(meta_descr_get, _) = meta_descr_get {
1841+
let is_data = !meta_attribute_ty
1842+
.class_member(db, "__set__")
1843+
.0
1844+
.is_unbound()
1845+
|| !meta_attribute_ty
1846+
.class_member(db, "__delete__")
1847+
.0
1848+
.is_unbound();
1849+
18401850
let return_ty = Symbol::Type(
18411851
meta_descr_get
18421852
.try_call(
@@ -1851,8 +1861,7 @@ impl<'db> Type<'db> {
18511861
.unwrap_or(Type::unknown()),
18521862
meta_attribute_boundness,
18531863
);
1854-
let is_data = !self.class_member(db, "__set__").0.is_unbound()
1855-
|| !self.class_member(db, "__delete__").0.is_unbound();
1864+
18561865
(return_ty, is_data)
18571866
} else {
18581867
(meta_attribute, false)
@@ -1879,7 +1888,7 @@ impl<'db> Type<'db> {
18791888
class_attr_boundness,
18801889
)
18811890
} else {
1882-
class_attr.0.clone() // TODO
1891+
class_attr.0
18831892
};
18841893

18851894
match (
@@ -1898,28 +1907,22 @@ impl<'db> Type<'db> {
18981907
Symbol::Type(_, _),
18991908
) => SymbolAndQualifiers(
19001909
Symbol::Type(
1901-
UnionType::from_elements(
1902-
db,
1903-
[class_attr_ty.clone(), meta_attr_ty.clone()],
1904-
),
1910+
UnionType::from_elements(db, [class_attr_ty, *meta_attr_ty]),
19051911
class_attr_boundness,
19061912
),
19071913
class_attr_qualifiers,
19081914
),
19091915
(Symbol::Type(_, _), false, Symbol::Type(_, Boundness::Bound)) => {
1910-
class_attr
1916+
SymbolAndQualifiers(class_attr_resolved, class_attr_qualifiers)
19111917
}
19121918
(
19131919
Symbol::Type(meta_attr_ty, meta_attr_boundness),
19141920
false,
19151921
Symbol::Type(_, Boundness::PossiblyUnbound),
19161922
) => SymbolAndQualifiers(
19171923
Symbol::Type(
1918-
UnionType::from_elements(
1919-
db,
1920-
[class_attr_ty.clone(), meta_attr_ty.clone()],
1921-
),
1922-
dbg!(*meta_attr_boundness),
1924+
UnionType::from_elements(db, [class_attr_ty, *meta_attr_ty]),
1925+
*meta_attr_boundness,
19231926
),
19241927
class_attr_qualifiers,
19251928
),
@@ -1934,7 +1937,9 @@ impl<'db> Type<'db> {
19341937

19351938
let meta_attribute = self.class_member(db, name);
19361939

1937-
if let Symbol::Type(meta_attribute_ty, _) = meta_attribute.0 {
1940+
if let Symbol::Type(meta_attribute_ty, meta_attribute_boundness) =
1941+
meta_attribute.0
1942+
{
19381943
let meta_descr_get = meta_attribute_ty.class_member(db, "__get__").0;
19391944
if let Symbol::Type(meta_descr_get, _) = meta_descr_get {
19401945
Symbol::Type(
@@ -1949,7 +1954,7 @@ impl<'db> Type<'db> {
19491954
)
19501955
.map(|outcome| outcome.return_type(db))
19511956
.unwrap_or(Type::unknown()),
1952-
Boundness::Bound,
1957+
meta_attribute_boundness,
19531958
)
19541959
.into()
19551960
} else {

crates/red_knot_python_semantic/src/types/class.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -479,12 +479,9 @@ impl<'db> Class<'db> {
479479

480480
let attribute_assignments = attribute_assignments(db, class_body_scope);
481481

482-
let Some(attribute_assignments) = attribute_assignments
482+
let attribute_assignments = attribute_assignments
483483
.as_deref()
484-
.and_then(|assignments| assignments.get(name))
485-
else {
486-
return None;
487-
};
484+
.and_then(|assignments| assignments.get(name))?;
488485

489486
for attribute_assignment in attribute_assignments {
490487
match attribute_assignment {
@@ -575,11 +572,10 @@ impl<'db> Class<'db> {
575572
if !inferred.is_unbound() {
576573
if declaredness == Boundness::Bound {
577574
return Symbol::Unbound.into();
578-
} else {
579-
return Self::implicit_instance_attribute(db, body_scope, name)
580-
.map_or(Symbol::Unbound, Symbol::bound)
581-
.into();
582575
}
576+
return Self::implicit_instance_attribute(db, body_scope, name)
577+
.map_or(Symbol::Unbound, Symbol::bound)
578+
.into();
583579
}
584580

585581
if declaredness == Boundness::Bound {

0 commit comments

Comments
 (0)