[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 <alex.waygood@gmail.com>
This commit is contained in:
Charlie Marsh
2026-01-14 12:41:04 -05:00
committed by GitHub
parent 7f0ce3e88d
commit 3e0299488e
12 changed files with 1772 additions and 93 deletions

View File

@@ -4025,7 +4025,7 @@ quux.<CURSOR>
__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, ...]

View File

@@ -1708,6 +1708,44 @@ class Foo(type("Ba<CURSOR>r", (), {})):
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 = Poi<CURSOR>nt(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() {

View File

@@ -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): ...
```

View File

@@ -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: (<class 'Url'>, <class 'tuple[str, int]'>, <class 'object'>)
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: <class 'InvalidNT'>
# 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: <class 'NT'>
reveal_mro(NT) # revealed: (<class 'NT'>, <class 'tuple[Unknown, ...]'>, <class 'object'>)
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: <class 'NT'>
reveal_mro(NT) # revealed: (<class 'NT'>, <class 'tuple[Unknown, ...]'>, <class 'object'>)
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: <class 'Url'>
# revealed: (<class 'mdtest_snippet.Url @ src/mdtest_snippet.py:4:7'>, <class 'mdtest_snippet.Url @ src/mdtest_snippet.py:4:11'>, <class 'tuple[str, str]'>, <class 'object'>)
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: (<class 'GroundTruth'>, <class 'tuple[Unknown, ...]'>, <class 'object'>)
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: <class 'Point'>
reveal_mro(Point1) # revealed: (<class 'Point'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# String field names with multiple spaces
Point1a = collections.namedtuple("Point", "x y")
reveal_type(Point1a) # revealed: <class 'Point'>
reveal_mro(Point1a) # revealed: (<class 'Point'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# String field names (comma-separated also works at runtime)
Point2 = collections.namedtuple("Point", "x, y")
reveal_type(Point2) # revealed: <class 'Point'>
reveal_mro(Point2) # revealed: (<class 'Point'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# List of strings
Point3 = collections.namedtuple("Point", ["x", "y"])
reveal_type(Point3) # revealed: <class 'Point'>
reveal_mro(Point3) # revealed: (<class 'Point'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# Tuple of strings
Point4 = collections.namedtuple("Point", ("x", "y"))
reveal_type(Point4) # revealed: <class 'Point'>
reveal_mro(Point4) # revealed: (<class 'Point'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# Invalid: integer is not a valid typename
# error: [invalid-argument-type]
Invalid = collections.namedtuple(123, ["x", "y"])
reveal_type(Invalid) # revealed: <class '<unknown>'>
reveal_mro(Invalid) # revealed: (<class '<unknown>'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# 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: <class 'TooMany'>
```
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: <class 'Person'>
# Tuple of tuples
Person2 = NamedTuple("Person", (("name", str), ("age", int)))
reveal_type(Person2) # revealed: <class 'Person'>
# 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: <class 'TooMany'>
```
### 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: <class 'Point'>
reveal_type(Point.__new__) # revealed: (cls: type, x: Any, _1: Any, _2: Any, z: Any, _4: Any) -> Point
reveal_mro(Point) # revealed: (<class 'Point'>, <class 'tuple[Any, Any, Any, Any, Any]'>, <class 'object'>)
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: <class 'Point2'>
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: <class 'Person'>
reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = ...) -> Person
reveal_mro(Person) # revealed: (<class 'Person'>, <class 'tuple[Any, Any, Any]'>, <class 'object'>)
# 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: <class 'Config'>
# 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: <class 'TooManyDefaults'>
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: <class 'Bad1'>
reveal_mro(Bad1) # revealed: (<class 'Bad1'>, <class 'tuple[Any, Any]'>, <class 'object'>)
# Multiple unknown keyword arguments
# error: [unknown-argument]
# error: [unknown-argument]
Bad2 = collections.namedtuple("Bad2", ["x"], invalid1=True, invalid2=False)
reveal_type(Bad2) # revealed: <class 'Bad2'>
reveal_mro(Bad2) # revealed: (<class 'Bad2'>, <class 'tuple[Any]'>, <class 'object'>)
# 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: <class 'Bad3'>
# 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: <class 'Bad4'>
# 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: <class 'Bad5'>
```
### 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: <class 'Bad6'>
```
### 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: <class 'Pair'>
# 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: <class 'Person'>
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 <class 'Person'>._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:

View File

@@ -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

View File

@@ -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__`

View File

@@ -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.

View File

@@ -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<StaticClassLiteral<'db>> {
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<DynamicClassLiteral<'db>> for ClassLiteral<'db> {
}
}
impl<'db> From<DynamicNamedTupleLiteral<'db>> 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<Specialization<'db>>)> {
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<Specialization<'db>>)> {
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<Item = (Name, Type<'db>, Option<Type<'db>>)>,
inherited_generic_context: Option<GenericContext<'db>>,
) -> Option<Type<'db>> {
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<Type<'db>>)]>,
/// 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<Definition<'db>> {
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<Type<'db>> {
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);

View File

@@ -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

View File

@@ -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<Definition<'db>>,
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 <unknown> 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("<unknown>")
};
// Handle fields based on which namedtuple variant.
let (fields, has_known_fields): (Box<[(Name, Type<'db>, Option<Type<'db>>)]>, 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<Box<[Name]>> =
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<Box<[(Name, Type<'db>, Option<Type<'db>>)]>> {
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<Box<[_]>> = 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<Box<[(Name, Type<'db>, Option<Type<'db>>)]>> {
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<Box<[_]>> = 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<Box<[Name]>> {
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<Box<[_]>> = 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<Self> {
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",
})
}
}

View File

@@ -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) {

View File

@@ -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
}
})
}
}