ruff/crates/ty_python_semantic/resources/mdtest/generics/scoping.md
David Peter 0092794302
[ty] Use typing.Self for the first parameter of instance methods (#20517)
## Summary

Modify the (external) signature of instance methods such that the first
parameter uses `Self` unless it is explicitly annotated. This allows us
to correctly type-check more code, and allows us to infer correct return
types for many functions that return `Self`. For example:

```py
from pathlib import Path
from datetime import datetime, timedelta

reveal_type(Path(".config") / ".ty")  # now Path, previously Unknown

def _(dt: datetime, delta: timedelta):
    reveal_type(dt - delta)  # now datetime, previously Unknown
```

part of https://github.com/astral-sh/ty/issues/159

## Performance

I ran benchmarks locally on `attrs`, `freqtrade` and `colour`, the
projects with the largest regressions on CodSpeed. I see much smaller
effects locally, but can definitely reproduce the regression on `attrs`.
From looking at the profiling results (on Codspeed), it seems that we
simply do more type inference work, which seems plausible, given that we
now understand much more return types (of many stdlib functions). In
particular, whenever a function uses an implicit `self` and returns
`Self` (without mentioning `Self` anywhere else in its signature), we
will now infer the correct type, whereas we would previously return
`Unknown`. This also means that we need to invoke the generics solver in
more cases. Comparing half a million lines of log output on attrs, I can
see that we do 5% more "work" (number of lines in the log), and have a
lot more `apply_specialization` events (7108 vs 4304). On freqtrade, I
see similar numbers for `apply_specialization` (11360 vs 5138 calls).
Given these results, I'm not sure if it's generally worth doing more
performance work, especially since none of the code modifications
themselves seem to be likely candidates for regressions.

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./ty_main check /home/shark/ecosystem/attrs` | 92.6 ± 3.6 | 85.9 |
102.6 | 1.00 |
| `./ty_self check /home/shark/ecosystem/attrs` | 101.7 ± 3.5 | 96.9 |
113.8 | 1.10 ± 0.06 |

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./ty_main check /home/shark/ecosystem/freqtrade` | 599.0 ± 20.2 |
568.2 | 627.5 | 1.00 |
| `./ty_self check /home/shark/ecosystem/freqtrade` | 607.9 ± 11.5 |
594.9 | 626.4 | 1.01 ± 0.04 |

| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `./ty_main check /home/shark/ecosystem/colour` | 423.9 ± 17.9 | 394.6
| 447.4 | 1.00 |
| `./ty_self check /home/shark/ecosystem/colour` | 426.9 ± 24.9 | 373.8
| 456.6 | 1.01 ± 0.07 |

## Test Plan

New Markdown tests

## Ecosystem report

* apprise: ~300 new diagnostics related to problematic stubs in apprise
😩
* attrs: a new true positive, since [this
function](4e2c89c823/tests/test_make.py (L2135))
is missing a `@staticmethod`?
* Some legitimate true positives
* sympy: lots of new `invalid-operator` false positives in [matrix
multiplication](cf9f4b6805/sympy/matrices/matrixbase.py (L3267-L3269))
due to our limited understanding of [generic `Callable[[Callable[[T1,
T2], T3]], Callable[[T1, T2], T3]]` "identity"
types](cf9f4b6805/sympy/core/decorators.py (L83-L84))
of decorators. This is not related to type-of-self.

## Typing conformance results

The changes are all correct, except for
```diff
+generics_self_usage.py:50:5: error[invalid-assignment] Object of type `def foo(self) -> int` is not assignable to `(typing.Self, /) -> int`
```
which is related to an assignability problem involving type variables on
both sides:
```py
class CallableAttribute:
    def foo(self) -> int:
        return 0

    bar: Callable[[Self], int] = foo  # <- we currently error on this assignment
```

---------

Co-authored-by: Shaygan Hooshyari <sh.hooshyari@gmail.com>
2025-09-29 21:08:08 +02:00

7.4 KiB

Scoping rules for type variables

[environment]
python-version = "3.12"

Most of these tests come from the Scoping rules for type variables section of the typing spec.

Typevar used outside of generic function or class

Typevars may only be used in generic function or class definitions.

from typing import TypeVar

T = TypeVar("T")

# TODO: error
x: T

class C:
    # TODO: error
    x: T

def f() -> None:
    # TODO: error
    x: T

Legacy typevar used multiple times

A type variable used in a generic function could be inferred to represent different types in the same code block.

This only applies to typevars defined using the legacy syntax, since the PEP 695 syntax creates a new distinct typevar for each occurrence.

from typing import TypeVar

T = TypeVar("T")

def f1(x: T) -> T:
    return x

def f2(x: T) -> T:
    return x

f1(1)
f2("a")

Typevar inferred multiple times

A type variable used in a generic function could be inferred to represent different types in the same code block.

This also applies to a single generic function being used multiple times, instantiating the typevar to a different type each time.

def f[T](x: T) -> T:
    return x

reveal_type(f(1))  # revealed: Literal[1]
reveal_type(f("a"))  # revealed: Literal["a"]

Methods can mention class typevars

A type variable used in a method of a generic class that coincides with one of the variables that parameterize this class is always bound to that variable.

class C[T]:
    def m1(self, x: T) -> T:
        return x

    def m2(self, x: T) -> T:
        return x

c: C[int] = C[int]()
c.m1(1)
c.m2(1)
# error: [invalid-argument-type] "Argument to bound method `m2` is incorrect: Expected `int`, found `Literal["string"]`"
c.m2("string")

Functions on generic classes are descriptors

This repeats the tests in the Functions as descriptors test suite, but on a generic class. This ensures that we are carrying any specializations through the entirety of the descriptor protocol, which is how self parameters are bound to instance methods.

from inspect import getattr_static

class C[T]:
    def f(self, x: T) -> str:
        return "a"

reveal_type(getattr_static(C[int], "f"))  # revealed: def f(self, x: int) -> str
reveal_type(getattr_static(C[int], "f").__get__)  # revealed: <method-wrapper `__get__` of `f`>
reveal_type(getattr_static(C[int], "f").__get__(None, C[int]))  # revealed: def f(self, x: int) -> str
# revealed: bound method C[int].f(x: int) -> str
reveal_type(getattr_static(C[int], "f").__get__(C[int](), C[int]))

reveal_type(C[int].f)  # revealed: def f(self, x: int) -> str
reveal_type(C[int]().f)  # revealed: bound method C[int].f(x: int) -> str

bound_method = C[int]().f
reveal_type(bound_method.__self__)  # revealed: C[int]
reveal_type(bound_method.__func__)  # revealed: def f(self, x: int) -> str

reveal_type(C[int]().f(1))  # revealed: str
reveal_type(bound_method(1))  # revealed: str

# error: [invalid-argument-type] "Argument to function `f` is incorrect: Argument type `Literal[1]` does not satisfy upper bound `C[T@C]` of type variable `Self`"
C[int].f(1)  # error: [missing-argument]
reveal_type(C[int].f(C[int](), 1))  # revealed: str

class D[U](C[U]):
    pass

reveal_type(D[int]().f)  # revealed: bound method D[int].f(x: int) -> str

Methods can mention other typevars

A type variable used in a method that does not match any of the variables that parameterize the class makes this method a generic function in that variable.

from typing import TypeVar, Generic

T = TypeVar("T")
S = TypeVar("S")

class Legacy(Generic[T]):
    def m(self, x: T, y: S) -> S:
        return y

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:

from ty_extensions import generic_context

legacy.m("string", None)  # error: [invalid-argument-type]
reveal_type(legacy.m)  # revealed: bound method Legacy[int].m[S](x: int, y: S@m) -> S@m
reveal_type(generic_context(Legacy))  # revealed: tuple[T@Legacy]
reveal_type(generic_context(legacy.m))  # revealed: tuple[Self@m, S@m]

With PEP 695 syntax, it is clearer that the method uses a separate typevar:

class C[T]:
    def m[S](self, x: T, y: S) -> S:
        return y

c: C[int] = C()
reveal_type(c.m(1, "string"))  # revealed: Literal["string"]

Unbound typevars

Unbound type variables should not appear in the bodies of generic functions, or in the class bodies apart from method definitions.

This is true with the legacy syntax:

from typing import TypeVar, Generic

T = TypeVar("T")
S = TypeVar("S")

def f(x: T) -> None:
    x: list[T] = []
    # TODO: invalid-assignment error
    y: list[S] = []

class C(Generic[T]):
    # TODO: error: cannot use S if it's not in the current generic context
    x: list[S] = []

    # This is not an error, as shown in the previous test
    def m(self, x: S) -> S:
        return x

This is true with PEP 695 syntax, as well, though we must use the legacy syntax to define the unbound typevars:

pep695.py:

from typing import TypeVar

S = TypeVar("S")

def f[T](x: T) -> None:
    x: list[T] = []
    # TODO: invalid assignment error
    y: list[S] = []

class C[T]:
    # TODO: error: cannot use S if it's not in the current generic context
    x: list[S] = []

    def m1(self, x: S) -> S:
        return x

    def m2[S](self, x: S) -> S:
        return x

Nested formal typevars must be distinct

Generic functions and classes can be nested in each other, but it is an error for the same typevar to be used in nested generic definitions.

Note that the typing spec only mentions two specific versions of this rule:

A generic class definition that appears inside a generic function should not use type variables that parameterize the generic function.

and

A generic class nested in another generic class cannot use the same type variables.

We assume that the more general form holds.

Generic function within generic function

def f[T](x: T, y: T) -> None:
    def ok[S](a: S, b: S) -> None: ...

    # TODO: error
    def bad[T](a: T, b: T) -> None: ...

Generic method within generic class

class C[T]:
    def ok[S](self, a: S, b: S) -> None: ...

    # TODO: error
    def bad[T](self, a: T, b: T) -> None: ...

Generic class within generic function

from typing import Iterable

def f[T](x: T, y: T) -> None:
    class Ok[S]: ...
    # TODO: error for reuse of typevar
    class Bad1[T]: ...
    # TODO: error for reuse of typevar
    class Bad2(Iterable[T]): ...

Generic class within generic class

from typing import Iterable

class C[T]:
    class Ok1[S]: ...
    # TODO: error for reuse of typevar
    class Bad1[T]: ...
    # TODO: error for reuse of typevar
    class Bad2(Iterable[T]): ...

Class scopes do not cover inner scopes

Just like regular symbols, the typevars of a generic class are only available in that class's scope, and are not available in nested scopes.

class C[T]:
    ok1: list[T] = []

    class Bad:
        # TODO: error: cannot refer to T in nested scope
        bad: list[T] = []

    class Inner[S]: ...
    ok2: Inner[T]