Skip to content

Commit 5a570c8

Browse files
authored
[ty] fix deferred name loading in PEP695 generic classes/functions (#19888)
## Summary For PEP 695 generic functions and classes, there is an extra "type params scope" (a child of the outer scope, and wrapping the body scope) in which the type parameters are defined; class bases and function parameter/return annotations are resolved in that type-params scope. This PR fixes some longstanding bugs in how we resolve name loads from inside these PEP 695 type parameter scopes, and also defers type inference of PEP 695 typevar bounds/constraints/default, so we can handle cycles without panicking. We were previously treating these type-param scopes as lazy nested scopes, which is wrong. In fact they are eager nested scopes; the class `C` here inherits `int`, not `str`, and previously we got that wrong: ```py Base = int class C[T](Base): ... Base = str ``` But certain syntactic positions within type param scopes (typevar bounds/constraints/defaults) are lazy at runtime, and we should use deferred name resolution for them. This also means they can have cycles; in order to handle that without panicking in type inference, we need to actually defer their type inference until after we have constructed the `TypeVarInstance`. PEP 695 does specify that typevar bounds and constraints cannot be generic, and that typevar defaults can only reference prior typevars, not later ones. This reduces the scope of (valid from the type-system perspective) cycles somewhat, although cycles are still possible (e.g. `class C[T: list[C]]`). And this is a type-system-only restriction; from the runtime perspective an "invalid" case like `class C[T: T]` actually works fine. I debated whether to implement the PEP 695 restrictions as a way to avoid some cycles up-front, but I ended up deciding against that; I'd rather model the runtime name-resolution semantics accurately, and implement the PEP 695 restrictions as a separate diagnostic on top. (This PR doesn't yet implement those diagnostics, thus some `# TODO: error` in the added tests.) Introducing the possibility of cyclic typevars made typevar display potentially stack overflow. For now I've handled this by simply removing typevar details (bounds/constraints/default) from typevar display. This impacts display of two kinds of types. If you `reveal_type(T)` on an unbound `T` you now get just `typing.TypeVar` instead of `typing.TypeVar("T", ...)` where `...` is the bound/constraints/default. This matches pyright and mypy; pyrefly uses `type[TypeVar[T]]` which seems a bit confusing, but does include the name. (We could easily include the name without cycle issues, if there's a syntax we like for that.) It also means that displaying a generic function type like `def f[T: int](x: T) -> T: ...` now displays as `f[T](x: T) -> T` instead of `f[T: int](x: T) -> T`. This matches pyright and pyrefly; mypy does include bound/constraints/defaults of typevars in function/callable type display. If we wanted to add this, we would either need to thread a visitor through all the type display code, or add a `decycle` type transformation that replaced recursive reoccurrence of a type with a marker. ## Test Plan Added mdtests and modified existing tests to improve their correctness. After this PR, there's only a single remaining py-fuzzer seed in the 0-500 range that panics! (Before this PR, there were 10; the fuzzer likes to generate cyclic PEP 695 syntax.) ## Ecosystem report It's all just the changes to `TypeVar` display.
1 parent baadb5a commit 5a570c8

22 files changed

+429
-239
lines changed

crates/ty_ide/src/hover.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -728,11 +728,11 @@ mod tests {
728728
);
729729

730730
// TODO: This should render T@Alias once we create GenericContexts for type alias scopes.
731-
assert_snapshot!(test.hover(), @r#"
732-
typing.TypeVar("T", bound=int, default=bool)
731+
assert_snapshot!(test.hover(), @r###"
732+
typing.TypeVar
733733
---------------------------------------------
734734
```python
735-
typing.TypeVar("T", bound=int, default=bool)
735+
typing.TypeVar
736736
```
737737
---------------------------------------------
738738
info[hover]: Hovered content is
@@ -743,7 +743,7 @@ mod tests {
743743
| |
744744
| source
745745
|
746-
"#);
746+
"###);
747747
}
748748

749749
#[test]

crates/ty_ide/src/inlay_hints.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -793,17 +793,17 @@ mod tests {
793793
identity('hello')",
794794
);
795795

796-
assert_snapshot!(test.inlay_hints(), @r#"
796+
assert_snapshot!(test.inlay_hints(), @r###"
797797
from typing import TypeVar, Generic
798798
799-
T[: typing.TypeVar("T")] = TypeVar([name=]'T')
799+
T[: typing.TypeVar] = TypeVar([name=]'T')
800800
801801
def identity(x: T) -> T:
802802
return x
803803
804804
identity([x=]42)
805805
identity([x=]'hello')
806-
"#);
806+
"###);
807807
}
808808

809809
#[test]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def name_1[name_0: name_0](name_2: name_0):
2+
try:
3+
pass
4+
except name_2:
5+
pass

crates/ty_python_semantic/resources/corpus/except_handler_with_Any_bound_typevar.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
def name_1[name_0: name_0](name_2: name_0):
2-
try:
3-
pass
4-
except name_2:
5-
pass
6-
71
from typing import Any
82

93
def name_2[T: Any](x: T):

crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ from typing import TypeVar
2020

2121
T = TypeVar("T")
2222
reveal_type(type(T)) # revealed: <class 'TypeVar'>
23-
reveal_type(T) # revealed: typing.TypeVar("T")
23+
reveal_type(T) # revealed: typing.TypeVar
2424
reveal_type(T.__name__) # revealed: Literal["T"]
2525
```
2626

@@ -80,7 +80,7 @@ from typing import TypeVar
8080

8181
T = TypeVar("T", default=int)
8282
reveal_type(type(T)) # revealed: <class 'TypeVar'>
83-
reveal_type(T) # revealed: typing.TypeVar("T", default=int)
83+
reveal_type(T) # revealed: typing.TypeVar
8484
reveal_type(T.__default__) # revealed: int
8585
reveal_type(T.__bound__) # revealed: None
8686
reveal_type(T.__constraints__) # revealed: tuple[()]
@@ -116,7 +116,7 @@ from typing import TypeVar
116116

117117
T = TypeVar("T", bound=int)
118118
reveal_type(type(T)) # revealed: <class 'TypeVar'>
119-
reveal_type(T) # revealed: typing.TypeVar("T", bound=int)
119+
reveal_type(T) # revealed: typing.TypeVar
120120
reveal_type(T.__bound__) # revealed: int
121121
reveal_type(T.__constraints__) # revealed: tuple[()]
122122

@@ -131,7 +131,7 @@ from typing import TypeVar
131131

132132
T = TypeVar("T", int, str)
133133
reveal_type(type(T)) # revealed: <class 'TypeVar'>
134-
reveal_type(T) # revealed: typing.TypeVar("T", int, str)
134+
reveal_type(T) # revealed: typing.TypeVar
135135
reveal_type(T.__constraints__) # revealed: tuple[int, str]
136136

137137
S = TypeVar("S")

crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ wrong_innards: C[int] = C("five", 1)
391391
### Some `__init__` overloads only apply to certain specializations
392392

393393
```py
394+
from __future__ import annotations
394395
from typing import overload
395396

396397
class C[T]:
@@ -541,6 +542,23 @@ class WithOverloadedMethod[T]:
541542
reveal_type(WithOverloadedMethod[int].method)
542543
```
543544

545+
## Scoping of typevars
546+
547+
### No back-references
548+
549+
Typevar bounds/constraints/defaults are lazy, but cannot refer to later typevars:
550+
551+
```py
552+
# TODO error
553+
class C[S: T, T]:
554+
pass
555+
556+
class D[S: X]:
557+
pass
558+
559+
X = int
560+
```
561+
544562
## Cyclic class definitions
545563

546564
### F-bounded quantification
@@ -591,7 +609,7 @@ class Derived[T](list[Derived[T]]): ...
591609

592610
Inheritance that would result in a cyclic MRO is detected as an error.
593611

594-
```py
612+
```pyi
595613
# error: [cyclic-class-definition]
596614
class C[T](C): ...
597615

crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
```toml
44
[environment]
5-
python-version = "3.12"
5+
python-version = "3.13"
66
```
77

88
[PEP 695] and Python 3.12 introduced new, more ergonomic syntax for type variables.
@@ -17,7 +17,7 @@ instances of `typing.TypeVar`, just like legacy type variables.
1717
```py
1818
def f[T]():
1919
reveal_type(type(T)) # revealed: <class 'TypeVar'>
20-
reveal_type(T) # revealed: typing.TypeVar("T")
20+
reveal_type(T) # revealed: typing.TypeVar
2121
reveal_type(T.__name__) # revealed: Literal["T"]
2222
```
2323

@@ -33,7 +33,7 @@ python-version = "3.13"
3333
```py
3434
def f[T = int]():
3535
reveal_type(type(T)) # revealed: <class 'TypeVar'>
36-
reveal_type(T) # revealed: typing.TypeVar("T", default=int)
36+
reveal_type(T) # revealed: typing.TypeVar
3737
reveal_type(T.__default__) # revealed: int
3838
reveal_type(T.__bound__) # revealed: None
3939
reveal_type(T.__constraints__) # revealed: tuple[()]
@@ -66,7 +66,7 @@ class Invalid[S = T]: ...
6666
```py
6767
def f[T: int]():
6868
reveal_type(type(T)) # revealed: <class 'TypeVar'>
69-
reveal_type(T) # revealed: typing.TypeVar("T", bound=int)
69+
reveal_type(T) # revealed: typing.TypeVar
7070
reveal_type(T.__bound__) # revealed: int
7171
reveal_type(T.__constraints__) # revealed: tuple[()]
7272

@@ -79,7 +79,7 @@ def g[S]():
7979
```py
8080
def f[T: (int, str)]():
8181
reveal_type(type(T)) # revealed: <class 'TypeVar'>
82-
reveal_type(T) # revealed: typing.TypeVar("T", int, str)
82+
reveal_type(T) # revealed: typing.TypeVar
8383
reveal_type(T.__constraints__) # revealed: tuple[int, str]
8484
reveal_type(T.__bound__) # revealed: None
8585

@@ -745,4 +745,88 @@ def constrained[T: (int, str)](x: T):
745745
reveal_type(type(x)) # revealed: type[int] | type[str]
746746
```
747747

748+
## Cycles
749+
750+
### Bounds and constraints
751+
752+
A typevar's bounds and constraints cannot be generic, cyclic or otherwise:
753+
754+
```py
755+
from typing import Any
756+
757+
# TODO: error
758+
def f[S, T: list[S]](x: S, y: T) -> S | T:
759+
return x or y
760+
761+
# TODO: error
762+
class C[S, T: list[S]]:
763+
x: S
764+
y: T
765+
766+
reveal_type(C[int, list[Any]]().x) # revealed: int
767+
reveal_type(C[int, list[Any]]().y) # revealed: list[Any]
768+
769+
# TODO: error
770+
def g[T: list[T]](x: T) -> T:
771+
return x
772+
773+
# TODO: error
774+
class D[T: list[T]]:
775+
x: T
776+
777+
reveal_type(D[list[Any]]().x) # revealed: list[Any]
778+
779+
# TODO: error
780+
def h[S, T: (list[S], str)](x: S, y: T) -> S | T:
781+
return x or y
782+
783+
# TODO: error
784+
class E[S, T: (list[S], str)]:
785+
x: S
786+
y: T
787+
788+
reveal_type(E[int, str]().x) # revealed: int
789+
reveal_type(E[int, str]().y) # revealed: str
790+
791+
# TODO: error
792+
def i[T: (list[T], str)](x: T) -> T:
793+
return x
794+
795+
# TODO: error
796+
class F[T: (list[T], str)]:
797+
x: T
798+
799+
reveal_type(F[list[Any]]().x) # revealed: list[Any]
800+
```
801+
802+
However, they are lazily evaluated and can cyclically refer to their own type:
803+
804+
```py
805+
class G[T: list[G]]:
806+
x: T
807+
808+
reveal_type(G[list[G]]().x) # revealed: list[G[Unknown]]
809+
```
810+
811+
### Defaults
812+
813+
Defaults can be generic, but can only refer to earlier typevars:
814+
815+
```py
816+
class C[T, U = T]:
817+
x: T
818+
y: U
819+
820+
reveal_type(C[int, str]().x) # revealed: int
821+
reveal_type(C[int, str]().y) # revealed: str
822+
reveal_type(C[int]().x) # revealed: int
823+
reveal_type(C[int]().y) # revealed: int
824+
825+
# TODO: error
826+
class D[T = T]:
827+
x: T
828+
829+
reveal_type(D().x) # revealed: T@D
830+
```
831+
748832
[pep 695]: https://peps.python.org/pep-0695/

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,7 @@ python-version = "3.12"
20512051
```
20522052

20532053
```py
2054+
from __future__ import annotations
20542055
from typing import cast, Protocol
20552056

20562057
class Iterator[T](Protocol):

crates/ty_python_semantic/resources/mdtest/scopes/eager.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,47 @@ reveal_type(C.var) # revealed: int | str
410410
x = str
411411
```
412412

413+
### Annotation scopes
414+
415+
```toml
416+
[environment]
417+
python-version = "3.12"
418+
```
419+
420+
#### Type alias annotation scopes are lazy
421+
422+
```py
423+
type Foo = Bar
424+
425+
class Bar:
426+
pass
427+
428+
def _(x: Foo):
429+
if isinstance(x, Bar):
430+
reveal_type(x) # revealed: Bar
431+
else:
432+
reveal_type(x) # revealed: Never
433+
```
434+
435+
#### Type-param scopes are eager, but bounds/constraints are deferred
436+
437+
```py
438+
# error: [unresolved-reference]
439+
class D[T](Bar):
440+
pass
441+
442+
class E[T: Bar]:
443+
pass
444+
445+
# error: [unresolved-reference]
446+
def g[T](x: Bar):
447+
pass
448+
449+
def h[T: Bar](x: T):
450+
pass
451+
452+
class Bar:
453+
pass
454+
```
455+
413456
[generators]: https://docs.python.org/3/reference/expressions.html#generator-expressions

0 commit comments

Comments
 (0)