From fdd9a97aef3442a04ed7aaa2290b3db79d57b887 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 2 Jan 2026 13:18:02 -0500 Subject: [PATCH] Use deferred evaluation --- .../resources/mdtest/typed_dict.md | 3 + .../ty_python_semantic/src/types/call/bind.rs | 123 +++++----- crates/ty_python_semantic/src/types/class.rs | 218 +++++++++++++++--- .../ty_python_semantic/src/types/display.rs | 15 +- .../src/types/infer/builder.rs | 119 +++++++++- 5 files changed, 381 insertions(+), 97 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 637365c45a..ebd6751e2b 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1297,6 +1297,9 @@ class Person(TypedDict): reveal_type(Person.__total__) # revealed: Literal[True] reveal_type(Person.__required_keys__) # revealed: tuple[Literal["age"], Literal["name"]] reveal_type(Person.__optional_keys__) # revealed: tuple[()] +reveal_type(Person.__annotations__) # revealed: dict[Unknown, Unknown] +# Calling .keys() on __annotations__ should work. +reveal_type(Person.__annotations__.keys()) # revealed: dict_keys[Unknown, Unknown] ``` These attributes cannot be accessed on inhabitants: diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 5a4acbc6f6..33f636074c 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -27,7 +27,7 @@ use crate::db::Db; use crate::dunder_all::dunder_all_names; use crate::place::{Definedness, Place, known_module_symbol}; use crate::types::call::arguments::{Expansion, is_expandable_type}; -use crate::types::class::FunctionalTypedDictLiteral; +use crate::types::class::{FunctionalTypedDictFieldsEvaluation, FunctionalTypedDictLiteral}; use crate::types::class_base::ClassBase; use crate::types::constraints::ConstraintSet; use crate::types::diagnostic::{ @@ -1606,7 +1606,12 @@ impl<'db> Bindings<'db> { }) .collect(); - let typeddict = FunctionalTypedDictLiteral::new(db, name, fields); + let typeddict = FunctionalTypedDictLiteral::new( + db, + name, + None, + Some(FunctionalTypedDictFieldsEvaluation::Eager(fields)), + ); let return_type = Type::ClassLiteral( ClassLiteral::FunctionalTypedDict(typeddict), ); @@ -1623,69 +1628,69 @@ impl<'db> Bindings<'db> { .map(|s| Name::new(s.value(db))); // Check if fields_type is a TypingNamedTupleFieldsSchema (from literal inference). - let fields: Option, Option>)]>> = - if let Type::KnownInstance( - KnownInstanceType::TypingNamedTupleFieldsSchema(schema), - ) = fields_type - { - // Extract fields from the schema. - Some( - schema - .fields(db) - .iter() - .map(|(name, ty)| (name.clone(), *ty, None)) - .collect(), - ) - } else { - // Fall back to extracting from a tuple type for the variable case: - // fields = (("x", int), ("y", str)) - // NamedTuple("Foo", fields) - let extract_field = |field_tuple: &Type<'db>| -> Option<( - Name, - Type<'db>, - Option>, - )> { - let field_spec = - field_tuple.exact_tuple_instance_spec(db)?; - let elements: Vec<_> = - field_spec.fixed_elements().collect(); - if elements.len() != 2 { - return None; + #[expect(clippy::type_complexity)] + let fields: Option< + Box<[(Name, Type<'db>, Option>)]>, + > = if let Type::KnownInstance( + KnownInstanceType::TypingNamedTupleFieldsSchema(schema), + ) = fields_type + { + // Extract fields from the schema. + Some( + schema + .fields(db) + .iter() + .map(|(name, ty)| (name.clone(), *ty, None)) + .collect(), + ) + } else { + // Fall back to extracting from a tuple type for the variable case: + // fields = (("x", int), ("y", str)) + // NamedTuple("Foo", fields) + let extract_field = |field_tuple: &Type<'db>| -> Option<( + Name, + Type<'db>, + Option>, + )> { + 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| Name::new(s.value(db)))?; + let field_ty = elements[1]; + let resolved_ty = match field_ty { + Type::ClassLiteral(class) => { + class.to_non_generic_instance(db) } - let field_name = elements[0] - .as_string_literal() - .map(|s| Name::new(s.value(db)))?; - let field_ty = elements[1]; - 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() { - SubclassOfInner::Class(class) => { - Type::instance(db, class) - } - _ => *field_ty, + Type::GenericAlias(alias) => { + Type::instance(db, ClassType::Generic(*alias)) + } + Type::SubclassOf(subclass_of) => { + match subclass_of.subclass_of() { + SubclassOfInner::Class(class) => { + Type::instance(db, class) } + _ => *field_ty, } - ty => *ty, - }; - Some((field_name, resolved_ty, None)) + } + ty => *ty, }; - - fields_type.exact_tuple_instance_spec(db).and_then( - |tuple_spec| { - tuple_spec - .fixed_elements() - .map(extract_field) - .collect::>>() - }, - ) + Some((field_name, resolved_ty, None)) }; + fields_type + .exact_tuple_instance_spec(db) + .and_then(|tuple_spec| { + tuple_spec + .fixed_elements() + .map(extract_field) + .collect::>>() + }) + }; + if let (Some(name), Some(fields)) = (name, fields) { let namedtuple = FunctionalNamedTupleLiteral::new(db, name, fields); overload.set_return_type(Type::ClassLiteral( diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 2f2f67e31e..28784de570 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -664,7 +664,7 @@ pub enum ClassLiteral<'db> { /// A namedtuple created via the functional form `namedtuple(name, fields)` or /// `NamedTuple(name, fields)`. FunctionalNamedTuple(FunctionalNamedTupleLiteral<'db>), - /// A TypedDict created via the functional form `TypedDict("Name", {"key": Type, ...})`. + /// A `TypedDict` created via the functional form `TypedDict("Name", {"key": Type, ...})`. FunctionalTypedDict(FunctionalTypedDictLiteral<'db>), } @@ -1054,9 +1054,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StmtClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Stmt(stmt)) => Some((stmt, None)), - Self::NonGeneric(ClassLiteral::Functional(_)) - | Self::NonGeneric(ClassLiteral::FunctionalNamedTuple(_)) - | Self::NonGeneric(ClassLiteral::FunctionalTypedDict(_)) => None, + Self::NonGeneric( + ClassLiteral::Functional(_) + | ClassLiteral::FunctionalNamedTuple(_) + | ClassLiteral::FunctionalTypedDict(_), + ) => None, Self::Generic(generic) => Some((generic.origin(db), Some(generic.specialization(db)))), } } @@ -1070,9 +1072,11 @@ impl<'db> ClassType<'db> { ) -> Option<(StmtClassLiteral<'db>, Option>)> { match self { Self::NonGeneric(ClassLiteral::Stmt(stmt)) => Some((stmt, None)), - Self::NonGeneric(ClassLiteral::Functional(_)) - | Self::NonGeneric(ClassLiteral::FunctionalNamedTuple(_)) - | Self::NonGeneric(ClassLiteral::FunctionalTypedDict(_)) => None, + Self::NonGeneric( + ClassLiteral::Functional(_) + | ClassLiteral::FunctionalNamedTuple(_) + | ClassLiteral::FunctionalTypedDict(_), + ) => None, Self::Generic(generic) => Some(( generic.origin(db), Some( @@ -2114,9 +2118,11 @@ impl<'db> VarianceInferable<'db> for ClassType<'db> { fn variance_of(self, db: &'db dyn Db, typevar: BoundTypeVarInstance<'db>) -> TypeVarVariance { match self { Self::NonGeneric(ClassLiteral::Stmt(stmt)) => stmt.variance_of(db, typevar), - Self::NonGeneric(ClassLiteral::Functional(_)) - | Self::NonGeneric(ClassLiteral::FunctionalNamedTuple(_)) - | Self::NonGeneric(ClassLiteral::FunctionalTypedDict(_)) => TypeVarVariance::Bivariant, + Self::NonGeneric( + ClassLiteral::Functional(_) + | ClassLiteral::FunctionalNamedTuple(_) + | ClassLiteral::FunctionalTypedDict(_), + ) => TypeVarVariance::Bivariant, Self::Generic(generic) => generic.variance_of(db, typevar), } } @@ -4676,10 +4682,10 @@ fn synthesize_namedtuple_class_member<'db>( } } -/// Synthesize a class member for a TypedDict. +/// Synthesize a class member for a `TypedDict`. /// -/// This is a shared implementation used by both declarative TypedDicts (class-based) -/// and functional TypedDicts (`TypedDict("Name", {...})`). +/// This is a shared implementation used by both declarative `TypedDict`s (class-based) +/// and functional `TypedDict`s (`TypedDict("Name", {...})`). fn synthesize_typed_dict_class_member<'db>( db: &'db dyn Db, name: &str, @@ -4707,14 +4713,12 @@ fn synthesize_typed_dict_class_member<'db>( } "__annotations__" => { // dict mapping field names to their types. - Some( - KnownClass::Dict - .to_class_literal(db) - .as_class_literal() - .expect("dict should be a class literal") - .default_specialization(db) - .into(), - ) + let dict_class = KnownClass::Dict + .to_class_literal(db) + .as_class_literal() + .expect("dict should be a class literal") + .default_specialization(db); + Some(Type::instance(db, dict_class)) } "__total__" => { // `__total__` is `True` if all fields are required, `False` otherwise. @@ -5070,6 +5074,7 @@ fn synthesize_typed_dict_class_member<'db>( .with_annotated_type(instance_ty), ]; + #[expect(clippy::explicit_iter_loop)] for (field_name, field) in items.iter() { let mut param = Parameter::keyword_only(field_name.clone()) .with_annotated_type(field.declared_ty()); @@ -5136,6 +5141,7 @@ impl<'db> FunctionalNamedTupleLiteral<'db> { /// Get the metaclass of this functional namedtuple. /// /// Namedtuples always have `type` as their metaclass. + #[expect(clippy::unused_self)] pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { KnownClass::Type.to_class_literal(db) } @@ -5159,7 +5165,7 @@ impl<'db> FunctionalNamedTupleLiteral<'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).iter() { + for (field_name, field_ty, _) in self.fields(db).as_ref() { if field_name.as_str() == name { return Place::bound(create_field_property(db, *field_ty)).into(); } @@ -5182,7 +5188,7 @@ impl<'db> FunctionalNamedTupleLiteral<'db> { } // Check if it's a field name (returns a property descriptor). - for (field_name, field_ty, _) in self.fields(db).iter() { + for (field_name, field_ty, _) in self.fields(db).as_ref() { if field_name.as_str() == name { return Place::bound(create_field_property(db, *field_ty)).into(); } @@ -5223,6 +5229,15 @@ impl<'db> FunctionalNamedTupleLiteral<'db> { } } +/// Whether fields are eagerly specified or lazily evaluated. +#[derive(Clone, Debug, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +pub enum FunctionalTypedDictFieldsEvaluation<'db> { + /// Field types need to be lazily evaluated. + Lazy, + /// The fields are eagerly specified with their types already resolved. + Eager(Box<[(Name, Type<'db>, bool)]>), +} + /// A TypedDict created via the functional form `TypedDict("Name", {"key": Type, ...})`. /// /// For example: @@ -5239,22 +5254,157 @@ pub struct FunctionalTypedDictLiteral<'db> { #[returns(ref)] pub name: Name, - /// The fields as (name, type, is_required) tuples. - #[returns(ref)] - pub fields: Box<[(Name, Type<'db>, bool)]>, + /// The definition where this TypedDict was created. Used for lazy field type resolution. + definition: Option>, + + /// The fields. Don't use this field directly; use the `fields()` method instead + /// (to evaluate any lazy fields). + _fields: Option>, } impl get_size2::GetSize for FunctionalTypedDictLiteral<'_> {} +fn lazy_fields_cycle_initial<'db>( + _db: &'db dyn Db, + _id: salsa::Id, + _typeddict: FunctionalTypedDictLiteral<'db>, +) -> Box<[(Name, Type<'db>, bool)]> { + Box::new([]) +} + +#[expect(clippy::borrowed_box)] +fn lazy_fields_cycle_recover<'db>( + _db: &'db dyn Db, + _cycle: &salsa::Cycle, + _previous: &Box<[(Name, Type<'db>, bool)]>, + current: Box<[(Name, Type<'db>, bool)]>, + _typeddict: FunctionalTypedDictLiteral<'db>, +) -> Box<[(Name, Type<'db>, bool)]> { + // Cycle recovery for recursive `TypedDict`s. The `current` value contains the fields + // resolved so far, which may include self-references that resolved to the `TypedDict` + // type being defined. This is the correct behavior for recursive types like: + // `RecursiveMovie = TypedDict("RecursiveMovie", {"predecessor": "RecursiveMovie"})` + current +} + +#[salsa::tracked] impl<'db> FunctionalTypedDictLiteral<'db> { - /// Get the metaclass of this functional TypedDict. + /// Get the resolved fields of this `TypedDict`. /// - /// TypedDicts always have `type` as their metaclass. + /// Returns the fields as `(name, type, is_required)` tuples. + /// If fields are lazily evaluated, this will trigger resolution. + pub(crate) fn fields(self, db: &'db dyn Db) -> Box<[(Name, Type<'db>, bool)]> { + match self._fields(db) { + Some(FunctionalTypedDictFieldsEvaluation::Eager(fields)) => fields, + Some(FunctionalTypedDictFieldsEvaluation::Lazy) => self.lazy_fields(db), + None => Box::new([]), + } + } + + #[salsa::tracked( + cycle_fn=lazy_fields_cycle_recover, + cycle_initial=lazy_fields_cycle_initial, + heap_size=ruff_memory_usage::heap_size + )] + fn lazy_fields(self, db: &'db dyn Db) -> Box<[(Name, Type<'db>, bool)]> { + let Some(definition) = self.definition(db) else { + return Box::new([]); + }; + + let module = parsed_module(db, definition.file(db)).load(db); + + let DefinitionKind::Assignment(assignment) = definition.kind(db) else { + return Box::new([]); + }; + + let value = assignment.value(&module); + let ast::Expr::Call(call_expr) = value else { + return Box::new([]); + }; + + // Determine the `total` parameter value (default is true). + let is_total = call_expr + .arguments + .find_keyword("total") + .map(|kw| { + let ty = definition_expression_type(db, definition, &kw.value); + ty.bool(db).is_always_true() + }) + .unwrap_or(true); + + // Find the fields argument (second positional argument or 'fields' keyword). + let fields_arg = call_expr.arguments.find_positional(1).or_else(|| { + call_expr + .arguments + .find_keyword("fields") + .map(|kw| &kw.value) + }); + + let Some(ast::Expr::Dict(dict_expr)) = fields_arg else { + return Box::new([]); + }; + + // Build fields by extracting names and types from the dict literal. + let mut fields: Vec<(Name, Type<'db>, bool)> = Vec::with_capacity(dict_expr.items.len()); + + for item in &dict_expr.items { + // Each key should be a string literal. + let Some(key_expr) = &item.key else { + continue; + }; + let key_ty = definition_expression_type(db, definition, key_expr); + let Some(key_lit) = key_ty.as_string_literal() else { + continue; + }; + let field_name = Name::new(key_lit.value(db)); + + // Get the field type. + let field_ty = definition_expression_type(db, definition, &item.value); + + // Determine is_required by examining the AST for Required/NotRequired syntax. + let is_required = detect_required_from_ast(&item.value).unwrap_or(is_total); + + fields.push((field_name, field_ty, is_required)); + } + + fields.into_boxed_slice() + } +} + +/// Detect whether an expression has `Required` or `NotRequired` qualifier syntactically. +/// Returns `Some(true)` for `Required`, `Some(false)` for `NotRequired`, `None` otherwise. +fn detect_required_from_ast(expr: &ast::Expr) -> Option { + // Check for Required[...] or NotRequired[...] syntax. + let ast::Expr::Subscript(subscript) = expr else { + return None; + }; + + let qualifier_name = match subscript.value.as_ref() { + ast::Expr::Attribute(attr) => Some(attr.attr.id()), + ast::Expr::Name(name) => Some(name.id()), + _ => None, + }?; + + if qualifier_name == "Required" { + Some(true) + } else if qualifier_name == "NotRequired" { + Some(false) + } else { + None + } +} + +impl<'db> FunctionalTypedDictLiteral<'db> { + /// Get the metaclass of this functional `TypedDict`. + /// + /// `TypedDict`s always have `type` as their metaclass. + #[expect(clippy::unused_self)] pub(crate) fn metaclass(self, db: &'db dyn Db) -> Type<'db> { KnownClass::Type.to_class_literal(db) } - /// Compute the dict base type that this TypedDict inherits from. + /// Compute the dict base type that this `TypedDict` inherits from. + #[expect(clippy::unused_self)] pub(crate) fn dict_base_type(self, db: &'db dyn Db) -> ClassType<'db> { KnownClass::Dict .to_class_literal(db) @@ -5266,7 +5416,7 @@ impl<'db> FunctionalTypedDictLiteral<'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, _is_required) in self.fields(db).iter() { + for (field_name, field_ty, _is_required) in self.fields(db).as_ref() { if field_name.as_str() == name { return Place::bound(*field_ty).into(); } @@ -5301,7 +5451,7 @@ impl<'db> FunctionalTypedDictLiteral<'db> { .class_member(db, name, policy) } - /// Generate synthesized class members for TypedDicts. + /// Generate synthesized class members for `TypedDict`s. fn synthesized_class_member(self, db: &'db dyn Db, name: &str) -> Option> { let typed_dict_type = TypedDictType::new(ClassType::NonGeneric(self.into())); let items = typed_dict_type.items(db); @@ -5309,14 +5459,14 @@ impl<'db> FunctionalTypedDictLiteral<'db> { synthesize_typed_dict_class_member(db, name, instance_ty, items) } - /// Returns an instance type for this functional TypedDict. + /// Returns an instance type for this functional `TypedDict`. pub(crate) fn to_instance(self, db: &'db dyn Db) -> Type<'db> { Type::instance(db, ClassType::NonGeneric(self.into())) } - /// Create a `Type::TypedDict` instance type from this functional TypedDict. + /// Create a `Type::TypedDict` instance type from this functional `TypedDict`. /// - /// This creates a `TypedDictType::Class` variant, which allows TypedDict operations + /// This creates a `TypedDictType::Class` variant, which allows `TypedDict` operations /// like subscript access to work correctly via synthesized `__getitem__`. pub(crate) fn to_typed_dict_type(self, _db: &'db dyn Db) -> Type<'db> { Type::typed_dict(ClassType::NonGeneric(self.into())) diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index 14a627e636..295aaab7a9 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -822,7 +822,20 @@ impl<'db> FmtDetailed<'db> for DisplayRepresentation<'db> { }, Type::SpecialForm(special_form) => { f.set_invalid_type_annotation(); - write!(f.with_type(self.ty), "") + // Display internal schema types with user-friendly names. + match special_form { + SpecialFormType::TypedDictFieldsSchema => { + f.with_type(self.ty).write_str("dict[str, type]") + } + SpecialFormType::TypingNamedTupleFieldsSchema + | SpecialFormType::CollectionsNamedTupleFieldsSchema => { + f.with_type(self.ty).write_str("Iterable[tuple[str, type]]") + } + SpecialFormType::CollectionsNamedTupleDefaultsSchema => { + f.with_type(self.ty).write_str("Iterable[object]") + } + _ => write!(f.with_type(self.ty), ""), + } } Type::KnownInstance(known_instance) => known_instance .display_with(self.db, self.settings.clone()) diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 334e53f5e5..cfaeace6f4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -53,7 +53,8 @@ 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::{ - ClassLiteral, CodeGeneratorKind, FieldKind, MetaclassErrorKind, MethodDecorator, + ClassLiteral, CodeGeneratorKind, FieldKind, FunctionalTypedDictFieldsEvaluation, + FunctionalTypedDictLiteral, MetaclassErrorKind, MethodDecorator, }; use crate::types::context::{InNoTypeCheck, InferContext}; use crate::types::cyclic::CycleDetector; @@ -5257,7 +5258,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_newtype_expression(target, call_expr, definition) } Some(_) | None => { - self.infer_call_expression_impl(call_expr, callable_type, tcx) + if callable_type.as_special_form() == Some(SpecialFormType::TypedDict) { + self.infer_functional_typeddict(call_expr, definition) + } else { + self.infer_call_expression_impl(call_expr, callable_type, tcx) + } } }; @@ -5788,6 +5793,86 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ))) } + /// Handle functional `TypedDict` creation: `Movie = TypedDict("Movie", {"name": str, ...})`. + /// + /// This method creates a `FunctionalTypedDictLiteral` with lazy field type resolution + /// to avoid cycles when dealing with recursive `TypedDict`s. + fn infer_functional_typeddict( + &mut self, + call_expr: &ast::ExprCall, + definition: Definition<'db>, + ) -> Type<'db> { + let db = self.db(); + let arguments = &call_expr.arguments; + + // Get the dict literal from the second argument (positional or keyword "fields"). + // Check this first, before inferring any expressions, so we can fall back cleanly. + let fields_arg = arguments + .find_positional(1) + .or_else(|| arguments.find_keyword("fields").map(|kw| &kw.value)); + + let Some(ast::Expr::Dict(dict_expr)) = fields_arg else { + // Fields argument is not a dict literal (e.g., dict() call or kwargs), + // fall back to normal call inference. + return self.infer_call_expression_impl( + call_expr, + Type::SpecialForm(SpecialFormType::TypedDict), + TypeContext::default(), + ); + }; + + // Check for dict unpacking and non-string-literal keys before inferring any expressions. + // This ensures we can fall back cleanly without double-inference issues. + for item in &dict_expr.items { + match &item.key { + // Dict unpacking (`**other`) is not supported. + None => { + return self.infer_call_expression_impl( + call_expr, + Type::SpecialForm(SpecialFormType::TypedDict), + TypeContext::default(), + ); + } + // Keys must be string literals. + Some(key) if !matches!(key, ast::Expr::StringLiteral(_)) => { + return self.infer_call_expression_impl( + call_expr, + Type::SpecialForm(SpecialFormType::TypedDict), + TypeContext::default(), + ); + } + _ => {} + } + } + + // Extract the name from the first argument. + let name_ty = arguments + .find_positional(0) + .map(|arg| self.infer_expression(arg, TypeContext::default())); + + let Some(name) = name_ty.and_then(|ty| ty.as_string_literal().map(|s| s.value(db))) else { + // Name is not a valid string literal, fall back to normal call inference. + return self.infer_call_expression_impl( + call_expr, + Type::SpecialForm(SpecialFormType::TypedDict), + TypeContext::default(), + ); + }; + + // Mark for deferred evaluation since field types need to be resolved later. + self.deferred.insert(definition, self.multi_inference_state); + + // Create the TypedDict literal with lazy fields. + let typeddict = FunctionalTypedDictLiteral::new( + db, + ast::name::Name::new(name), + Some(definition), + Some(FunctionalTypedDictFieldsEvaluation::Lazy), + ); + + Type::ClassLiteral(ClassLiteral::FunctionalTypedDict(typeddict)) + } + /// Extract fields from a list or tuple literal for `typing.NamedTuple`. fn infer_typing_namedtuple_fields_schema( &mut self, @@ -5887,7 +5972,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } fn infer_assignment_deferred(&mut self, value: &ast::Expr) { - // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec / NewType. + // Infer deferred bounds/constraints/defaults of a legacy TypeVar / ParamSpec / NewType / TypedDict. let ast::Expr::Call(ast::ExprCall { func, arguments, .. }) = value @@ -5897,6 +5982,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.as_special_form() == Some(SpecialFormType::TypedDict) { + self.infer_functional_typeddict_deferred(arguments); + return; + } + let known_class = func_ty .as_class_literal() .and_then(|cls| cls.known(self.db())); @@ -5922,6 +6013,28 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + /// Infer field types for a functional `TypedDict` in the deferred pass. + fn infer_functional_typeddict_deferred(&mut self, arguments: &ast::Arguments) { + // Get the dict literal from the second argument (positional or keyword "fields"). + let fields_arg = arguments + .find_positional(1) + .or_else(|| arguments.find_keyword("fields").map(|kw| &kw.value)); + + let Some(ast::Expr::Dict(dict_expr)) = fields_arg else { + return; + }; + + // Infer field types as annotation expressions. + for item in &dict_expr.items { + // Infer key as a regular expression. + if let Some(key) = &item.key { + self.infer_expression(key, TypeContext::default()); + } + // Infer value as an annotation expression to capture Required/NotRequired qualifiers. + self.infer_annotation_expression(&item.value, DeferredExpressionState::Deferred); + } + } + // Infer the deferred base type of a NewType. fn infer_newtype_assignment_deferred(&mut self, arguments: &ast::Arguments) { let inferred = self.infer_type_expression(&arguments.args[1]);