diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 4b6253afc1258..ae9e2480eeeb7 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -2373,6 +2373,23 @@ partial_no_year = PartialWithRequired(name="The Matrix") reveal_type(partial_no_year) # revealed: PartialWithRequired ``` +## Function syntax with invalid qualifiers + +All type qualifiers except for `ReadOnly`, `Required` and `NotRequired` are rejected: + +```py +from typing_extensions import ClassVar, Final, TypedDict +from dataclasses import InitVar + +TD1 = TypedDict("TD1", {"x": ClassVar[int]}) # error: [invalid-type-form] +TD2 = TypedDict("TD2", {"x": Final[int]}) # error: [invalid-type-form] +TD3 = TypedDict("TD3", {"x": InitVar[int]}) # error: [invalid-type-form] + +class TD4(TypedDict("TD4", {"x": ClassVar[int]})): ... # error: [invalid-type-form] +class TD5(TypedDict("TD5", {"x": Final[int]})): ... # error: [invalid-type-form] +class TD6(TypedDict("TD6", {"x": InitVar[int]})): ... # error: [invalid-type-form] +``` + ## Function syntax with `closed` The `closed` keyword is accepted but not yet fully supported: @@ -2398,7 +2415,8 @@ def f(closed: bool) -> None: The `extra_items` keyword is accepted and validated as an annotation expression: ```py -from typing_extensions import ReadOnly, TypedDict +from typing_extensions import ReadOnly, TypedDict, NotRequired, Required, ClassVar, Final +from dataclasses import InitVar # extra_items is accepted (no error) MovieWithExtras = TypedDict("MovieWithExtras", {"name": str}, extra_items=bool) @@ -2415,10 +2433,24 @@ class Foo(TypedDict("T", {}, extra_items="Foo | None")): ... reveal_type(Foo) # revealed: -# Type qualifiers like ReadOnly are valid in extra_items (annotation expression, not type expression): +# The `ReadOnly` type qualifier is valid in `extra_items` (annotation expression, not type expression): TD2 = TypedDict("TD2", {}, extra_items=ReadOnly[int]) class Bar(TypedDict("TD3", {}, extra_items=ReadOnly[int])): ... + +# But all other qualifiers are rejected: + +TD4 = TypedDict("TD4", {}, extra_items=Required[int]) # error: [invalid-type-form] +TD5 = TypedDict("TD5", {}, extra_items=NotRequired[int]) # error: [invalid-type-form] +TD6 = TypedDict("TD6", {}, extra_items=ClassVar[int]) # error: [invalid-type-form] +TD7 = TypedDict("TD7", {}, extra_items=InitVar[int]) # error: [invalid-type-form] +TD8 = TypedDict("TD8", {}, extra_items=Final[int]) # error: [invalid-type-form] + +class TD9(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form] +class TD10(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form] +class TD11(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form] +class TD12(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form] +class TD13(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form] ``` ## Function syntax with forward references diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 0810b2cda1dcf..a4ee51fc72d8e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -4199,22 +4199,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }); match class_kind { - Some(CodeGeneratorKind::TypedDict) => match qualifier { - TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => { - let Some(builder) = + Some(CodeGeneratorKind::TypedDict) => { + if !qualifier.is_valid_in_typeddict_field() + && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, annotation) - else { - continue; - }; + { builder.into_diagnostic(format_args!( "`{name}` is not allowed in TypedDict fields", name = qualifier.name() )); } - TypeQualifier::NotRequired - | TypeQualifier::ReadOnly - | TypeQualifier::Required => {} - }, + } Some(CodeGeneratorKind::DataclassLike(_)) => match qualifier { TypeQualifier::NotRequired | TypeQualifier::ReadOnly diff --git a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs index 6cc75bf77f1b1..ab90b41acfd1f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs @@ -1,15 +1,19 @@ use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, NodeIndex}; use smallvec::SmallVec; +use strum::IntoEnumIterator; use super::TypeInferenceBuilder; +use crate::TypeQualifiers; use crate::semantic_index::definition::Definition; use crate::types::class::{ClassLiteral, DynamicTypedDictAnchor, DynamicTypedDictLiteral}; use crate::types::diagnostic::{ - INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT, + INVALID_ARGUMENT_TYPE, INVALID_TYPE_FORM, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, + UNKNOWN_ARGUMENT, }; +use crate::types::special_form::TypeQualifier; use crate::types::typed_dict::{TypedDictSchema, functional_typed_dict_field}; -use crate::types::{IntersectionType, KnownClass, Type, TypeContext}; +use crate::types::{IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext}; impl<'db> TypeInferenceBuilder<'db, '_> { /// Infer a `TypedDict(name, fields)` call expression. @@ -124,7 +128,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } "extra_items" => { if definition.is_none() { - self.infer_annotation_expression(&kw.value, self.deferred_state); + self.infer_extra_items_kwarg(&kw.value); } } unknown_kwarg => { @@ -293,7 +297,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { return TypedDictSchema::default(); }; - let annotation = self.infer_annotation_expression(&item.value, self.deferred_state); + let annotation = self.infer_typeddict_field(&item.value); schema.insert( Name::new(key_literal.value(db)), @@ -321,16 +325,54 @@ impl<'db> TypeInferenceBuilder<'db, '_> { if let Some(ast::Expr::Dict(dict_expr)) = arguments.args.get(1) { for ast::DictItem { key, value } in dict_expr { if key.is_some() { - self.infer_annotation_expression(value, self.deferred_state); + self.infer_typeddict_field(value); } } } if let Some(extra_items_kwarg) = arguments.find_keyword("extra_items") { - self.infer_annotation_expression(&extra_items_kwarg.value, self.deferred_state); + self.infer_extra_items_kwarg(&extra_items_kwarg.value); } } + fn infer_typeddict_field(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> { + let annotation = self.infer_annotation_expression(value, self.deferred_state); + for qualifier in TypeQualifier::iter() { + if !qualifier.is_valid_in_typeddict_field() + && annotation + .qualifiers + .contains(TypeQualifiers::from(qualifier)) + && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Type qualifier `{qualifier}` is not valid in a TypedDict field" + )); + diagnostic.info( + "Only `Required`, `NotRequired` and `ReadOnly` are valid in this context", + ); + } + } + annotation + } + + fn infer_extra_items_kwarg(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> { + let annotation = self.infer_annotation_expression(value, self.deferred_state); + for qualifier in TypeQualifier::iter() { + if qualifier != TypeQualifier::ReadOnly + && annotation + .qualifiers + .contains(TypeQualifiers::from(qualifier)) + && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, value) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "Type qualifier `{qualifier}` is not valid in a TypedDict `extra_items` argument" + )); + diagnostic.info("`ReadOnly` is the only permitted type qualifier here"); + } + } + annotation + } + /// Infer all non-type expressions in the `fields` argument of a functional `TypedDict` definition, /// and emit diagnostics for invalid field keys. Type expressions are not inferred during this pass, /// because it must be deferred for` TypedDict` definitions that may hold recursive references to diff --git a/crates/ty_python_semantic/src/types/special_form.rs b/crates/ty_python_semantic/src/types/special_form.rs index 2758c1adfe510..d1b1deeff5174 100644 --- a/crates/ty_python_semantic/src/types/special_form.rs +++ b/crates/ty_python_semantic/src/types/special_form.rs @@ -904,6 +904,13 @@ impl TypeQualifier { TypeQualifier::Final => true, } } + + pub(crate) const fn is_valid_in_typeddict_field(self) -> bool { + match self { + TypeQualifier::ReadOnly | TypeQualifier::Required | TypeQualifier::NotRequired => true, + TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => false, + } + } } impl From for SpecialFormType {