[ty] `dict` is not assignable to `TypedDict` (#21238)

## Summary

A lot of the bidirectional inference work relies on `dict` not being
assignable to `TypedDict`, so I think it makes sense to add this before
fully implementing https://github.com/astral-sh/ty/issues/1387.
This commit is contained in:
Ibraheem Ahmed 2025-11-03 16:57:49 -05:00 committed by GitHub
parent 42adfd40ea
commit 3c8fb68765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 169 additions and 75 deletions

View File

@ -76,6 +76,7 @@ def _() -> TD:
def _() -> TD: def _() -> TD:
# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor"
# error: [invalid-return-type]
return {} return {}
``` ```

View File

@ -1685,8 +1685,7 @@ def int_or_str() -> int | str:
x = f([{"x": 1}], int_or_str()) x = f([{"x": 1}], int_or_str())
reveal_type(x) # revealed: int | str reveal_type(x) # revealed: int | str
# TODO: error: [no-matching-overload] "No overload of function `f` matches arguments" # error: [no-matching-overload] "No overload of function `f` matches arguments"
# we currently incorrectly consider `list[dict[str, int]]` a subtype of `list[T]`
f([{"y": 1}], int_or_str()) f([{"y": 1}], int_or_str())
``` ```

View File

@ -277,7 +277,6 @@ def _(flag: bool):
x = f({"x": 1}) x = f({"x": 1})
reveal_type(x) # revealed: int reveal_type(x) # revealed: int
# TODO: error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int]`" # error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[Unknown | str, Unknown | int]`"
# we currently consider `TypedDict` instances to be subtypes of `dict`
f({"y": 1}) f({"y": 1})
``` ```

View File

@ -162,10 +162,13 @@ The type context is propagated down into the comprehension:
class Person(TypedDict): class Person(TypedDict):
name: str name: str
# TODO: This should not error.
# error: [invalid-assignment]
persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]] persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]]
reveal_type(persons) # revealed: list[Person] reveal_type(persons) # revealed: list[Person]
# TODO: This should be an error # TODO: This should be an invalid-key error.
# error: [invalid-assignment]
invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]] invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]]
``` ```

View File

@ -39,16 +39,19 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
25 | person[str_key] = "Alice" # error: [invalid-key] 25 | person[str_key] = "Alice" # error: [invalid-key]
26 | 26 |
27 | def create_with_invalid_string_key(): 27 | def create_with_invalid_string_key():
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] 28 | # error: [invalid-key]
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] 29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
30 | from typing_extensions import ReadOnly 30 |
31 | 31 | # error: [invalid-key]
32 | class Employee(TypedDict): 32 | bob = Person(name="Bob", age=25, unknown="Bar")
33 | id: ReadOnly[int] 33 | from typing_extensions import ReadOnly
34 | name: str 34 |
35 | 35 | class Employee(TypedDict):
36 | def write_to_readonly_key(employee: Employee): 36 | id: ReadOnly[int]
37 | employee["id"] = 42 # error: [invalid-assignment] 37 | name: str
38 |
39 | def write_to_readonly_key(employee: Employee):
40 | employee["id"] = 42 # error: [invalid-assignment]
``` ```
# Diagnostics # Diagnostics
@ -158,16 +161,17 @@ info: rule `invalid-key` is enabled by default
``` ```
error[invalid-key]: Invalid key for TypedDict `Person` error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:28:21 --> src/mdtest_snippet.py:29:21
| |
27 | def create_with_invalid_string_key(): 27 | def create_with_invalid_string_key():
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] 28 | # error: [invalid-key]
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
| -----------------------------^^^^^^^^^-------- | -----------------------------^^^^^^^^^--------
| | | | | |
| | Unknown key "unknown" | | Unknown key "unknown"
| TypedDict `Person` | TypedDict `Person`
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] 30 |
30 | from typing_extensions import ReadOnly 31 | # error: [invalid-key]
| |
info: rule `invalid-key` is enabled by default info: rule `invalid-key` is enabled by default
@ -175,13 +179,12 @@ info: rule `invalid-key` is enabled by default
``` ```
error[invalid-key]: Invalid key for TypedDict `Person` error[invalid-key]: Invalid key for TypedDict `Person`
--> src/mdtest_snippet.py:29:11 --> src/mdtest_snippet.py:32:11
| |
27 | def create_with_invalid_string_key(): 31 | # error: [invalid-key]
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] 32 | bob = Person(name="Bob", age=25, unknown="Bar")
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
| ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown" | ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown"
30 | from typing_extensions import ReadOnly 33 | from typing_extensions import ReadOnly
| |
info: rule `invalid-key` is enabled by default info: rule `invalid-key` is enabled by default
@ -189,21 +192,21 @@ info: rule `invalid-key` is enabled by default
``` ```
error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee` error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee`
--> src/mdtest_snippet.py:37:5 --> src/mdtest_snippet.py:40:5
| |
36 | def write_to_readonly_key(employee: Employee): 39 | def write_to_readonly_key(employee: Employee):
37 | employee["id"] = 42 # error: [invalid-assignment] 40 | employee["id"] = 42 # error: [invalid-assignment]
| -------- ^^^^ key is marked read-only | -------- ^^^^ key is marked read-only
| | | |
| TypedDict `Employee` | TypedDict `Employee`
| |
info: Item declaration info: Item declaration
--> src/mdtest_snippet.py:33:5 --> src/mdtest_snippet.py:36:5
| |
32 | class Employee(TypedDict): 35 | class Employee(TypedDict):
33 | id: ReadOnly[int] 36 | id: ReadOnly[int]
| ----------------- Read-only item declared here | ----------------- Read-only item declared here
34 | name: str 37 | name: str
| |
info: rule `invalid-assignment` is enabled by default info: rule `invalid-assignment` is enabled by default

