Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions crates/ty_python_semantic/resources/mdtest/typed_dict.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -2415,10 +2433,24 @@ class Foo(TypedDict("T", {}, extra_items="Foo | None")): ...

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

# 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
Expand Down
15 changes: 5 additions & 10 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 48 additions & 6 deletions crates/ty_python_semantic/src/types/infer/builder/typed_dict.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/ty_python_semantic/src/types/special_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeQualifier> for SpecialFormType {
Expand Down
Loading