From 3e0299488e72fa80f59473565cf724041d0f3f3c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 14 Jan 2026 12:41:04 -0500 Subject: [PATCH] [ty] Add support for functional `namedtuple` creation (#22327) ## Summary This PR is intended to demonstrate how the pattern established in https://github.com/astral-sh/ruff/pull/22291 generalizes to other class "kinds". Closes https://github.com/astral-sh/ty/issues/1049. --------- Co-authored-by: Alex Waygood --- crates/ty_ide/src/completion.rs | 2 +- crates/ty_ide/src/goto_definition.rs | 38 + .../annotations/unsupported_special_types.md | 8 +- .../resources/mdtest/named_tuple.md | 653 +++++++++++++++++- ...ltiple_Inheritance_(82ed33d1b3b433d8).snap | 19 + crates/ty_python_semantic/src/types.rs | 4 - .../ty_python_semantic/src/types/call/bind.rs | 5 - crates/ty_python_semantic/src/types/class.rs | 559 +++++++++++++-- crates/ty_python_semantic/src/types/enums.rs | 1 + .../src/types/infer/builder.rs | 565 ++++++++++++++- .../ty_python_semantic/src/types/instance.rs | 3 + crates/ty_python_semantic/src/types/mro.rs | 8 + 12 files changed, 1772 insertions(+), 93 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 411637304b..14604c9c66 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -4025,7 +4025,7 @@ quux. __module__ :: str __mul__ :: bound method Quux.__mul__(value: SupportsIndex, /) -> tuple[int | str, ...] __ne__ :: bound method Quux.__ne__(value: object, /) -> bool - __new__ :: (x: int, y: str) -> None + __new__ :: (x: int, y: str) -> Quux __orig_bases__ :: tuple[Any, ...] __reduce__ :: bound method Quux.__reduce__() -> str | tuple[Any, ...] __reduce_ex__ :: bound method Quux.__reduce_ex__(protocol: SupportsIndex, /) -> str | tuple[Any, ...] diff --git a/crates/ty_ide/src/goto_definition.rs b/crates/ty_ide/src/goto_definition.rs index a23055ccdb..92df069dc4 100644 --- a/crates/ty_ide/src/goto_definition.rs +++ b/crates/ty_ide/src/goto_definition.rs @@ -1708,6 +1708,44 @@ class Foo(type("Bar", (), {})): assert_snapshot!(test.goto_definition(), @"No goto target found"); } + /// goto-definition on a dynamic namedtuple class literal (created via `collections.namedtuple()`) + #[test] + fn goto_definition_dynamic_namedtuple_literal() { + let test = CursorTest::builder() + .source( + "main.py", + r#" +from collections import namedtuple + +Point = namedtuple("Point", ["x", "y"]) + +p = Point(1, 2) +"#, + ) + .build(); + + assert_snapshot!(test.goto_definition(), @r#" + info[goto-definition]: Go to definition + --> main.py:6:5 + | + 4 | Point = namedtuple("Point", ["x", "y"]) + 5 | + 6 | p = Point(1, 2) + | ^^^^^ Clicking here + | + info: Found 1 definition + --> main.py:4:1 + | + 2 | from collections import namedtuple + 3 | + 4 | Point = namedtuple("Point", ["x", "y"]) + | ----- + 5 | + 6 | p = Point(1, 2) + | + "#); + } + // TODO: Should only list `a: int` #[test] fn redeclarations() { diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md index dc22ac2539..cad4f50a7f 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/unsupported_special_types.md @@ -1,7 +1,7 @@ # Unsupported special types -We do not understand the functional syntax for creating `NamedTuple`s, `TypedDict`s or `Enum`s yet. -But we also do not emit false positives when these are used in type expressions. +We do not understand the functional syntax for creating `TypedDict`s or `Enum`s yet. But we also do +not emit false positives when these are used in type expressions. ```py import collections @@ -11,8 +11,6 @@ import typing MyEnum = enum.Enum("MyEnum", ["foo", "bar", "baz"]) MyIntEnum = enum.IntEnum("MyIntEnum", ["foo", "bar", "baz"]) MyTypedDict = typing.TypedDict("MyTypedDict", {"foo": int}) -MyNamedTuple1 = typing.NamedTuple("MyNamedTuple1", [("foo", int)]) -MyNamedTuple2 = collections.namedtuple("MyNamedTuple2", ["foo"]) -def f(a: MyEnum, b: MyTypedDict, c: MyNamedTuple1, d: MyNamedTuple2): ... +def f(a: MyEnum, b: MyTypedDict): ... ``` diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index cb0f201989..da7003cdc0 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -84,17 +84,489 @@ alice.id = 42 bob.age = None ``` -Alternative functional syntax: +Alternative functional syntax with a list of tuples: ```py Person2 = NamedTuple("Person", [("id", int), ("name", str)]) alice2 = Person2(1, "Alice") -# TODO: should be an error +# error: [missing-argument] Person2(1) -reveal_type(alice2.id) # revealed: @Todo(functional `NamedTuple` syntax) -reveal_type(alice2.name) # revealed: @Todo(functional `NamedTuple` syntax) +reveal_type(alice2.id) # revealed: int +reveal_type(alice2.name) # revealed: str +``` + +Functional syntax with a tuple of tuples: + +```py +Person3 = NamedTuple("Person", (("id", int), ("name", str))) +alice3 = Person3(1, "Alice") + +reveal_type(alice3.id) # revealed: int +reveal_type(alice3.name) # revealed: str +``` + +Functional syntax with a tuple of lists: + +```py +Person4 = NamedTuple("Person", (["id", int], ["name", str])) +alice4 = Person4(1, "Alice") + +reveal_type(alice4.id) # revealed: int +reveal_type(alice4.name) # revealed: str +``` + +Functional syntax with a list of lists: + +```py +Person5 = NamedTuple("Person", [["id", int], ["name", str]]) +alice5 = Person5(1, "Alice") + +reveal_type(alice5.id) # revealed: int +reveal_type(alice5.name) # revealed: str +``` + +### Functional syntax with variable name + +When the typename is passed via a variable, we can extract it from the inferred literal string type: + +```py +from typing import NamedTuple + +name = "Person" +Person = NamedTuple(name, [("id", int), ("name", str)]) + +p = Person(1, "Alice") +reveal_type(p.id) # revealed: int +reveal_type(p.name) # revealed: str +``` + +### Functional syntax with tuple variable fields + +When fields are passed via a tuple variable, we can extract the literal field names and types from +the inferred tuple type: + +```py +from typing import NamedTuple +from ty_extensions import static_assert, is_subtype_of, reveal_mro + +fields = (("host", str), ("port", int)) +Url = NamedTuple("Url", fields) + +url = Url("localhost", 8080) +reveal_type(url.host) # revealed: str +reveal_type(url.port) # revealed: int + +# Generic types are also correctly converted to instance types. +generic_fields = (("items", list[int]), ("mapping", dict[str, bool])) +Container = NamedTuple("Container", generic_fields) +container = Container([1, 2, 3], {"a": True}) +reveal_type(container.items) # revealed: list[int] +reveal_type(container.mapping) # revealed: dict[str, bool] + +# MRO includes the properly specialized tuple type. +# revealed: (, , ) +reveal_mro(Url) + +static_assert(is_subtype_of(Url, tuple[str, int])) + +# Invalid type expressions in fields produce a diagnostic. +invalid_fields = (("x", 42),) # 42 is not a valid type +# error: [invalid-type-form] "Invalid type `Literal[42]` in `NamedTuple` field type" +InvalidNT = NamedTuple("InvalidNT", invalid_fields) +reveal_type(InvalidNT) # revealed: + +# Unpacking works correctly with the field types. +host, port = url +reveal_type(host) # revealed: str +reveal_type(port) # revealed: int + +# error: [invalid-assignment] "Too many values to unpack: Expected 1" +(only_one,) = url + +# error: [invalid-assignment] "Not enough values to unpack: Expected 3" +a, b, c = url + +# Indexing works correctly. +reveal_type(url[0]) # revealed: str +reveal_type(url[1]) # revealed: int + +# error: [index-out-of-bounds] +url[2] +``` + +### Functional syntax with variadic tuple fields + +When fields are passed as a variadic tuple (e.g., `tuple[..., *tuple[T, ...]]`), we cannot determine +the exact field count statically. In this case, we fall back to unknown fields: + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import NamedTuple +from ty_extensions import reveal_mro + +# Variadic tuple - we can't determine the exact fields statically. +def get_fields() -> tuple[tuple[str, type[int]], *tuple[tuple[str, type[str]], ...]]: + return (("x", int), ("y", str)) + +fields = get_fields() +NT = NamedTuple("NT", fields) + +# Fields are unknown, so attribute access returns Any and MRO has Unknown tuple. +reveal_type(NT) # revealed: +reveal_mro(NT) # revealed: (, , ) +reveal_type(NT(1, "a").x) # revealed: Any +``` + +Similarly for `collections.namedtuple`: + +```py +import collections +from ty_extensions import reveal_mro + +def get_field_names() -> tuple[str, *tuple[str, ...]]: + return ("x", "y") + +field_names = get_field_names() +NT = collections.namedtuple("NT", field_names) + +# Fields are unknown, so attribute access returns Any and MRO has Unknown tuple. +reveal_type(NT) # revealed: +reveal_mro(NT) # revealed: (, , ) +reveal_type(NT(1, 2).x) # revealed: Any +``` + +### Class inheriting from functional NamedTuple + +Classes can inherit from functional namedtuples. The constructor parameters and field types are +properly inherited: + +```py +from typing import NamedTuple +from ty_extensions import reveal_mro + +class Url(NamedTuple("Url", [("host", str), ("path", str)])): + pass + +reveal_type(Url) # revealed: +# revealed: (, , , ) +reveal_mro(Url) +reveal_type(Url.__new__) # revealed: (cls: type, host: str, path: str) -> Url + +# Constructor works with the inherited fields. +url = Url("example.com", "/path") +reveal_type(url) # revealed: Url +reveal_type(url.host) # revealed: str +reveal_type(url.path) # revealed: str + +# Error handling works correctly. +# error: [missing-argument] +Url("example.com") + +# error: [too-many-positional-arguments] +Url("example.com", "/path", "extra") +``` + +Subclasses can add methods that use inherited fields: + +```py +from typing import NamedTuple +from typing_extensions import Self + +class Url(NamedTuple("Url", [("host", str), ("port", int)])): + def with_port(self, port: int) -> Self: + reveal_type(self.host) # revealed: str + reveal_type(self.port) # revealed: int + return self._replace(port=port) + +url = Url("localhost", 8080) +reveal_type(url.with_port(9000)) # revealed: Url +``` + +For `class Foo(namedtuple("Foo", ...)): ...`, the inner call creates a namedtuple class, but the +outer class is just a regular class inheriting from it. This is equivalent to: + +```py +class _Foo(NamedTuple): ... + +class Foo(_Foo): # Regular class, not a namedtuple + ... +``` + +Because the outer class is not itself a namedtuple, it can use `super()` and override `__new__`: + +```py +from collections import namedtuple +from typing import NamedTuple + +class ExtType(namedtuple("ExtType", "code data")): + """Override __new__ to add validation.""" + + def __new__(cls, code, data): + if not isinstance(code, int): + raise TypeError("code must be int") + return super().__new__(cls, code, data) + +class Url(NamedTuple("Url", [("host", str), ("path", str)])): + """Override __new__ to normalize the path.""" + + def __new__(cls, host, path): + if path and not path.startswith("/"): + path = "/" + path + return super().__new__(cls, host, path) + +# Both work correctly. +ext = ExtType(42, b"hello") +reveal_type(ext) # revealed: ExtType + +url = Url("example.com", "path") +reveal_type(url) # revealed: Url +``` + +### Functional syntax with list variable fields + +When fields are passed via a list variable (not a literal), the field names cannot be determined +statically. Attribute access returns `Any` and the constructor accepts any arguments: + +```py +from typing import NamedTuple +from typing_extensions import Self + +fields = [("host", str), ("port", int)] + +class Url(NamedTuple("Url", fields)): + def with_port(self, port: int) -> Self: + # Fields are unknown, so attribute access returns Any. + reveal_type(self.host) # revealed: Any + reveal_type(self.port) # revealed: Any + reveal_type(self.unknown) # revealed: Any + return self._replace(port=port) +``` + +When constructing a namedtuple directly with dynamically-defined fields, keyword arguments are +accepted because the constructor uses a gradual signature: + +```py +import collections +from ty_extensions import reveal_mro + +CheckerConfig = ["duration", "video_fps", "audio_sample_rate"] +GroundTruth = collections.namedtuple("GroundTruth", " ".join(CheckerConfig)) + +# No error - fields are unknown, so any keyword arguments are accepted +config = GroundTruth(duration=0, video_fps=30, audio_sample_rate=44100) +reveal_type(config) # revealed: GroundTruth +reveal_type(config.duration) # revealed: Any + +# Namedtuples with unknown fields inherit from tuple[Unknown, ...] to avoid false positives. +# revealed: (, , ) +reveal_mro(GroundTruth) + +# No index-out-of-bounds error since the tuple length is unknown. +reveal_type(config[0]) # revealed: Unknown +reveal_type(config[100]) # revealed: Unknown +``` + +### Functional syntax signature validation + +The `collections.namedtuple` function accepts `str | Iterable[str]` for `field_names`: + +```py +import collections +from ty_extensions import reveal_mro + +# String field names (space-separated) +Point1 = collections.namedtuple("Point", "x y") +reveal_type(Point1) # revealed: +reveal_mro(Point1) # revealed: (, , ) + +# String field names with multiple spaces +Point1a = collections.namedtuple("Point", "x y") +reveal_type(Point1a) # revealed: +reveal_mro(Point1a) # revealed: (, , ) + +# String field names (comma-separated also works at runtime) +Point2 = collections.namedtuple("Point", "x, y") +reveal_type(Point2) # revealed: +reveal_mro(Point2) # revealed: (, , ) + +# List of strings +Point3 = collections.namedtuple("Point", ["x", "y"]) +reveal_type(Point3) # revealed: +reveal_mro(Point3) # revealed: (, , ) + +# Tuple of strings +Point4 = collections.namedtuple("Point", ("x", "y")) +reveal_type(Point4) # revealed: +reveal_mro(Point4) # revealed: (, , ) + +# Invalid: integer is not a valid typename +# error: [invalid-argument-type] +Invalid = collections.namedtuple(123, ["x", "y"]) +reveal_type(Invalid) # revealed: '> +reveal_mro(Invalid) # revealed: ('>, , ) + +# Invalid: too many positional arguments +# error: [too-many-positional-arguments] "Too many positional arguments to function `namedtuple`: expected 2, got 4" +TooMany = collections.namedtuple("TooMany", "x", "y", "z") +reveal_type(TooMany) # revealed: +``` + +The `typing.NamedTuple` function accepts `Iterable[tuple[str, Any]]` for `fields`: + +```py +from typing import NamedTuple + +# List of tuples +Person1 = NamedTuple("Person", [("name", str), ("age", int)]) +reveal_type(Person1) # revealed: + +# Tuple of tuples +Person2 = NamedTuple("Person", (("name", str), ("age", int))) +reveal_type(Person2) # revealed: + +# Invalid: integer is not a valid typename +# error: [invalid-argument-type] +NamedTuple(123, [("name", str)]) + +# Invalid: too many positional arguments +# error: [too-many-positional-arguments] "Too many positional arguments to function `NamedTuple`: expected 2, got 4" +TooMany = NamedTuple("TooMany", [("x", int)], "extra", "args") +reveal_type(TooMany) # revealed: +``` + +### Keyword arguments for `collections.namedtuple` + +The `collections.namedtuple` function accepts `rename`, `defaults`, and `module` keyword arguments: + +```py +import collections +from ty_extensions import reveal_mro + +# `rename=True` replaces invalid identifiers with positional names +Point = collections.namedtuple("Point", ["x", "class", "_y", "z", "z"], rename=True) +reveal_type(Point) # revealed: +reveal_type(Point.__new__) # revealed: (cls: type, x: Any, _1: Any, _2: Any, z: Any, _4: Any) -> Point +reveal_mro(Point) # revealed: (, , ) +p = Point(1, 2, 3, 4, 5) +reveal_type(p.x) # revealed: Any +reveal_type(p._1) # revealed: Any +reveal_type(p._2) # revealed: Any +reveal_type(p.z) # revealed: Any +reveal_type(p._4) # revealed: Any + +# Truthy non-bool values for `rename` are also handled, but emit a diagnostic +# error: [invalid-argument-type] "Invalid argument to parameter `rename` of `namedtuple()`" +Point2 = collections.namedtuple("Point2", ["_x", "class"], rename=1) +reveal_type(Point2) # revealed: +reveal_type(Point2.__new__) # revealed: (cls: type, _0: Any, _1: Any) -> Point2 + +# `defaults` provides default values for the rightmost fields +Person = collections.namedtuple("Person", ["name", "age", "city"], defaults=["Unknown"]) +reveal_type(Person) # revealed: +reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = ...) -> Person +reveal_mro(Person) # revealed: (, , ) +# Can create with all fields +person1 = Person("Alice", 30, "NYC") +# Can omit the field with default +person2 = Person("Bob", 25) +reveal_type(person1.city) # revealed: Any +reveal_type(person2.city) # revealed: Any + +# `module` is valid but doesn't affect type checking +Config = collections.namedtuple("Config", ["host", "port"], module="myapp") +reveal_type(Config) # revealed: + +# When more defaults are provided than fields, we treat all fields as having defaults. +# TODO: This should emit a diagnostic since it would fail at runtime. +TooManyDefaults = collections.namedtuple("TooManyDefaults", ["x", "y"], defaults=("a", "b", "c")) +reveal_type(TooManyDefaults) # revealed: +reveal_type(TooManyDefaults.__new__) # revealed: (cls: type, x: Any = ..., y: Any = ...) -> TooManyDefaults + +# Unknown keyword arguments produce an error +# error: [unknown-argument] +Bad1 = collections.namedtuple("Bad1", ["x", "y"], foobarbaz=42) +reveal_type(Bad1) # revealed: +reveal_mro(Bad1) # revealed: (, , ) + +# Multiple unknown keyword arguments +# error: [unknown-argument] +# error: [unknown-argument] +Bad2 = collections.namedtuple("Bad2", ["x"], invalid1=True, invalid2=False) +reveal_type(Bad2) # revealed: +reveal_mro(Bad2) # revealed: (, , ) + +# Invalid type for `defaults` (not Iterable[Any] | None) +# error: [invalid-argument-type] "Invalid argument to parameter `defaults` of `namedtuple()`" +Bad3 = collections.namedtuple("Bad3", ["x"], defaults=123) +reveal_type(Bad3) # revealed: + +# Invalid type for `module` (not str | None) +# error: [invalid-argument-type] "Invalid argument to parameter `module` of `namedtuple()`" +Bad4 = collections.namedtuple("Bad4", ["x"], module=456) +reveal_type(Bad4) # revealed: + +# Invalid type for `field_names` (not str | Iterable[str]) +# error: [invalid-argument-type] "Invalid argument to parameter `field_names` of `namedtuple()`" +Bad5 = collections.namedtuple("Bad5", 12345) +reveal_type(Bad5) # revealed: +``` + +### Keyword arguments for `typing.NamedTuple` + +The `typing.NamedTuple` function does not accept any keyword arguments: + +```py +from typing import NamedTuple + +# error: [unknown-argument] +Bad3 = NamedTuple("Bad3", [("x", int)], rename=True) + +# error: [unknown-argument] +Bad4 = NamedTuple("Bad4", [("x", int)], defaults=[0]) + +# error: [unknown-argument] +Bad5 = NamedTuple("Bad5", [("x", int)], foobarbaz=42) + +# Invalid type for `fields` (not an iterable) +# error: [invalid-argument-type] "Invalid argument to parameter `fields` of `NamedTuple()`" +Bad6 = NamedTuple("Bad6", 12345) +reveal_type(Bad6) # revealed: +``` + +### Starred and double-starred arguments + +When starred (`*args`) or double-starred (`**kwargs`) arguments are used, we fall back to normal +call binding since we can't statically determine the arguments. This results in `NamedTupleFallback` +being returned: + +```py +import collections +from typing import NamedTuple + +args = ("Point", ["x", "y"]) +kwargs = {"rename": True} + +# Starred positional arguments - falls back to NamedTupleFallback +Point1 = collections.namedtuple(*args) +reveal_type(Point1) # revealed: type[NamedTupleFallback] + +# Ideally we'd catch this false negative, +# but it's unclear if it's worth the added complexity +Point2 = NamedTuple(*args) +reveal_type(Point2) # revealed: type[NamedTupleFallback] + +# Double-starred keyword arguments - falls back to NamedTupleFallback +Point3 = collections.namedtuple("Point", ["x", "y"], **kwargs) +reveal_type(Point3) # revealed: type[NamedTupleFallback] + +Point4 = NamedTuple("Point", [("x", int), ("y", int)], **kwargs) +reveal_type(Point4) # revealed: type[NamedTupleFallback] ``` ### Definition @@ -154,6 +626,84 @@ class D( class E(NamedTuple, Protocol): ... ``` +However, as explained above, for `class Foo(namedtuple("Foo", ...)): ...` the outer class is not +itself a namedtuple—it just inherits from one. So it can use multiple inheritance freely: + +```py +from abc import ABC +from collections import namedtuple +from typing import NamedTuple + +class Point(namedtuple("Point", ["x", "y"]), ABC): + """No error - functional namedtuple inheritance allows multiple inheritance.""" + +class Url(NamedTuple("Url", [("host", str), ("port", int)]), ABC): + """No error - typing.NamedTuple functional syntax also allows multiple inheritance.""" + +p = Point(1, 2) +reveal_type(p.x) # revealed: Any +reveal_type(p.y) # revealed: Any + +u = Url("localhost", 8080) +reveal_type(u.host) # revealed: str +reveal_type(u.port) # revealed: int +``` + +### Inherited tuple methods + +Namedtuples inherit methods from their tuple base class, including `count`, `index`, and comparison +methods (`__lt__`, `__le__`, `__gt__`, `__ge__`). + +```py +from collections import namedtuple +from typing import NamedTuple + +# typing.NamedTuple inherits tuple methods +class Point(NamedTuple): + x: int + y: int + +p = Point(1, 2) +reveal_type(p.count(1)) # revealed: int +reveal_type(p.index(2)) # revealed: int + +# collections.namedtuple also inherits tuple methods +Person = namedtuple("Person", ["name", "age"]) +alice = Person("Alice", 30) +reveal_type(alice.count("Alice")) # revealed: int +reveal_type(alice.index(30)) # revealed: int +``` + +The `@total_ordering` decorator should not emit a diagnostic, since the required `__lt__` method is +already present: + +```py +from collections import namedtuple +from functools import total_ordering +from typing import NamedTuple + +# No error - __lt__ is inherited from the tuple base class +@total_ordering +class Point(namedtuple("Point", "x y")): ... + +p1 = Point(1, 2) +p2 = Point(3, 4) +# TODO: should be `bool`, not `Any | Literal[False]` +reveal_type(p1 < p2) # revealed: Any | Literal[False] +reveal_type(p1 <= p2) # revealed: Any | Literal[True] + +# Same for typing.NamedTuple - no error +@total_ordering +class Person(NamedTuple): + name: str + age: int + +alice = Person("Alice", 30) +bob = Person("Bob", 25) +reveal_type(alice < bob) # revealed: bool +reveal_type(alice >= bob) # revealed: bool +``` + ### Inheriting from a `NamedTuple` Inheriting from a `NamedTuple` is supported, but new fields on the subclass will not be part of the @@ -254,6 +804,34 @@ reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str reveal_type(LegacyProperty("height", 3.4).value) # revealed: int | float ``` +Generic namedtuples can also be defined using the functional syntax with type variables in the field +types. We don't currently support this, but mypy does: + +```py +from typing import NamedTuple, TypeVar + +T = TypeVar("T") + +# TODO: ideally this would create a generic namedtuple class +Pair = NamedTuple("Pair", [("first", T), ("second", T)]) + +# For now, the TypeVar is not specialized, so the field types remain as `T@Pair` and argument type +# errors are emitted when calling the constructor. +reveal_type(Pair) # revealed: + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +reveal_type(Pair(1, 2)) # revealed: Pair + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +reveal_type(Pair(1, 2).first) # revealed: T@Pair + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +reveal_type(Pair(1, 2).second) # revealed: T@Pair +``` + ## Attributes on `NamedTuple` The following attributes are available on `NamedTuple` classes / instances: @@ -311,6 +889,73 @@ alice = Person(1, "Alice", 42) bob = Person(2, "Bob") ``` +## `collections.namedtuple` with tuple variable field names + +When field names are passed via a tuple variable, we can extract the literal field names from the +inferred tuple type. The class is properly synthesized (not a fallback), but field types are `Any` +since `collections.namedtuple` doesn't include type annotations: + +```py +from collections import namedtuple + +field_names = ("name", "age") +Person = namedtuple("Person", field_names) + +reveal_type(Person) # revealed: + +alice = Person("Alice", 42) +reveal_type(alice) # revealed: Person +reveal_type(alice.name) # revealed: Any +reveal_type(alice.age) # revealed: Any +``` + +## `collections.namedtuple` with list variable field names + +When field names are passed via a list variable (not a literal), we fall back to +`NamedTupleFallback` which allows any attribute access. This is a regression test for accessing +`Self` attributes in methods of classes that inherit from namedtuples with dynamic fields: + +```py +from collections import namedtuple +from typing_extensions import Self + +field_names = ["host", "port"] + +class Url(namedtuple("Url", field_names)): + def with_port(self, port: int) -> Self: + # Fields are unknown, so attribute access returns `Any`. + reveal_type(self.host) # revealed: Any + reveal_type(self.port) # revealed: Any + reveal_type(self.unknown) # revealed: Any + return self._replace(port=port) +``` + +## `collections.namedtuple` attributes + +Functional namedtuples have synthesized attributes similar to class-based namedtuples: + +```py +from collections import namedtuple + +Person = namedtuple("Person", ["name", "age"]) + +reveal_type(Person._fields) # revealed: tuple[Literal["name"], Literal["age"]] +reveal_type(Person._field_defaults) # revealed: dict[str, Any] +reveal_type(Person._make) # revealed: bound method ._make(iterable: Iterable[Any]) -> Person +reveal_type(Person._asdict) # revealed: def _asdict(self) -> dict[str, Any] +reveal_type(Person._replace) # revealed: (self: Self, *, name: Any = ..., age: Any = ...) -> Self + +# _make creates instances from an iterable. +reveal_type(Person._make(["Alice", 30])) # revealed: Person + +# _asdict converts to a dictionary. +person = Person("Alice", 30) +reveal_type(person._asdict()) # revealed: dict[str, Any] + +# _replace creates a copy with replaced fields. +reveal_type(person._replace(name="Bob")) # revealed: Person +``` + ## The symbol `NamedTuple` itself At runtime, `NamedTuple` is a function, and we understand this: diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_`typing.NamedTuple`_-_Multiple_Inheritance_(82ed33d1b3b433d8).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_`typing.NamedTuple`_-_Multiple_Inheritance_(82ed33d1b3b433d8).snap index da7cbc917b..f73e7f92c4 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_`typing.NamedTuple`_-_Multiple_Inheritance_(82ed33d1b3b433d8).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/named_tuple.md_-_`NamedTuple`_-_`typing.NamedTuple`_-_Multiple_Inheritance_(82ed33d1b3b433d8).snap @@ -30,6 +30,23 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/named_tuple.md 15 | 16 | # error: [invalid-named-tuple] 17 | class E(NamedTuple, Protocol): ... +18 | from abc import ABC +19 | from collections import namedtuple +20 | from typing import NamedTuple +21 | +22 | class Point(namedtuple("Point", ["x", "y"]), ABC): +23 | """No error - functional namedtuple inheritance allows multiple inheritance.""" +24 | +25 | class Url(NamedTuple("Url", [("host", str), ("port", int)]), ABC): +26 | """No error - typing.NamedTuple functional syntax also allows multiple inheritance.""" +27 | +28 | p = Point(1, 2) +29 | reveal_type(p.x) # revealed: Any +30 | reveal_type(p.y) # revealed: Any +31 | +32 | u = Url("localhost", 8080) +33 | reveal_type(u.host) # revealed: str +34 | reveal_type(u.port) # revealed: int ``` # Diagnostics @@ -68,6 +85,8 @@ error[invalid-named-tuple]: NamedTuple class `E` cannot use multiple inheritance 16 | # error: [invalid-named-tuple] 17 | class E(NamedTuple, Protocol): ... | ^^^^^^^^ +18 | from abc import ABC +19 | from collections import namedtuple | info: rule `invalid-named-tuple` is enabled by default diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index b6ff5bae68..4a54a12f55 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4402,10 +4402,6 @@ impl<'db> Type<'db> { .into() } - Type::SpecialForm(SpecialFormType::NamedTuple) => { - Binding::single(self, Signature::todo("functional `NamedTuple` syntax")).into() - } - Type::GenericAlias(_) => { // TODO annotated return type on `__new__` or metaclass `__call__` // TODO check call vs signatures of `__new__` and/or `__init__` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index e22baa02b9..340b86c8d3 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1187,11 +1187,6 @@ impl<'db> Bindings<'db> { } } - Some(KnownFunction::NamedTuple) => { - overload - .set_return_type(todo_type!("Support for functional `namedtuple`")); - } - _ => { // Ideally, either the implementation, or exactly one of the overloads // of the function can have the dataclass_transform decorator applied. diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index f2b4142230..c393534d9f 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -194,6 +194,7 @@ impl<'db> CodeGeneratorKind<'db> { Self::from_static_class(db, static_class, specialization) } ClassLiteral::Dynamic(dynamic_class) => Self::from_dynamic_class(db, dynamic_class), + ClassLiteral::DynamicNamedTuple(_) => Some(Self::NamedTuple), } } @@ -463,6 +464,8 @@ pub enum ClassLiteral<'db> { Static(StaticClassLiteral<'db>), /// A class created dynamically via `type(name, bases, dict)`. Dynamic(DynamicClassLiteral<'db>), + /// A class created via `collections.namedtuple()` or `typing.NamedTuple()`. + DynamicNamedTuple(DynamicNamedTupleLiteral<'db>), } impl<'db> ClassLiteral<'db> { @@ -471,6 +474,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.name(db), Self::Dynamic(class) => class.name(db), + Self::DynamicNamedTuple(namedtuple) => namedtuple.name(db), } } @@ -495,6 +499,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.metaclass(db), Self::Dynamic(class) => class.metaclass(db), + Self::DynamicNamedTuple(namedtuple) => namedtuple.metaclass(db), } } @@ -508,6 +513,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.class_member(db, name, policy), Self::Dynamic(class) => class.class_member(db, name, policy), + Self::DynamicNamedTuple(namedtuple) => namedtuple.class_member(db, name, policy), } } @@ -523,7 +529,7 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.class_member_from_mro(db, name, policy, mro_iter), - Self::Dynamic(_) => { + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { // Dynamic classes don't have inherited generic context and are never `object`. let result = MroLookup::new(db, mro_iter).class_member(name, policy, None, false); match result { @@ -549,7 +555,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn default_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.default_specialization(db), - Self::Dynamic(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), } } @@ -557,7 +563,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn identity_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.identity_specialization(db), - Self::Dynamic(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), } } @@ -575,7 +581,7 @@ impl<'db> ClassLiteral<'db> { pub fn is_typed_dict(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_typed_dict(db), - Self::Dynamic(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, } } @@ -583,7 +589,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn is_tuple(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_tuple(db), - Self::Dynamic(_) => false, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => false, } } @@ -605,6 +611,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.file(db), Self::Dynamic(class) => class.scope(db).file(db), + Self::DynamicNamedTuple(class) => class.scope(db).file(db), } } @@ -616,6 +623,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.header_range(db), Self::Dynamic(class) => class.header_range(db), + Self::DynamicNamedTuple(class) => class.header_range(db), } } @@ -628,8 +636,10 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn is_final(self, db: &'db dyn Db) -> bool { match self { Self::Static(class) => class.is_final(db), - // Dynamic classes created via `type()` cannot be marked as final. + // Dynamic classes created via `type()`, `collections.namedtuple()`, etc. cannot be + // marked as final. Self::Dynamic(_) => false, + Self::DynamicNamedTuple(_) => false, } } @@ -646,6 +656,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.has_own_ordering_method(db), Self::Dynamic(class) => class.has_own_ordering_method(db), + Self::DynamicNamedTuple(_) => false, } } @@ -653,7 +664,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn as_static(self) -> Option> { match self { Self::Static(class) => Some(class), - Self::Dynamic(_) => None, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => None, } } @@ -662,6 +673,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => Some(class.definition(db)), Self::Dynamic(class) => class.definition(db), + Self::DynamicNamedTuple(namedtuple) => namedtuple.definition(db), } } @@ -673,6 +685,9 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => Some(TypeDefinition::StaticClass(class.definition(db))), Self::Dynamic(class) => class.definition(db).map(TypeDefinition::DynamicClass), + Self::DynamicNamedTuple(namedtuple) => { + namedtuple.definition(db).map(TypeDefinition::DynamicClass) + } } } @@ -689,6 +704,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.header_span(db), Self::Dynamic(class) => class.header_span(db), + Self::DynamicNamedTuple(namedtuple) => namedtuple.header_span(db), } } @@ -713,6 +729,9 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.as_disjoint_base(db), Self::Dynamic(class) => class.as_disjoint_base(db), + // Dynamic namedtuples define `__slots__ = ()`, but `__slots__` must be + // non-empty for a class to be a disjoint base. + Self::DynamicNamedTuple(_) => None, } } @@ -720,7 +739,9 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn to_non_generic_instance(self, db: &'db dyn Db) -> Type<'db> { match self { Self::Static(class) => class.to_non_generic_instance(db), - Self::Dynamic(_) => Type::instance(db, ClassType::NonGeneric(self)), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => { + Type::instance(db, ClassType::NonGeneric(self)) + } } } @@ -741,7 +762,7 @@ impl<'db> ClassLiteral<'db> { ) -> ClassType<'db> { match self { Self::Static(class) => class.apply_specialization(db, f), - Self::Dynamic(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), } } @@ -755,6 +776,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.instance_member(db, specialization, name), Self::Dynamic(class) => class.instance_member(db, name), + Self::DynamicNamedTuple(namedtuple) => namedtuple.instance_member(db, name), } } @@ -762,7 +784,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn top_materialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.top_materialization(db), - Self::Dynamic(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), } } @@ -776,11 +798,16 @@ impl<'db> ClassLiteral<'db> { ) -> PlaceAndQualifiers<'db> { match self { Self::Static(class) => class.typed_dict_member(db, specialization, name, policy), - Self::Dynamic(_) => Place::Undefined.into(), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => Place::Undefined.into(), } } /// Returns a new `ClassLiteral` with the given dataclass params, preserving all other fields. + /// + /// TODO: Applying `@dataclasses.dataclass` to a `NamedTuple` subclass doesn't fail at runtime + /// (e.g., `@dataclasses.dataclass class Foo(NamedTuple): ...`), and neither does + /// `dataclasses.dataclass(collections.namedtuple("A", ()))`. We should either infer these + /// accurately or emit a diagnostic on them. pub(crate) fn with_dataclass_params( self, db: &'db dyn Db, @@ -791,6 +818,7 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => { Self::Dynamic(class.with_dataclass_params(db, dataclass_params)) } + Self::DynamicNamedTuple(_) => self, } } } @@ -807,6 +835,12 @@ impl<'db> From> for ClassLiteral<'db> { } } +impl<'db> From> for ClassLiteral<'db> { + fn from(literal: DynamicNamedTupleLiteral<'db>) -> Self { + ClassLiteral::DynamicNamedTuple(literal) + } +} + /// Represents a class type, which might be a non-generic class, or a specialization of a generic /// class. #[derive( @@ -899,7 +933,7 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_)) => None, + Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, Self::Generic(generic) => Some((generic.origin(db), Some(generic.specialization(db)))), } } @@ -913,7 +947,7 @@ impl<'db> ClassType<'db> { ) -> Option<(StaticClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Static(class)) => Some((class, None)), - Self::NonGeneric(ClassLiteral::Dynamic(_)) => None, + Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => None, Self::Generic(generic) => Some(( generic.origin(db), Some( @@ -1323,6 +1357,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { return dynamic.own_class_member(db, name); } + Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { + return namedtuple.own_class_member(db, name); + } Self::NonGeneric(ClassLiteral::Static(class)) => (class, None), Self::Generic(generic) => (generic.origin(db), Some(generic.specialization(db))), }; @@ -1613,6 +1650,9 @@ impl<'db> ClassType<'db> { pub(super) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { match self { Self::NonGeneric(ClassLiteral::Dynamic(class)) => class.instance_member(db, name), + Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { + namedtuple.instance_member(db, name) + } Self::NonGeneric(ClassLiteral::Static(class)) => { if class.is_typed_dict(db) { return Place::Undefined.into(); @@ -1641,6 +1681,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { dynamic.own_instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { + namedtuple.own_instance_member(db, name) + } Self::NonGeneric(ClassLiteral::Static(class_literal)) => { class_literal.own_instance_member(db, name) } @@ -1903,7 +1946,9 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::NonGeneric(ClassLiteral::Static(class)) => class.variance_of(db, typevar), - Self::NonGeneric(ClassLiteral::Dynamic(_)) => TypeVarVariance::Bivariant, + Self::NonGeneric(ClassLiteral::Dynamic(_) | ClassLiteral::DynamicNamedTuple(_)) => { + TypeVarVariance::Bivariant + } Self::Generic(generic) => generic.variance_of(db, typevar), } } @@ -2060,6 +2105,20 @@ impl<'db> StaticClassLiteral<'db> { self.is_known(db, KnownClass::Tuple) } + /// Returns `true` if this class inherits from a functional namedtuple + /// (`DynamicNamedTupleLiteral`) that has unknown fields. + /// + /// When the base namedtuple's fields were determined dynamically (e.g., from a variable), + /// we can't synthesize precise method signatures and should fall back to `NamedTupleFallback`. + pub(crate) fn namedtuple_base_has_unknown_fields(self, db: &'db dyn Db) -> bool { + self.explicit_bases(db).iter().any(|base| match base { + Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) => { + !namedtuple.has_known_fields(db) + } + _ => false, + }) + } + /// Returns a new [`StaticClassLiteral`] with the given dataclass params, preserving all other fields. pub(crate) fn with_dataclass_params( self, @@ -2141,6 +2200,8 @@ impl<'db> StaticClassLiteral<'db> { return Some(ty); } } + // Dynamic namedtuples don't define their own ordering methods. + ClassLiteral::DynamicNamedTuple(_) => {} } } } @@ -3161,37 +3222,44 @@ impl<'db> StaticClassLiteral<'db> { .with_annotated_type(instance_ty); signature_from_fields(vec![self_parameter], Type::none(db)) } - (CodeGeneratorKind::NamedTuple, "__new__") => { - let cls_parameter = Parameter::positional_or_keyword(Name::new_static("cls")) - .with_annotated_type(KnownClass::Type.to_instance(db)); - signature_from_fields(vec![cls_parameter], Type::none(db)) + ( + CodeGeneratorKind::NamedTuple, + "__new__" | "__init__" | "_replace" | "__replace__" | "_fields", + ) if self.namedtuple_base_has_unknown_fields(db) => { + // When the namedtuple base has unknown fields, fall back to NamedTupleFallback + // which has generic signatures that accept any arguments. + KnownClass::NamedTupleFallback + .to_class_literal(db) + .as_class_literal()? + .as_static()? + .own_class_member(db, inherited_generic_context, None, name) + .ignore_possibly_undefined() + .map(|ty| { + ty.apply_type_mapping( + db, + &TypeMapping::ReplaceSelf { + new_upper_bound: instance_ty, + }, + TypeContext::default(), + ) + }) } - (CodeGeneratorKind::NamedTuple, "_replace" | "__replace__") => { - if name == "__replace__" - && Program::get(db).python_version(db) < PythonVersion::PY313 - { - return None; - } - // Use `Self` type variable as return type so that subclasses get the correct - // return type when calling `_replace`. For example, if `IntBox` inherits from - // `Box[int]` (a NamedTuple), then `IntBox(1)._replace(content=42)` should return - // `IntBox`, not `Box[int]`. - let self_ty = Type::TypeVar(BoundTypeVarInstance::synthetic_self( - db, - instance_ty, - BindingContext::Synthetic, - )); - let self_parameter = Parameter::positional_or_keyword(Name::new_static("self")) - .with_annotated_type(self_ty); - signature_from_fields(vec![self_parameter], self_ty) - } - (CodeGeneratorKind::NamedTuple, "_fields") => { - // Synthesize a precise tuple type for _fields using literal string types. - // For example, a NamedTuple with `name` and `age` fields gets - // `tuple[Literal["name"], Literal["age"]]`. + (CodeGeneratorKind::NamedTuple, "__new__" | "_replace" | "__replace__" | "_fields") => { let fields = self.fields(db, specialization, field_policy); - let field_types = fields.keys().map(|name| Type::string_literal(db, name)); - Some(Type::heterogeneous_tuple(db, field_types)) + let fields_iter = fields.iter().map(|(name, field)| { + let default_ty = match &field.kind { + FieldKind::NamedTuple { default_ty } => *default_ty, + _ => None, + }; + (name.clone(), field.declared_ty, default_ty) + }); + synthesize_namedtuple_class_member( + db, + name, + instance_ty, + fields_iter, + specialization.map(|s| s.generic_context(db)), + ) } (CodeGeneratorKind::DataclassLike(_), "__lt__" | "__le__" | "__gt__" | "__ge__") => { if !has_dataclass_param(DataclassFlags::ORDER) { @@ -4676,7 +4744,7 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::Static(class) => class.variance_of(db, typevar), - Self::Dynamic(_) => TypeVarVariance::Bivariant, + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => TypeVarVariance::Bivariant, } } } @@ -5086,6 +5154,404 @@ pub(crate) struct DynamicMetaclassConflict<'db> { pub(crate) base2: ClassBase<'db>, } +/// Create a property type for a namedtuple field. +fn create_field_property<'db>(db: &'db dyn Db, field_ty: Type<'db>) -> Type<'db> { + let property_getter_signature = Signature::new( + Parameters::new( + db, + [Parameter::positional_only(Some(Name::new_static("self")))], + ), + field_ty, + ); + let property_getter = Type::single_callable(db, property_getter_signature); + let property = PropertyInstanceType::new(db, Some(property_getter), None); + Type::PropertyInstance(property) +} + +/// Synthesize a namedtuple class member given the field information. +/// +/// This is used by both `DynamicNamedTupleLiteral` and `StaticClassLiteral` (for declarative +/// namedtuples) to avoid duplicating the synthesis logic. +/// +/// The `inherited_generic_context` parameter is used for declarative namedtuples to preserve +/// generic context in the synthesized `__new__` signature. +fn synthesize_namedtuple_class_member<'db>( + db: &'db dyn Db, + name: &str, + instance_ty: Type<'db>, + fields: impl Iterator, Option>)>, + inherited_generic_context: Option>, +) -> Option> { + match name { + "__new__" => { + // __new__(cls, field1, field2, ...) -> Self + let mut parameters = vec![ + Parameter::positional_or_keyword(Name::new_static("cls")) + .with_annotated_type(KnownClass::Type.to_instance(db)), + ]; + + for (field_name, field_ty, default_ty) in fields { + let mut param = + Parameter::positional_or_keyword(field_name).with_annotated_type(field_ty); + if let Some(default) = default_ty { + param = param.with_default_type(default); + } + parameters.push(param); + } + + let signature = Signature::new_generic( + inherited_generic_context, + Parameters::new(db, parameters), + instance_ty, + ); + Some(Type::function_like_callable(db, signature)) + } + "_fields" => { + // _fields: tuple[Literal["field1"], Literal["field2"], ...] + let field_types = + fields.map(|(field_name, _, _)| Type::string_literal(db, &field_name)); + Some(Type::heterogeneous_tuple(db, field_types)) + } + "_replace" | "__replace__" => { + if name == "__replace__" && Program::get(db).python_version(db) < PythonVersion::PY313 { + return None; + } + + // _replace(self, *, field1=..., field2=...) -> Self + let self_ty = Type::TypeVar(BoundTypeVarInstance::synthetic_self( + db, + instance_ty, + BindingContext::Synthetic, + )); + + let mut parameters = vec![ + Parameter::positional_or_keyword(Name::new_static("self")) + .with_annotated_type(self_ty), + ]; + + for (field_name, field_ty, _) in fields { + parameters.push( + Parameter::keyword_only(field_name) + .with_annotated_type(field_ty) + .with_default_type(field_ty), + ); + } + + let signature = Signature::new(Parameters::new(db, parameters), self_ty); + Some(Type::function_like_callable(db, signature)) + } + "__init__" => { + // Namedtuples don't have a custom __init__. All construction happens in __new__. + None + } + _ => { + // Fall back to NamedTupleFallback for other synthesized methods. + KnownClass::NamedTupleFallback + .to_class_literal(db) + .as_class_literal()? + .as_static()? + .own_class_member(db, inherited_generic_context, None, name) + .ignore_possibly_undefined() + } + } +} + +/// A namedtuple created via the functional form `namedtuple(name, fields)` or +/// `NamedTuple(name, fields)`. +/// +/// For example: +/// ```python +/// from collections import namedtuple +/// Point = namedtuple("Point", ["x", "y"]) +/// +/// from typing import NamedTuple +/// Person = NamedTuple("Person", [("name", str), ("age", int)]) +/// ``` +/// +/// The type of `Point` would be `type[Point]` where `Point` is a `DynamicNamedTupleLiteral`. +#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct DynamicNamedTupleLiteral<'db> { + /// The name of the namedtuple (from the first argument). + #[returns(ref)] + pub name: Name, + + /// The fields as (name, type, default) tuples. + /// For `collections.namedtuple`, all types are `Any`. + /// For `typing.NamedTuple`, types come from the field definitions. + /// The third element is the default type, if any. + #[returns(ref)] + pub fields: Box<[(Name, Type<'db>, Option>)]>, + + /// Whether the fields are known statically. + /// + /// When `true`, the fields were determined from a literal (list or tuple). + /// When `false`, the fields argument was dynamic (e.g., a variable), + /// and attribute lookups should return `Any` instead of failing. + pub has_known_fields: bool, + + /// The anchor for this dynamic namedtuple, providing stable identity. + /// + /// - `Definition`: The call is assigned to a variable. The definition + /// uniquely identifies this namedtuple and can be used to find the call. + /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset + /// is relative to the enclosing scope's anchor node index. + pub anchor: DynamicClassAnchor<'db>, +} + +impl get_size2::GetSize for DynamicNamedTupleLiteral<'_> {} + +#[salsa::tracked] +impl<'db> DynamicNamedTupleLiteral<'db> { + /// Returns the definition where this namedtuple is created, if it was assigned to a variable. + pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { + match self.anchor(db) { + DynamicClassAnchor::Definition(definition) => Some(definition), + DynamicClassAnchor::ScopeOffset { .. } => None, + } + } + + /// Returns the scope in which this dynamic class was created. + pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { + match self.anchor(db) { + DynamicClassAnchor::Definition(definition) => definition.scope(db), + DynamicClassAnchor::ScopeOffset { scope, .. } => scope, + } + } + + /// Returns an instance type for this dynamic namedtuple. + pub(crate) fn to_instance(self, db: &'db dyn Db) -> Type<'db> { + Type::instance(db, ClassType::NonGeneric(self.into())) + } + + /// Returns the range of the namedtuple call expression. + pub(crate) fn header_range(self, db: &'db dyn Db) -> TextRange { + let scope = self.scope(db); + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + + match self.anchor(db) { + DynamicClassAnchor::Definition(definition) => { + // For definitions, get the range from the definition's value. + // The namedtuple call is the value of the assignment. + definition + .kind(db) + .value(&module) + .expect("DynamicClassAnchor::Definition should only be used for assignments") + .range() + } + DynamicClassAnchor::ScopeOffset { offset, .. } => { + // For dangling calls, compute the absolute index from the offset. + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + offset); + + // Get the node and return its range. + let node: &ast::ExprCall = module + .get_by_index(absolute_index) + .try_into() + .expect("scope offset should point to ExprCall"); + node.range() + } + } + } + + /// Returns a [`Span`] pointing to the namedtuple call expression. + pub(super) fn header_span(self, db: &'db dyn Db) -> Span { + Span::from(self.scope(db).file(db)).with_range(self.header_range(db)) + } + + /// Compute the MRO for this namedtuple. + /// + /// The MRO is `[self, tuple[field_types...], object]`. + /// For example, `namedtuple("Point", [("x", int), ("y", int)])` has MRO + /// `[Point, tuple[int, int], object]`. + #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + pub(crate) fn mro(self, db: &'db dyn Db) -> Mro<'db> { + let self_base = ClassBase::Class(ClassType::NonGeneric(self.into())); + let tuple_class = self.tuple_base_class(db); + let object_class = KnownClass::Object + .to_class_literal(db) + .as_class_literal() + .expect("object should be a class literal") + .default_specialization(db); + Mro::from([ + self_base, + ClassBase::Class(tuple_class), + ClassBase::Class(object_class), + ]) + } + + /// Get the metaclass of this dynamic namedtuple. + /// + /// Namedtuples always have `type` as their metaclass. + pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { + let _ = self; + KnownClass::Type.to_class_literal(db) + } + + /// Compute the specialized tuple class that this namedtuple inherits from. + /// + /// For example, `namedtuple("Point", [("x", int), ("y", int)])` inherits from `tuple[int, int]`. + pub(crate) fn tuple_base_class(self, db: &'db dyn Db) -> ClassType<'db> { + // If fields are unknown, return `tuple[Unknown, ...]` to avoid false positives + // like index-out-of-bounds errors. + if !self.has_known_fields(db) { + return TupleType::homogeneous(db, Type::unknown()).to_class_type(db); + } + + let field_types = self.fields(db).iter().map(|(_, ty, _)| *ty); + TupleType::heterogeneous(db, field_types) + .map(|t| t.to_class_type(db)) + .unwrap_or_else(|| { + KnownClass::Tuple + .to_class_literal(db) + .as_class_literal() + .expect("tuple should be a class literal") + .default_specialization(db) + }) + } + + /// Look up an instance member defined directly on this class (not inherited). + /// + /// For dynamic namedtuples, instance members are the field names. + /// If fields are unknown (dynamic), returns `Any` for any attribute. + pub(super) fn own_instance_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + for (field_name, field_ty, _) in self.fields(db).as_ref() { + if field_name.as_str() == name { + return Member::definitely_declared(*field_ty); + } + } + + if !self.has_known_fields(db) { + return Member::definitely_declared(Type::any()); + } + + Member::unbound() + } + + /// Look up an instance member by name (including superclasses). + pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + // First check own instance members. + let result = self.own_instance_member(db, name); + if !result.is_undefined() { + return result.inner; + } + + // Fall back to the tuple base type for other attributes. + Type::instance(db, self.tuple_base_class(db)).instance_member(db, name) + } + + /// Look up a class-level member by name. + pub(crate) fn class_member( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + // First check synthesized members and fields. + let member = self.own_class_member(db, name); + if !member.is_undefined() { + return member.inner; + } + + // Fall back to tuple class members. + let result = self + .tuple_base_class(db) + .class_literal(db) + .class_member(db, name, policy); + + // If fields are unknown (dynamic) and the attribute wasn't found, + // return `Any` instead of failing. + if !self.has_known_fields(db) && result.place.is_undefined() { + return Place::bound(Type::any()).into(); + } + + result + } + + /// Look up a class-level member defined directly on this class (not inherited). + /// + /// This only checks synthesized members and field properties, without falling + /// back to tuple or other base classes. + pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> Member<'db> { + // Handle synthesized namedtuple attributes. + if let Some(ty) = self.synthesized_class_member(db, name) { + return Member::definitely_declared(ty); + } + + // Check if it's a field name (returns a property descriptor). + for (field_name, field_ty, _) in self.fields(db).as_ref() { + if field_name.as_str() == name { + return Member::definitely_declared(create_field_property(db, *field_ty)); + } + } + + Member::default() + } + + /// Generate synthesized class members for namedtuples. + fn synthesized_class_member(self, db: &'db dyn Db, name: &str) -> Option> { + let instance_ty = self.to_instance(db); + + // When fields are unknown, handle constructor and field-specific methods specially. + if !self.has_known_fields(db) { + match name { + // For constructors, return a gradual signature that accepts any arguments. + "__new__" | "__init__" => { + let signature = Signature::new(Parameters::gradual_form(), instance_ty); + return Some(Type::function_like_callable(db, signature)); + } + // For other field-specific methods, fall through to NamedTupleFallback. + "_fields" | "_replace" | "__replace__" => { + return KnownClass::NamedTupleFallback + .to_class_literal(db) + .as_class_literal()? + .as_static()? + .own_class_member(db, None, None, name) + .ignore_possibly_undefined() + .map(|ty| { + ty.apply_type_mapping( + db, + &TypeMapping::ReplaceSelf { + new_upper_bound: instance_ty, + }, + TypeContext::default(), + ) + }); + } + _ => {} + } + } + + let result = synthesize_namedtuple_class_member( + db, + name, + instance_ty, + self.fields(db).iter().cloned(), + None, + ); + // For fallback members from NamedTupleFallback, apply type mapping to handle + // `Self` types. The explicitly synthesized members (__new__, _fields, _replace, + // __replace__) don't need this mapping. + if matches!(name, "__new__" | "_fields" | "_replace" | "__replace__") { + result + } else { + result.map(|ty| { + ty.apply_type_mapping( + db, + &TypeMapping::ReplaceSelf { + new_upper_bound: instance_ty, + }, + TypeContext::default(), + ) + }) + } + } +} + /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance @@ -5362,6 +5828,11 @@ impl<'db> QualifiedClassName<'db> { let scope = class.scope(self.db); (scope.file(self.db), scope.file_scope_id(self.db), 0) } + ClassLiteral::DynamicNamedTuple(namedtuple) => { + // Dynamic namedtuples don't have a body scope; start from the enclosing scope. + let scope = namedtuple.scope(self.db); + (scope.file(self.db), scope.file_scope_id(self.db), 0) + } }; let module_ast = parsed_module(self.db, file).load(self.db); diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index e3f3e9b083..793521911f 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -69,6 +69,7 @@ pub(crate) fn enum_metadata<'db>( // ``` return None; } + ClassLiteral::DynamicNamedTuple(..) => return None, }; // This is a fast path to avoid traversing the MRO of known classes diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 41397c6b43..10965bc424 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -5,12 +5,15 @@ use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span}; use ruff_db::files::File; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_db::source::source_text; +use ruff_python_ast::name::Name; use ruff_python_ast::visitor::{Visitor, walk_expr}; use ruff_python_ast::{ self as ast, AnyNodeRef, ArgOrKeyword, ArgumentsSourceOrder, ExprContext, HasNodeIndex, NodeIndex, PythonVersion, }; use ruff_python_stdlib::builtins::version_builtin_was_added; +use ruff_python_stdlib::identifiers::is_identifier; +use ruff_python_stdlib::keyword::is_keyword; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; @@ -54,6 +57,7 @@ use crate::semantic_index::{ use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; +use crate::types::class::DynamicNamedTupleLiteral; use crate::types::class::{ ClassLiteral, CodeGeneratorKind, DynamicClassAnchor, DynamicClassLiteral, DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator, @@ -72,9 +76,10 @@ use crate::types::diagnostic::{ INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPED_DICT_STATEMENT, IncompatibleBases, NO_MATCHING_OVERLOAD, NOT_SUBSCRIPTABLE, POSSIBLY_MISSING_ATTRIBUTE, POSSIBLY_MISSING_IMPLICIT_CALL, POSSIBLY_MISSING_IMPORT, SUBCLASS_OF_FINAL_CLASS, - TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, - UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, - USELESS_OVERLOAD_BODY, hint_if_stdlib_attribute_exists_on_other_versions, + TOO_MANY_POSITIONAL_ARGUMENTS, TypedDictDeleteErrorKind, UNDEFINED_REVEAL, UNKNOWN_ARGUMENT, + UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, + UNSUPPORTED_DYNAMIC_BASE, UNSUPPORTED_OPERATOR, USELESS_OVERLOAD_BODY, + hint_if_stdlib_attribute_exists_on_other_versions, hint_if_stdlib_submodule_exists_on_other_versions, report_attempted_protocol_instantiation, report_bad_dunder_set_call, report_bad_frozen_dataclass_inheritance, report_cannot_delete_typed_dict_key, report_cannot_pop_required_field_on_typed_dict, @@ -5557,35 +5562,49 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { TypeContext::default(), ); - let ty = match callable_type - .as_class_literal() - .and_then(|cls| cls.known(self.db())) + let ty = if let Some(namedtuple_kind) = + NamedTupleKind::from_type(self.db(), callable_type) { - Some( - typevar_class @ (KnownClass::TypeVar | KnownClass::ExtensionsTypeVar), - ) => { - self.infer_legacy_typevar(target, call_expr, definition, typevar_class) - } - Some( - paramspec_class @ (KnownClass::ParamSpec - | KnownClass::ExtensionsParamSpec), - ) => self.infer_legacy_paramspec( - target, + self.infer_namedtuple_call_expression( call_expr, - definition, - paramspec_class, - ), - Some(KnownClass::NewType) => { - self.infer_newtype_expression(target, call_expr, definition) - } - Some(KnownClass::Type) => { - // Try to extract the dynamic class with definition. - // This returns `None` if it's not a three-arg call to `type()`, - // signalling that we must fall back to normal call inference. - self.infer_builtins_type_call(call_expr, Some(definition)) - } - Some(_) | None => { - self.infer_call_expression_impl(call_expr, callable_type, tcx) + Some(definition), + namedtuple_kind, + ) + } else { + match callable_type + .as_class_literal() + .and_then(|cls| cls.known(self.db())) + { + Some( + typevar_class @ (KnownClass::TypeVar + | KnownClass::ExtensionsTypeVar), + ) => self.infer_legacy_typevar( + target, + call_expr, + definition, + typevar_class, + ), + Some( + paramspec_class @ (KnownClass::ParamSpec + | KnownClass::ExtensionsParamSpec), + ) => self.infer_legacy_paramspec( + target, + call_expr, + definition, + paramspec_class, + ), + Some(KnownClass::NewType) => { + self.infer_newtype_expression(target, call_expr, definition) + } + Some(KnownClass::Type) => { + // Try to extract the dynamic class with definition. + // This returns `None` if it's not a three-arg call to `type()`, + // signalling that we must fall back to normal call inference. + self.infer_builtins_type_call(call_expr, Some(definition)) + } + Some(_) | None => { + self.infer_call_expression_impl(call_expr, callable_type, tcx) + } } }; @@ -6483,6 +6502,456 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::ClassLiteral(ClassLiteral::Dynamic(dynamic_class)) } + /// Infer a `typing.NamedTuple(typename, fields)` or `collections.namedtuple(typename, field_names)` call. + /// + /// This method *does not* call `infer_expression` on the object being called; + /// it is assumed that the type for this AST node has already been inferred before this method is called. + #[expect(clippy::type_complexity)] + fn infer_namedtuple_call_expression( + &mut self, + call_expr: &ast::ExprCall, + definition: Option>, + kind: NamedTupleKind, + ) -> Type<'db> { + let db = self.db(); + + let ast::Arguments { + args, + keywords, + range: _, + node_index: _, + } = &call_expr.arguments; + + // Need at least typename and fields/field_names. + let [name_arg, fields_arg, rest @ ..] = &**args else { + for arg in args { + self.infer_expression(arg, TypeContext::default()); + } + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + } + return KnownClass::NamedTupleFallback.to_subclass_of(self.db()); + }; + + let name_type = self.infer_expression(name_arg, TypeContext::default()); + let fields_type = self.infer_expression(fields_arg, TypeContext::default()); + + for arg in rest { + self.infer_expression(arg, TypeContext::default()); + } + + // If any argument is a starred expression or any keyword is a double-starred expression, + // we can't statically determine the arguments, so fall back to normal call binding. + if args.iter().any(ast::Expr::is_starred_expr) || keywords.iter().any(|kw| kw.arg.is_none()) + { + for kw in keywords { + self.infer_expression(&kw.value, TypeContext::default()); + } + return KnownClass::NamedTupleFallback.to_subclass_of(self.db()); + } + + // Check for excess positional arguments (only typename and fields are expected). + if !rest.is_empty() { + if let Some(builder) = self + .context + .report_lint(&TOO_MANY_POSITIONAL_ARGUMENTS, &rest[0]) + { + builder.into_diagnostic(format_args!( + "Too many positional arguments to function `{kind}`: expected 2, got {}", + args.len() + )); + } + } + + // Infer keyword arguments. + let mut defaults_count = None; + let mut rename_type = None; + + for kw in keywords { + let kw_type = self.infer_expression(&kw.value, TypeContext::default()); + + let Some(arg) = &kw.arg else { + continue; + }; + match arg.id.as_str() { + "defaults" if kind.is_collections() => { + defaults_count = kw_type + .exact_tuple_instance_spec(db) + .and_then(|spec| spec.len().maximum()) + .or_else(|| kw.value.as_list_expr().map(|list| list.elts.len())); + + // Emit diagnostic for invalid types (not Iterable[Any] | None). + let iterable_any = + KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); + let valid_type = UnionType::from_elements(db, [iterable_any, Type::none(db)]); + if !kw_type.is_assignable_to(db, valid_type) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `defaults` of `namedtuple()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `Iterable[Any] | None`, found `{}`", + kw_type.display(db) + )); + } + } + "rename" if kind.is_collections() => { + rename_type = Some(kw_type); + + // Emit diagnostic for non-bool types. + if !kw_type.is_assignable_to(db, KnownClass::Bool.to_instance(db)) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `rename` of `namedtuple()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `bool`, found `{}`", + kw_type.display(db) + )); + } + } + "module" if kind.is_collections() => { + // Emit diagnostic for invalid types (not str | None). + let valid_type = UnionType::from_elements( + db, + [KnownClass::Str.to_instance(db), Type::none(db)], + ); + if !kw_type.is_assignable_to(db, valid_type) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, &kw.value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `module` of `namedtuple()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str | None`, found `{}`", + kw_type.display(db) + )); + } + } + unknown_kwarg => { + // Report unknown keyword argument. + if let Some(builder) = self.context.report_lint(&UNKNOWN_ARGUMENT, kw) { + builder.into_diagnostic(format_args!( + "Argument `{unknown_kwarg}` does not match any known parameter of function `{kind}`", + )); + } + } + } + } + + let defaults_count = defaults_count.unwrap_or_default(); + + // Extract name. + let name = if let Type::StringLiteral(literal) = name_type { + Name::new(literal.value(db)) + } else { + // Name is not a string literal; use like we do for type() calls. + if !name_type.is_assignable_to(db, KnownClass::Str.to_instance(db)) + && let Some(builder) = self.context.report_lint(&INVALID_ARGUMENT_TYPE, name_arg) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `typename` of `{kind}()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str`, found `{}`", + name_type.display(db) + )); + } + Name::new_static("") + }; + + // Handle fields based on which namedtuple variant. + let (fields, has_known_fields): (Box<[(Name, Type<'db>, Option>)]>, bool) = + match kind { + NamedTupleKind::Typing => { + let fields = self + .extract_typing_namedtuple_fields(fields_arg, fields_type) + .or_else(|| self.extract_typing_namedtuple_fields_from_ast(fields_arg)); + + // Emit diagnostic if the type is outright invalid (not an iterable). + if fields.is_none() { + let iterable_any = + KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); + if !fields_type.is_assignable_to(db, iterable_any) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, fields_arg) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `fields` of `NamedTuple()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected an iterable of `(name, type)` pairs, found `{}`", + fields_type.display(db) + )); + } + } + + let has_known_fields = fields.is_some(); + (fields.unwrap_or_default(), has_known_fields) + } + NamedTupleKind::Collections => { + // `collections.namedtuple`: `field_names` is a list or tuple of strings, or a space or + // comma-separated string. + + // Check for `rename=True`. Use `is_always_true()` to handle truthy values + // (e.g., `rename=1`), though we'd still want a diagnostic for non-bool types. + let rename = rename_type.is_some_and(|ty| ty.bool(db).is_always_true()); + + // Extract field names, first from the inferred type, then from the AST. + let maybe_field_names: Option> = + if let Type::StringLiteral(string_literal) = fields_type { + // Handle space/comma-separated string. + Some( + string_literal + .value(db) + .replace(',', " ") + .split_whitespace() + .map(Name::new) + .collect(), + ) + } else if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + // Handle list/tuple of strings (must be fixed-length). + fixed_tuple + .all_elements() + .iter() + .map(|elt| elt.as_string_literal().map(|s| Name::new(s.value(db)))) + .collect() + } else { + self.extract_collections_namedtuple_fields_from_ast(fields_arg) + }; + + if maybe_field_names.is_none() { + // Emit diagnostic if the type is outright invalid (not str | Iterable[str]). + let iterable_str = + KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); + let valid_type = UnionType::from_elements( + db, + [KnownClass::Str.to_instance(db), iterable_str], + ); + if !fields_type.is_assignable_to(db, valid_type) + && let Some(builder) = + self.context.report_lint(&INVALID_ARGUMENT_TYPE, fields_arg) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Invalid argument to parameter `field_names` of `namedtuple()`" + )); + diagnostic.set_primary_message(format_args!( + "Expected `str` or an iterable of strings, found `{}`", + fields_type.display(db) + )); + } + } + + if let Some(mut field_names) = maybe_field_names { + // TODO: When `rename` is false (or not specified), emit diagnostics for: + // - Duplicate field names (e.g., `namedtuple("Foo", "x x")`) + // - Field names starting with underscore (e.g., `namedtuple("Bar", "_x")`) + // - Field names that are Python keywords (e.g., `namedtuple("Baz", "class")`) + // - Field names that are not valid identifiers + // These all raise ValueError at runtime. When `rename=True`, invalid names + // are automatically replaced with `_0`, `_1`, etc., so no diagnostic is needed. + + // Apply rename logic. + if rename { + let mut seen_names = FxHashSet::<&str>::default(); + for (i, field_name) in field_names.iter_mut().enumerate() { + let name_str = field_name.as_str(); + let needs_rename = name_str.starts_with('_') + || is_keyword(name_str) + || !is_identifier(name_str) + || seen_names.contains(name_str); + if needs_rename { + *field_name = Name::new(format!("_{i}")); + } + seen_names.insert(field_name.as_str()); + } + } + + // Build fields with `Any` type and optional defaults. + // TODO: emit a diagnostic when `defaults_count > num_fields` (which would + // fail at runtime with `TypeError: Got more default values than field names`). + let num_fields = field_names.len(); + let defaults_count = defaults_count.min(num_fields); + let fields = field_names + .iter() + .enumerate() + .map(|(i, field_name)| { + let default = + if defaults_count > 0 && i >= num_fields - defaults_count { + Some(Type::any()) + } else { + None + }; + (field_name.clone(), Type::any(), default) + }) + .collect(); + (fields, true) + } else { + // Couldn't determine fields statically; attribute lookups will return Any. + (Box::new([]), false) + } + } + }; + + let scope = self.scope(); + + // Create the anchor for identifying this dynamic namedtuple. + // - For assigned namedtuple calls, the Definition uniquely identifies the namedtuple. + // - For dangling calls, compute a relative offset from the scope's node index. + let anchor = if let Some(def) = definition { + DynamicClassAnchor::Definition(def) + } else { + let call_node_index = call_expr.node_index.load(); + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("scope anchor should not be NodeIndex::NONE"); + let call_u32 = call_node_index + .as_u32() + .expect("call node should not be NodeIndex::NONE"); + DynamicClassAnchor::ScopeOffset { + scope, + offset: call_u32 - anchor_u32, + } + }; + + let namedtuple = DynamicNamedTupleLiteral::new(db, name, fields, has_known_fields, anchor); + + Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) + } + + /// Extract fields from a typing.NamedTuple fields argument. + #[expect(clippy::type_complexity)] + fn extract_typing_namedtuple_fields( + &mut self, + fields_arg: &ast::Expr, + fields_type: Type<'db>, + ) -> Option, Option>)]>> { + let db = self.db(); + let scope_id = self.scope(); + let typevar_binding_context = self.typevar_binding_context; + + // Try to extract from a fixed-length tuple type. + let tuple_spec = fields_type.tuple_instance_spec(db)?; + let fixed_tuple = tuple_spec.as_fixed_length()?; + let fields: Option> = fixed_tuple + .all_elements() + .iter() + .map(|field_tuple| { + // Each field must also be a fixed-length tuple of exactly 2 elements. + let field_spec = field_tuple.exact_tuple_instance_spec(db)?; + let field_fixed = field_spec.as_fixed_length()?; + let [Type::StringLiteral(name), field_type] = field_fixed.all_elements() else { + return None; + }; + // Convert value types to type expression types (e.g., class literals to instances). + let resolved_ty = + match field_type.in_type_expression(db, scope_id, typevar_binding_context) { + Ok(ty) => ty, + Err(error) => { + // Report diagnostic for invalid type expression. + if let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, fields_arg) + { + builder.into_diagnostic(format_args!( + "Invalid type `{}` in `NamedTuple` field type", + field_type.display(db) + )); + } + error.fallback_type + } + }; + Some((Name::new(name.value(self.db())), resolved_ty, None)) + }) + .collect(); + + fields + } + + /// Extract fields from a typing.NamedTuple fields argument by looking at the AST directly. + /// This handles list/tuple literals that contain (name, type) pairs. + #[expect(clippy::type_complexity)] + fn extract_typing_namedtuple_fields_from_ast( + &mut self, + fields_arg: &ast::Expr, + ) -> Option, Option>)]>> { + let db = self.db(); + + // Get the elements from the list or tuple literal. + let elements: &[ast::Expr] = match fields_arg { + ast::Expr::List(list) => &list.elts, + ast::Expr::Tuple(tuple) => &tuple.elts, + _ => return None, + }; + + let fields: Option> = elements + .iter() + .map(|elt| { + // Each element should be a tuple or list like ("field_name", type) or ["field_name", type]. + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => return None, + }; + if field_spec_elts.len() != 2 { + return None; + } + + // First element: field name (string literal). + let field_name_expr = &field_spec_elts[0]; + let field_name_ty = self.expression_type(field_name_expr); + let field_name_lit = field_name_ty.as_string_literal()?; + let field_name = Name::new(field_name_lit.value(db)); + + // Second element: field type (infer as type expression). + let field_type_expr = &field_spec_elts[1]; + let field_ty = self + .expression_type(field_type_expr) + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .ok()?; + + Some((field_name, field_ty, None)) + }) + .collect(); + + fields + } + + /// Extract field names from a collections.namedtuple fields argument by looking at the AST directly. + /// This handles list/tuple literals that contain string literals. + fn extract_collections_namedtuple_fields_from_ast( + &mut self, + fields_arg: &ast::Expr, + ) -> Option> { + let db = self.db(); + + // Get the elements from the list or tuple literal. + let elements: &[ast::Expr] = match fields_arg { + ast::Expr::List(list) => &list.elts, + ast::Expr::Tuple(tuple) => &tuple.elts, + _ => return None, + }; + + let field_names: Option> = elements + .iter() + .map(|elt| { + // Each element should be a string literal. + let field_ty = self.expression_type(elt); + let field_lit = field_ty.as_string_literal()?; + Some(Name::new(field_lit.value(db))) + }) + .collect(); + + field_names + } + /// Extract base classes from the second argument of a `type()` call. /// /// Returns the extracted bases and any disjoint bases found (for instance-layout-conflict @@ -9720,6 +10189,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return self.infer_builtins_type_call(call_expression, None); } + // Handle `typing.NamedTuple(typename, fields)` and `collections.namedtuple(typename, field_names)`. + if let Some(namedtuple_kind) = NamedTupleKind::from_type(self.db(), callable_type) { + return self.infer_namedtuple_call_expression(call_expression, None, namedtuple_kind); + } + // We don't call `Type::try_call`, because we want to perform type inference on the // arguments after matching them to parameters, but before checking that the argument types // are assignable to any parameter annotations. @@ -14855,3 +15329,34 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { }) } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NamedTupleKind { + Collections, + Typing, +} + +impl NamedTupleKind { + const fn is_collections(self) -> bool { + matches!(self, Self::Collections) + } + + fn from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option { + match ty { + Type::SpecialForm(SpecialFormType::NamedTuple) => Some(NamedTupleKind::Typing), + Type::FunctionLiteral(function) => function + .is_known(db, KnownFunction::NamedTuple) + .then_some(NamedTupleKind::Collections), + _ => None, + } + } +} + +impl std::fmt::Display for NamedTupleKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + NamedTupleKind::Collections => "namedtuple", + NamedTupleKind::Typing => "NamedTuple", + }) + } +} diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 9065b4b87e..43d681b4af 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -42,6 +42,9 @@ impl<'db> Type<'db> { ClassLiteral::Dynamic(_) => { Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) } + ClassLiteral::DynamicNamedTuple(_) => { + Type::NominalInstance(NominalInstanceType(NominalInstanceInner::NonTuple(class))) + } ClassLiteral::Static(class_literal) => { let specialization = class.into_generic_alias().map(|g| g.specialization(db)); match class_literal.known(db) { diff --git a/crates/ty_python_semantic/src/types/mro.rs b/crates/ty_python_semantic/src/types/mro.rs index 5af9b20a90..0c838ec0c3 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -500,6 +500,9 @@ impl<'db> MroIterator<'db> { ClassLiteral::Dynamic(literal) => { ClassBase::Class(ClassType::NonGeneric(literal.into())) } + ClassLiteral::DynamicNamedTuple(literal) => { + ClassBase::Class(ClassType::NonGeneric(literal.into())) + } } } @@ -524,6 +527,11 @@ impl<'db> MroIterator<'db> { full_mro_iter.next(); full_mro_iter } + ClassLiteral::DynamicNamedTuple(literal) => { + let mut full_mro_iter = literal.mro(self.db).iter(); + full_mro_iter.next(); + full_mro_iter + } }) } }