diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index f577ae4a6c716..13d602b69e79c 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -129,6 +129,130 @@ reveal_type(alice5.id) # revealed: int reveal_type(alice5.name) # revealed: str ``` +### Functional syntax with string annotations + +String annotations (forward references) are properly evaluated to types: + +```py +from typing import NamedTuple + +Point = NamedTuple("Point", [("x", "int"), ("y", "int")]) +p = Point(1, 2) + +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +``` + +Recursive references in functional syntax are supported: + +```py +from typing import NamedTuple + +Node = NamedTuple("Node", [("value", int), ("next", "Node | None")]) +n = Node(1, None) + +reveal_type(n.value) # revealed: int +reveal_type(n.next) # revealed: Node | None + +A = NamedTuple("A", [("x", "B | None")]) +B = NamedTuple("B", [("x", "C")]) +C = NamedTuple("C", [("x", A)]) + +a = A(x=B(x=C(x=A(x=None)))) + +reveal_type(a.x) # revealed: B | None + +if a.x: + reveal_type(a.x and a.x.x) # revealed: C + reveal_type(a.x and a.x.x.x) # revealed: A + reveal_type(a.x and a.x.x.x.x) # revealed: B | None + +A(x=42) # error: [invalid-argument-type] + +# error: [invalid-argument-type] +# error: [missing-argument] +A(x=C()) + +# error: [invalid-argument-type] +A(x=C(x=A(x=None))) +``` + +### Functional syntax as base class (dangling call) + +When `NamedTuple` is used directly as a base class without being assigned to a variable first, it's +a "dangling call". The types are still properly inferred: + +```py +from typing import NamedTuple + +class Point(NamedTuple("Point", [("x", int), ("y", int)])): + def magnitude(self) -> float: + return (self.x**2 + self.y**2) ** 0.5 + +p = Point(3, 4) +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +reveal_type(p.magnitude()) # revealed: int | float +``` + +String annotations in dangling calls work correctly for forward references to classes defined in the +same scope. This allows recursive types: + +```py +from typing import NamedTuple + +class Node(NamedTuple("Node", [("value", int), ("next", "Node | None")])): + pass + +n = Node(1, None) +reveal_type(n.value) # revealed: int +reveal_type(n.next) # revealed: Node | None + +class A(NamedTuple("A", [("x", "B | None")])): ... +class B(NamedTuple("B", [("x", "C")])): ... +class C(NamedTuple("C", [("x", "A")])): ... + +reveal_type(A(x=B(x=C(x=A(x=None))))) # revealed: A + +# error: [invalid-argument-type] "Argument is incorrect: Expected `B | None`, found `C`" +# error: [missing-argument] "No argument provided for required parameter `x`" +A(x=C()) + +# error: [invalid-argument-type] "Argument is incorrect: Expected `B | None`, found `C`" +A(x=C(x=A(x=None))) +``` + +Note that the string annotation must reference a name that exists in scope. References to the +internal NamedTuple name (if different from the class name) won't work: + +```py +from typing import NamedTuple + +# The string "X" in "next"'s type refers to the internal name, not "BadNode", so it won't resolve: +# +# error: [unresolved-reference] "Name `X` used when not defined" +class BadNode(NamedTuple("X", [("value", int), ("next", "X | None")])): + pass + +n = BadNode(1, None) +reveal_type(n.value) # revealed: int +# X is not in scope, so it resolves to Unknown; None is correctly resolved +reveal_type(n.next) # revealed: Unknown | None +``` + +Dangling calls cannot contain other dangling calls; that's an invalid type form: + +```py +from ty_extensions import reveal_mro + +# error: [invalid-type-form] +class A(NamedTuple("B", [("x", NamedTuple("C", [("x", "A" | None)]))])): + pass + +# revealed: (, , , , , , , , typing.Protocol, typing.Generic, ) +reveal_mro(A) +``` + ### Functional syntax with variable name When the typename is passed via a variable, we can extract it from the inferred literal string type: @@ -146,56 +270,70 @@ 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: +When fields are passed via a tuple variable, we cannot extract the literal field names and types +from the inferred tuple type. We instead emit a diagnostic: ```py from typing import NamedTuple from ty_extensions import static_assert, is_subtype_of, reveal_mro fields = (("host", str), ("port", int)) +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" Url = NamedTuple("Url", fields) url = Url("localhost", 8080) -reveal_type(url.host) # revealed: str -reveal_type(url.port) # revealed: int +reveal_type(url.host) # revealed: Any +reveal_type(url.port) # revealed: Any -# Generic types are also correctly converted to instance types. generic_fields = (("items", list[int]), ("mapping", dict[str, bool])) +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" 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] +reveal_type(container.items) # revealed: Any +reveal_type(container.mapping) # revealed: Any -# MRO includes the properly specialized tuple type. -# revealed: (, , , , , , , typing.Protocol, typing.Generic, ) +# revealed: (, , , , , , , typing.Protocol, typing.Generic, ) 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] "Object of type `Literal[42]` is not valid as a `NamedTuple` field type" +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" InvalidNT = NamedTuple("InvalidNT", invalid_fields) reveal_type(InvalidNT) # revealed: -# Unpacking works correctly with the field types. host, port = url -reveal_type(host) # revealed: str -reveal_type(port) # revealed: int +reveal_type(host) # revealed: Unknown +reveal_type(port) # revealed: Unknown -# error: [invalid-assignment] "Too many values to unpack: Expected 1" +# fails at runtime but we can't detect that (only_one,) = url -# error: [invalid-assignment] "Not enough values to unpack: Expected 3" +# will error at runtime, but we can't detect that a, b, c = url -# Indexing works correctly. -reveal_type(url[0]) # revealed: str -reveal_type(url[1]) # revealed: int +reveal_type(url[0]) # revealed: Unknown +reveal_type(url[1]) # revealed: Unknown + +# will error at runtime, but we can't detect that +reveal_type(url[2]) # revealed: Unknown +``` + +### Functional syntax with Final variable field names + +When field names are `Final` variables, they resolve to their literal string values: + +```py +from typing import Final, NamedTuple + +X: Final = "x" +Y: Final = "y" +N = NamedTuple("N", [(X, int), (Y, int)]) -# error: [index-out-of-bounds] -url[2] +reveal_type(N(x=3, y=4).x) # revealed: int +reveal_type(N(x=3, y=4).y) # revealed: int + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +N(x="", y="") ``` ### Functional syntax with variadic tuple fields @@ -217,6 +355,7 @@ def get_fields() -> tuple[tuple[str, type[int]], *tuple[tuple[str, type[str]], . return (("x", int), ("y", str)) fields = get_fields() +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" NT = NamedTuple("NT", fields) # Fields are unknown, so attribute access returns Any and MRO has Unknown tuple. @@ -343,6 +482,7 @@ from typing_extensions import Self fields = [("host", str), ("port", int)] +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" class Url(NamedTuple("Url", fields)): def with_port(self, port: int) -> Self: # Fields are unknown, so attribute access returns Any. @@ -626,19 +766,18 @@ Bad4 = NamedTuple("Bad4", [("x", int)], defaults=[0]) 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()`" +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a literal list or tuple" Bad6 = NamedTuple("Bad6", 12345) reveal_type(Bad6) # revealed: # Invalid field definitions: strings instead of (name, type) tuples -# error: [invalid-argument-type] "Invalid `NamedTuple()` field definition" -# error: [invalid-argument-type] "Invalid `NamedTuple()` field definition" +# error: [invalid-named-tuple] "Invalid argument to parameter `fields` of `NamedTuple()`: `fields` must be a sequence of literal lists or tuples" Bad7 = NamedTuple("Bad7", ["a", "b"]) reveal_type(Bad7) # revealed: # Invalid field definitions: type is not a valid type expression (e.g., int literals) -# error: [invalid-type-form] "Object of type `Literal[123]` is not valid as a `NamedTuple` field type" -# error: [invalid-type-form] "Object of type `Literal[456]` is not valid as a `NamedTuple` field type" +# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression" +# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression" Bad8 = NamedTuple("Bad8", [("a", 123), ("b", 456)]) reveal_type(Bad8) # revealed: ``` @@ -958,6 +1097,8 @@ reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str reveal_type(LegacyProperty("height", 3.4).value) # revealed: int | float ``` +### Functional syntax with generics + 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: @@ -979,11 +1120,11 @@ reveal_type(Pair(1, 2)) # revealed: Pair # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).first) # revealed: T@Pair +reveal_type(Pair(1, 2).first) # revealed: TypeVar # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).second) # revealed: T@Pair +reveal_type(Pair(1, 2).second) # revealed: TypeVar ``` ## Attributes on `NamedTuple` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 762d0664b9ed6..0375a7c0758b5 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -49,6 +49,7 @@ use crate::suppression::check_suppressions; use crate::types::bound_super::BoundSuperType; use crate::types::builder::RecursivelyDefined; use crate::types::call::{Binding, Bindings, CallArguments, CallableBinding}; +use crate::types::class::NamedTupleSpec; pub(crate) use crate::types::class_base::ClassBase; use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension}; use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder}; @@ -5523,6 +5524,10 @@ impl<'db> Type<'db> { invalid_expressions: smallvec_inline![InvalidTypeExpression::Generic], fallback_type: Type::unknown(), }), + KnownInstanceType::NamedTupleSpec(_) => Err(InvalidTypeExpressionError { + invalid_expressions: smallvec_inline![InvalidTypeExpression::NamedTupleSpec], + fallback_type: Type::unknown(), + }), KnownInstanceType::UnionType(instance) => { // Cloning here is cheap if the result is a `Type` (which is `Copy`). It's more // expensive if there are errors. @@ -5945,6 +5950,7 @@ impl<'db> Type<'db> { KnownInstanceType::Specialization(_) | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) | + KnownInstanceType::NamedTupleSpec(_) | KnownInstanceType::NewType(_) => { // TODO: For some of these, we may need to apply the type mapping to inner types. self @@ -6352,6 +6358,7 @@ impl<'db> Type<'db> { | KnownInstanceType::Specialization(_) | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) + | KnownInstanceType::NamedTupleSpec(_) | KnownInstanceType::NewType(_) => { // TODO: For some of these, we may need to try to find legacy typevars in inner types. } @@ -7019,6 +7026,9 @@ pub enum KnownInstanceType<'db> { /// An identity callable created with `typing.NewType(name, base)`, which behaves like a /// subtype of `base` in type expressions. See the `struct NewType` payload for an example. NewType(NewType<'db>), + + /// The inferred spec for a functional `NamedTuple` class. + NamedTupleSpec(NamedTupleSpec<'db>), } fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( @@ -7065,6 +7075,11 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( KnownInstanceType::NewType(newtype) => { visitor.visit_type(db, newtype.concrete_base_type(db)); } + KnownInstanceType::NamedTupleSpec(spec) => { + for field in spec.fields(db) { + visitor.visit_type(db, field.ty); + } + } } } @@ -7105,6 +7120,7 @@ impl<'db> KnownInstanceType<'db> { newtype .map_base_class_type(db, |class_type| class_type.normalized_impl(db, visitor)), ), + Self::NamedTupleSpec(spec) => Self::NamedTupleSpec(spec.normalized_impl(db, visitor)), Self::Deprecated(_) | Self::ConstraintSet(_) | Self::GenericContext(_) @@ -7161,6 +7177,9 @@ impl<'db> KnownInstanceType<'db> { Self::Specialization(specialization) => specialization .recursive_type_normalized_impl(db, div, true) .map(Self::Specialization), + Self::NamedTupleSpec(spec) => spec + .recursive_type_normalized_impl(db, div, true) + .map(Self::NamedTupleSpec), } } @@ -7187,6 +7206,7 @@ impl<'db> KnownInstanceType<'db> { | Self::Callable(_) => KnownClass::GenericAlias, Self::LiteralStringAlias(_) => KnownClass::Str, Self::NewType(_) => KnownClass::NewType, + Self::NamedTupleSpec(_) => KnownClass::Sequence, } } @@ -7458,6 +7478,8 @@ enum InvalidTypeExpression<'db> { GenericContext, /// Same for `ty_extensions.Specialization` Specialization, + /// Same for `NamedTupleSpec` + NamedTupleSpec, /// Same for `typing.TypedDict` TypedDict, /// Same for `typing.TypeAlias`, anywhere except for as the sole annotation on an annotated @@ -7516,6 +7538,9 @@ impl<'db> InvalidTypeExpression<'db> { InvalidTypeExpression::Specialization => f.write_str( "`ty_extensions.GenericContext` is not allowed in type expressions", ), + InvalidTypeExpression::NamedTupleSpec => { + f.write_str("`NamedTupleSpec` is not allowed in type expressions") + } InvalidTypeExpression::TypedDict => f.write_str( "The special form `typing.TypedDict` \ is not allowed in type expressions", diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index f2047ed62490f..eb15e465171b5 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -5417,27 +5417,14 @@ pub struct DynamicNamedTupleLiteral<'db> { #[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(deref)] - pub fields: Box<[NamedTupleField<'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>, + #[returns(ref)] + pub anchor: DynamicNamedTupleAnchor<'db>, } impl get_size2::GetSize for DynamicNamedTupleLiteral<'_> {} @@ -5447,16 +5434,18 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// Returns the definition where this namedtuple is created, if it was assigned to a variable. pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => Some(definition), - DynamicClassAnchor::ScopeOffset { .. } => None, + DynamicNamedTupleAnchor::CollectionsDefinition { definition, .. } + | DynamicNamedTupleAnchor::TypingDefinition(definition) => Some(*definition), + DynamicNamedTupleAnchor::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, + DynamicNamedTupleAnchor::CollectionsDefinition { definition, .. } + | DynamicNamedTupleAnchor::TypingDefinition(definition) => definition.scope(db), + DynamicNamedTupleAnchor::ScopeOffset { scope, .. } => *scope, } } @@ -5472,7 +5461,8 @@ impl<'db> DynamicNamedTupleLiteral<'db> { let module = parsed_module(db, file).load(db); match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => { + DynamicNamedTupleAnchor::CollectionsDefinition { definition, .. } + | DynamicNamedTupleAnchor::TypingDefinition(definition) => { // For definitions, get the range from the definition's value. // The namedtuple call is the value of the assignment. definition @@ -5481,7 +5471,7 @@ impl<'db> DynamicNamedTupleLiteral<'db> { .expect("DynamicClassAnchor::Definition should only be used for assignments") .range() } - DynamicClassAnchor::ScopeOffset { offset, .. } => { + DynamicNamedTupleAnchor::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 @@ -5519,7 +5509,11 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// 8. `typing.Protocol` /// 9. `typing.Generic` /// 10. `` - #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + #[salsa::tracked( + returns(ref), + heap_size=ruff_memory_usage::heap_size, + cycle_initial=dynamic_namedtuple_mro_cycle_initial + )] 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); @@ -5697,8 +5691,185 @@ impl<'db> DynamicNamedTupleLiteral<'db> { }) } } + + fn spec(self, db: &'db dyn Db) -> NamedTupleSpec<'db> { + #[salsa::tracked(cycle_initial=deferred_spec_initial, heap_size=ruff_memory_usage::heap_size)] + fn deferred_spec<'db>(db: &'db dyn Db, definition: Definition<'db>) -> NamedTupleSpec<'db> { + let module = parsed_module(db, definition.file(db)).load(db); + let node = definition + .kind(db) + .value(&module) + .expect("Expected `NamedTuple` definition to be an assignment") + .as_call_expr() + .expect("Expected `NamedTuple` definition r.h.s. to be a call expression"); + match definition_expression_type(db, definition, &node.arguments.args[1]) { + Type::KnownInstance(KnownInstanceType::NamedTupleSpec(spec)) => spec, + _ => NamedTupleSpec::unknown(db), + } + } + + fn deferred_spec_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + _definition: Definition<'db>, + ) -> NamedTupleSpec<'db> { + NamedTupleSpec::unknown(db) + } + + match self.anchor(db) { + DynamicNamedTupleAnchor::CollectionsDefinition { spec, .. } + | DynamicNamedTupleAnchor::ScopeOffset { spec, .. } => *spec, + DynamicNamedTupleAnchor::TypingDefinition(definition) => deferred_spec(db, *definition), + } + } + + fn fields(self, db: &'db dyn Db) -> &'db [NamedTupleField<'db>] { + self.spec(db).fields(db) + } + + fn has_known_fields(self, db: &'db dyn Db) -> bool { + self.spec(db).has_known_fields(db) + } } +fn dynamic_namedtuple_mro_cycle_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + self_: DynamicNamedTupleLiteral<'db>, +) -> Mro<'db> { + Mro::from_error( + db, + ClassType::NonGeneric(ClassLiteral::DynamicNamedTuple(self_)), + ) +} + +/// Anchor for identifying a dynamic `namedtuple`/`NamedTuple` class literal. +/// +/// This enum provides stable identity for `DynamicNamedTupleLiteral` instances: +/// - For assigned calls, the `Definition` uniquely identifies the class. +/// - For dangling calls, a relative offset provides stable identity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +pub enum DynamicNamedTupleAnchor<'db> { + /// We're dealing with a `collections.namedtuple()` call + /// that's assigned to a variable. + /// + /// The `Definition` uniquely identifies this class. The `namedtuple()` + /// call expression is the `value` of the assignment, so we can get its + /// range from the definition. + CollectionsDefinition { + definition: Definition<'db>, + spec: NamedTupleSpec<'db>, + }, + + /// We're dealing with a `typing.NamedTuple()` call + /// that's assigned to a variable. + /// + /// The `Definition` uniquely identifies this class. The `NamedTuple()` + /// call expression is the `value` of the assignment, so we can get its + /// range from the definition. + /// + /// Unlike the `CollectionsDefinition` variant, this variant does not + /// hold a `NamedTupleSpec`. This is because the spec for a + /// `typing.NamedTuple` call can contain forward references and recursive + /// references that must be evaluated lazily. The spec is computed + /// on-demand from the definition. + TypingDefinition(Definition<'db>), + + /// We're dealing with a `namedtuple()` or `NamedTuple` call that is + /// "dangling" (not assigned to a variable). + /// + /// The offset is relative to the enclosing scope's anchor node index. + /// For module scope, this is equivalent to an absolute index (anchor is 0). + /// + /// Dangling calls can always store the spec. They *can* contain + /// forward references if they appear in class bases: + /// + /// ```python + /// from typing import NamedTuple + /// + /// class F(NamedTuple("F", [("x", "F | None")]): + /// pass + /// ``` + /// + /// But this doesn't matter, because all class bases are deferred in their + /// entirety during type inference. + ScopeOffset { + scope: ScopeId<'db>, + offset: u32, + spec: NamedTupleSpec<'db>, + }, +} + +/// A specification describing the fields of a dynamic `namedtuple` +/// or `NamedTuple` class. +/// +/// # Ordering +/// +/// Ordering is based on the spec's salsa-assigned id and not on its values. +/// The id may change between runs, or when the spec was garbage collected and recreated. +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct NamedTupleSpec<'db> { + #[returns(deref)] + pub(crate) fields: Box<[NamedTupleField<'db>]>, + + pub(crate) has_known_fields: bool, +} + +impl<'db> NamedTupleSpec<'db> { + /// Create a [`NamedTupleSpec`] with the given fields. + pub(crate) fn known(db: &'db dyn Db, fields: Box<[NamedTupleField<'db>]>) -> Self { + Self::new(db, fields, true) + } + + /// Create a [`NamedTupleSpec`] that indicates a namedtuple class has unknown fields. + pub(crate) fn unknown(db: &'db dyn Db) -> Self { + Self::new(db, Box::default(), false) + } + + pub(crate) fn normalized_impl(self, db: &'db dyn Db, visitor: &NormalizedVisitor<'db>) -> Self { + let fields: Box<_> = self + .fields(db) + .iter() + .map(|f| NamedTupleField { + name: f.name.clone(), + ty: f.ty.normalized_impl(db, visitor), + default: None, + }) + .collect(); + + Self::new(db, fields, self.has_known_fields(db)) + } + + pub(crate) fn recursive_type_normalized_impl( + self, + db: &'db dyn Db, + div: Type<'db>, + nested: bool, + ) -> Option { + let fields = self + .fields(db) + .iter() + .map(|f| { + Some(NamedTupleField { + name: f.name.clone(), + ty: if nested { + f.ty.recursive_type_normalized_impl(db, div, nested)? + } else { + f.ty.recursive_type_normalized_impl(db, div, nested) + .unwrap_or(div) + }, + default: None, + }) + }) + .collect::>>()?; + + Some(Self::new(db, fields, self.has_known_fields(db))) + } +} + +impl get_size2::GetSize for NamedTupleSpec<'_> {} + /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index e1b2daa1b12cf..3111a0890e2c4 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -193,6 +193,7 @@ impl<'db> ClassBase<'db> { | KnownInstanceType::UnionType(_) | KnownInstanceType::Literal(_) | KnownInstanceType::LiteralStringAlias(_) + | KnownInstanceType::NamedTupleSpec(_) // A class inheriting from a newtype would make intuitive sense, but newtype // wrappers are just identity callables at runtime, so this sort of inheritance // doesn't work and isn't allowed. diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 0d8d4836e557b..07b118ffeb352 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -2731,6 +2731,7 @@ impl<'db> FmtDetailed<'db> for DisplayKnownInstanceRepr<'db> { f.with_type(ty).write_str(declaration.name(self.db))?; f.write_str("'>") } + KnownInstanceType::NamedTupleSpec(_) => f.write_str("NamedTupleSpec"), } } } diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 64b761f7ee353..6d0c970df39bd 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -1725,7 +1725,13 @@ impl<'db> SpecializationBuilder<'db> { actual: Type<'db>, mut f: impl FnMut(TypeVarAssignment<'db>) -> Option>, ) -> Result<(), SpecializationError<'db>> { - self.infer_map_impl(formal, actual, TypeVarVariance::Covariant, &mut f) + self.infer_map_impl( + formal, + actual, + TypeVarVariance::Covariant, + &mut f, + &mut FxHashSet::default(), + ) } fn infer_map_impl( @@ -1734,6 +1740,7 @@ impl<'db> SpecializationBuilder<'db> { actual: Type<'db>, polarity: TypeVarVariance, mut f: &mut dyn FnMut(TypeVarAssignment<'db>) -> Option>, + seen: &mut FxHashSet<(Type<'db>, Type<'db>)>, ) -> Result<(), SpecializationError<'db>> { // TODO: Eventually, the builder will maintain a constraint set, instead of a hash-map of // type mappings, to represent the specialization that we are building up. At that point, @@ -1748,6 +1755,11 @@ impl<'db> SpecializationBuilder<'db> { return Ok(()); } + // Avoid infinite recursion + if !seen.insert((formal, actual)) { + return Ok(()); + } + // Remove the union elements from `actual` that are not related to `formal`, and vice // versa. // @@ -1850,7 +1862,8 @@ impl<'db> SpecializationBuilder<'db> { let mut first_error = None; let mut found_matching_element = false; for formal_element in union_formal.elements(self.db) { - let result = self.infer_map_impl(*formal_element, actual, polarity, &mut f); + let result = + self.infer_map_impl(*formal_element, actual, polarity, &mut f, seen); if let Err(err) = result { first_error.get_or_insert(err); } else { @@ -1876,7 +1889,7 @@ impl<'db> SpecializationBuilder<'db> { // actual type must also be disjoint from every negative element of the // intersection, but that doesn't help us infer any type mappings.) for positive in formal.iter_positive(self.db) { - self.infer_map_impl(positive, actual, polarity, f)?; + self.infer_map_impl(positive, actual, polarity, f, seen)?; } } @@ -1949,7 +1962,13 @@ impl<'db> SpecializationBuilder<'db> { { let formal_instance = Type::TypeVar(subclass_of.into_type_var().unwrap()); if let Some(actual_instance) = ty.to_instance(self.db) { - return self.infer_map_impl(formal_instance, actual_instance, polarity, f); + return self.infer_map_impl( + formal_instance, + actual_instance, + polarity, + f, + seen, + ); } } @@ -1965,6 +1984,7 @@ impl<'db> SpecializationBuilder<'db> { Type::NominalInstance(actual_nominal), polarity, f, + seen, ); } } @@ -1992,7 +2012,13 @@ impl<'db> SpecializationBuilder<'db> { .zip(actual_tuple.all_elements()) { let variance = TypeVarVariance::Covariant.compose(polarity); - self.infer_map_impl(*formal_element, *actual_element, variance, &mut f)?; + self.infer_map_impl( + *formal_element, + *actual_element, + variance, + &mut f, + seen, + )?; } return Ok(()); } @@ -2035,7 +2061,7 @@ impl<'db> SpecializationBuilder<'db> { base_specialization ) { let variance = typevar.variance_with_polarity(self.db, polarity); - self.infer_map_impl(*formal_ty, *base_ty, variance, &mut f)?; + self.infer_map_impl(*formal_ty, *base_ty, variance, &mut f, seen)?; } return Ok(()); } @@ -2102,7 +2128,13 @@ impl<'db> SpecializationBuilder<'db> { actual: Type<'db>, mut f: impl FnMut(TypeVarAssignment<'db>) -> Option>, ) -> Result<(), SpecializationError<'db>> { - self.infer_reverse_map_impl(formal, actual, TypeVarVariance::Covariant, &mut f) + self.infer_reverse_map_impl( + formal, + actual, + TypeVarVariance::Covariant, + &mut f, + &mut FxHashSet::default(), + ) } fn infer_reverse_map_impl( @@ -2111,7 +2143,13 @@ impl<'db> SpecializationBuilder<'db> { actual: Type<'db>, polarity: TypeVarVariance, f: &mut dyn FnMut(TypeVarAssignment<'db>) -> Option>, + seen: &mut FxHashSet<(Type<'db>, Type<'db>)>, ) -> Result<(), SpecializationError<'db>> { + // Avoid infinite recursion + if !seen.insert((formal, actual)) { + return Ok(()); + } + // Assign each type variable on the formal type to a unique synthetic type variable. let type_mapping = TypeMapping::UniqueSpecialization { specialization: RefCell::new(Vec::new()), @@ -2142,7 +2180,7 @@ impl<'db> SpecializationBuilder<'db> { // // This is the base case for when `actual` is an inferable type variable. if forward_type_mappings.is_empty() { - return self.infer_map_impl(actual, formal, polarity, f); + return self.infer_map_impl(actual, formal, polarity, f, seen); } // Consider the reverse inference of `Sequence[int]` given `list[T]`. @@ -2157,7 +2195,7 @@ impl<'db> SpecializationBuilder<'db> { // Note that it is possible that we need to recurse deeper, so we continue // to perform a reverse inference on the nested types. - self.infer_reverse_map_impl(formal_type, *actual_type, variance, f)?; + self.infer_reverse_map_impl(formal_type, *actual_type, variance, f, seen)?; } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 6b31123f9d593..9de96bf9c8966 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -59,9 +59,9 @@ use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::class::{ ClassLiteral, CodeGeneratorKind, DynamicClassAnchor, DynamicClassLiteral, - DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator, + DynamicMetaclassConflict, DynamicNamedTupleAnchor, DynamicNamedTupleLiteral, FieldKind, + MetaclassErrorKind, MethodDecorator, NamedTupleField, NamedTupleSpec, }; -use crate::types::class::{DynamicNamedTupleLiteral, NamedTupleField}; use crate::types::context::{InNoTypeCheck, InferContext}; use crate::types::cyclic::CycleDetector; use crate::types::diagnostic::{ @@ -6159,6 +6159,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let func_ty = self .try_expression_type(func) .unwrap_or_else(|| self.infer_expression(func, TypeContext::default())); + if func_ty == Type::SpecialForm(SpecialFormType::NamedTuple) { + // Only the `fields` argument is deferred for `NamedTuple`; + // other arguments are inferred eagerly. + self.infer_typing_namedtuple_fields(&arguments.args[1]); + return; + } let known_class = func_ty .as_class_literal() .and_then(|cls| cls.known(self.db())); @@ -6652,7 +6658,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; 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()); @@ -6853,425 +6858,395 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; // Handle fields based on which namedtuple variant. - let (fields, has_known_fields): (Box<[NamedTupleField<'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)); - - // Validate field names if we have known fields. - if let Some(ref fields) = fields { - let field_names: Vec<_> = - fields.iter().map(|field| field.name.clone()).collect(); - self.report_invalid_namedtuple_field_names( - &field_names, + let anchor = match definition { + Some(definition) => match kind { + NamedTupleKind::Collections => { + let spec = self.infer_collections_namedtuple_fields( + rename_type, fields_arg, - NamedTupleKind::Typing, + &default_types, + defaults_kw, ); + DynamicNamedTupleAnchor::CollectionsDefinition { definition, spec } } - - // Emit diagnostic if the type is outright invalid (not an iterable) or - // if we have a list/tuple literal with invalid field specs. - if fields.is_none() { - let iterable_any = - KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); - if !fields_type.is_assignable_to(db, iterable_any) { - if 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) - )); - } - } else { - // Check if we have a list/tuple literal with invalid elements - // (e.g., strings instead of (name, type) tuples). - let elements: Option<&[ast::Expr]> = match fields_arg { - ast::Expr::List(list) => Some(&list.elts), - ast::Expr::Tuple(tuple) => Some(&tuple.elts), - _ => None, - }; - if let Some(elements) = elements { - for elt in elements { - let is_valid_field_spec = matches!( - elt, - ast::Expr::Tuple(t) if t.elts.len() == 2 - ) || matches!( - elt, - ast::Expr::List(l) if l.elts.len() == 2 - ); - if !is_valid_field_spec { - let elt_type = self.expression_type(elt); - if let Some(builder) = - self.context.report_lint(&INVALID_ARGUMENT_TYPE, elt) - { - let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid `NamedTuple()` field definition" - )); - diagnostic.set_primary_message(format_args!( - "Expected a `(name, type)` tuple, found `{}`", - elt_type.display(db) - )); - } - } - } - } - } + NamedTupleKind::Typing => { + // The `fields` argument to `typing.NamedTuple` cannot be inferred + // eagerly if it's not a dangling call, as it may contain forward references + // or recursive references. + self.deferred.insert(definition, self.multi_inference_state); + DynamicNamedTupleAnchor::TypingDefinition(definition) + } + }, + None => { + let call_node_index = call_expr.node_index.load(); + let scope = self.scope(); + 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"); + let spec = match kind { + NamedTupleKind::Collections => self.infer_collections_namedtuple_fields( + rename_type, + fields_arg, + &default_types, + defaults_kw, + ), + NamedTupleKind::Typing => self.infer_typing_namedtuple_fields(fields_arg), + }; + DynamicNamedTupleAnchor::ScopeOffset { + scope, + offset: call_u32 - anchor_u32, + spec, } - - let has_known_fields = fields.is_some(); - (fields.unwrap_or_default(), has_known_fields) } - NamedTupleKind::Collections => { - // `collections.namedtuple`: `field_names` is a list or tuple of strings, or a space or - // comma-separated string. - - // Check for `rename=True`. Use `is_always_true()` to handle truthy values - // (e.g., `rename=1`), though we'd still want a diagnostic for non-bool types. - let rename = rename_type.is_some_and(|ty| ty.bool(db).is_always_true()); - - // Extract field names, first from the inferred type, then from the AST. - let maybe_field_names: Option> = - if let Type::StringLiteral(string_literal) = fields_type { - // Handle space/comma-separated string. - Some( - string_literal - .value(db) - .replace(',', " ") - .split_whitespace() - .map(Name::new) - .collect(), - ) - } else if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) - && let Some(fixed_tuple) = tuple_spec.as_fixed_length() - { - // Handle list/tuple of strings (must be fixed-length). - fixed_tuple - .all_elements() - .iter() - .map(|elt| elt.as_string_literal().map(|s| Name::new(s.value(db)))) - .collect() - } else { - self.extract_collections_namedtuple_fields_from_ast(fields_arg) - }; + }; - if maybe_field_names.is_none() { - // Emit diagnostic if the type is outright invalid (not str | Iterable[str]). - let iterable_str = - KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); - let valid_type = UnionType::from_elements( - db, - [KnownClass::Str.to_instance(db), iterable_str], - ); - if !fields_type.is_assignable_to(db, valid_type) - && let Some(builder) = - self.context.report_lint(&INVALID_ARGUMENT_TYPE, fields_arg) - { - let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid argument to parameter `field_names` of `namedtuple()`" - )); - diagnostic.set_primary_message(format_args!( - "Expected `str` or an iterable of strings, found `{}`", - fields_type.display(db) - )); - } - } + let namedtuple = DynamicNamedTupleLiteral::new(db, name, anchor); - if let Some(mut field_names) = maybe_field_names { - // When `rename` is false (or not specified), emit diagnostics for invalid - // field names. These all raise ValueError at runtime. When `rename=True`, - // invalid names are automatically replaced with `_0`, `_1`, etc., so no - // diagnostic is needed. - if !rename { - self.report_invalid_namedtuple_field_names( - &field_names, - fields_arg, - NamedTupleKind::Collections, - ); - } else { - // Apply rename logic. - 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()); - } - } + Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) + } - let num_fields = field_names.len(); - let defaults_count = default_types.len(); + fn infer_collections_namedtuple_fields( + &mut self, + rename_type: Option>, + fields_arg: &ast::Expr, + default_types: &[Type<'db>], + defaults_kw: Option<&ast::Keyword>, + ) -> NamedTupleSpec<'db> { + let db = self.db(); - if defaults_count > num_fields - && let Some(defaults_kw) = defaults_kw - && let Some(builder) = - self.context.report_lint(&INVALID_NAMED_TUPLE, defaults_kw) - { - let mut diagnostic = builder - .into_diagnostic(format_args!("Too many defaults for `namedtuple()`")); - diagnostic.set_primary_message(format_args!( - "Got {defaults_count} default values but only {num_fields} field names" - )); - diagnostic.info("This will raise `TypeError` at runtime"); - } + // `collections.namedtuple`: `field_names` is a list or tuple of strings, or a space or + // comma-separated string. - 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 - { - // Index into default_types: first default corresponds to first - // field that has a default. - let default_idx = i - (num_fields - defaults_count); - Some(default_types[default_idx]) - } else { - None - }; - NamedTupleField { - name: field_name.clone(), - ty: Type::any(), - default, - } + // 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()); + + let fields_type = self.infer_expression(fields_arg, TypeContext::default()); + + // Extract field names, first from the inferred type, then from the AST. + let maybe_field_names: Option> = + if let Type::StringLiteral(string_literal) = fields_type { + // Handle space/comma-separated string. + Some( + string_literal + .value(db) + .replace(',', " ") + .split_whitespace() + .map(Name::new) + .collect(), + ) + } else if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + // Handle list/tuple of strings (must be fixed-length). + fixed_tuple + .all_elements() + .iter() + .map(|elt| elt.as_string_literal().map(|s| Name::new(s.value(db)))) + .collect() + } else { + // Get the elements from the list or tuple literal. + let elements = match fields_arg { + ast::Expr::List(list) => Some(&list.elts), + ast::Expr::Tuple(tuple) => Some(&tuple.elts), + _ => None, + }; + + elements.and_then(|elts| { + elts.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(); - (fields, true) - } else { - // Couldn't determine fields statically; attribute lookups will return Any. - (Box::new([]), false) - } + .collect::>() + }) + }; + + 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) + )); } - }; + } - let scope = self.scope(); + let Some(mut field_names) = maybe_field_names else { + // Couldn't determine fields statically; attribute lookups will return Any. + return NamedTupleSpec::unknown(db); + }; - // 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) + // When `rename` is false (or not specified), emit diagnostics for invalid + // field names. These all raise ValueError at runtime. When `rename=True`, + // invalid names are automatically replaced with `_0`, `_1`, etc., so no + // diagnostic is needed. + if !rename { + self.check_invalid_namedtuple_field_names( + &field_names, + fields_arg, + NamedTupleKind::Collections, + ); } 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, + // Apply rename logic. + 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()); } - }; + } - let namedtuple = DynamicNamedTupleLiteral::new(db, name, fields, has_known_fields, anchor); + let num_fields = field_names.len(); + let defaults_count = default_types.len(); - Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) - } + if defaults_count > num_fields + && let Some(defaults_kw) = defaults_kw + && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, defaults_kw) + { + let mut diagnostic = + builder.into_diagnostic(format_args!("Too many defaults for `namedtuple()`")); + diagnostic.set_primary_message(format_args!( + "Got {defaults_count} default values but only {num_fields} field names" + )); + diagnostic.info("This will raise `TypeError` at runtime"); + } - /// Extract fields from a typing.NamedTuple fields argument. - fn extract_typing_namedtuple_fields( - &mut self, - fields_arg: &ast::Expr, - fields_type: Type<'db>, - ) -> Option]>> { - let db = self.db(); - let scope_id = self.scope(); - let typevar_binding_context = self.typevar_binding_context; - - // Try to extract from a fixed-length tuple type. - let tuple_spec = fields_type.tuple_instance_spec(db)?; - let fixed_tuple = tuple_spec.as_fixed_length()?; - let fields: Option> = fixed_tuple - .all_elements() + let defaults_count = defaults_count.min(num_fields); + let fields = field_names .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; + .enumerate() + .map(|(i, field_name)| { + let default = if defaults_count > 0 && i >= num_fields - defaults_count { + // Index into default_types: first default corresponds to first + // field that has a default. + let default_idx = i - (num_fields - defaults_count); + Some(default_types[default_idx]) + } else { + 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!( - "Object of type `{}` is not valid as a `NamedTuple` field type", - field_type.display(db) - )); - } - error.fallback_type - } - }; - Some(NamedTupleField { - name: Name::new(name.value(db)), - ty: resolved_ty, - default: None, - }) + NamedTupleField { + name: field_name.clone(), + ty: Type::any(), + default, + } }) .collect(); - fields + NamedTupleSpec::known(db, fields) + } + + fn infer_typing_namedtuple_fields(&mut self, fields_arg: &ast::Expr) -> NamedTupleSpec<'db> { + #[derive(Debug, Copy, Clone, PartialEq, Eq)] + enum SequenceKind { + List, + Tuple, + } + + let db = self.db(); + + // Get the elements from the list or tuple literal. + let (elements, field_arg_kind) = match fields_arg { + ast::Expr::List(list) => (&list.elts, SequenceKind::List), + ast::Expr::Tuple(tuple) => (&tuple.elts, SequenceKind::Tuple), + _ => { + self.infer_expression(fields_arg, TypeContext::default()); + if let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { + let mut diagnostic = builder.into_diagnostic( + "Invalid argument to parameter `fields` of `NamedTuple()`", + ); + diagnostic.set_primary_message("`fields` must be a literal list or tuple"); + } + return NamedTupleSpec::unknown(db); + } + }; + + let mut fields = vec![]; + + for (i, element) in elements.iter().enumerate() { + // Each element should be a tuple or list like ("field_name", type) or ["field_name", type]. + let (field_spec_elts, field_spec_kind) = match element { + ast::Expr::Tuple(tuple) => (&tuple.elts, SequenceKind::Tuple), + ast::Expr::List(list) => (&list.elts, SequenceKind::List), + _ => { + self.infer_expression(element, TypeContext::default()); + for element in &elements[(i + 1)..] { + self.infer_expression(element, TypeContext::default()); + } + match field_arg_kind { + SequenceKind::List => { + self.store_expression_type( + fields_arg, + KnownClass::List.to_instance(db), + ); + } + SequenceKind::Tuple => self.store_expression_type( + fields_arg, + Type::homogeneous_tuple(db, Type::unknown()), + ), + } + if let Some(builder) = + self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) + { + let mut diagnostic = builder.into_diagnostic( + "Invalid argument to parameter `fields` of `NamedTuple()`", + ); + diagnostic.set_primary_message( + "`fields` must be a sequence of literal lists or tuples", + ); + } + return NamedTupleSpec::unknown(db); + } + }; + + let [name_expr, declaration_expr] = &**field_spec_elts else { + self.infer_expression(element, TypeContext::default()); + for element in &elements[(i + 1)..] { + self.infer_expression(element, TypeContext::default()); + } + match field_arg_kind { + SequenceKind::List => { + self.store_expression_type(fields_arg, KnownClass::List.to_instance(db)); + } + SequenceKind::Tuple => self.store_expression_type( + fields_arg, + Type::homogeneous_tuple(db, Type::unknown()), + ), + } + if let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { + let mut diagnostic = builder.into_diagnostic( + "Invalid argument to parameter `fields` of `NamedTuple()`", + ); + diagnostic.set_primary_message( + "Each element in `fields` must be a length-2 tuple or list", + ); + } + return NamedTupleSpec::unknown(db); + }; + + let name_type = self.infer_expression(name_expr, TypeContext::default()); + let declared_type = self.infer_type_expression(declaration_expr); + + let element_type = match field_spec_kind { + SequenceKind::Tuple => Type::heterogeneous_tuple(db, [name_type, declared_type]), + SequenceKind::List => KnownClass::List.to_specialized_instance( + db, + &[UnionType::from_elements(db, [name_type, declared_type])], + ), + }; + + self.store_expression_type(element, element_type); + + let Type::StringLiteral(name) = name_type else { + for element in &elements[(i + 1)..] { + self.infer_expression(element, TypeContext::default()); + } + match field_arg_kind { + SequenceKind::List => { + self.store_expression_type(fields_arg, KnownClass::List.to_instance(db)); + } + SequenceKind::Tuple => self.store_expression_type( + fields_arg, + Type::homogeneous_tuple(db, Type::unknown()), + ), + } + if let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, name_expr) { + let mut diagnostic = + builder.into_diagnostic("Invalid `NamedTuple` field name definition"); + diagnostic.set_primary_message(format_args!( + "Expected a string literal for the field name, found `{}`", + name_type.display(db) + )); + } + return NamedTupleSpec::unknown(db); + }; + + let field = NamedTupleField { + name: Name::new(name.value(db)), + ty: declared_type, + default: None, + }; + + fields.push(field); + } + + let names: Vec = fields.iter().map(|f| f.name.clone()).collect(); + + self.check_invalid_namedtuple_field_names(&names, fields_arg, NamedTupleKind::Typing); + + let spec = NamedTupleSpec::known(db, fields.into_boxed_slice()); + self.store_expression_type( + fields_arg, + Type::KnownInstance(KnownInstanceType::NamedTupleSpec(spec)), + ); + spec } /// Report diagnostics for invalid field names in a namedtuple definition. - fn report_invalid_namedtuple_field_names( + fn check_invalid_namedtuple_field_names( &self, field_names: &[Name], fields_arg: &ast::Expr, kind: NamedTupleKind, ) { for (i, field_name) in field_names.iter().enumerate() { - let name_str = field_name.as_str(); - // Check for duplicate field names. - if field_names[..i].iter().any(|f| f.as_str() == name_str) + if field_names[..i].iter().any(|f| f == field_name) && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { let mut diagnostic = builder.into_diagnostic(format_args!( - "Duplicate field name `{name_str}` in `{kind}()`" + "Duplicate field name `{field_name}` in `{kind}()`" )); diagnostic.set_primary_message(format_args!( - "Field `{name_str}` already defined; will raise `ValueError` at runtime" + "Field `{field_name}` already defined; will raise `ValueError` at runtime" )); } - if name_str.starts_with('_') + if field_name.starts_with('_') && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { let mut diagnostic = builder.into_diagnostic(format_args!( - "Field name `{name_str}` in `{kind}()` cannot start with an underscore" + "Field name `{field_name}` in `{kind}()` cannot start with an underscore" )); diagnostic.set_primary_message("Will raise `ValueError` at runtime"); - } else if is_keyword(name_str) + } else if is_keyword(field_name) && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { let mut diagnostic = builder.into_diagnostic(format_args!( - "Field name `{name_str}` in `{kind}()` cannot be a Python keyword" + "Field name `{field_name}` in `{kind}()` cannot be a Python keyword" )); diagnostic.set_primary_message("Will raise `ValueError` at runtime"); - } else if !is_identifier(name_str) + } else if !is_identifier(field_name) && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, fields_arg) { let mut diagnostic = builder.into_diagnostic(format_args!( - "Field name `{name_str}` in `{kind}()` is not a valid identifier" + "Field name `{field_name}` in `{kind}()` is not a valid identifier" )); diagnostic.set_primary_message("Will raise `ValueError` at runtime"); } } } - /// Extract fields from a typing.NamedTuple fields argument by looking at the AST directly. - /// This handles list/tuple literals that contain (name, type) pairs. - fn extract_typing_namedtuple_fields_from_ast( - &mut self, - fields_arg: &ast::Expr, - ) -> Option]>> { - let db = self.db(); - let scope_id = self.scope(); - let typevar_binding_context = self.typevar_binding_context; - - // Get the elements from the list or tuple literal. - let elements: &[ast::Expr] = match fields_arg { - ast::Expr::List(list) => &list.elts, - ast::Expr::Tuple(tuple) => &tuple.elts, - _ => return None, - }; - - let fields: Option> = elements - .iter() - .map(|elt| { - // Each element should be a tuple or list like ("field_name", type) or ["field_name", type]. - let field_spec_elts: &[ast::Expr] = match elt { - ast::Expr::Tuple(tuple) => &tuple.elts, - ast::Expr::List(list) => &list.elts, - _ => return None, - }; - if field_spec_elts.len() != 2 { - return None; - } - - // First element: field name (string literal). - let field_name_expr = &field_spec_elts[0]; - let field_name_ty = self.expression_type(field_name_expr); - let field_name_lit = field_name_ty.as_string_literal()?; - let field_name = Name::new(field_name_lit.value(db)); - - // Second element: field type (infer as type expression). - let field_type_expr = &field_spec_elts[1]; - let field_value_ty = self.expression_type(field_type_expr); - let field_ty = field_value_ty - .in_type_expression(db, scope_id, typevar_binding_context) - .unwrap_or_else(|error| { - // Report diagnostic for invalid type expression. - if let Some(builder) = self - .context - .report_lint(&INVALID_TYPE_FORM, field_type_expr) - { - builder.into_diagnostic(format_args!( - "Object of type `{}` is not valid as a `NamedTuple` field type", - field_value_ty.display(db) - )); - } - error.fallback_type - }); - - Some(NamedTupleField { - name: field_name, - ty: field_ty, - default: None, - }) - }) - .collect(); - - fields - } - - /// Extract field names from a collections.namedtuple fields argument by looking at the AST directly. - /// This handles list/tuple literals that contain string literals. - fn extract_collections_namedtuple_fields_from_ast( - &mut self, - fields_arg: &ast::Expr, - ) -> Option> { - let db = self.db(); - - // Get the elements from the list or tuple literal. - let elements: &[ast::Expr] = match fields_arg { - ast::Expr::List(list) => &list.elts, - ast::Expr::Tuple(tuple) => &tuple.elts, - _ => return None, - }; - - let field_names: Option> = elements - .iter() - .map(|elt| { - // Each element should be a string literal. - let field_ty = self.expression_type(elt); - let field_lit = field_ty.as_string_literal()?; - Some(Name::new(field_lit.value(db))) - }) - .collect(); - - field_names - } - /// Extract base classes from the second argument of a `type()` call. /// /// Returns the extracted bases and any disjoint bases found (for instance-layout-conflict diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index a63f94443d487..5c0ed5b398223 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1033,6 +1033,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } Type::unknown() } + KnownInstanceType::NamedTupleSpec(_) => { + self.infer_type_expression(&subscript.slice); + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { + builder.into_diagnostic(format_args!( + "`NamedTuple` specs cannot be specialized", + )); + } + Type::unknown() + } }, Type::Dynamic(DynamicType::UnknownGeneric(_)) => { self.infer_explicit_type_alias_specialization(subscript, value_ty, true)