View File

@ -96,29 +96,29 @@ The construction of a `TypedDict` is checked for type correctness:
```py ```py
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
eve1a: Person = {"name": b"Eve", "age": None} eve1a: Person = {"name": b"Eve", "age": None}
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
eve1b = Person(name=b"Eve", age=None) eve1b = Person(name=b"Eve", age=None)
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) reveal_type(eve1a) # revealed: Person
reveal_type(eve1a) # revealed: dict[Unknown | str, Unknown | bytes | None]
reveal_type(eve1b) # revealed: Person reveal_type(eve1b) # revealed: Person
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
eve2a: Person = {"age": 22} eve2a: Person = {"age": 22}
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
eve2b = Person(age=22) eve2b = Person(age=22)
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) reveal_type(eve2a) # revealed: Person
reveal_type(eve2a) # revealed: dict[Unknown | str, Unknown | int]
reveal_type(eve2b) # revealed: Person reveal_type(eve2b) # revealed: Person
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
eve3a: Person = {"name": "Eve", "age": 25, "extra": True} eve3a: Person = {"name": "Eve", "age": 25, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
eve3b = Person(name="Eve", age=25, extra=True) eve3b = Person(name="Eve", age=25, extra=True)
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) reveal_type(eve3a) # revealed: Person
reveal_type(eve3a) # revealed: dict[Unknown | str, Unknown | str | int]
reveal_type(eve3b) # revealed: Person reveal_type(eve3b) # revealed: Person
``` ```
@ -238,15 +238,19 @@ All of these are missing the required `age` field:
```py ```py
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
alice2: Person = {"name": "Alice"} alice2: Person = {"name": "Alice"}
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
Person(name="Alice") Person(name="Alice")
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
Person({"name": "Alice"}) Person({"name": "Alice"})
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
# error: [invalid-argument-type]
accepts_person({"name": "Alice"}) accepts_person({"name": "Alice"})
# TODO: this should be an error, similar to the above # TODO: this should be an invalid-key error, similar to the above
# error: [invalid-assignment]
house.owner = {"name": "Alice"} house.owner = {"name": "Alice"}
a_person: Person a_person: Person
@ -259,19 +263,25 @@ All of these have an invalid type for the `name` field:
```py ```py
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
alice3: Person = {"name": None, "age": 30} alice3: Person = {"name": None, "age": 30}
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
Person(name=None, age=30) Person(name=None, age=30)
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
Person({"name": None, "age": 30}) Person({"name": None, "age": 30})
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
# error: [invalid-argument-type]
accepts_person({"name": None, "age": 30}) accepts_person({"name": None, "age": 30})
# TODO: this should be an error, similar to the above
# TODO: this should be an invalid-key error
# error: [invalid-assignment]
house.owner = {"name": None, "age": 30} house.owner = {"name": None, "age": 30}
a_person: Person a_person: Person
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
a_person = {"name": None, "age": 30} a_person = {"name": None, "age": 30}
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`" # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
(a_person := {"name": None, "age": 30}) (a_person := {"name": None, "age": 30})
``` ```
@ -281,19 +291,25 @@ All of these have an extra field that is not defined in the `TypedDict`:
```py ```py
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
alice4: Person = {"name": "Alice", "age": 30, "extra": True} alice4: Person = {"name": "Alice", "age": 30, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
Person(name="Alice", age=30, extra=True) Person(name="Alice", age=30, extra=True)
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
Person({"name": "Alice", "age": 30, "extra": True}) Person({"name": "Alice", "age": 30, "extra": True})
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
# error: [invalid-argument-type]
accepts_person({"name": "Alice", "age": 30, "extra": True}) accepts_person({"name": "Alice", "age": 30, "extra": True})
# TODO: this should be an error
# TODO: this should be an invalid-key error
# error: [invalid-assignment]
house.owner = {"name": "Alice", "age": 30, "extra": True} house.owner = {"name": "Alice", "age": 30, "extra": True}
a_person: Person a_person: Person
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
a_person = {"name": "Alice", "age": 30, "extra": True} a_person = {"name": "Alice", "age": 30, "extra": True}
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
(a_person := {"name": "Alice", "age": 30, "extra": True}) (a_person := {"name": "Alice", "age": 30, "extra": True})
``` ```
@ -490,6 +506,15 @@ dangerous(alice)
reveal_type(alice["name"]) # revealed: str reveal_type(alice["name"]) # revealed: str
``` ```
Likewise, `dict`s are not assignable to typed dictionaries:
```py
alice: dict[str, str] = {"name": "Alice"}
# error: [invalid-assignment] "Object of type `dict[str, str]` is not assignable to `Person`"
alice: Person = alice
```
## Key-based access ## Key-based access
### Reading ### Reading
@ -977,7 +1002,7 @@ class Person(TypedDict):
name: str name: str
age: int | None age: int | None
# TODO: this should be an error # error: [invalid-assignment] "Object of type `MyDict` is not assignable to `Person`"
x: Person = MyDict({"name": "Alice", "age": 30}) x: Person = MyDict({"name": "Alice", "age": 30})
``` ```
@ -1029,8 +1054,11 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
person[str_key] = "Alice" # error: [invalid-key] person[str_key] = "Alice" # error: [invalid-key]
def create_with_invalid_string_key(): def create_with_invalid_string_key():
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] # error: [invalid-key]
bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
# error: [invalid-key]
bob = Person(name="Bob", age=25, unknown="Bar")
``` ```
Assignment to `ReadOnly` keys: Assignment to `ReadOnly` keys:

View File

@ -1987,11 +1987,14 @@ impl<'db> Type<'db> {
ConstraintSet::from(false) ConstraintSet::from(false)
} }
(Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => { (Type::TypedDict(_), _) => {
// TODO: Implement assignability and subtyping for TypedDict // TODO: Implement assignability and subtyping for TypedDict
ConstraintSet::from(relation.is_assignability()) ConstraintSet::from(relation.is_assignability())
} }
// A non-`TypedDict` cannot subtype a `TypedDict`
(_, Type::TypedDict(_)) => ConstraintSet::from(false),
// Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`. // Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`.
// If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively. // If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively.
(left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()), (left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()),

