diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 9fa17eddf669b..c9d892e2e45df 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1849,7 +1849,7 @@ msg = Message(id=1, content="Hello") # No errors for yet-unsupported features (`closed`): OtherMessage = TypedDict("OtherMessage", {"id": int, "content": str}, closed=True) -reveal_type(Message.__required_keys__) # revealed: @Todo(Support for functional `TypedDict`) +reveal_type(Message.__required_keys__) # revealed: @Todo(Functional TypedDicts) # TODO: this should be an error msg.content @@ -1883,6 +1883,53 @@ def bad( ): ... ``` +### `Required` and `NotRequired` not allowed outside `TypedDict` + +```py +from typing_extensions import Required, NotRequired, TypedDict + +# error: [invalid-type-form] "`Required` is only allowed in TypedDict fields" +x: Required[int] +# error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields" +y: NotRequired[str] + +class MyClass: + # error: [invalid-type-form] "`Required` is only allowed in TypedDict fields" + x: Required[int] + # error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields" + y: NotRequired[str] + +def f(): + # error: [invalid-type-form] "`Required` is only allowed in TypedDict fields" + x: Required[int] = 1 + # error: [invalid-type-form] "`NotRequired` is only allowed in TypedDict fields" + y: NotRequired[str] = "" + +# fine +MyFunctionalTypedDict = TypedDict("MyFunctionalTypedDict", {"not-an-identifier": Required[int]}) + +class FunctionalTypedDictSubclass(MyFunctionalTypedDict): + y: NotRequired[int] # fine +``` + +### Nested `Required` and `NotRequired` + +`Required` and `NotRequired` cannot be nested inside each other: + +```py +from typing_extensions import TypedDict, Required, NotRequired + +class TD(TypedDict): + # error: [invalid-type-form] "`typing.Required` cannot be nested inside `Required` or `NotRequired`" + a: Required[Required[int]] + # error: [invalid-type-form] "`typing.NotRequired` cannot be nested inside `Required` or `NotRequired`" + b: NotRequired[NotRequired[int]] + # error: [invalid-type-form] "`typing.Required` cannot be nested inside `Required` or `NotRequired`" + c: Required[NotRequired[int]] + # error: [invalid-type-form] "`typing.NotRequired` cannot be nested inside `Required` or `NotRequired`" + d: NotRequired[Required[int]] +``` + ### `dict`-subclass inhabitants Values that inhabit a `TypedDict` type must be instances of `dict` itself, not a subclass: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 166e2a7bc2dda..9cedb38fb8a8f 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -939,6 +939,7 @@ impl<'db> Type<'db> { DynamicType::Todo(_) | DynamicType::TodoStarredExpression | DynamicType::TodoUnpack + | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoTypeVarTuple => true, }) } @@ -4221,7 +4222,7 @@ impl<'db> Type<'db> { .with_annotated_type(Type::any()), ], ), - Type::unknown(), + Type::Dynamic(DynamicType::TodoFunctionalTypedDict), ), ) .into() @@ -6817,7 +6818,15 @@ impl<'db> Type<'db> { Self::AlwaysFalsy => Type::SpecialForm(SpecialFormType::AlwaysFalsy).definition(db), // These types have no definition - Self::Dynamic(DynamicType::Divergent(_) | DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression | DynamicType::TodoTypeVarTuple | DynamicType::UnspecializedTypeVar) + Self::Dynamic( + DynamicType::Divergent(_) + | DynamicType::Todo(_) + | DynamicType::TodoUnpack + | DynamicType::TodoStarredExpression + | DynamicType::TodoTypeVarTuple + | DynamicType::UnspecializedTypeVar + | DynamicType::TodoFunctionalTypedDict + ) | Self::Callable(_) | Self::TypeIs(_) | Self::TypeGuard(_) => None, @@ -7538,6 +7547,8 @@ pub enum DynamicType<'db> { TodoStarredExpression, /// A special Todo-variant for `TypeVarTuple` instances encountered in type expressions TodoTypeVarTuple, + /// A special Todo-variant for functional `TypedDict`s. + TodoFunctionalTypedDict, /// A type that is determined to be divergent during recursive type inference. Divergent(DivergentType), } @@ -7564,6 +7575,7 @@ impl std::fmt::Display for DynamicType<'_> { DynamicType::TodoUnpack => f.write_str("@Todo(typing.Unpack)"), DynamicType::TodoStarredExpression => f.write_str("@Todo(StarredExpression)"), DynamicType::TodoTypeVarTuple => f.write_str("@Todo(TypeVarTuple)"), + DynamicType::TodoFunctionalTypedDict => f.write_str("@Todo(Functional TypedDicts)"), DynamicType::Divergent(_) => f.write_str("Divergent"), } } @@ -8411,6 +8423,7 @@ impl<'db> TypeVarInstance<'db> { DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression + | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoTypeVarTuple => Parameters::todo(), DynamicType::Any | DynamicType::Unknown diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b3a7b4b73b720..ae7d999f4d8a8 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -49,7 +49,6 @@ use crate::types::{ KnownClass, KnownInstanceType, LiteralValueTypeKind, MemberLookupPolicy, NominalInstanceType, PropertyInstanceType, SpecialFormType, TypeAliasType, TypeContext, TypeVarBoundOrConstraints, TypeVarVariance, UnionBuilder, UnionType, WrapperDescriptorKind, enums, list_members, - todo_type, }; use crate::unpack::EvaluationMode; use crate::{DisplaySettings, Program}; @@ -1956,7 +1955,9 @@ impl<'db> Bindings<'db> { }, Type::SpecialForm(SpecialFormType::TypedDict) => { - overload.set_return_type(todo_type!("Support for functional `TypedDict`")); + overload.set_return_type(Type::Dynamic( + crate::types::DynamicType::TodoFunctionalTypedDict, + )); } // Not a special case diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 520846c731c1f..bfc189535ac18 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -59,6 +59,7 @@ impl<'db> ClassBase<'db> { ClassBase::Dynamic(DynamicType::UnspecializedTypeVar) => "UnspecializedTypeVar", ClassBase::Dynamic( DynamicType::Todo(_) + | DynamicType::TodoFunctionalTypedDict | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression | DynamicType::TodoTypeVarTuple, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 9d1c6670c624d..6dc8a30a5182e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -117,7 +117,7 @@ use crate::types::generics::{ GenericContext, InferableTypeVars, SpecializationBuilder, bind_typevar, enclosing_generic_contexts, typing_self, }; -use crate::types::infer::nearest_enclosing_function; +use crate::types::infer::{nearest_enclosing_class, nearest_enclosing_function}; use crate::types::mro::{DynamicMroErrorKind, StaticMroErrorKind}; use crate::types::newtype::NewType; use crate::types::special_form::AliasSpec; @@ -9225,6 +9225,41 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } } + + // `Required`, `NotRequired`, and `ReadOnly` are only valid inside TypedDict classes. + if declared.qualifiers.intersects( + TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED | TypeQualifiers::READ_ONLY, + ) { + let in_typed_dict = current_scope.kind() == ScopeKind::Class + && nearest_enclosing_class(self.db(), self.index, self.scope()).is_some_and( + |class| { + class.iter_mro(self.db(), None).any(|base| { + matches!( + base, + ClassBase::TypedDict + | ClassBase::Dynamic(DynamicType::TodoFunctionalTypedDict) + ) + }) + }, + ); + if !in_typed_dict { + for qualifier in [ + TypeQualifiers::REQUIRED, + TypeQualifiers::NOT_REQUIRED, + TypeQualifiers::READ_ONLY, + ] { + if declared.qualifiers.contains(qualifier) + && let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, annotation) + { + builder.into_diagnostic(format_args!( + "`{name}` is only allowed in TypedDict fields", + name = qualifier.name() + )); + } + } + } + } } if target @@ -11608,7 +11643,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Avoid false positives for the functional `TypedDict` form, which is currently // unsupported. - if let Some(Type::Dynamic(DynamicType::Todo(_))) = tcx.annotation { + if let Some(Type::Dynamic(DynamicType::TodoFunctionalTypedDict)) = tcx.annotation { return KnownClass::Dict .to_specialized_instance(self.db(), &[Type::unknown(), Type::unknown()]); } @@ -14318,6 +14353,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { (typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _, _) | (_, typevar @ Type::Dynamic(DynamicType::UnspecializedTypeVar), _) => Some(typevar), + (todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _, _) + | (_, todo @ Type::Dynamic(DynamicType::TodoFunctionalTypedDict), _) => Some(todo), + // When both operands are the same constrained TypeVar (e.g., `T: (int, str)`), // we check if the operation is valid for each constraint paired with itself. // This is different from treating it as a union, where we'd check all combinations. diff --git a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs index 7a245bd545f33..9d3a807a36b5b 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/annotation_expression.rs @@ -309,6 +309,23 @@ impl<'db> TypeInferenceBuilder<'db, '_> { "`ClassVar` cannot contain type variables", ); } + + // Reject nested `Required`/`NotRequired`, e.g. + // `Required[Required[int]]` or `Required[NotRequired[int]]`. + if matches!( + qualifier, + TypeQualifier::Required | TypeQualifier::NotRequired + ) && type_and_qualifiers.qualifiers.intersects( + TypeQualifiers::REQUIRED | TypeQualifiers::NOT_REQUIRED, + ) && let Some(builder) = + self.context.report_lint(&INVALID_TYPE_FORM, subscript) + { + builder.into_diagnostic(format_args!( + "`{qualifier}` cannot be nested inside \ + `Required` or `NotRequired`", + )); + } + type_and_qualifiers.with_qualifier(TypeQualifiers::from(qualifier)) } else { for element in arguments { diff --git a/crates/ty_test/src/matcher.rs b/crates/ty_test/src/matcher.rs index cabbd32305deb..415ac2bf7419d 100644 --- a/crates/ty_test/src/matcher.rs +++ b/crates/ty_test/src/matcher.rs @@ -212,6 +212,7 @@ fn discard_todo_metadata(ty: &str) -> Cow<'_, str> { "@Todo(StarredExpression)", "@Todo(typing.Unpack)", "@Todo(TypeVarTuple)", + "@Todo(Functional TypedDicts)", ]; static TODO_METADATA_REGEX: LazyLock =