Skip to content

Commit b0f9526

Browse files
[ty] Preserve checks for recursive descriptor setter types (astral-sh#29148)
## Summary This PR fixes a stack overflow when checking a protocol's writable member whose descriptor setter accepts a recursive type. For example, checking the assignment below previously aborted the process instead of reporting an invalid assignment: ```python from __future__ import annotations from typing import Protocol class Recursive[T](Protocol): next: Recursive[list[T]] class Descriptor: def __init__(self, getter: object) -> None: ... def __get__(self, instance: object, owner: type | None = None) -> int: return 1 def __set__[U](self, instance: object, value: Recursive[int]) -> None: ... class HasValue(Protocol): @descriptor def value(self) -> int: ... def update(obj: HasValue) -> None: obj.value = 1 # Invalid: the setter requires Recursive[int]. ``` The setter's unused `U` triggers a search to determine whether the accepted value type depends on that type parameter. The existing search follows `next` through `Recursive[int]`, `Recursive[list[int]]`, `Recursive[list[list[int]]]`, and so on. Its recursion guard remembers exact types, so these changing specializations never repeat and the search overflows the stack. Removing `U` avoids the search and the crash. We replace that general type search with a dependency check that recognizes repeated definitions and inspects the arguments of recursive references without repeatedly expanding their bodies. Here, it establishes that the setter accepts `Recursive[int]` independently of `U`, so we can report the invalid assignment normally. The same fix covers recursive `TypedDict`s and type aliases, while retaining checks for assignments and protocol compatibility. Co-authored-by: Ibraheem Ahmed <ibraheem@ibraheem.ca>
1 parent 32869b4 commit b0f9526

6 files changed

Lines changed: 607 additions & 14 deletions

File tree

‎crates/ty_python_semantic/resources/mdtest/protocols.md‎

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3833,6 +3833,359 @@ def update_bounded_value(value: HasBoundedValue) -> None:
38333833
value.bounded_value = "bad" # error: [invalid-assignment]
38343834
```
38353835

3836+
### Specialized protocol setter value types
3837+
3838+
`P[int]` does not depend on the setter's unused `U`. A property setter accepting `P[int]` therefore
3839+
satisfies the same writable member, including for strict subtyping:
3840+
3841+
```toml
3842+
[environment]
3843+
python-version = "3.12"
3844+
```
3845+
3846+
```py
3847+
from typing import Protocol
3848+
from ty_extensions import static_assert
3849+
from ty_extensions._internal import is_subtype_of
3850+
3851+
class P[T](Protocol):
3852+
def method(self) -> T: ...
3853+
3854+
class Descriptor:
3855+
def __init__(self, getter: object) -> None: ...
3856+
def __get__(self, instance: object, owner: type | None = None) -> object:
3857+
return object()
3858+
3859+
def __set__[U](self, instance: object, value: P[int]) -> None: ...
3860+
3861+
class HasValue(Protocol):
3862+
@Descriptor
3863+
def value(self) -> object: ...
3864+
3865+
class Implementation:
3866+
@property
3867+
def value(self) -> object:
3868+
return object()
3869+
3870+
@value.setter
3871+
def value(self, value: P[int]) -> None: ...
3872+
3873+
static_assert(is_subtype_of(Implementation, HasValue))
3874+
```
3875+
3876+
A setter accepting only `P[Literal[1]]` is too narrow:
3877+
3878+
```py
3879+
from typing import Literal
3880+
3881+
class Narrowed:
3882+
@property
3883+
def value(self) -> object:
3884+
return object()
3885+
3886+
@value.setter
3887+
def value(self, value: P[Literal[1]]) -> None: ...
3888+
3889+
x: HasValue = Narrowed() # error: [invalid-assignment]
3890+
```
3891+
3892+
### Local protocol setter types
3893+
3894+
A setter's unused type parameter does not affect the accepted value type, even when that type is a
3895+
local protocol. A property setter accepting the same protocol satisfies the writable member:
3896+
3897+
```toml
3898+
[environment]
3899+
python-version = "3.12"
3900+
```
3901+
3902+
```py
3903+
from typing import Protocol
3904+
from ty_extensions import static_assert
3905+
from ty_extensions._internal import is_subtype_of
3906+
3907+
def _() -> None:
3908+
class Local(Protocol):
3909+
member: int
3910+
3911+
class Descriptor:
3912+
def __init__(self, getter: object) -> None: ...
3913+
def __get__(self, instance: object, owner: type | None = None) -> int:
3914+
return 1
3915+
3916+
def __set__[U](self, instance: object, value: Local) -> None: ...
3917+
3918+
class HasValue(Protocol):
3919+
@Descriptor
3920+
def value(self) -> int: ...
3921+
3922+
class Implementation:
3923+
@property
3924+
def value(self) -> int:
3925+
return 1
3926+
3927+
@value.setter
3928+
def value(self, value: Local) -> None: ...
3929+
3930+
static_assert(is_subtype_of(Implementation, HasValue))
3931+
```
3932+
3933+
### Recursive protocol setter value types
3934+
3935+
`P[int]` does not depend on the setter's `U`, so a matching property setter satisfies the writable
3936+
member even though `P`'s method returns another instance of the protocol:
3937+
3938+
```toml
3939+
[environment]
3940+
python-version = "3.12"
3941+
```
3942+
3943+
```py
3944+
from __future__ import annotations
3945+
3946+
from typing import Protocol
3947+
from ty_extensions import static_assert
3948+
from ty_extensions._internal import is_subtype_of
3949+
3950+
class P[T](Protocol):
3951+
def method(self) -> P[int]: ...
3952+
3953+
class Descriptor:
3954+
def __init__(self, getter: object) -> None: ...
3955+
def __get__(self, instance: object, owner: type | None = None) -> object:
3956+
return object()
3957+
3958+
def __set__[U](self, instance: object, value: P[int]) -> None: ...
3959+
3960+
class HasValue(Protocol):
3961+
@Descriptor
3962+
def value(self) -> object: ...
3963+
3964+
class Implementation:
3965+
@property
3966+
def value(self) -> object:
3967+
return object()
3968+
3969+
@value.setter
3970+
def value(self, value: P[int]) -> None: ...
3971+
3972+
static_assert(is_subtype_of(Implementation, HasValue))
3973+
```
3974+
3975+
### Setter value types with permuted protocol arguments
3976+
3977+
Recursion through different specializations remains independent of the setter's type parameter.
3978+
`Swapped[int, str]` alternates with `Swapped[str, int]`, and a setter accepting that type satisfies
3979+
the writable member:
3980+
3981+
```toml
3982+
[environment]
3983+
python-version = "3.12"
3984+
```
3985+
3986+
```py
3987+
from __future__ import annotations
3988+
3989+
from typing import Protocol
3990+
from ty_extensions import static_assert
3991+
from ty_extensions._internal import is_subtype_of
3992+
3993+
class Swapped[A, B](Protocol):
3994+
def value(self) -> A: ...
3995+
def next(self) -> Swapped[B, A]: ...
3996+
3997+
class Descriptor:
3998+
def __init__(self, getter: object) -> None: ...
3999+
def __get__(self, instance: object, owner: type | None = None) -> object:
4000+
return object()
4001+
4002+
def __set__[U](self, instance: object, value: Swapped[int, str]) -> None: ...
4003+
4004+
class HasValue(Protocol):
4005+
@Descriptor
4006+
def value(self) -> object: ...
4007+
4008+
class Implementation:
4009+
@property
4010+
def value(self) -> object:
4011+
return object()
4012+
4013+
@value.setter
4014+
def value(self, value: Swapped[int, str]) -> None: ...
4015+
4016+
static_assert(is_subtype_of(Implementation, HasValue))
4017+
```
4018+
4019+
The second argument is used after following the recursive reference. The setter's `U: int` bound
4020+
therefore restricts that argument:
4021+
4022+
```py
4023+
class GenericDescriptor:
4024+
def __init__(self, getter: object) -> None: ...
4025+
def __get__(self, instance: object, owner: type | None = None) -> object:
4026+
return object()
4027+
4028+
def __set__[U: int](self, instance: object, value: Swapped[str, U]) -> None: ...
4029+
4030+
class HasGenericValue(Protocol):
4031+
@GenericDescriptor
4032+
def value(self) -> object: ...
4033+
4034+
def update(value: HasGenericValue, accepted: Swapped[str, int], rejected: Swapped[str, str]) -> None:
4035+
value.value = accepted # no diagnostic
4036+
value.value = rejected # error: [invalid-assignment]
4037+
```
4038+
4039+
### Setter value types with nested recursive arguments
4040+
4041+
Each `next` member nests another `list` in the protocol's type argument. This recursion does not
4042+
depend on `__set__`'s type parameter, so assignments are checked against `Recursive[int]`:
4043+
4044+
```toml
4045+
[environment]
4046+
python-version = "3.12"
4047+
```
4048+
4049+
```py
4050+
from __future__ import annotations
4051+
4052+
from typing import Protocol
4053+
from ty_extensions import static_assert
4054+
from ty_extensions._internal import is_subtype_of
4055+
4056+
class Recursive[T](Protocol):
4057+
next: Recursive[list[T]]
4058+
4059+
class Descriptor:
4060+
def __init__(self, getter: object) -> None: ...
4061+
def __get__(self, instance: object, owner: type | None = None) -> int:
4062+
return 1
4063+
4064+
def __set__[U](self, instance: object, value: Recursive[int]) -> None: ...
4065+
4066+
class HasValue(Protocol):
4067+
@Descriptor
4068+
def value(self) -> int: ...
4069+
4070+
def _(x: HasValue, value: Recursive[int]) -> None:
4071+
x.value = value # no diagnostic
4072+
x.value = 1 # error: [invalid-assignment]
4073+
```
4074+
4075+
The same property setter satisfies the protocol, while an `int` attribute cannot accept
4076+
`Recursive[int]` values:
4077+
4078+
```py
4079+
class Implementation:
4080+
@property
4081+
def value(self) -> int:
4082+
return 1
4083+
4084+
@value.setter
4085+
def value(self, value: Recursive[int]) -> None: ...
4086+
4087+
static_assert(is_subtype_of(Implementation, HasValue))
4088+
4089+
class IntAttribute:
4090+
value: int
4091+
4092+
x: HasValue = IntAttribute() # error: [invalid-assignment]
4093+
```
4094+
4095+
### Recursive `TypedDict` setter value types
4096+
4097+
A recursive `TypedDict` can also be independent of the setter's type parameter. Assignments are
4098+
checked against `Payload[int]`:
4099+
4100+
```toml
4101+
[environment]
4102+
python-version = "3.12"
4103+
```
4104+
4105+
```py
4106+
from __future__ import annotations
4107+
4108+
from typing import Protocol, TypedDict
4109+
4110+
class Payload[T](TypedDict):
4111+
child: Payload[list[T]]
4112+
4113+
class PayloadDescriptor:
4114+
def __init__(self, getter: object) -> None: ...
4115+
def __get__(self, instance: object, owner: type | None = None) -> int:
4116+
return 1
4117+
4118+
def __set__[U](self, instance: object, value: Payload[int]) -> None: ...
4119+
4120+
class HasPayload(Protocol):
4121+
@PayloadDescriptor
4122+
def value(self) -> int: ...
4123+
4124+
def _(x: HasPayload, value: Payload[int]) -> None:
4125+
x.value = value # no diagnostic
4126+
x.value = 1 # error: [invalid-assignment]
4127+
```
4128+
4129+
### Recursive alias setter value types
4130+
4131+
A recursive alias independent of the setter's type parameter behaves the same way:
4132+
4133+
```toml
4134+
[environment]
4135+
python-version = "3.12"
4136+
```
4137+
4138+
```py
4139+
from typing import Protocol
4140+
4141+
type RecursiveAlias[T] = list[RecursiveAlias[list[T]]]
4142+
4143+
class AliasDescriptor:
4144+
def __init__(self, getter: object) -> None: ...
4145+
def __get__(self, instance: object, owner: type | None = None) -> int:
4146+
return 1
4147+
4148+
def __set__[U](self, instance: object, value: RecursiveAlias[int]) -> None: ...
4149+
4150+
class HasAlias(Protocol):
4151+
@AliasDescriptor
4152+
def value(self) -> int: ...
4153+
4154+
def _(x: HasAlias, value: RecursiveAlias[int]) -> None:
4155+
x.value = value # no diagnostic
4156+
x.value = 1 # error: [invalid-assignment]
4157+
```
4158+
4159+
### Recursive aliases in generic setters
4160+
4161+
A generic setter still rejects values incompatible with the outer type of a recursive alias.
4162+
`Recursive[T]` always denotes a `list`, so an `int` assignment is invalid:
4163+
4164+
```toml
4165+
[environment]
4166+
python-version = "3.12"
4167+
```
4168+
4169+
```py
4170+
from typing import Protocol
4171+
4172+
type Recursive[T] = list[Recursive[list[T]]]
4173+
4174+
class Descriptor:
4175+
def __init__(self, getter: object) -> None: ...
4176+
def __get__(self, instance: object, owner: type | None = None) -> int:
4177+
return 1
4178+
4179+
def __set__[T](self, instance: object, value: Recursive[T]) -> None: ...
4180+
4181+
class HasValue(Protocol):
4182+
@Descriptor
4183+
def value(self) -> int: ...
4184+
4185+
def _(x: HasValue) -> None:
4186+
x.value = 1 # error: [invalid-assignment]
4187+
```
4188+
38364189
### Type variables from the surrounding function
38374190

38384191
A type variable supplied by the surrounding function is still the descriptor's value type. Assigning
@@ -3910,6 +4263,37 @@ def update_aliased_receiver(value: HasAliasedReceiver) -> None:
39104263
value.value = "bad" # error: [invalid-assignment]
39114264
```
39124265

4266+
### Unused alias arguments do not constrain setters
4267+
4268+
`Ignored[T]` always denotes `int`, so the setter accepts `int` independently of its type parameter
4269+
and rejects `str`.
4270+
4271+
```toml
4272+
[environment]
4273+
python-version = "3.12"
4274+
```
4275+
4276+
```py
4277+
from typing import Protocol
4278+
4279+
type Ignored[T] = int
4280+
4281+
class Descriptor:
4282+
def __init__(self, getter: object) -> None: ...
4283+
def __get__(self, instance: object, owner: type | None = None) -> int:
4284+
return 1
4285+
4286+
def __set__[T](self, instance: object, value: Ignored[T]) -> None: ...
4287+
4288+
class HasValue(Protocol):
4289+
@Descriptor
4290+
def value(self) -> int: ...
4291+
4292+
def _(x: HasValue) -> None:
4293+
x.value = 1 # no diagnostic
4294+
x.value = "bad" # error: [invalid-assignment]
4295+
```
4296+
39134297
### Constrained generic setters
39144298

39154299
A setter with a constrained type variable must choose one constraint for each call. It accepts `int`

0 commit comments

Comments
 (0)