Skip to content

Commit

Permalink
Clean up of descriptor protocol suite
Browse files Browse the repository at this point in the history
  • Loading branch information
sharkdp committed Mar 6, 2025
1 parent 4dfd3de commit 7abdb56
Showing 1 changed file with 123 additions and 124 deletions.
247 changes: 123 additions & 124 deletions crates/red_knot_python_semantic/resources/mdtest/descriptor_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,130 +133,6 @@ reveal_type(C.non_data_descriptor) # revealed: Unknown | Literal["non-data"]
C.data_descriptor = "something else" # This is okay
```

## Possibly unbound descriptor attributes

```py
class DataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> int:
return 1

def __set__(self, instance: int, value) -> None:
pass

class NonDataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> int:
return 1

def _(flag: bool):
class PossiblyUnbound:
if flag:
non_data: NonDataDescriptor = NonDataDescriptor()
data: DataDescriptor = DataDescriptor()

# error: [possibly-unbound-attribute] "Attribute `non_data` on type `Literal[PossiblyUnbound]` is possibly unbound"
reveal_type(PossiblyUnbound.non_data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `non_data` on type `PossiblyUnbound` is possibly unbound"
reveal_type(PossiblyUnbound().non_data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `data` on type `Literal[PossiblyUnbound]` is possibly unbound"
reveal_type(PossiblyUnbound.data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `data` on type `PossiblyUnbound` is possibly unbound"
reveal_type(PossiblyUnbound().data) # revealed: int
```

## Unions of descriptors and non-descriptor attributes

```py
from typing import Literal

def _(flag1: bool, flag2: bool, flag3: bool, flag4: bool, flag5: bool, flag6: bool, flag7: bool, flag8: bool):
if flag1:
class Descriptor:
if flag2:
def __get__(self, instance: object, owner: type | None = None) -> Literal[1]:
return 1
else:
def __get__(self, instance: object, owner: type | None = None) -> Literal[2]:
return 2

else:
class Descriptor:
def __get__(self, instance: object, owner: type | None = None) -> Literal[3]:
return 3

class OtherDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> Literal[4]:
return 4

if flag4:
class C:
if flag5:
x: Descriptor | OtherDescriptor = Descriptor() if flag6 else OtherDescriptor()
else:
x: Literal[5] = 5

else:
class C:
if flag7:
x: Literal[6] = 6
else:
def f(self):
self.x: Literal[7] = 7

class OtherC:
x: Literal[8] = 8

c = C() if flag8 else OtherC()

# Note: the attribute could be possibly-unbound, since we don't know if `f` has been called.

# error: [possibly-unbound-attribute]
reveal_type(c.x) # revealed: Literal[1, 2, 3, 4, 5, 6, 7, 8]
```

## Recursive descriptors

The descriptor protocol is recursive, i.e. looking up `__get__` can involve triggering the
descriptor protocol on the callable:

```py
from __future__ import annotations

class ReturnedCallable2:
def __call__(self, descriptor: Descriptor1, instance: None, owner: type[C]) -> int:
return 1

class ReturnedCallable1:
def __call__(self, descriptor: Descriptor2, instance: Callable1, owner: type[Callable1]) -> ReturnedCallable2:
return ReturnedCallable2()

class Callable3:
def __call__(self, descriptor: Descriptor3, instance: Callable2, owner: type[Callable2]) -> ReturnedCallable1:
return ReturnedCallable1()

class Descriptor3:
__get__: Callable3 = Callable3()

class Callable2:
__call__: Descriptor3 = Descriptor3()

class Descriptor2:
__get__: Callable2 = Callable2()

class Callable1:
__call__: Descriptor2 = Descriptor2()

class Descriptor1:
__get__: Callable1 = Callable1()

class C:
d: Descriptor1 = Descriptor1()

reveal_type(C.d) # revealed: int
```

## Descriptor protocol for class objects

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

## Partial fall back

Our implementation of the descriptor protocol takes into account that symbols can be possibly
unbound. In those cases, we fall back to lower precedence steps of the descriptor protocol and union
all possible results accordingly. We start by defining a data and a non-data descriptor:

```py
from typing import Literal

class DataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> Literal["data"]:
return "data"

def __set__(self, instance: object, value: int) -> None:
pass

class NonDataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> Literal["non-data"]:
return "non-data"
```

Then, we demonstrate that we fall back to an instance attribute if a data descriptor is possibly
unbound:

```py
def f1(flag: bool):
class C1:
if flag:
attr = DataDescriptor()

def f(self):
self.attr = "normal"

reveal_type(C1().attr) # revealed: Unknown | Literal["data", "normal"]
```

We never treat implicit instance attributes as definitely bound, so we fall back to the non-data
descriptor here:

```py
def f2(flag: bool):
class C2:
def f(self):
self.attr = "normal"
attr = NonDataDescriptor()

reveal_type(C2().attr) # revealed: Unknown | Literal["non-data", "normal"]
```

## Built-in `property` descriptor

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

## Possibly unbound descriptor attributes

```py
class DataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> int:
return 1

def __set__(self, instance: int, value) -> None:
pass

class NonDataDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> int:
return 1

def _(flag: bool):
class PossiblyUnbound:
if flag:
non_data: NonDataDescriptor = NonDataDescriptor()
data: DataDescriptor = DataDescriptor()

# error: [possibly-unbound-attribute] "Attribute `non_data` on type `Literal[PossiblyUnbound]` is possibly unbound"
reveal_type(PossiblyUnbound.non_data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `non_data` on type `PossiblyUnbound` is possibly unbound"
reveal_type(PossiblyUnbound().non_data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `data` on type `Literal[PossiblyUnbound]` is possibly unbound"
reveal_type(PossiblyUnbound.data) # revealed: int

# error: [possibly-unbound-attribute] "Attribute `data` on type `PossiblyUnbound` is possibly unbound"
reveal_type(PossiblyUnbound().data) # revealed: int
```

## Possibly-unbound `__get__` method

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

## Descriptors with non-function `__get__` callables that are descriptors themselves

The descriptor protocol is recursive, i.e. looking up `__get__` can involve triggering the
descriptor protocol on the callables `__call__` method:

```py
from __future__ import annotations

class ReturnedCallable2:
def __call__(self, descriptor: Descriptor1, instance: None, owner: type[C]) -> int:
return 1

class ReturnedCallable1:
def __call__(self, descriptor: Descriptor2, instance: Callable1, owner: type[Callable1]) -> ReturnedCallable2:
return ReturnedCallable2()

class Callable3:
def __call__(self, descriptor: Descriptor3, instance: Callable2, owner: type[Callable2]) -> ReturnedCallable1:
return ReturnedCallable1()

class Descriptor3:
__get__: Callable3 = Callable3()

class Callable2:
__call__: Descriptor3 = Descriptor3()

class Descriptor2:
__get__: Callable2 = Callable2()

class Callable1:
__call__: Descriptor2 = Descriptor2()

class Descriptor1:
__get__: Callable1 = Callable1()

class C:
d: Descriptor1 = Descriptor1()

reveal_type(C.d) # revealed: int
```

## Dunder methods

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

0 comments on commit 7abdb56

Please sign in to comment.