
## 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>
13 KiB
NamedTuple
NamedTuple
is a type-safe way to define named tuples — a tuple where each field can be accessed by
name, and not just by its numeric position within the tuple:
typing.NamedTuple
Basics
from typing import NamedTuple
from ty_extensions import static_assert, is_subtype_of, is_assignable_to
class Person(NamedTuple):
id: int
name: str
age: int | None = None
alice = Person(1, "Alice", 42)
alice = Person(id=1, name="Alice", age=42)
bob = Person(2, "Bob")
bob = Person(id=2, name="Bob")
reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int | None
# revealed: tuple[<class 'Person'>, <class 'tuple[int, str, int | None]'>, <class 'Sequence[int | str | None]'>, <class 'Reversible[int | str | None]'>, <class 'Collection[int | str | None]'>, <class 'Iterable[int | str | None]'>, <class 'Container[int | str | None]'>, typing.Protocol, typing.Generic, <class 'object'>]
reveal_type(Person.__mro__)
static_assert(is_subtype_of(Person, tuple[int, str, int | None]))
static_assert(is_subtype_of(Person, tuple[object, ...]))
static_assert(not is_assignable_to(Person, tuple[int, str, int]))
static_assert(not is_assignable_to(Person, tuple[int, str]))
reveal_type(len(alice)) # revealed: Literal[3]
reveal_type(bool(alice)) # revealed: Literal[True]
reveal_type(alice[0]) # revealed: int
reveal_type(alice[1]) # revealed: str
reveal_type(alice[2]) # revealed: int | None
# error: [index-out-of-bounds] "Index 3 is out of bounds for tuple `Person` with length 3"
reveal_type(alice[3]) # revealed: Unknown
reveal_type(alice[-1]) # revealed: int | None
reveal_type(alice[-2]) # revealed: str
reveal_type(alice[-3]) # revealed: int
# error: [index-out-of-bounds] "Index -4 is out of bounds for tuple `Person` with length 3"
reveal_type(alice[-4]) # revealed: Unknown
reveal_type(alice[1:]) # revealed: tuple[str, int | None]
reveal_type(alice[::-1]) # revealed: tuple[int | None, str, int]
alice_id, alice_name, alice_age = alice
reveal_type(alice_id) # revealed: int
reveal_type(alice_name) # revealed: str
reveal_type(alice_age) # revealed: int | None
# error: [invalid-assignment] "Not enough values to unpack: Expected 4"
a, b, c, d = alice
# error: [invalid-assignment] "Too many values to unpack: Expected 2"
a, b = alice
*_, age = alice
reveal_type(age) # revealed: int | None
# error: [missing-argument]
Person(3)
# error: [too-many-positional-arguments]
Person(3, "Eve", 99, "extra")
# error: [invalid-argument-type]
Person(id="3", name="Eve")
reveal_type(Person.id) # revealed: property
reveal_type(Person.name) # revealed: property
reveal_type(Person.age) # revealed: property
# error: [invalid-assignment] "Cannot assign to read-only property `id` on object of type `Person`"
alice.id = 42
# error: [invalid-assignment]
bob.age = None
Alternative functional syntax:
Person2 = NamedTuple("Person", [("id", int), ("name", str)])
alice2 = Person2(1, "Alice")
# TODO: should be an error
Person2(1)
reveal_type(alice2.id) # revealed: @Todo(functional `NamedTuple` syntax)
reveal_type(alice2.name) # revealed: @Todo(functional `NamedTuple` syntax)
Definition
Fields without default values must come before fields with.
from typing import NamedTuple
class Location(NamedTuple):
altitude: float = 0.0
# error: [invalid-named-tuple] "NamedTuple field without default value cannot follow field(s) with default value(s): Field `latitude` defined here without a default value"
latitude: float
# error: [invalid-named-tuple] "NamedTuple field without default value cannot follow field(s) with default value(s): Field `longitude` defined here without a default value"
longitude: float
class StrangeLocation(NamedTuple):
altitude: float
altitude: float = 0.0
altitude: float
altitude: float = 0.0
latitude: float # error: [invalid-named-tuple]
longitude: float # error: [invalid-named-tuple]
class VeryStrangeLocation(NamedTuple):
altitude: float = 0.0
latitude: float # error: [invalid-named-tuple]
longitude: float # error: [invalid-named-tuple]
altitude: float = 0.0
Multiple Inheritance
Multiple inheritance is not supported for NamedTuple
classes except with Generic
:
from typing import NamedTuple, Protocol
# error: [invalid-named-tuple] "NamedTuple class `C` cannot use multiple inheritance except with `Generic[]`"
class C(NamedTuple, object):
id: int
# fmt: off
class D(
int, # error: [invalid-named-tuple]
NamedTuple
): ...
# fmt: on
# error: [invalid-named-tuple]
class E(NamedTuple, Protocol): ...
Inheriting from a NamedTuple
Inheriting from a NamedTuple
is supported, but new fields on the subclass will not be part of the
synthesized __new__
signature:
from typing import NamedTuple
class User(NamedTuple):
id: int
name: str
class SuperUser(User):
level: int
# This is fine:
alice = SuperUser(1, "Alice")
reveal_type(alice.level) # revealed: int
# This is an error because `level` is not part of the signature:
# error: [too-many-positional-arguments]
alice = SuperUser(1, "Alice", 3)
TODO: If any fields added by the subclass conflict with those in the base class, that should be flagged.
from typing import NamedTuple
class User(NamedTuple):
id: int
name: str
age: int | None
nickname: str
class SuperUser(User):
# TODO: this should be an error because it implies that the `id` attribute on
# `SuperUser` is mutable, but the read-only `id` property from the superclass
# has not been overridden in the class body
id: int
# this is fine; overriding a read-only attribute with a mutable one
# does not conflict with the Liskov Substitution Principle
name: str = "foo"
# this is also fine
@property
def age(self) -> int:
return super().age or 42
def now_called_robert(self):
self.name = "Robert" # fine because overridden with a mutable attribute
# TODO: this should cause us to emit an error as we're assigning to a read-only property
# inherited from the `NamedTuple` superclass (requires https://github.com/astral-sh/ty/issues/159)
self.nickname = "Bob"
james = SuperUser(0, "James", 42, "Jimmy")
# fine because the property on the superclass was overridden with a mutable attribute
# on the subclass
james.name = "Robert"
# error: [invalid-assignment] "Cannot assign to read-only property `nickname` on object of type `SuperUser`"
james.nickname = "Bob"
Generic named tuples
[environment]
python-version = "3.12"
from typing import NamedTuple, Generic, TypeVar
class Property[T](NamedTuple):
name: str
value: T
reveal_type(Property("height", 3.4)) # revealed: Property[float]
reveal_type(Property.value) # revealed: property
reveal_type(Property.value.fget) # revealed: (self, /) -> Unknown
reveal_type(Property[str].value.fget) # revealed: (self, /) -> str
reveal_type(Property("height", 3.4).value) # revealed: float
T = TypeVar("T")
class LegacyProperty(NamedTuple, Generic[T]):
name: str
value: T
reveal_type(LegacyProperty("height", 42)) # revealed: LegacyProperty[int]
reveal_type(LegacyProperty.value) # revealed: property
reveal_type(LegacyProperty.value.fget) # revealed: (self, /) -> Unknown
reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str
reveal_type(LegacyProperty("height", 3.4).value) # revealed: float
Attributes on NamedTuple
The following attributes are available on NamedTuple
classes / instances:
from typing import NamedTuple
class Person(NamedTuple):
name: str
age: int | None = None
reveal_type(Person._field_defaults) # revealed: dict[str, Any]
reveal_type(Person._fields) # revealed: tuple[str, ...]
reveal_type(Person._make) # revealed: bound method <class 'Person'>._make(iterable: Iterable[Any]) -> Person
reveal_type(Person._asdict) # revealed: def _asdict(self) -> dict[str, Any]
reveal_type(Person._replace) # revealed: def _replace(self, **kwargs: Any) -> Self@_replace
# TODO: should be `Person` once we support implicit type of `self`
reveal_type(Person._make(("Alice", 42))) # revealed: Unknown
person = Person("Alice", 42)
reveal_type(person._asdict()) # revealed: dict[str, Any]
reveal_type(person._replace(name="Bob")) # revealed: Person
When accessing them on child classes of generic NamedTuple
s, the return type is specialized
accordingly:
from typing import NamedTuple, Generic, TypeVar
T = TypeVar("T")
class Box(NamedTuple, Generic[T]):
content: T
class IntBox(Box[int]):
pass
reveal_type(IntBox(1)._replace(content=42)) # revealed: IntBox
collections.namedtuple
from collections import namedtuple
Person = namedtuple("Person", ["id", "name", "age"], defaults=[None])
alice = Person(1, "Alice", 42)
bob = Person(2, "Bob")
The symbol NamedTuple
itself
At runtime, NamedTuple
is a function, and we understand this:
import types
import typing
def expects_functiontype(x: types.FunctionType): ...
expects_functiontype(typing.NamedTuple)
This means we also understand that all attributes on function objects are available on the symbol
typing.NamedTuple
:
reveal_type(typing.NamedTuple.__name__) # revealed: str
reveal_type(typing.NamedTuple.__qualname__) # revealed: str
reveal_type(typing.NamedTuple.__kwdefaults__) # revealed: dict[str, Any] | None
# TODO: this should cause us to emit a diagnostic and reveal `Unknown` (function objects don't have an `__mro__` attribute),
# but the fact that we don't isn't actually a `NamedTuple` bug (https://github.com/astral-sh/ty/issues/986)
reveal_type(typing.NamedTuple.__mro__) # revealed: tuple[<class 'FunctionType'>, <class 'object'>]
By the normal rules, NamedTuple
and type[NamedTuple]
should not be valid in type expressions --
there is no object at runtime that is an "instance of NamedTuple
", nor is there any class at
runtime that is a "subclass of NamedTuple
" -- these are both impossible, since NamedTuple
is a
function and not a class. However, for compatibility with other type checkers, we allow NamedTuple
in type expressions and understand it as describing an interface that all NamedTuple
classes would
satisfy:
def expects_named_tuple(x: typing.NamedTuple):
reveal_type(x) # revealed: tuple[object, ...] & NamedTupleLike
reveal_type(x._make) # revealed: bound method type[NamedTupleLike]._make(iterable: Iterable[Any]) -> NamedTupleLike
reveal_type(x._replace) # revealed: bound method NamedTupleLike._replace(**kwargs) -> NamedTupleLike
# revealed: Overload[(value: tuple[object, ...], /) -> tuple[object, ...], (value: tuple[_T@__add__, ...], /) -> tuple[object, ...]]
reveal_type(x.__add__)
reveal_type(x.__iter__) # revealed: bound method tuple[object, ...].__iter__() -> Iterator[object]
def _(y: type[typing.NamedTuple]):
reveal_type(y) # revealed: @Todo(unsupported type[X] special form)
# error: [invalid-type-form] "Special form `typing.NamedTuple` expected no type parameter"
def _(z: typing.NamedTuple[int]): ...
Any instance of a NamedTuple
class can therefore be passed for a function parameter that is
annotated with NamedTuple
:
from typing import NamedTuple, Protocol, Iterable, Any
from ty_extensions import static_assert, is_assignable_to
class Point(NamedTuple):
x: int
y: int
reveal_type(Point._make) # revealed: bound method <class 'Point'>._make(iterable: Iterable[Any]) -> Point
reveal_type(Point._asdict) # revealed: def _asdict(self) -> dict[str, Any]
reveal_type(Point._replace) # revealed: def _replace(self, **kwargs: Any) -> Self@_replace
static_assert(is_assignable_to(Point, NamedTuple))
expects_named_tuple(Point(x=42, y=56)) # fine
# error: [invalid-argument-type] "Argument to function `expects_named_tuple` is incorrect: Expected `tuple[object, ...] & NamedTupleLike`, found `tuple[Literal[1], Literal[2]]`"
expects_named_tuple((1, 2))
The type described by NamedTuple
in type expressions is understood as being assignable to
tuple[object, ...]
and tuple[Any, ...]
:
static_assert(is_assignable_to(NamedTuple, tuple))
static_assert(is_assignable_to(NamedTuple, tuple[object, ...]))
static_assert(is_assignable_to(NamedTuple, tuple[Any, ...]))
def expects_tuple(x: tuple[object, ...]): ...
def _(x: NamedTuple):
expects_tuple(x) # fine
NamedTuple with custom __getattr__
This is a regression test for https://github.com/astral-sh/ty/issues/322. Make sure that the
__getattr__
method does not interfere with the NamedTuple
behavior.
from typing import NamedTuple
class Vec2(NamedTuple):
x: float = 0.0
y: float = 0.0
def __getattr__(self, attrs: str): ...
Vec2(0.0, 0.0)