diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 56cbb263bf..cedb597b45 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -266,7 +266,7 @@ class Person(NamedTuple): age: int | None = None reveal_type(Person._field_defaults) # revealed: dict[str, Any] -reveal_type(Person._fields) # revealed: tuple[str, ...] +reveal_type(Person._fields) # revealed: tuple[Literal["name"], Literal["age"]] 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: str = ..., age: int | None = ...) -> Self diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index d5e26b6b57..18a6d01996 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -2583,6 +2583,14 @@ impl<'db> ClassLiteral<'db> { .with_annotated_type(self_ty); signature_from_fields(vec![self_parameter], Some(self_ty)) } + (CodeGeneratorKind::NamedTuple, "_fields") => { + // Synthesize a precise tuple type for _fields using literal string types. + // For example, a NamedTuple with `name` and `age` fields gets + // `tuple[Literal["name"], Literal["age"]]`. + let fields = self.fields(db, specialization, field_policy); + let field_types = fields.keys().map(|name| Type::string_literal(db, name)); + Some(Type::heterogeneous_tuple(db, field_types)) + } (CodeGeneratorKind::DataclassLike(_), "__lt__" | "__le__" | "__gt__" | "__ge__") => { if !has_dataclass_param(DataclassFlags::ORDER) { return None; diff --git a/crates/ty_vendored/ty_extensions/ty_extensions.pyi b/crates/ty_vendored/ty_extensions/ty_extensions.pyi index ed0bf16186..36e6c51b1f 100644 --- a/crates/ty_vendored/ty_extensions/ty_extensions.pyi +++ b/crates/ty_vendored/ty_extensions/ty_extensions.pyi @@ -184,9 +184,13 @@ def reveal_mro(cls: type | types.GenericAlias) -> None: # A protocol describing an interface that should be satisfied by all named tuples # created using `typing.NamedTuple` or `collections.namedtuple`. class NamedTupleLike(Protocol): - # from typing.NamedTuple stub + # _fields is defined as `tuple[Any, ...]` rather than `tuple[str, ...]` so + # that instances of actual `NamedTuple` classes with more precise `_fields` + # types are considered assignable to this protocol (protocol attribute members + # are invariant, and `tuple[str, str]` is not invariantly assignable to + # `tuple[str, ...]`). + _fields: ClassVar[tuple[Any, ...]] _field_defaults: ClassVar[dict[str, Any]] - _fields: ClassVar[tuple[str, ...]] @classmethod def _make(cls: type[Self], iterable: Iterable[Any]) -> Self: ... def _asdict(self, /) -> dict[str, Any]: ...