From 9cd4499fe548a3fd043e1c5c6dabc73ef5cfa0b7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 11 Jan 2026 22:24:40 -0500 Subject: [PATCH] [ty] Add support for functional namedtuple --- crates/ty_ide/src/completion.rs | 2 +- .../resources/mdtest/named_tuple.md | 212 ++++++- crates/ty_python_semantic/src/types.rs | 45 +- .../ty_python_semantic/src/types/call/bind.rs | 5 - crates/ty_python_semantic/src/types/class.rs | 534 +++++++++++++++++- crates/ty_python_semantic/src/types/enums.rs | 1 + .../src/types/infer/builder.rs | 332 +++++++++++ .../ty_python_semantic/src/types/instance.rs | 3 + crates/ty_python_semantic/src/types/mro.rs | 8 + .../ty_python_semantic/src/types/overrides.rs | 5 +- .../src/types/protocol_class.rs | 17 +- .../_typeshed/_type_checker_internals.pyi | 10 +- 12 files changed, 1118 insertions(+), 56 deletions(-) diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index f5d33d43c7..682d6c4de8 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_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 7034a74176..13d2a9039c 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -84,17 +84,154 @@ alice.id = 42 bob.age = None ``` -Alternative functional syntax: +Alternative functional syntax with a list literal: ```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 literal: + +```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 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 + +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 +``` + +### 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 + +class Url(NamedTuple("Url", [("host", str), ("path", str)])): + pass + +reveal_type(Url) # revealed: +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 +``` + +Unlike classes that directly use `class Foo(NamedTuple):` syntax, classes inheriting from functional +namedtuples 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), 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 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: + # Attribute access on Self works via NamedTupleFallback.__getattr__. + reveal_type(self.host) # revealed: Any + reveal_type(self.port) # revealed: Any + reveal_type(self.unknown) # revealed: Any + return self._replace(port=port) ``` ### Definition @@ -311,6 +448,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: + # Attribute access on Self works via NamedTupleFallback.__getattr__. + 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/src/types.rs b/crates/ty_python_semantic/src/types.rs index 4c9df1bf3c..1e0d5fb4fe 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4005,6 +4005,33 @@ impl<'db> Type<'db> { .into() } + // collections.namedtuple(typename, field_names, ...) + Some(KnownFunction::NamedTuple) => Binding::single( + self, + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_or_keyword(Name::new_static("typename")) + .with_annotated_type(KnownClass::Str.to_instance(db)), + Parameter::positional_or_keyword(Name::new_static("field_names")) + .with_annotated_type(Type::any()), + // Additional optional parameters have defaults. + Parameter::keyword_only(Name::new_static("rename")) + .with_annotated_type(KnownClass::Bool.to_instance(db)) + .with_default_type(Type::BooleanLiteral(false)), + Parameter::keyword_only(Name::new_static("defaults")) + .with_annotated_type(Type::any()) + .with_default_type(Type::none(db)), + Parameter::keyword_only(Name::new_static("module")) + .with_default_type(Type::none(db)), + ], + ), + KnownClass::NamedTupleFallback.to_class_literal(db), + ), + ) + .into(), + _ => CallableBinding::from_overloads( self, function_type.signature(db).overloads.iter().cloned(), @@ -4435,7 +4462,23 @@ impl<'db> Type<'db> { } Type::SpecialForm(SpecialFormType::NamedTuple) => { - Binding::single(self, Signature::todo("functional `NamedTuple` syntax")).into() + // typing.NamedTuple(typename: str, fields: ...) + Binding::single( + self, + Signature::new( + Parameters::new( + db, + [ + Parameter::positional_or_keyword(Name::new_static("typename")) + .with_annotated_type(KnownClass::Str.to_instance(db)), + Parameter::positional_or_keyword(Name::new_static("fields")) + .with_annotated_type(Type::any()), + ], + ), + KnownClass::NamedTupleFallback.to_class_literal(db), + ), + ) + .into() } Type::GenericAlias(_) => { diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index bbfc311dc2..e1bf8bd45c 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 3669a5b127..211dc25e03 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), } } @@ -229,6 +230,14 @@ impl<'db> CodeGeneratorKind<'db> { .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)) { Some(CodeGeneratorKind::NamedTuple) + } else if class + .explicit_bases(db) + .iter() + .any(|base| matches!(base, Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(_)))) + { + // Class inherits from a functional namedtuple like: + // class Url(NamedTuple("Url", [("host", str)])): ... + Some(CodeGeneratorKind::NamedTuple) } else if class.is_typed_dict(db) { Some(CodeGeneratorKind::TypedDict) } else { @@ -463,6 +472,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 +482,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 +507,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 +521,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 +537,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 +563,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 +571,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), } } @@ -584,7 +598,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, } } @@ -592,7 +606,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, } } @@ -614,6 +628,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.file(db), Self::Dynamic(class) => class.file(db), + Self::DynamicNamedTuple(class) => class.file(db), } } @@ -625,6 +640,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), } } @@ -653,6 +669,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, } } @@ -660,7 +677,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, } } @@ -668,7 +685,7 @@ impl<'db> ClassLiteral<'db> { pub(crate) fn unknown_specialization(self, db: &'db dyn Db) -> ClassType<'db> { match self { Self::Static(class) => class.unknown_specialization(db), - Self::Dynamic(_) => ClassType::NonGeneric(self), + Self::Dynamic(_) | Self::DynamicNamedTuple(_) => ClassType::NonGeneric(self), } } @@ -677,6 +694,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), } } @@ -688,6 +706,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) + } } } @@ -704,6 +725,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), } } @@ -728,6 +750,7 @@ impl<'db> ClassLiteral<'db> { match self { Self::Static(class) => class.as_disjoint_base(db), Self::Dynamic(class) => class.as_disjoint_base(db), + Self::DynamicNamedTuple(_) => None, } } @@ -735,7 +758,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)) + } } } @@ -756,7 +781,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), } } @@ -770,6 +795,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), } } @@ -777,7 +803,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), } } @@ -791,7 +817,7 @@ 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(), } } @@ -806,6 +832,7 @@ impl<'db> ClassLiteral<'db> { Self::Dynamic(class) => { Self::Dynamic(class.with_dataclass_params(db, dataclass_params)) } + Self::DynamicNamedTuple(_) => self, } } } @@ -822,6 +849,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( @@ -914,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(generic.specialization(db)))), } } @@ -928,7 +961,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( @@ -1338,6 +1371,13 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { return dynamic.own_class_member(db, name); } + Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => { + return Member { + inner: namedtuple + .own_class_member(db, name) + .unwrap_or_else(|| Place::Undefined.into()), + }; + } Self::NonGeneric(ClassLiteral::Static(class)) => (class, None), Self::Generic(generic) => (generic.origin(db), Some(generic.specialization(db))), }; @@ -1628,6 +1668,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(); @@ -1656,6 +1699,9 @@ impl<'db> ClassType<'db> { Self::NonGeneric(ClassLiteral::Dynamic(dynamic)) => { dynamic.own_instance_member(db, name) } + Self::NonGeneric(ClassLiteral::DynamicNamedTuple(namedtuple)) => Member { + inner: namedtuple.instance_member(db, name), + }, Self::NonGeneric(ClassLiteral::Static(class_literal)) => { class_literal.own_instance_member(db, name) } @@ -1918,7 +1964,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), } } @@ -2075,6 +2123,34 @@ impl<'db> StaticClassLiteral<'db> { self.is_known(db, KnownClass::Tuple) } + /// Returns `true` if this class directly inherits from the `NamedTuple` special form + /// using class syntax (e.g., `class Foo(NamedTuple): ...`). + /// + /// This is distinct from inheriting from a functional namedtuple like + /// `class Foo(namedtuple("Foo", ...)): ...`, which creates a regular class. + /// + /// The distinction matters because: + /// - Classes using class syntax cannot use `super()` or override `__new__` + /// - Classes inheriting from functional namedtuples can do both + pub(crate) fn directly_inherits_from_named_tuple_special_form(self, db: &'db dyn Db) -> bool { + self.explicit_bases(db) + .contains(&Type::SpecialForm(SpecialFormType::NamedTuple)) + } + + /// 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, @@ -2156,6 +2232,10 @@ impl<'db> StaticClassLiteral<'db> { return Some(ty); } } + ClassLiteral::DynamicNamedTuple(_) => { + // NamedTuples cannot define ordering methods in their namespace dict. + continue; + } } } } @@ -2958,12 +3038,16 @@ impl<'db> StaticClassLiteral<'db> { && !self .iter_mro(db, specialization) .filter_map(ClassBase::into_class) - .filter_map(|class| class.static_class_literal(db)) - .filter(|(class, _)| !class.is_known(db, KnownClass::Object)) - .any(|(class, _)| { - class_member(db, class.body_scope(db), name) + .filter(|class| !class.is_object(db)) + .any(|class| match class.class_literal(db) { + ClassLiteral::Static(literal) => class_member(db, literal.body_scope(db), name) .ignore_possibly_undefined() - .is_some() + .is_some(), + ClassLiteral::Dynamic(literal) => literal + .members(db) + .iter() + .any(|(member_name, _)| member_name.as_str() == name), + ClassLiteral::DynamicNamedTuple(_) => false, }) && self.has_ordering_method_in_mro(db, specialization) && let Some(root_method_ty) = self.total_ordering_root_method(db, specialization) @@ -3171,10 +3255,31 @@ impl<'db> StaticClassLiteral<'db> { .with_annotated_type(instance_ty); signature_from_fields(vec![self_parameter], Type::none(db)) } + (CodeGeneratorKind::NamedTuple, "__new__" | "_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, "__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)) + signature_from_fields(vec![cls_parameter], instance_ty) } (CodeGeneratorKind::NamedTuple, "_replace" | "__replace__") => { if name == "__replace__" @@ -3759,8 +3864,33 @@ impl<'db> StaticClassLiteral<'db> { ) -> FxIndexMap> { if field_policy == CodeGeneratorKind::NamedTuple { // NamedTuples do not allow multiple inheritance, so it is sufficient to enumerate the - // fields of this class only. - return self.own_fields(db, specialization, field_policy); + // fields of this class only. However, if the class inherits from a functional namedtuple + // (DynamicNamedTupleLiteral), we need to include the base's fields. + let mut fields = FxIndexMap::default(); + + // Check for functional namedtuple base first. + for base in self.explicit_bases(db) { + if let Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) = base { + for (name, ty, default) in namedtuple.fields(db).as_ref() { + fields.insert( + name.clone(), + Field { + declared_ty: *ty, + kind: FieldKind::NamedTuple { + default_ty: *default, + }, + // No definition for functional namedtuple fields. + first_declaration: None, + }, + ); + } + break; + } + } + + // Then add own fields (which can override base fields). + fields.extend(self.own_fields(db, specialization, field_policy)); + return fields; } let matching_classes_in_mro: Vec<(StaticClassLiteral<'db>, Option>)> = @@ -4686,7 +4816,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, } } } @@ -5042,6 +5172,345 @@ 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 file containing the namedtuple definition. + pub file: File, + + /// The file scope containing the namedtuple definition. + pub file_scope: FileScopeId, + + /// The definition if it came from an assignment (e.g., `Point = namedtuple(...)`). + pub definition: Option>, + + /// The range of the namedtuple call expression. + pub call_range: TextRange, +} + +impl get_size2::GetSize for DynamicNamedTupleLiteral<'_> {} + +#[salsa::tracked] +impl<'db> DynamicNamedTupleLiteral<'db> { + /// 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: &dyn Db) -> TextRange { + self.call_range(db) + } + + /// Returns a [`Span`] pointing to the namedtuple call expression. + pub(super) fn header_span(self, db: &'db dyn Db) -> Span { + Span::from(self.file(db)).with_range(self.header_range(db)) + } + + /// Compute the MRO for this namedtuple. + /// + /// The MRO is `[self, tuple, 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 = KnownClass::Tuple + .to_class_literal(db) + .as_class_literal() + .expect("tuple should be a class literal") + .default_specialization(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 tuple type that this namedtuple inherits from. + /// + /// For example, `namedtuple("Point", [("x", int), ("y", int)])` inherits from `tuple[int, int]`. + pub(crate) fn tuple_base_type(self, db: &'db dyn Db) -> ClassType<'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 by name. + pub(crate) fn instance_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> { + // First check if it's one of the field names. + for (field_name, field_ty, _) in self.fields(db).as_ref() { + if field_name.as_str() == name { + return Place::bound(*field_ty).into(); + } + } + + // Fall back to the tuple base type for other attributes. + let result = Type::instance(db, self.tuple_base_type(db)).instance_member(db, name); + + // 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 by name. + pub(crate) fn class_member( + self, + db: &'db dyn Db, + name: &str, + policy: MemberLookupPolicy, + ) -> PlaceAndQualifiers<'db> { + // First check synthesized members and fields. + if let Some(result) = self.own_class_member(db, name) { + return result; + } + + // Fall back to tuple class members. + let result = self + .tuple_base_type(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(crate) fn own_class_member( + self, + db: &'db dyn Db, + name: &str, + ) -> Option> { + // Handle synthesized namedtuple attributes. + if let Some(ty) = self.synthesized_class_member(db, name) { + return Some(Place::bound(ty).into()); + } + + // 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 Some(Place::bound(create_field_property(db, *field_ty)).into()); + } + } + + None + } + + /// 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, skip synthesizing field-specific methods and let them + // fall through to NamedTupleFallback which has generic signatures. + if !self.has_known_fields(db) + && matches!(name, "__new__" | "_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 @@ -5317,6 +5786,9 @@ impl<'db> QualifiedClassName<'db> { // Dynamic classes don't have a body scope; start from the enclosing scope. (class.file(self.db), class.file_scope(self.db), 0) } + ClassLiteral::DynamicNamedTuple(namedtuple) => { + (namedtuple.file(self.db), namedtuple.file_scope(self.db), 0) + } }; let module_ast = parsed_module(self.db, file).load(self.db); @@ -7008,8 +7480,10 @@ impl KnownClass { return; }; - // Check if the enclosing class is a `NamedTuple`, which forbids the use of `super()`. - if CodeGeneratorKind::NamedTuple.matches(db, enclosing_class.into(), None) { + // Check if the enclosing class directly inherits from NamedTuple special form, + // which forbids the use of `super()`. Classes inheriting from functional + // namedtuples (e.g., `class Foo(namedtuple(...)):`) can use `super()` normally. + if enclosing_class.directly_inherits_from_named_tuple_special_form(db) { if let Some(builder) = context .report_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD, call_expression) { @@ -7061,13 +7535,11 @@ impl KnownClass { overload.set_return_type(bound_super); } [Some(pivot_class_type), Some(owner_type)] => { - // Check if the enclosing class is a `NamedTuple`, which forbids the use of `super()`. + // Check if the enclosing class directly inherits from NamedTuple special form, + // which forbids the use of `super()`. Classes inheriting from functional + // namedtuples (e.g., `class Foo(namedtuple(...)):`) can use `super()` normally. if let Some(enclosing_class) = nearest_enclosing_class(db, index, scope) { - if CodeGeneratorKind::NamedTuple.matches( - db, - enclosing_class.into(), - None, - ) { + if enclosing_class.directly_inherits_from_named_tuple_special_form(db) { if let Some(builder) = context .report_lint(&SUPER_CALL_IN_NAMED_TUPLE_METHOD, call_expression) { diff --git a/crates/ty_python_semantic/src/types/enums.rs b/crates/ty_python_semantic/src/types/enums.rs index f5067cc675..cef790065d 100644 --- a/crates/ty_python_semantic/src/types/enums.rs +++ b/crates/ty_python_semantic/src/types/enums.rs @@ -70,6 +70,7 @@ pub(crate) fn enum_metadata<'db>( // TODO: Add a diagnostic for including an enum in a `type(...)` call. 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 22206094cd..17285f8033 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -53,6 +53,7 @@ use crate::semantic_index::{ use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; +use crate::types::class::DynamicNamedTupleLiteral; use crate::types::class::{ ClassLiteral, CodeGeneratorKind, DynamicClassLiteral, DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator, @@ -690,6 +691,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { base_class, Type::SpecialForm(SpecialFormType::NamedTuple) | Type::KnownInstance(KnownInstanceType::SubscriptedGeneric(_)) + | Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(_)) ) { if let Some(builder) = self @@ -6205,6 +6207,329 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(Type::ClassLiteral(ClassLiteral::Dynamic(dynamic_class))) } + /// Try to infer a `typing.NamedTuple(typename, fields)` or `collections.namedtuple(typename, field_names)` call. + /// + /// Returns `None` if the call doesn't match a namedtuple pattern, signalling that + /// we should fall back to normal call binding. + #[allow(clippy::type_complexity)] + fn infer_namedtuple_call_expression( + &mut self, + call_expr: &ast::ExprCall, + callable_type: Type<'db>, + definition: Option>, + ) -> Option> { + let db = self.db(); + + let ast::Arguments { + args, + keywords, + range: _, + node_index: _, + } = &call_expr.arguments; + + // Check if this is a `typing.NamedTuple` or `collections.namedtuple` call. + let is_typing_namedtuple = matches!( + callable_type, + Type::SpecialForm(SpecialFormType::NamedTuple) + ); + let is_collections_namedtuple = callable_type + .as_function_literal() + .and_then(|f| f.known(db)) + == Some(KnownFunction::NamedTuple); + + if !is_typing_namedtuple && !is_collections_namedtuple { + return None; + } + + // Need at least typename and fields/field_names. + if args.len() < 2 { + return None; + } + + let name_arg = &args[0]; + let fields_arg = &args[1]; + + // Infer name argument type. + let name_type = self.infer_expression(name_arg, TypeContext::default()); + + // Infer keyword arguments. + let mut defaults_count = 0usize; + let mut rename_type = None; + for kw in keywords { + if let Some(arg) = &kw.arg { + match arg.id.as_str() { + "defaults" => { + // First try to retrieve the count from the AST (for list and tuple literals). + defaults_count = match &kw.value { + ast::Expr::List(list) => list.elts.len(), + ast::Expr::Tuple(tuple) => tuple.elts.len(), + _ => { + // Fall back to inferring the type. + let ty = self.infer_expression(&kw.value, TypeContext::default()); + ty.exact_tuple_instance_spec(db) + .and_then(|spec| spec.len().maximum()) + .unwrap_or(0) + } + }; + // Make sure to infer list and tuple elements. + if let ast::Expr::List(list) = &kw.value { + for elt in &list.elts { + self.infer_expression(elt, TypeContext::default()); + } + } else if let ast::Expr::Tuple(tuple) = &kw.value { + for elt in &tuple.elts { + self.infer_expression(elt, TypeContext::default()); + } + } + } + "rename" => { + rename_type = + Some(self.infer_expression(&kw.value, TypeContext::default())); + } + _ => { + self.infer_expression(&kw.value, TypeContext::default()); + } + } + } else { + self.infer_expression(&kw.value, TypeContext::default()); + } + } + + // Extract name. + let name = if let Type::StringLiteral(literal) = name_type { + ast::name::Name::new(literal.value(db)) + } else { + // Name must be a string literal for us to synthesize a proper type. + return None; + }; + + // Handle fields based on which namedtuple variant. + let (fields, has_known_fields): ( + Box<[(ast::name::Name, Type<'db>, Option>)]>, + bool, + ) = if is_typing_namedtuple { + // `typing.NamedTuple`: `fields` is a list or tuple of (name, type) pairs. + // First try to extract from the AST directly (for list or tuple literals). + if let Some(fields) = self.extract_typing_namedtuple_fields_from_ast(fields_arg) { + (fields, true) + } else { + // Otherwise, infer the type and try to extract from that. + let fields_type = self.infer_expression(fields_arg, TypeContext::default()); + if let Some(fields) = self.extract_typing_namedtuple_fields(fields_type) { + (fields, true) + } else { + // Couldn't determine fields statically; attribute lookups will return Any. + (Box::new([]), false) + } + } + } else { + // `collections.namedtuple`: `field_names` is a list or tuple of strings, or a space or + // comma-separated string. + + // Check for `rename=True`. + let rename = matches!(rename_type, Some(Type::BooleanLiteral(true))); + + // Extract field names, first from the AST, then from the inferred type. + let maybe_field_names: Option> = if let Some(names) = + self.extract_collections_namedtuple_fields_from_ast(fields_arg) + { + Some(names) + } else { + let fields_type = self.infer_expression(fields_arg, TypeContext::default()); + if let Some(string_literal) = fields_type.as_string_literal() { + // Handle space/comma-separated string. + let field_str = string_literal.value(db); + Some( + field_str + .replace(',', " ") + .split_whitespace() + .map(ast::name::Name::new) + .collect(), + ) + } else if let Some(tuple_spec) = fields_type.exact_tuple_instance_spec(db) { + // Handle list/tuple of strings. + tuple_spec + .fixed_elements() + .map(|elt| { + elt.as_string_literal() + .map(|s| ast::name::Name::new(s.value(db))) + }) + .collect() + } else { + // Couldn't determine field names statically. + None + } + }; + + if let Some(mut field_names) = maybe_field_names { + // Apply rename logic, if `rename=True`. + if rename { + use ruff_python_stdlib::identifiers::is_identifier; + use ruff_python_stdlib::keyword::is_keyword; + use rustc_hash::FxHashSet; + + 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 = ast::name::Name::new(format!("_{i}")); + } + seen_names.insert(field_name.as_str()); + } + } + + // Build fields with `Any` type and optional defaults. + let num_fields = field_names.len(); + 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 file = self.file(); + let file_scope = self.scope().file_scope_id(db); + let namedtuple = DynamicNamedTupleLiteral::new( + db, + name, + fields, + has_known_fields, + file, + file_scope, + definition, + call_expr.range, + ); + + Some(Type::ClassLiteral(ClassLiteral::DynamicNamedTuple( + namedtuple, + ))) + } + + /// Extract fields from a typing.NamedTuple fields argument. + #[allow(clippy::type_complexity)] + fn extract_typing_namedtuple_fields( + &mut self, + fields_type: Type<'db>, + ) -> Option, Option>)]>> { + let db = self.db(); + + // Try to extract from a tuple/list type. + let tuple_spec = fields_type.exact_tuple_instance_spec(db)?; + let fields: Option> = tuple_spec + .fixed_elements() + .map(|field_tuple| { + let field_spec = field_tuple.exact_tuple_instance_spec(db)?; + let elements: Vec<_> = field_spec.fixed_elements().collect(); + if elements.len() != 2 { + return None; + } + let field_name = elements[0] + .as_string_literal() + .map(|s| ast::name::Name::new(s.value(db)))?; + let field_ty = elements[1]; + // Convert class literals to instances. + let resolved_ty = match field_ty { + Type::ClassLiteral(class) => class.to_non_generic_instance(db), + Type::GenericAlias(alias) => Type::instance(db, ClassType::Generic(*alias)), + Type::SubclassOf(subclass_of) => match subclass_of.subclass_of() { + crate::types::SubclassOfInner::Class(class) => Type::instance(db, class), + _ => *field_ty, + }, + ty => *ty, + }; + Some((field_name, 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. + #[allow(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 like ("field_name", type). + let tuple_expr = elt.as_tuple_expr()?; + if tuple_expr.elts.len() != 2 { + return None; + } + + // First element: field name (string literal). + let field_name_expr = &tuple_expr.elts[0]; + let field_name_ty = self.infer_expression(field_name_expr, TypeContext::default()); + let field_name_lit = field_name_ty.as_string_literal()?; + let field_name = ast::name::Name::new(field_name_lit.value(db)); + + // Second element: field type (infer as type expression). + let field_type_expr = &tuple_expr.elts[1]; + let field_ty = self.infer_type_expression(field_type_expr); + + 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.infer_expression(elt, TypeContext::default()); + let field_lit = field_ty.as_string_literal()?; + Some(ast::name::Name::new(field_lit.value(db))) + }) + .collect(); + + field_names + } + /// Extract base classes from the second argument of a `type()` call. /// /// If any bases were invalid, diagnostics are emitted and the dynamic @@ -9138,6 +9463,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return dynamic_type; } + // Handle `typing.NamedTuple(typename, fields)` and `collections.namedtuple(typename, field_names)`. + if let Some(namedtuple_type) = + self.infer_namedtuple_call_expression(call_expression, callable_type, None) + { + return namedtuple_type; + } + // 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. 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 82430a0e8a..4918bb9044 100644 --- a/crates/ty_python_semantic/src/types/mro.rs +++ b/crates/ty_python_semantic/src/types/mro.rs @@ -494,6 +494,9 @@ impl<'db> MroIterator<'db> { ClassLiteral::Dynamic(literal) => { ClassBase::Class(ClassType::NonGeneric(literal.into())) } + ClassLiteral::DynamicNamedTuple(literal) => { + ClassBase::Class(ClassType::NonGeneric(literal.into())) + } } } @@ -518,6 +521,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 + } }) } } diff --git a/crates/ty_python_semantic/src/types/overrides.rs b/crates/ty_python_semantic/src/types/overrides.rs index 15094518e8..0e6086b924 100644 --- a/crates/ty_python_semantic/src/types/overrides.rs +++ b/crates/ty_python_semantic/src/types/overrides.rs @@ -131,7 +131,10 @@ fn check_class_declaration<'db>( // `NamedTuple` classes have certain synthesized attributes (like `_asdict`, `_make`, etc.) // that cannot be overwritten. Attempting to assign to these attributes (without type // annotations) or define methods with these names will raise an `AttributeError` at runtime. - if class_kind == Some(CodeGeneratorKind::NamedTuple) + // + // This only applies to classes that directly inherit from the `NamedTuple` special form, + // not to classes that inherit from functional namedtuples (which create regular classes). + if literal.directly_inherits_from_named_tuple_special_form(db) && configuration.check_prohibited_named_tuple_attrs() && PROHIBITED_NAMEDTUPLE_ATTRS.contains(&member.name.as_str()) && let Some(symbol_id) = place_table(db, class_scope).symbol_id(&member.name) diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 83a9a9c89e..50ddc254fb 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -76,12 +76,17 @@ impl<'db> ProtocolClass<'db> { } pub(super) fn is_runtime_checkable(self, db: &'db dyn Db) -> bool { - self.static_class_literal(db) - .is_some_and(|(class_literal, _)| { - class_literal - .known_function_decorators(db) - .contains(&KnownFunction::RuntimeCheckable) - }) + // Check if this class or any ancestor protocol is decorated with @runtime_checkable. + // Per PEP 544, @runtime_checkable propagates to subclasses. + self.0.iter_mro(db).any(|base| { + base.into_class() + .and_then(|class| class.static_class_literal(db)) + .is_some_and(|(class_literal, _)| { + class_literal + .known_function_decorators(db) + .contains(&KnownFunction::RuntimeCheckable) + }) + }) } /// Iterate through the body of the protocol class. Check that all definitions diff --git a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.pyi b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.pyi index feb22aae00..c960a73ced 100644 --- a/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.pyi +++ b/crates/ty_vendored/vendor/typeshed/stdlib/_typeshed/_type_checker_internals.pyi @@ -60,13 +60,9 @@ class NamedTupleFallback(tuple[Any, ...]): if sys.version_info >= (3, 12): __orig_bases__: ClassVar[tuple[Any, ...]] - @overload - def __init__(self, typename: str, fields: Iterable[tuple[str, Any]], /) -> None: ... - @overload - @typing_extensions.deprecated( - "Creating a typing.NamedTuple using keyword arguments is deprecated and support will be removed in Python 3.15" - ) - def __init__(self, typename: str, fields: None = None, /, **kwargs: Any) -> None: ... + # For instance construction when field names are unknown: Point(1, 2). + def __new__(cls, *args: Any, **kwargs: Any) -> typing_extensions.Self: ... + def __init__(self, *args: Any, **kwargs: Any) -> None: ... @classmethod def _make(cls, iterable: Iterable[Any]) -> typing_extensions.Self: ... def _asdict(self) -> dict[str, Any]: ...