use internal `TypedDictSchema` type for functional `TypedDict` constructor

This commit is contained in:
Ibraheem Ahmed 2025-10-07 15:35:33 -04:00
parent f2a25b0fd7
commit b753851379
10 changed files with 131 additions and 84 deletions

View File

@ -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 | <class 'str'>]`"
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:

View File

@ -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.

View File

@ -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,
)),

View File

@ -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(),
}
}

View File

@ -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()),

View File

@ -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)),

View File

@ -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),
)))
}

View File

@ -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!(

View File

@ -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",
}
}
}

View File

@ -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<Name, Field<'db>>) -> Self {
TypedDictType::Synthesized(SynthesizedTypedDictType::new(
db,
None,
TypedDictParams::default(),
items,
))
}
pub(crate) fn items(&self, db: &'db dyn Db) -> Cow<'db, FxOrderMap<Name, Field<'db>>> {
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<Name, TypedDictSchemaField<'db>>,
}
// The Salsa heap is tracked separately.
impl get_size2::GetSize for TypedDictSchema<'_> {}
impl<'db> TypedDictSchema<'db> {
fn heap_size((items,): &(FxOrderMap<Name, TypedDictSchemaField<'db>>,)) -> 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<Name>,
pub(crate) name: Name,
pub(crate) params: TypedDictParams,
@ -441,7 +455,7 @@ impl<'db> SynthesizedTypedDictType<'db> {
}
fn heap_size(
(name, params, items): &(Option<Name>, TypedDictParams, FxOrderMap<Name, Field<'db>>),
(name, params, items): &(Name, TypedDictParams, FxOrderMap<Name, Field<'db>>),
) -> usize {
ruff_memory_usage::heap_size(name)
+ ruff_memory_usage::heap_size(params)