From b7538513798f59b18a39a96ebff211fd2fedc8c6 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Tue, 7 Oct 2025 15:35:33 -0400 Subject: [PATCH] use internal `TypedDictSchema` type for functional `TypedDict` constructor --- .../resources/mdtest/typed_dict.md | 10 +++- crates/ty_python_semantic/src/types.rs | 45 +++++++++++------ .../ty_python_semantic/src/types/call/bind.rs | 31 ++++++------ crates/ty_python_semantic/src/types/class.rs | 6 +-- .../src/types/class_base.rs | 4 +- .../ty_python_semantic/src/types/display.rs | 8 +-- .../src/types/infer/builder.rs | 34 ++++++------- .../types/infer/builder/type_expression.rs | 13 ++++- .../src/types/special_form.rs | 14 +++++- .../src/types/typed_dict.rs | 50 ++++++++++++------- 10 files changed, 131 insertions(+), 84 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 6e05e7f729..1d96506729 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -97,10 +97,18 @@ from typing import TypedDict from typing_extensions import Required, NotRequired Person = TypedDict("Person", {"name": Required[str], "age": int | None}) - reveal_type(Person) # revealed: typing.TypedDict ``` +The `TypedDict` schema must be passed directly as the second argument: + +```py +fields = {"name": str} + +# error: [invalid-argument-type] "Argument is incorrect: Expected `_TypedDictSchema`, found `dict[Unknown | str, Unknown | ]`" +Other = TypedDict("Other", fields) +``` + New inhabitants can be created from dict literals. When accessing keys, the correct types should be inferred based on the `TypedDict` definition: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index d166b8c7c0..7bae91b8c8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -66,7 +66,7 @@ use crate::types::mro::{Mro, MroError, MroIterator}; pub(crate) use crate::types::narrow::infer_narrowing_constraint; use crate::types::signatures::{ParameterForm, walk_signature}; use crate::types::tuple::TupleSpec; -use crate::types::typed_dict::SynthesizedTypedDictType; +use crate::types::typed_dict::{SynthesizedTypedDictType, TypedDictSchema}; pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_typed_dict_type}; use crate::types::variance::{TypeVarVariance, VarianceInferable}; use crate::types::visitor::any_over_type; @@ -1523,6 +1523,11 @@ impl<'db> Type<'db> { .has_relation_to_impl(db, right, relation, visitor) } + ( + Type::KnownInstance(KnownInstanceType::TypedDictSchema(_)), + Type::SpecialForm(SpecialFormType::TypedDictSchema), + ) => ConstraintSet::from(true), + // Dynamic is only a subtype of `object` and only a supertype of `Never`; both were // handled above. It's always assignable, though. // @@ -4767,10 +4772,9 @@ impl<'db> Type<'db> { Parameter::positional_only(Some(Name::new_static("typename"))) .with_annotated_type(KnownClass::Str.to_instance(db)), Parameter::positional_only(Some(Name::new_static("fields"))) - // We infer this type as an anonymous `TypedDict` instance, such that the - // complete `TypeDict` instance can be constructed from it after. Note that - // `typing.TypedDict` is not otherwise allowed in type-form expressions. - .with_annotated_type(Type::SpecialForm(SpecialFormType::TypedDict)) + .with_annotated_type(Type::SpecialForm( + SpecialFormType::TypedDictSchema, + )) .with_default_type(Type::any()), Parameter::keyword_only(Name::new_static("total")) .with_annotated_type(KnownClass::Bool.to_instance(db)) @@ -5678,6 +5682,12 @@ impl<'db> Type<'db> { KnownInstanceType::TypedDictType(typed_dict) => { Ok(Type::TypedDict(TypedDictType::Synthesized(*typed_dict))) } + KnownInstanceType::TypedDictSchema(_) => Err(InvalidTypeExpressionError { + invalid_expressions: smallvec::smallvec![InvalidTypeExpression::InvalidType( + *self, scope_id + )], + fallback_type: Type::unknown(), + }), KnownInstanceType::Deprecated(_) => Err(InvalidTypeExpressionError { invalid_expressions: smallvec::smallvec![InvalidTypeExpression::Deprecated], fallback_type: Type::unknown(), @@ -5768,6 +5778,12 @@ impl<'db> Type<'db> { ], fallback_type: Type::unknown(), }), + SpecialFormType::TypedDictSchema => Err(InvalidTypeExpressionError { + invalid_expressions: smallvec::smallvec_inline![ + InvalidTypeExpression::InvalidType(*self, scope_id) + ], + fallback_type: Type::unknown(), + }), SpecialFormType::Literal | SpecialFormType::Union @@ -6875,6 +6891,10 @@ pub enum KnownInstanceType<'db> { /// A single instance of `typing.TypedDict`. TypedDictType(SynthesizedTypedDictType<'db>), + /// An internal type representing the dictionary literal argument to the functional `TypedDict` + /// constructor. + TypedDictSchema(TypedDictSchema<'db>), + /// A single instance of `warnings.deprecated` or `typing_extensions.deprecated` Deprecated(DeprecatedInstance<'db>), @@ -6905,7 +6925,9 @@ fn walk_known_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Sized>( KnownInstanceType::TypedDictType(typed_dict) => { visitor.visit_typed_dict_type(db, TypedDictType::Synthesized(typed_dict)); } - KnownInstanceType::Deprecated(_) | KnownInstanceType::ConstraintSet(_) => { + KnownInstanceType::Deprecated(_) + | KnownInstanceType::ConstraintSet(_) + | KnownInstanceType::TypedDictSchema(_) => { // Nothing to visit } KnownInstanceType::Field(field) => { @@ -6930,15 +6952,8 @@ impl<'db> KnownInstanceType<'db> { Self::TypedDictType(typed_dict) => { Self::TypedDictType(typed_dict.normalized_impl(db, visitor)) } - Self::Deprecated(deprecated) => { - // Nothing to normalize - Self::Deprecated(deprecated) - } Self::Field(field) => Self::Field(field.normalized_impl(db, visitor)), - Self::ConstraintSet(set) => { - // Nothing to normalize - Self::ConstraintSet(set) - } + Self::Deprecated(_) | Self::TypedDictSchema(_) | Self::ConstraintSet(_) => self, } } @@ -6951,6 +6966,7 @@ impl<'db> KnownInstanceType<'db> { } Self::TypeAliasType(_) => KnownClass::TypeAliasType, Self::TypedDictType(_) => KnownClass::TypedDictFallback, + Self::TypedDictSchema(_) => KnownClass::Object, Self::Deprecated(_) => KnownClass::Deprecated, Self::Field(_) => KnownClass::Field, Self::ConstraintSet(_) => KnownClass::ConstraintSet, @@ -7008,6 +7024,7 @@ impl<'db> KnownInstanceType<'db> { } } KnownInstanceType::TypedDictType(_) => f.write_str("typing.TypedDict"), + KnownInstanceType::TypedDictSchema(_) => f.write_str("_TypedDictSchema"), // This is a legacy `TypeVar` _outside_ of any generic class or function, so we render // it as an instance of `typing.TypeVar`. Inside of a generic class or function, we'll // have a `Type::TypeVar(_)`, which is rendered as the typevar's name. diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index a321edcdfd..8e48cd7c5d 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -1098,8 +1098,12 @@ impl<'db> Bindings<'db> { }, Type::SpecialForm(SpecialFormType::TypedDict) => { - let [Some(name), Some(Type::TypedDict(typed_dict)), total, ..] = - overload.parameter_types() + let [ + Some(name), + Some(Type::KnownInstance(KnownInstanceType::TypedDictSchema(schema))), + total, + .., + ] = overload.parameter_types() else { continue; }; @@ -1113,26 +1117,19 @@ impl<'db> Bindings<'db> { let is_total = to_bool(total, true); params.set(TypedDictParams::TOTAL, is_total); - let items = typed_dict.items(db); - let items = items + let items = schema + .items(db) .iter() .map(|(name, field)| { - let FieldKind::TypedDict { - is_required, - is_read_only, - } = field.kind - else { - unreachable!() - }; - let field = Field { + single_declaration: None, + declared_ty: field.declared_ty, kind: FieldKind::TypedDict { - is_read_only, - // If there is no explicit `Required`/`NotRequired` qualifier, use + is_read_only: field.is_read_only, + // If there is no explicit `Required` or `NotRequired` qualifier, use // the `total` parameter. - is_required: is_required.unwrap_or(is_total).into(), + is_required: field.is_required.unwrap_or(is_total), }, - ..field.clone() }; (name.clone(), field) @@ -1142,7 +1139,7 @@ impl<'db> Bindings<'db> { overload.set_return_type(Type::KnownInstance( KnownInstanceType::TypedDictType(SynthesizedTypedDictType::new( db, - Some(Name::new(name.value(db))), + Name::new(name.value(db)), params, items, )), diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index a59c2b3cc3..8e19150f35 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1286,7 +1286,7 @@ pub(crate) enum FieldKind<'db> { /// `TypedDict` field metadata TypedDict { /// Whether this field is required - is_required: Truthiness, + is_required: bool, /// Whether this field is marked read-only is_read_only: bool, }, @@ -1313,7 +1313,7 @@ impl<'db> Field<'db> { FieldKind::Dataclass { init, default_ty, .. } => default_ty.is_none() && *init, - FieldKind::TypedDict { is_required, .. } => is_required.is_always_true(), + FieldKind::TypedDict { is_required, .. } => *is_required, } } @@ -2566,7 +2566,7 @@ impl<'db> ClassLiteral<'db> { }; FieldKind::TypedDict { - is_required: Truthiness::from(is_required), + is_required, is_read_only: attr.is_read_only(), } } diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 25ad8fb796..98dd5a501f 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -170,6 +170,7 @@ impl<'db> ClassBase<'db> { KnownInstanceType::TypeAliasType(_) | KnownInstanceType::TypeVar(_) | KnownInstanceType::TypedDictType(_) + | KnownInstanceType::TypedDictSchema(_) | KnownInstanceType::Deprecated(_) | KnownInstanceType::Field(_) | KnownInstanceType::ConstraintSet(_) => None, @@ -201,7 +202,8 @@ impl<'db> ClassBase<'db> { | SpecialFormType::TypeOf | SpecialFormType::CallableTypeOf | SpecialFormType::AlwaysTruthy - | SpecialFormType::AlwaysFalsy => None, + | SpecialFormType::AlwaysFalsy + | SpecialFormType::TypedDictSchema => None, SpecialFormType::Any => Some(Self::Dynamic(DynamicType::Any)), SpecialFormType::Unknown => Some(Self::unknown()), diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index bf05e462bb..d5b8a290c4 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -517,13 +517,7 @@ impl Display for DisplayRepresentation<'_> { .0 .display_with(self.db, self.settings.clone()) .fmt(f), - TypedDictType::Synthesized(synthesized) => { - let name = synthesized - .name(self.db) - .expect("cannot have incomplete `TypedDict` in type expression"); - - write!(f, "{name}") - } + TypedDictType::Synthesized(synthesized) => synthesized.name(self.db).fmt(f), }, Type::TypeAlias(alias) => f.write_str(alias.name(self.db)), diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1e1e659552..b692d55ed3 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -46,9 +46,7 @@ use crate::semantic_index::{ }; use crate::types::call::bind::MatchingOverloadIndex; use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind}; -use crate::types::class::{ - CodeGeneratorKind, Field, FieldKind, MetaclassErrorKind, MethodDecorator, -}; +use crate::types::class::{CodeGeneratorKind, FieldKind, MetaclassErrorKind, MethodDecorator}; use crate::types::context::{InNoTypeCheck, InferContext}; use crate::types::cyclic::CycleDetector; use crate::types::diagnostic::{ @@ -86,7 +84,8 @@ use crate::types::signatures::Signature; use crate::types::subclass_of::SubclassOfInner; use crate::types::tuple::{Tuple, TupleLength, TupleSpec, TupleType}; use crate::types::typed_dict::{ - TypedDictAssignmentKind, validate_typed_dict_constructor, validate_typed_dict_dict_literal, + TypedDictAssignmentKind, TypedDictSchema, TypedDictSchemaField, + validate_typed_dict_constructor, validate_typed_dict_dict_literal, validate_typed_dict_key_assignment, }; use crate::types::visitor::any_over_type; @@ -97,7 +96,7 @@ use crate::types::{ Parameters, SpecialFormType, SubclassOfType, TrackedConstraintSet, Truthiness, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeQualifiers, TypeVarBoundOrConstraintsEvaluation, TypeVarDefaultEvaluation, TypeVarInstance, TypeVarKind, - TypedDictType, UnionBuilder, UnionType, binding_type, todo_type, + UnionBuilder, UnionType, binding_type, todo_type, }; use crate::types::{ClassBase, add_inferred_python_version_hint_to_diagnostic}; use crate::unpack::{EvaluationMode, UnpackPosition}; @@ -5478,8 +5477,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return ty; } - // Infer the dictionary literal passed to the `TypedDict` constructor. - if let Some(ty) = self.infer_typed_dict_constructor_literal(dict, tcx) { + // Infer the dictionary literal passed to the functional `TypedDict` constructor. + if let Some(ty) = self.infer_typed_dict_schema(dict, tcx) { return ty; } @@ -5631,8 +5630,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .map(|_| Type::TypedDict(typed_dict)) } - // Infer the dictionary literal passed to the `TypedDict` constructor. - fn infer_typed_dict_constructor_literal( + // Infer the dictionary literal passed to the functional `TypedDict` constructor. + fn infer_typed_dict_schema( &mut self, dict: &ast::ExprDict, tcx: TypeContext<'db>, @@ -5643,7 +5642,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { items, } = dict; - let Some(Type::SpecialForm(SpecialFormType::TypedDict)) = tcx.annotation else { + let Some(Type::SpecialForm(SpecialFormType::TypedDictSchema)) = tcx.annotation else { return None; }; @@ -5673,22 +5672,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Truthiness::Ambiguous }; - let field = Field { - single_declaration: None, + let field = TypedDictSchemaField { + is_required, declared_ty: field_ty.inner_type(), - kind: FieldKind::TypedDict { - is_required, - is_read_only: field_ty.qualifiers.contains(TypeQualifiers::READ_ONLY), - }, + is_read_only: field_ty.qualifiers.contains(TypeQualifiers::READ_ONLY), }; typed_dict_items.insert(ast::name::Name::new(key.value(self.db())), field); } - // Create an anonymous `TypedDict` from the items, to be completed by the `TypedDict` constructor binding. - Some(Type::TypedDict(TypedDictType::from_items( - self.db(), - typed_dict_items, + Some(Type::KnownInstance(KnownInstanceType::TypedDictSchema( + TypedDictSchema::new(self.db(), typed_dict_items), ))) } 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 a84983bdc7..ebd1ca8ea5 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 @@ -769,6 +769,15 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } Type::unknown() } + KnownInstanceType::TypedDictSchema(_) => { + self.infer_type_expression(slice); + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { + builder.into_diagnostic(format_args!( + "`_TypedDictSchema` is not allowed in type expressions", + )); + } + Type::unknown() + } KnownInstanceType::TypeVar(_) => { self.infer_type_expression(slice); todo_type!("TypeVar annotations") @@ -1383,7 +1392,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { SpecialFormType::Tuple => { Type::tuple(self.infer_tuple_type_expression(arguments_slice)) } - SpecialFormType::Generic | SpecialFormType::Protocol => { + SpecialFormType::Generic + | SpecialFormType::Protocol + | SpecialFormType::TypedDictSchema => { self.infer_expression(arguments_slice, TypeContext::default()); if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { builder.into_diagnostic(format_args!( diff --git a/crates/ty_python_semantic/src/types/special_form.rs b/crates/ty_python_semantic/src/types/special_form.rs index 7a8a19e45b..ea7e2f1977 100644 --- a/crates/ty_python_semantic/src/types/special_form.rs +++ b/crates/ty_python_semantic/src/types/special_form.rs @@ -127,6 +127,10 @@ pub enum SpecialFormType { /// Typeshed defines this symbol as a class, but this isn't accurate: it's actually a factory function /// at runtime. We therefore represent it as a special form internally. NamedTuple, + + /// An internal type representing the dictionary literal argument to the functional `TypedDict` + /// constructor. + TypedDictSchema, } impl SpecialFormType { @@ -179,7 +183,9 @@ impl SpecialFormType { | Self::ChainMap | Self::OrderedDict => KnownClass::StdlibAlias, - Self::Unknown | Self::AlwaysTruthy | Self::AlwaysFalsy => KnownClass::Object, + Self::Unknown | Self::AlwaysTruthy | Self::AlwaysFalsy | Self::TypedDictSchema => { + KnownClass::Object + } Self::NamedTuple => KnownClass::FunctionType, } @@ -264,6 +270,8 @@ impl SpecialFormType { | Self::Intersection | Self::TypeOf | Self::CallableTypeOf => module.is_ty_extensions(), + + Self::TypedDictSchema => false, } } @@ -324,7 +332,8 @@ impl SpecialFormType { | Self::ReadOnly | Self::Protocol | Self::Any - | Self::Generic => false, + | Self::Generic + | Self::TypedDictSchema => false, } } @@ -375,6 +384,7 @@ impl SpecialFormType { SpecialFormType::Protocol => "typing.Protocol", SpecialFormType::Generic => "typing.Generic", SpecialFormType::NamedTuple => "typing.NamedTuple", + SpecialFormType::TypedDictSchema => "_TypedDictSchema", } } } diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index 8a9ddda465..691b1dd71b 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -18,7 +18,7 @@ use crate::types::generics::GenericContext; use crate::types::variance::TypeVarVariance; use crate::types::{ BoundTypeVarInstance, CallableSignature, CallableType, KnownClass, NormalizedVisitor, - Parameter, Parameters, Signature, StringLiteralType, SubclassOfType, UnionType, + Parameter, Parameters, Signature, StringLiteralType, SubclassOfType, Truthiness, UnionType, }; use crate::{Db, FxOrderMap}; @@ -62,19 +62,6 @@ impl<'db> TypedDictType<'db> { TypedDictType::FromClass(class) } - /// Returns an anonymous (incomplete) `TypedDictType` from its items. - /// - /// This is used to instantiate a `TypedDictType` from the dictionary literal passed to a - /// `typing.TypedDict` constructor (functional form for creating `TypedDict`s). - pub(crate) fn from_items(db: &'db dyn Db, items: FxOrderMap>) -> Self { - TypedDictType::Synthesized(SynthesizedTypedDictType::new( - db, - None, - TypedDictParams::default(), - items, - )) - } - pub(crate) fn items(&self, db: &'db dyn Db) -> Cow<'db, FxOrderMap>> { match self { TypedDictType::Synthesized(synthesized) => Cow::Borrowed(synthesized.items(db)), @@ -393,12 +380,39 @@ impl<'db> TypedDictType<'db> { } } +/// An internal type representing the dictionary literal argument to the functional `TypedDict` +/// constructor. +#[salsa::interned(debug, heap_size=TypedDictSchema::heap_size)] +#[derive(PartialOrd, Ord)] +pub struct TypedDictSchema<'db> { + pub(crate) items: FxOrderMap>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for TypedDictSchema<'_> {} + +impl<'db> TypedDictSchema<'db> { + fn heap_size((items,): &(FxOrderMap>,)) -> usize { + ruff_memory_usage::order_map_heap_size(items) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize)] +pub struct TypedDictSchemaField<'db> { + /// The declared type of the field + pub(crate) declared_ty: Type<'db>, + + /// Whether this field is required. + pub(crate) is_required: Truthiness, + + /// Whether this field is marked read-only. + pub(crate) is_read_only: bool, +} + #[salsa::interned(debug, heap_size=SynthesizedTypedDictType::heap_size)] #[derive(PartialOrd, Ord)] pub struct SynthesizedTypedDictType<'db> { - // The dictionary literal passed to the `TypedDict` constructor is inferred as - // an anonymous (incomplete) `SynthesizedTypedDictType`. - pub(crate) name: Option, + pub(crate) name: Name, pub(crate) params: TypedDictParams, @@ -441,7 +455,7 @@ impl<'db> SynthesizedTypedDictType<'db> { } fn heap_size( - (name, params, items): &(Option, TypedDictParams, FxOrderMap>), + (name, params, items): &(Name, TypedDictParams, FxOrderMap>), ) -> usize { ruff_memory_usage::heap_size(name) + ruff_memory_usage::heap_size(params)