Skip to content

Commit 130da28

Browse files
authored
[ty] Infer the extra_items keyword argument to class-based TypedDicts as an annotation expression (#24362)
1 parent a617c54 commit 130da28

3 files changed

Lines changed: 108 additions & 6 deletions

File tree

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4264,7 +4264,8 @@ e: MovieFunctional = {"name": "Blade Runner", "year": 1982} # error: [invalid-k
42644264
always implicitly non-required.
42654265

42664266
```py
4267-
from typing_extensions import TypedDict, ReadOnly, Required, NotRequired
4267+
from typing_extensions import TypedDict, ReadOnly, Required, NotRequired, ClassVar, Final
4268+
from dataclasses import InitVar
42684269

42694270
# OK
42704271
class A(TypedDict, extra_items=int):
@@ -4274,13 +4275,25 @@ class A(TypedDict, extra_items=int):
42744275
class B(TypedDict, extra_items=ReadOnly[int]):
42754276
name: str
42764277

4277-
# TODO: should be error: [invalid-typed-dict-header]
4278+
# error: [invalid-type-form] "Type qualifier `typing.Required` is not valid in a TypedDict `extra_items` argument"
42784279
class C(TypedDict, extra_items=Required[int]):
42794280
name: str
42804281

4281-
# TODO: should be error: [invalid-typed-dict-header]
4282+
# error: [invalid-type-form] "Type qualifier `typing.NotRequired` is not valid in a TypedDict `extra_items` argument"
42824283
class D(TypedDict, extra_items=NotRequired[int]):
42834284
name: str
4285+
4286+
# error: [invalid-type-form] "Type qualifier `typing.ClassVar` is not valid in a TypedDict `extra_items` argument"
4287+
class D(TypedDict, extra_items=ClassVar[int]):
4288+
name: str
4289+
4290+
# error: [invalid-type-form] "Type qualifier `typing.Final` is not valid in a TypedDict `extra_items` argument"
4291+
class D(TypedDict, extra_items=Final[int]):
4292+
name: str
4293+
4294+
# error: [invalid-type-form] "Type qualifier `dataclasses.InitVar` is not valid in a TypedDict `extra_items` argument"
4295+
class D(TypedDict, extra_items=InitVar[int]):
4296+
name: str
42844297
```
42854298

42864299
It is an error to specify both `closed` and `extra_items`:
@@ -4291,6 +4304,62 @@ class E(TypedDict, closed=True, extra_items=int):
42914304
name: str
42924305
```
42934306

4307+
### Forward references in `extra_items`
4308+
4309+
Stringified forward references are understood:
4310+
4311+
`a.py`:
4312+
4313+
```py
4314+
from typing import TypedDict
4315+
4316+
class F(TypedDict, extra_items="F | None"): ...
4317+
```
4318+
4319+
While invalid syntax in forward annotations is rejected:
4320+
4321+
`b.py`:
4322+
4323+
```py
4324+
from typing import TypedDict
4325+
4326+
# error: [invalid-syntax-in-forward-annotation]
4327+
class G(TypedDict, extra_items="not a type expression"): ...
4328+
```
4329+
4330+
In non-stub files, forward references in `extra_items` must be stringified:
4331+
4332+
`c.py`:
4333+
4334+
```py
4335+
from typing import TypedDict
4336+
4337+
# error: [unresolved-reference] "Name `H` used when not defined"
4338+
class H(TypedDict, extra_items=H | None): ...
4339+
```
4340+
4341+
but stringification is unnecessary in stubs:
4342+
4343+
`stub.pyi`:
4344+
4345+
```pyi
4346+
from typing import TypedDict
4347+
4348+
class I(TypedDict, extra_items=I | None): ...
4349+
```
4350+
4351+
The `extra_items` keyword is not parsed as an annotation expression for non-TypedDict classes:
4352+
4353+
`d.py`:
4354+
4355+
```py
4356+
class TypedDict: # not typing.TypedDict!
4357+
def __init_subclass__(cls, extra_items: int): ...
4358+
4359+
class Foo(TypedDict, extra_items=42): ... # fine
4360+
class Bar(TypedDict, extra_items=int): ... # error: [invalid-argument-type]
4361+
```
4362+
42944363
### Writing to an undeclared literal key of an `extra_items` TypedDict is allowed, if the type is assignable
42954364

42964365
```py

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::{
99
TypeInferenceBuilder,
1010
builder::{DeclaredAndInferredType, DeferredExpressionState},
1111
},
12+
infer_definition_types,
1213
signatures::ParameterForm,
1314
special_form::TypeQualifier,
1415
},
@@ -219,7 +220,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
219220
let previous_deferred_state =
220221
std::mem::replace(&mut self.deferred_state, in_stub.into());
221222
for keyword in class_node.keywords() {
222-
self.infer_expression(&keyword.value, TypeContext::default());
223+
if keyword.arg.as_deref() != Some("extra_items") {
224+
self.infer_expression(&keyword.value, TypeContext::default());
225+
}
223226
}
224227
self.deferred_state = previous_deferred_state;
225228

