Skip to content

Commit 7abdb56

Browse files
committed
Clean up of descriptor protocol suite
1 parent 4dfd3de commit 7abdb56

File tree

1 file changed

+123
-124
lines changed

1 file changed

+123
-124
lines changed

crates/red_knot_python_semantic/resources/mdtest/descriptor_protocol.md

Lines changed: 123 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -133,130 +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-
## Possibly unbound descriptor attributes
137-
138-
```py
139-
class DataDescriptor:
140-
def __get__(self, instance: object, owner: type | None = None) -> int:
141-
return 1
142-
143-
def __set__(self, instance: int, value) -> None:
144-
pass
145-
146-
class NonDataDescriptor:
147-
def __get__(self, instance: object, owner: type | None = None) -> int:
148-
return 1
149-
150-
def _(flag: bool):
151-
class PossiblyUnbound:
152-
if flag:
153-
non_data: NonDataDescriptor = NonDataDescriptor()
154-
data: DataDescriptor = DataDescriptor()
155-
156-
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `Literal[PossiblyUnbound]` is possibly unbound"
157-
reveal_type(PossiblyUnbound.non_data) # revealed: int
158-
159-
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `PossiblyUnbound` is possibly unbound"
160-
reveal_type(PossiblyUnbound().non_data) # revealed: int
161-
162-
# error: [possibly-unbound-attribute] "Attribute `data` on type `Literal[PossiblyUnbound]` is possibly unbound"
163-
reveal_type(PossiblyUnbound.data) # revealed: int
164-
165-
# error: [possibly-unbound-attribute] "Attribute `data` on type `PossiblyUnbound` is possibly unbound"
166-
reveal_type(PossiblyUnbound().data) # revealed: int
167-
```
168-
169-
## Unions of descriptors and non-descriptor attributes
170-
171-
```py
172-
from typing import Literal
173-
174-
def _(flag1: bool, flag2: bool, flag3: bool, flag4: bool, flag5: bool, flag6: bool, flag7: bool, flag8: bool):
175-
if flag1:
176-
class Descriptor:
177-
if flag2:
178-
def __get__(self, instance: object, owner: type | None = None) -> Literal[1]:
179-
return 1
180-
else:
181-
def __get__(self, instance: object, owner: type | None = None) -> Literal[2]:
182-
return 2
183-
184-
else:
185-
class Descriptor:
186-
def __get__(self, instance: object, owner: type | None = None) -> Literal[3]:
187-
return 3
188-
189-
class OtherDescriptor:
190-
def __get__(self, instance: object, owner: type | None = None) -> Literal[4]:
191-
return 4
192-
193-
if flag4:
194-
class C:
195-
if flag5:
196-
x: Descriptor | OtherDescriptor = Descriptor() if flag6 else OtherDescriptor()
197-
else:
198-
x: Literal[5] = 5
199-
200-
else:
201-
class C:
202-
if flag7:
203-
x: Literal[6] = 6
204-
else:
205-
def f(self):
206-
self.x: Literal[7] = 7
207-
208-
class OtherC:
209-
x: Literal[8] = 8
210-
211-
c = C() if flag8 else OtherC()
212-
213-
# Note: the attribute could be possibly-unbound, since we don't know if `f` has been called.
214-
215-
# error: [possibly-unbound-attribute]
216-
reveal_type(c.x) # revealed: Literal[1, 2, 3, 4, 5, 6, 7, 8]
217-
```
218-
219-
## Recursive descriptors
220-
221-
The descriptor protocol is recursive, i.e. looking up `__get__` can involve triggering the
222-
descriptor protocol on the callable:
223-
224-
```py
225-
from __future__ import annotations
226-
227-
class ReturnedCallable2:
228-
def __call__(self, descriptor: Descriptor1, instance: None, owner: type[C]) -> int:
229-
return 1
230-
231-
class ReturnedCallable1:
232-
def __call__(self, descriptor: Descriptor2, instance: Callable1, owner: type[Callable1]) -> ReturnedCallable2:
233-
return ReturnedCallable2()
234-
235-
class Callable3:
236-
def __call__(self, descriptor: Descriptor3, instance: Callable2, owner: type[Callable2]) -> ReturnedCallable1:
237-
return ReturnedCallable1()
238-
239-
class Descriptor3:
240-
__get__: Callable3 = Callable3()
241-
242-
class Callable2:
243-
__call__: Descriptor3 = Descriptor3()
244-
245-
class Descriptor2:
246-
__get__: Callable2 = Callable2()
247-
248-
class Callable1:
249-
__call__: Descriptor2 = Descriptor2()
250-
251-
class Descriptor1:
252-
__get__: Callable1 = Callable1()
253-
254-
class C:
255-
d: Descriptor1 = Descriptor1()
256-
257-
reveal_type(C.d) # revealed: int
258-
```
259-
260136
## Descriptor protocol for class objects
261137

