[ty] Don't include already-bound legacy typevars in function generic context (#19558)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

We now correctly exclude legacy typevars from enclosing scopes when
constructing the generic context for a generic function.

more detail:

A function is generic if it refers to legacy typevars in its signature:

```py
from typing import TypeVar

T = TypeVar("T")

def f(t: T) -> T:
    return t
```

Generic functions are allowed to appear inside of other generic
contexts. When they do, they can refer to the typevars of those
enclosing generic contexts, and that should not rebind the typevar:

```py
from typing import TypeVar, Generic

T = TypeVar("T")
U = TypeVar("U")

class C(Generic[T]):
    @staticmethod
    def method(t: T, u: U) -> None: ...

# revealed: def method(t: int, u: U) -> None
reveal_type(C[int].method)
```

This substitution was already being performed correctly, but we were
also still including the enclosing legacy typevars in the method's own
generic context, which can be seen via `ty_extensions.generic_context`
(which has been updated to work on generic functions and methods):

```py
from ty_extensions import generic_context

# before: tuple[T, U]
# after: tuple[U]
reveal_type(generic_context(C[int].method))
```

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Douglas Creager 2025-07-25 18:14:19 -04:00 committed by GitHub
parent 72fdb7d439
commit e867830848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 145 additions and 18 deletions

View file

@ -433,17 +433,31 @@ the typevars of the enclosing generic class, and introduce new (distinct) typeva
scope for the method.
```py
from ty_extensions import generic_context
from typing import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U")
class C(Generic[T]):
def method(self, u: U) -> U:
def method(self, u: int) -> int:
return u
def generic_method(self, t: T, u: U) -> U:
return u
reveal_type(generic_context(C)) # revealed: tuple[T]
reveal_type(generic_context(C.method)) # revealed: None
reveal_type(generic_context(C.generic_method)) # revealed: tuple[U]
reveal_type(generic_context(C[int])) # revealed: None
reveal_type(generic_context(C[int].method)) # revealed: None
reveal_type(generic_context(C[int].generic_method)) # revealed: tuple[U]
c: C[int] = C[int]()
reveal_type(c.method("string")) # revealed: Literal["string"]
reveal_type(c.generic_method(1, "string")) # revealed: Literal["string"]
reveal_type(generic_context(c)) # revealed: None
reveal_type(generic_context(c.method)) # revealed: None
reveal_type(generic_context(c.generic_method)) # revealed: tuple[U]
```
## Specializations propagate

View file

@ -392,8 +392,13 @@ the typevars of the enclosing generic class, and introduce new (distinct) typeva
scope for the method.
```py
from ty_extensions import generic_context
class C[T]:
def method[U](self, u: U) -> U:
def method(self, u: int) -> int:
return u
def generic_method[U](self, t: T, u: U) -> U:
return u
# error: [unresolved-reference]
def cannot_use_outside_of_method(self, u: U): ...
@ -401,8 +406,18 @@ class C[T]:
# TODO: error
def cannot_shadow_class_typevar[T](self, t: T): ...
reveal_type(generic_context(C)) # revealed: tuple[T]
reveal_type(generic_context(C.method)) # revealed: None
reveal_type(generic_context(C.generic_method)) # revealed: tuple[U]
reveal_type(generic_context(C[int])) # revealed: None
reveal_type(generic_context(C[int].method)) # revealed: None
reveal_type(generic_context(C[int].generic_method)) # revealed: tuple[U]
c: C[int] = C[int]()
reveal_type(c.method("string")) # revealed: Literal["string"]
reveal_type(c.generic_method(1, "string")) # revealed: Literal["string"]
reveal_type(generic_context(c)) # revealed: None
reveal_type(generic_context(c.method)) # revealed: None
reveal_type(generic_context(c.generic_method)) # revealed: tuple[U]
```
## Specializations propagate

View file

@ -141,10 +141,22 @@ class Legacy(Generic[T]):
def m(self, x: T, y: S) -> S:
return y
legacy: Legacy[int] = Legacy()
legacy: Legacy[int] = Legacy[int]()
reveal_type(legacy.m(1, "string")) # revealed: Literal["string"]
```
The class typevar in the method signature does not bind a _new_ instance of the typevar; it was
already solved and specialized when the class was specialized:
```py
from ty_extensions import generic_context
legacy.m("string", None) # error: [invalid-argument-type]
reveal_type(legacy.m) # revealed: bound method Legacy[int].m(x: int, y: S) -> S
reveal_type(generic_context(Legacy)) # revealed: tuple[T]
reveal_type(generic_context(legacy.m)) # revealed: tuple[S]
```
With PEP 695 syntax, it is clearer that the method uses a separate typevar:
```py