@@ -229,6 +232,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
229232
.bases()
230233
.iter()
231234
.any(|expr| any_over_expr(expr, &ast::Expr::is_string_literal_expr))
235+
|| class_node
236+
.arguments
237+
.as_deref()
238+
.and_then(|args| args.find_keyword("extra_items"))
239+
.is_some()
232240
{
233241
self.deferred.insert(definition);
234242
} else {
@@ -260,5 +268,24 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
260268
}
261269
}
262270
self.typevar_binding_context = previous_typevar_binding_context;
271+
272+
if let Some(arguments) = class.arguments.as_deref()
273+
&& let Some(extra_items_keyword) = arguments.find_keyword("extra_items")
274+
{
275+
let class_type = infer_definition_types(self.db(), definition).binding_type(definition);
276+
if let Type::ClassLiteral(class_literal) = class_type
277+
&& class_literal.is_typed_dict(self.db())
278+
{
279+
self.infer_extra_items_kwarg(&extra_items_keyword.value);
280+
} else if self.in_stub() {
281+
self.infer_expression_with_state(
282+
&extra_items_keyword.value,
283+
TypeContext::default(),
284+
DeferredExpressionState::Deferred,
285+
);
286+
} else {
287+
self.infer_expression(&extra_items_keyword.value, TypeContext::default());
288+
}
289+
}
263290
}
264291
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::types::diagnostic::{
1111
INVALID_ARGUMENT_TYPE, INVALID_TYPE_FORM, MISSING_ARGUMENT, TOO_MANY_POSITIONAL_ARGUMENTS,
1212
UNKNOWN_ARGUMENT,
1313
};
14+
use crate::types::infer::builder::DeferredExpressionState;
1415
use crate::types::special_form::TypeQualifier;
1516
use crate::types::typed_dict::{TypedDictSchema, functional_typed_dict_field};
1617
use crate::types::{IntersectionType, KnownClass, Type, TypeAndQualifiers, TypeContext};
@@ -355,8 +356,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
355356
annotation
356357
}
357358

358-
fn infer_extra_items_kwarg(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> {
359-
let annotation = self.infer_annotation_expression(value, self.deferred_state);
359+
pub(super) fn infer_extra_items_kwarg(&mut self, value: &ast::Expr) -> TypeAndQualifiers<'db> {
360+
let state = if self.in_stub() {
361+
DeferredExpressionState::Deferred
362+
} else {
363+
self.deferred_state
364+
};
365+
let annotation = self.infer_annotation_expression(value, state);
360366
for qualifier in TypeQualifier::iter() {
361367
if qualifier != TypeQualifier::ReadOnly
362368
&& annotation

0 commit comments

Comments
 (0)