View File

@ -3582,6 +3582,11 @@ impl<'db> BindingError<'db> {
expected_ty, expected_ty,
provided_ty, provided_ty,
} => { } => {
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
// silenced diagnostics during overload evaluation, and rely on the assignability
// diagnostic being emitted here.
let range = Self::get_node(node, *argument_index); let range = Self::get_node(node, *argument_index);
let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else { let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else {
return; return;

View File

@ -2003,6 +2003,20 @@ pub(super) fn report_slice_step_size_zero(context: &InferContext, node: AnyNodeR
builder.into_diagnostic("Slice step size cannot be zero"); builder.into_diagnostic("Slice step size cannot be zero");
} }
// We avoid emitting invalid assignment diagnostic for literal assignments to a `TypedDict`, as
// they can only occur if we already failed to validate the dict (and emitted some diagnostic).
pub(crate) fn is_invalid_typed_dict_literal(
db: &dyn Db,
target_ty: Type,
source: AnyNodeRef<'_>,
) -> bool {
target_ty
.filter_union(db, Type::is_typed_dict)
.as_typed_dict()
.is_some()
&& matches!(source, AnyNodeRef::ExprDict(_))
}
fn report_invalid_assignment_with_message( fn report_invalid_assignment_with_message(
context: &InferContext, context: &InferContext,
node: AnyNodeRef, node: AnyNodeRef,
@ -2040,15 +2054,27 @@ pub(super) fn report_invalid_assignment<'db>(
target_ty: Type, target_ty: Type,
mut source_ty: Type<'db>, mut source_ty: Type<'db>,
) { ) {
let value_expr = match definition.kind(context.db()) {
DefinitionKind::Assignment(def) => Some(def.value(context.module())),
DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()),
DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value),
_ => None,
};
if let Some(value_expr) = value_expr
&& is_invalid_typed_dict_literal(context.db(), target_ty, value_expr.into())
{
return;
}
let settings = let settings =
DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty); DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty);
if let DefinitionKind::AnnotatedAssignment(annotated_assignment) = definition.kind(context.db()) if let Some(value_expr) = value_expr {
&& let Some(value) = annotated_assignment.value(context.module())
{
// Re-infer the RHS of the annotated assignment, ignoring the type context for more precise // Re-infer the RHS of the annotated assignment, ignoring the type context for more precise
// error messages. // error messages.
source_ty = infer_isolated_expression(context.db(), definition.scope(context.db()), value); source_ty =
infer_isolated_expression(context.db(), definition.scope(context.db()), value_expr);
} }
report_invalid_assignment_with_message( report_invalid_assignment_with_message(
@ -2070,6 +2096,11 @@ pub(super) fn report_invalid_attribute_assignment(
source_ty: Type, source_ty: Type,
attribute_name: &'_ str, attribute_name: &'_ str,
) { ) {
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
// silenced diagnostics during attribute resolution, and rely on the assignability
// diagnostic being emitted here.
report_invalid_assignment_with_message( report_invalid_assignment_with_message(
context, context,
node, node,

View File

@ -5,7 +5,9 @@ use ruff_db::diagnostic::{Annotation, DiagnosticId, Severity};
use ruff_db::files::File; use ruff_db::files::File;
use ruff_db::parsed::ParsedModuleRef; use ruff_db::parsed::ParsedModuleRef;
use ruff_python_ast::visitor::{Visitor, walk_expr}; use ruff_python_ast::visitor::{Visitor, walk_expr};
use ruff_python_ast::{self as ast, AnyNodeRef, ExprContext, PythonVersion}; use ruff_python_ast::{
self as ast, AnyNodeRef, ExprContext, HasNodeIndex, NodeIndex, PythonVersion,
};
use ruff_python_stdlib::builtins::version_builtin_was_added; use ruff_python_stdlib::builtins::version_builtin_was_added;
use ruff_text_size::{Ranged, TextRange}; use ruff_text_size::{Ranged, TextRange};
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
@ -5859,15 +5861,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
expression.map(|expr| self.infer_expression(expr, tcx)) expression.map(|expr| self.infer_expression(expr, tcx))
} }
fn get_or_infer_expression(
&mut self,
expression: &ast::Expr,
tcx: TypeContext<'db>,
) -> Type<'db> {
self.try_expression_type(expression)
.unwrap_or_else(|| self.infer_expression(expression, tcx))
}
#[track_caller] #[track_caller]
fn infer_expression(&mut self, expression: &ast::Expr, tcx: TypeContext<'db>) -> Type<'db> { fn infer_expression(&mut self, expression: &ast::Expr, tcx: TypeContext<'db>) -> Type<'db> {
debug_assert!( debug_assert!(
@ -6223,7 +6216,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
} = list; } = list;
let elts = elts.iter().map(|elt| [Some(elt)]); let elts = elts.iter().map(|elt| [Some(elt)]);
self.infer_collection_literal(elts, tcx, KnownClass::List) let infer_elt_ty = |builder: &mut Self, elt, tcx| builder.infer_expression(elt, tcx);
self.infer_collection_literal(elts, tcx, infer_elt_ty, KnownClass::List)
.unwrap_or_else(|| { .unwrap_or_else(|| {
KnownClass::List.to_specialized_instance(self.db(), [Type::unknown()]) KnownClass::List.to_specialized_instance(self.db(), [Type::unknown()])
}) })
@ -6237,7 +6231,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
} = set; } = set;
let elts = elts.iter().map(|elt| [Some(elt)]); let elts = elts.iter().map(|elt| [Some(elt)]);
self.infer_collection_literal(elts, tcx, KnownClass::Set) let infer_elt_ty = |builder: &mut Self, elt, tcx| builder.infer_expression(elt, tcx);
self.infer_collection_literal(elts, tcx, infer_elt_ty, KnownClass::Set)
.unwrap_or_else(|| { .unwrap_or_else(|| {
KnownClass::Set.to_specialized_instance(self.db(), [Type::unknown()]) KnownClass::Set.to_specialized_instance(self.db(), [Type::unknown()])
}) })
@ -6250,12 +6245,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
items, items,
} = dict; } = dict;
let mut item_types = FxHashMap::default();
// Validate `TypedDict` dictionary literal assignments. // Validate `TypedDict` dictionary literal assignments.
if let Some(tcx) = tcx.annotation if let Some(tcx) = tcx.annotation
&& let Some(typed_dict) = tcx && let Some(typed_dict) = tcx
.filter_union(self.db(), Type::is_typed_dict) .filter_union(self.db(), Type::is_typed_dict)
.as_typed_dict() .as_typed_dict()
&& let Some(ty) = self.infer_typed_dict_expression(dict, typed_dict) && let Some(ty) = self.infer_typed_dict_expression(dict, typed_dict, &mut item_types)
{ {
return ty; return ty;
} }
@ -6271,7 +6268,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
.iter() .iter()
.map(|item| [item.key.as_ref(), Some(&item.value)]); .map(|item| [item.key.as_ref(), Some(&item.value)]);
self.infer_collection_literal(items, tcx, KnownClass::Dict) // Avoid inferring the items multiple times if we already attempted to infer the
// dictionary literal as a `TypedDict`. This also allows us to infer using the
// type context of the expected `TypedDict` field.
let infer_elt_ty = |builder: &mut Self, elt: &ast::Expr, tcx| {
item_types
.get(&elt.node_index().load())
.copied()
.unwrap_or_else(|| builder.infer_expression(elt, tcx))
};
self.infer_collection_literal(items, tcx, infer_elt_ty, KnownClass::Dict)
.unwrap_or_else(|| { .unwrap_or_else(|| {
KnownClass::Dict KnownClass::Dict
.to_specialized_instance(self.db(), [Type::unknown(), Type::unknown()]) .to_specialized_instance(self.db(), [Type::unknown(), Type::unknown()])
@ -6282,6 +6289,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
&mut self, &mut self,
dict: &ast::ExprDict, dict: &ast::ExprDict,
typed_dict: TypedDictType<'db>, typed_dict: TypedDictType<'db>,
item_types: &mut FxHashMap<NodeIndex, Type<'db>>,
) -> Option<Type<'db>> { ) -> Option<Type<'db>> {
let ast::ExprDict { let ast::ExprDict {
range: _, range: _,
@ -6293,14 +6301,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
for item in items { for item in items {
let key_ty = self.infer_optional_expression(item.key.as_ref(), TypeContext::default()); let key_ty = self.infer_optional_expression(item.key.as_ref(), TypeContext::default());
if let Some((key, key_ty)) = item.key.as_ref().zip(key_ty) {
item_types.insert(key.node_index().load(), key_ty);
}
if let Some(Type::StringLiteral(key)) = key_ty let value_ty = if let Some(Type::StringLiteral(key)) = key_ty
&& let Some(field) = typed_dict_items.get(key.value(self.db())) && let Some(field) = typed_dict_items.get(key.value(self.db()))
{ {
self.infer_expression(&item.value, TypeContext::new(Some(field.declared_ty))); self.infer_expression(&item.value, TypeContext::new(Some(field.declared_ty)))
} else { } else {
self.infer_expression(&item.value, TypeContext::default()); self.infer_expression(&item.value, TypeContext::default())
} };
item_types.insert(item.value.node_index().load(), value_ty);
} }
validate_typed_dict_dict_literal(&self.context, typed_dict, dict, dict.into(), |expr| { validate_typed_dict_dict_literal(&self.context, typed_dict, dict, dict.into(), |expr| {
@ -6311,12 +6324,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
} }
// Infer the type of a collection literal expression. // Infer the type of a collection literal expression.
fn infer_collection_literal<'expr, const N: usize>( fn infer_collection_literal<'expr, const N: usize, F, I>(
&mut self, &mut self,
elts: impl Iterator<Item = [Option<&'expr ast::Expr>; N]>, elts: I,
tcx: TypeContext<'db>, tcx: TypeContext<'db>,
mut infer_elt_expression: F,
collection_class: KnownClass, collection_class: KnownClass,
) -> Option<Type<'db>> { ) -> Option<Type<'db>>
where
I: Iterator<Item = [Option<&'expr ast::Expr>; N]>,
F: FnMut(&mut Self, &'expr ast::Expr, TypeContext<'db>) -> Type<'db>,
{
// Extract the type variable `T` from `list[T]` in typeshed. // Extract the type variable `T` from `list[T]` in typeshed.
let elt_tys = |collection_class: KnownClass| { let elt_tys = |collection_class: KnownClass| {
let class_literal = collection_class.try_to_class_literal(self.db())?; let class_literal = collection_class.try_to_class_literal(self.db())?;
@ -6332,7 +6350,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// Infer the element types without type context, and fallback to unknown for // Infer the element types without type context, and fallback to unknown for
// custom typesheds. // custom typesheds.
for elt in elts.flatten().flatten() { for elt in elts.flatten().flatten() {
self.get_or_infer_expression(elt, TypeContext::default()); infer_elt_expression(self, elt, TypeContext::default());
} }
return None; return None;
@ -6387,7 +6405,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
for elts in elts { for elts in elts {
// An unpacking expression for a dictionary. // An unpacking expression for a dictionary.
if let &[None, Some(value)] = elts.as_slice() { if let &[None, Some(value)] = elts.as_slice() {
let inferred_value_ty = self.get_or_infer_expression(value, TypeContext::default()); let inferred_value_ty = infer_elt_expression(self, value, TypeContext::default());
// Merge the inferred type of the nested dictionary. // Merge the inferred type of the nested dictionary.
if let Some(specialization) = if let Some(specialization) =
@ -6410,7 +6428,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
{ {
let Some(elt) = elt else { continue }; let Some(elt) = elt else { continue };
let inferred_elt_ty = self.get_or_infer_expression(elt, elt_tcx); let inferred_elt_ty = infer_elt_expression(self, elt, elt_tcx);
// Simplify the inference based on the declared type of the element. // Simplify the inference based on the declared type of the element.
if let Some(elt_tcx) = elt_tcx.annotation { if let Some(elt_tcx) = elt_tcx.annotation {

View File

@ -8,7 +8,7 @@ use ruff_text_size::Ranged;
use super::class::{ClassType, CodeGeneratorKind, Field}; use super::class::{ClassType, CodeGeneratorKind, Field};
use super::context::InferContext; use super::context::InferContext;
use super::diagnostic::{ use super::diagnostic::{
INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict, self, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, report_invalid_key_on_typed_dict,
report_missing_typed_dict_key, report_missing_typed_dict_key,
}; };
use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor}; use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor};
@ -213,9 +213,13 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>(
return true; return true;
} }
let value_node = value_node.into();
if diagnostic::is_invalid_typed_dict_literal(context.db(), item.declared_ty, value_node) {
return false;
}
// Invalid assignment - emit diagnostic // Invalid assignment - emit diagnostic
if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node.into()) if let Some(builder) = context.report_lint(assignment_kind.diagnostic_type(), value_node) {
{
let typed_dict_ty = Type::TypedDict(typed_dict); let typed_dict_ty = Type::TypedDict(typed_dict);
let typed_dict_d = typed_dict_ty.display(db); let typed_dict_d = typed_dict_ty.display(db);
let value_d = value_ty.display(db); let value_d = value_ty.display(db);