Skip to content

Commit a617c54

Browse files
authored
[ty] Validate type qualifiers in functional TypedDict fields and the extra_items keyword to functional TypedDicts (#24360)
1 parent d851708 commit a617c54

File tree

4 files changed

+94
-18
lines changed

4 files changed

+94
-18
lines changed

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2373,6 +2373,23 @@ partial_no_year = PartialWithRequired(name="The Matrix")
23732373
reveal_type(partial_no_year) # revealed: PartialWithRequired
23742374
```
23752375

2376+
## Function syntax with invalid qualifiers
2377+
2378+
All type qualifiers except for `ReadOnly`, `Required` and `NotRequired` are rejected:
2379+
2380+
```py
2381+
from typing_extensions import ClassVar, Final, TypedDict
2382+
from dataclasses import InitVar
2383+
2384+
TD1 = TypedDict("TD1", {"x": ClassVar[int]}) # error: [invalid-type-form]
2385+
TD2 = TypedDict("TD2", {"x": Final[int]}) # error: [invalid-type-form]
2386+
TD3 = TypedDict("TD3", {"x": InitVar[int]}) # error: [invalid-type-form]
2387+
2388+
class TD4(TypedDict("TD4", {"x": ClassVar[int]})): ... # error: [invalid-type-form]
2389+
class TD5(TypedDict("TD5", {"x": Final[int]})): ... # error: [invalid-type-form]
2390+
class TD6(TypedDict("TD6", {"x": InitVar[int]})): ... # error: [invalid-type-form]
2391+
```
2392+
23762393
## Function syntax with `closed`
23772394

23782395
The `closed` keyword is accepted but not yet fully supported:
@@ -2398,7 +2415,8 @@ def f(closed: bool) -> None:
23982415
The `extra_items` keyword is accepted and validated as an annotation expression:
23992416

24002417
```py
2401-
from typing_extensions import ReadOnly, TypedDict
2418+
from typing_extensions import ReadOnly, TypedDict, NotRequired, Required, ClassVar, Final
2419+
from dataclasses import InitVar
24022420

24032421
# extra_items is accepted (no error)
24042422
MovieWithExtras = TypedDict("MovieWithExtras", {"name": str}, extra_items=bool)
@@ -2415,10 +2433,24 @@ class Foo(TypedDict("T", {}, extra_items="Foo | None")): ...
24152433

24162434
reveal_type(Foo) # revealed: <class 'Foo'>
24172435

2418-
# Type qualifiers like ReadOnly are valid in extra_items (annotation expression, not type expression):
2436+
# The `ReadOnly` type qualifier is valid in `extra_items` (annotation expression, not type expression):
24192437
TD2 = TypedDict("TD2", {}, extra_items=ReadOnly[int])
24202438

24212439
class Bar(TypedDict("TD3", {}, extra_items=ReadOnly[int])): ...
2440+
2441+
# But all other qualifiers are rejected:
2442+
2443+
TD4 = TypedDict("TD4", {}, extra_items=Required[int]) # error: [invalid-type-form]
2444+
TD5 = TypedDict("TD5", {}, extra_items=NotRequired[int]) # error: [invalid-type-form]
2445+
TD6 = TypedDict("TD6", {}, extra_items=ClassVar[int]) # error: [invalid-type-form]
2446+
TD7 = TypedDict("TD7", {}, extra_items=InitVar[int]) # error: [invalid-type-form]
2447+
TD8 = TypedDict("TD8", {}, extra_items=Final[int]) # error: [invalid-type-form]
2448+
2449+
class TD9(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form]
2450+
class TD10(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form]
2451+
class TD11(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form]
2452+
class TD12(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form]
2453+
class TD13(TypedDict("TD9", {}, extra_items=Required[int])): ... # error: [invalid-type-form]
24222454
```
24232455

24242456
## Function syntax with forward references

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4199,22 +4199,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
41994199
});
42004200

42014201
match class_kind {
4202-
Some(CodeGeneratorKind::TypedDict) => match qualifier {
4203-
TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => {
4204-
let Some(builder) =
4202+
Some(CodeGeneratorKind::TypedDict) => {
4203+
if !qualifier.is_valid_in_typeddict_field()
4204+
&& let Some(builder) =
42054205
self.context.report_lint(&INVALID_TYPE_FORM, annotation)
4206-
else {
4207-
continue;
4208-
};
4206+
{
42094207
builder.into_diagnostic(format_args!(
42104208
"`{name}` is not allowed in TypedDict fields",
42114209
name = qualifier.name()
42124210
));
42134211
}
4214-
TypeQualifier::NotRequired
4215-
| TypeQualifier::ReadOnly
4216-
| TypeQualifier::Required => {}
4217-
},
4212+
}
42184213
Some(CodeGeneratorKind::DataclassLike(_)) => match qualifier {
42194214
TypeQualifier::NotRequired
42204215
| TypeQualifier::ReadOnly

crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
use ruff_python_ast::name::Name;
22
use ruff_python_ast::{self as ast, NodeIndex};
33
use smallvec::SmallVec;
4+
use strum::IntoEnumIterator;
45

56
use super::TypeInferenceBuilder;
7+
use crate::TypeQualifiers;
68
use crate::semantic_index::definition::Definition;
79
use crate::types::class::{ClassLiteral, DynamicTypedDictAnchor, DynamicTypedDictLiteral};
810
use crate::types::diagnostic::{
9-
INVALID_ARGUMENT_TYPE, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS, UNKNOWN_ARGUMENT,
11+
INVALID_ARGUMENT_TYPE, INVALID_TYPE_FORM, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS,
12+
UNKNOWN_ARGUMENT,
1013
};
14+
use crate::types::special_form::TypeQualifier;
1115
use crate::types::typed_dict::{TypedDictSchema, functional_typed_dict_field};
12-
use crate::types::{IntersectionType, KnownClass, Type, TypeContext};
16+
use crate::types::{IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext};
1317

1418
impl<'db> TypeInferenceBuilder<'db, '_> {
1519
/// Infer a `TypedDict(name, fields)` call expression.
@@ -124,7 +128,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
124128
}
125129
"extra_items" => {
126130
if definition.is_none() {
127-
self.infer_annotation_expression(&kw.value, self.deferred_state);
131+
self.infer_extra_items_kwarg(&kw.value);
128132
}
129133
}
130134
unknown_kwarg => {
@@ -293,7 +297,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
293297
return TypedDictSchema::default();
294298
};
295299

296-
let annotation = self.infer_annotation_expression(&item.value, self.deferred_state);
300+
let annotation = self.infer_typeddict_field(&item.value);
297301

298302
schema.insert(
299303
Name::new(key_literal.value(db)),
@@ -321,16 +325,54 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
321325
if let Some(ast::Expr::Dict(dict_expr)) = arguments.args.get(1) {
322326
for ast::DictItem { key, value } in dict_expr {
323327
if key.is_some() {
324-
self.infer_annotation_expression(value, self.deferred_state);
328+
self.infer_typeddict_field(value);
325329
}
326330
}
327331
}
328332

329333
if let Some(extra_items_kwarg) = arguments.find_keyword("extra_items") {
330-
self.infer_annotation_expression(&extra_items_kwarg.value, self.deferred_state);
334+
self.infer_extra_items_kwarg(&extra_items_kwarg.value);
331335
}
332336
}
333337

338+
fn infer_typeddict_field(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> {
339+
let annotation = self.infer_annotation_expression(value, self.deferred_state);
340+
for qualifier in TypeQualifier::iter() {
341+
if !qualifier.is_valid_in_typeddict_field()
342+
&& annotation
343+
.qualifiers
344+
.contains(TypeQualifiers::from(qualifier))
345+
&& let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, value)
346+
{
347+
let mut diagnostic = builder.into_diagnostic(format_args!(
348+
"Type qualifier `{qualifier}` is not valid in a TypedDict field"
349+
));
350+
diagnostic.info(
351+
"Only `Required`, `NotRequired` and `ReadOnly` are valid in this context",
352+
);
353+
}
354+
}
355+
annotation
356+
}
357+
358+
fn infer_extra_items_kwarg(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> {
359+
let annotation = self.infer_annotation_expression(value, self.deferred_state);
360+
for qualifier in TypeQualifier::iter() {
361+
if qualifier != TypeQualifier::ReadOnly
362+
&& annotation
363+
.qualifiers
364+
.contains(TypeQualifiers::from(qualifier))
365+
&& let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, value)
366+
{
367+
let mut diagnostic = builder.into_diagnostic(format_args!(
368+
"Type qualifier `{qualifier}` is not valid in a TypedDict `extra_items` argument"
369+
));
370+
diagnostic.info("`ReadOnly` is the only permitted type qualifier here");
371+
}
372+
}
373+
annotation
374+
}
375+
334376
/// Infer all non-type expressions in the `fields` argument of a functional `TypedDict` definition,
335377
/// and emit diagnostics for invalid field keys. Type expressions are not inferred during this pass,
336378
/// because it must be deferred for` TypedDict` definitions that may hold recursive references to

crates/ty_python_semantic/src/types/special_form.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,13 @@ impl TypeQualifier {
904904
TypeQualifier::Final => true,
905905
}
906906
}
907+
908+
pub(crate) const fn is_valid_in_typeddict_field(self) -> bool {
909+
match self {
910+
TypeQualifier::ReadOnly | TypeQualifier::Required | TypeQualifier::NotRequired => true,
911+
TypeQualifier::ClassVar | TypeQualifier::Final | TypeQualifier::InitVar => false,
912+
}
913+
}
907914
}
908915

909916
impl From<TypeQualifier> for SpecialFormType {

0 commit comments

Comments
 (0)