262138
When attributes are accessed on a class object, the following [precedence chain] is used:
@@ -432,6 +308,55 @@ def _(flag: bool):
432308
reveal_type(C7.union_of_class_data_descriptor_and_attribute) # revealed: Literal["data", 2]
433309
```
434310

311+
## Partial fall back
312+
313+
Our implementation of the descriptor protocol takes into account that symbols can be possibly
314+
unbound. In those cases, we fall back to lower precedence steps of the descriptor protocol and union
315+
all possible results accordingly. We start by defining a data and a non-data descriptor:
316+
317+
```py
318+
from typing import Literal
319+
320+
class DataDescriptor:
321+
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
322+
return "data"
323+
324+
def __set__(self, instance: object, value: int) -> None:
325+
pass
326+
327+
class NonDataDescriptor:
328+
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
329+
return "non-data"
330+
```
331+
332+
Then, we demonstrate that we fall back to an instance attribute if a data descriptor is possibly
333+
unbound:
334+
335+
```py
336+
def f1(flag: bool):
337+
class C1:
338+
if flag:
339+
attr = DataDescriptor()
340+
341+
def f(self):
342+
self.attr = "normal"
343+
344+
reveal_type(C1().attr) # revealed: Unknown | Literal["data", "normal"]
345+
```
346+
347+
We never treat implicit instance attributes as definitely bound, so we fall back to the non-data
348+
descriptor here:
349+
350+
```py
351+
def f2(flag: bool):
352+
class C2:
353+
def f(self):
354+
self.attr = "normal"
355+
attr = NonDataDescriptor()
356+
357+
reveal_type(C2().attr) # revealed: Unknown | Literal["non-data", "normal"]
358+
```
359+
435360
## Built-in `property` descriptor
436361

437362
The built-in `property` decorator creates a descriptor. The names for attribute reads/writes are
@@ -624,6 +549,39 @@ reveal_type(C.descriptor) # revealed: Descriptor
624549
reveal_type(C().descriptor) # revealed: Descriptor
625550
```
626551

552+
## Possibly unbound descriptor attributes
553+
554+
```py
555+
class DataDescriptor:
556+
def __get__(self, instance: object, owner: type | None = None) -> int:
557+
return 1
558+
559+
def __set__(self, instance: int, value) -> None:
560+
pass
561+
562+
class NonDataDescriptor:
563+
def __get__(self, instance: object, owner: type | None = None) -> int:
564+
return 1
565+
566+
def _(flag: bool):
567+
class PossiblyUnbound:
568+
if flag:
569+
non_data: NonDataDescriptor = NonDataDescriptor()
570+
data: DataDescriptor = DataDescriptor()
571+
572+
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `Literal[PossiblyUnbound]` is possibly unbound"
573+
reveal_type(PossiblyUnbound.non_data) # revealed: int
574+
575+
# error: [possibly-unbound-attribute] "Attribute `non_data` on type `PossiblyUnbound` is possibly unbound"
576+
reveal_type(PossiblyUnbound().non_data) # revealed: int
577+
578+
# error: [possibly-unbound-attribute] "Attribute `data` on type `Literal[PossiblyUnbound]` is possibly unbound"
579+
reveal_type(PossiblyUnbound.data) # revealed: int
580+
581+
# error: [possibly-unbound-attribute] "Attribute `data` on type `PossiblyUnbound` is possibly unbound"
582+
reveal_type(PossiblyUnbound().data) # revealed: int
583+
```
584+
627585
## Possibly-unbound `__get__` method
628586

629587
```py
@@ -641,6 +599,47 @@ def _(flag: bool):
641599
reveal_type(C().descriptor) # revealed: int | MaybeDescriptor
642600
```
643601

602+
## Descriptors with non-function `__get__` callables that are descriptors themselves
603+
604+
The descriptor protocol is recursive, i.e. looking up `__get__` can involve triggering the
605+
descriptor protocol on the callables `__call__` method:
606+
607+
```py
608+
from __future__ import annotations
609+
610+
class ReturnedCallable2:
611+
def __call__(self, descriptor: Descriptor1, instance: None, owner: type[C]) -> int:
612+
return 1
613+
614+
class ReturnedCallable1:
615+
def __call__(self, descriptor: Descriptor2, instance: Callable1, owner: type[Callable1]) -> ReturnedCallable2:
616+
return ReturnedCallable2()
617+
618+
class Callable3:
619+
def __call__(self, descriptor: Descriptor3, instance: Callable2, owner: type[Callable2]) -> ReturnedCallable1:
620+
return ReturnedCallable1()
621+
622+
class Descriptor3:
623+
__get__: Callable3 = Callable3()
624+
625+
class Callable2:
626+
__call__: Descriptor3 = Descriptor3()
627+
628+
class Descriptor2:
629+
__get__: Callable2 = Callable2()
630+
631+
class Callable1:
632+
__call__: Descriptor2 = Descriptor2()
633+
634+
class Descriptor1:
635+
__get__: Callable1 = Callable1()
636+
637+
class C:
638+
d: Descriptor1 = Descriptor1()
639+
640+
reveal_type(C.d) # revealed: int
641+
```
642+
644643
## Dunder methods
645644

646645
Dunder methods are looked up on the meta type, but we still need to invoke the descriptor protocol:

0 commit comments

Comments
 (0)