From 3c8fb68765eafe9b43766fd64d5fd9a0297bc0e4 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Mon, 3 Nov 2025 16:57:49 -0500 Subject: [PATCH] [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. --- .../resources/mdtest/bidirectional.md | 1 + .../resources/mdtest/call/overloads.md | 3 +- .../resources/mdtest/call/union.md | 3 +- .../resources/mdtest/comprehensions/basic.md | 5 +- ...ict`_-_Diagnostics_(e5289abf5c570c29).snap | 55 ++++++++-------- .../resources/mdtest/typed_dict.md | 52 +++++++++++---- crates/ty_python_semantic/src/types.rs | 5 +- .../ty_python_semantic/src/types/call/bind.rs | 5 ++ .../src/types/diagnostic.rs | 39 +++++++++-- .../src/types/infer/builder.rs | 66 ++++++++++++------- .../src/types/typed_dict.rs | 10 ++- 11 files changed, 169 insertions(+), 75 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 3fee0513ed..6b90873728 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -76,6 +76,7 @@ def _() -> TD: def _() -> TD: # error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor" + # error: [invalid-return-type] return {} ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/overloads.md b/crates/ty_python_semantic/resources/mdtest/call/overloads.md index 726d74a630..e6ef48276a 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/call/overloads.md @@ -1685,8 +1685,7 @@ def int_or_str() -> int | str: x = f([{"x": 1}], int_or_str()) reveal_type(x) # revealed: int | str -# TODO: error: [no-matching-overload] "No overload of function `f` matches arguments" -# we currently incorrectly consider `list[dict[str, int]]` a subtype of `list[T]` +# error: [no-matching-overload] "No overload of function `f` matches arguments" f([{"y": 1}], int_or_str()) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/union.md b/crates/ty_python_semantic/resources/mdtest/call/union.md index 69695c3f5c..7bb4e02044 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/union.md +++ b/crates/ty_python_semantic/resources/mdtest/call/union.md @@ -277,7 +277,6 @@ def _(flag: bool): x = f({"x": 1}) reveal_type(x) # revealed: int - # TODO: error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int]`" - # we currently consider `TypedDict` instances to be subtypes of `dict` + # error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[Unknown | str, Unknown | int]`" f({"y": 1}) ``` diff --git a/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md b/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md index 254ac03d73..5fac394404 100644 --- a/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md @@ -162,10 +162,13 @@ The type context is propagated down into the comprehension: class Person(TypedDict): name: str +# TODO: This should not error. +# error: [invalid-assignment] persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]] 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"]] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap index 155b4ea618..a5b9456acd 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap @@ -39,16 +39,19 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md 25 | person[str_key] = "Alice" # error: [invalid-key] 26 | 27 | def create_with_invalid_string_key(): -28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] -29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] -30 | from typing_extensions import ReadOnly -31 | -32 | class Employee(TypedDict): -33 | id: ReadOnly[int] -34 | name: str -35 | -36 | def write_to_readonly_key(employee: Employee): -37 | employee["id"] = 42 # error: [invalid-assignment] +28 | # error: [invalid-key] +29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} +30 | +31 | # error: [invalid-key] +32 | bob = Person(name="Bob", age=25, unknown="Bar") +33 | from typing_extensions import ReadOnly +34 | +35 | class Employee(TypedDict): +36 | id: ReadOnly[int] +37 | name: str +38 | +39 | def write_to_readonly_key(employee: Employee): +40 | employee["id"] = 42 # error: [invalid-assignment] ``` # Diagnostics @@ -158,16 +161,17 @@ info: rule `invalid-key` is enabled by default ``` 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(): -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" | TypedDict `Person` -29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] -30 | from typing_extensions import ReadOnly +30 | +31 | # error: [invalid-key] | 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` - --> src/mdtest_snippet.py:29:11 + --> src/mdtest_snippet.py:32:11 | -27 | def create_with_invalid_string_key(): -28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] -29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] +31 | # error: [invalid-key] +32 | bob = Person(name="Bob", age=25, unknown="Bar") | ------ 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 @@ -189,21 +192,21 @@ info: rule `invalid-key` is enabled by default ``` 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): -37 | employee["id"] = 42 # error: [invalid-assignment] +39 | def write_to_readonly_key(employee: Employee): +40 | employee["id"] = 42 # error: [invalid-assignment] | -------- ^^^^ key is marked read-only | | | TypedDict `Employee` | info: Item declaration - --> src/mdtest_snippet.py:33:5 + --> src/mdtest_snippet.py:36:5 | -32 | class Employee(TypedDict): -33 | id: ReadOnly[int] +35 | class Employee(TypedDict): +36 | id: ReadOnly[int] | ----------------- Read-only item declared here -34 | name: str +37 | name: str | info: rule `invalid-assignment` is enabled by default diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 30bbb2132b..b4203ce2b6 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -96,29 +96,29 @@ The construction of a `TypedDict` is checked for type correctness: ```py # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" eve1a: Person = {"name": b"Eve", "age": None} + # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" eve1b = Person(name=b"Eve", age=None) -# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) -reveal_type(eve1a) # revealed: dict[Unknown | str, Unknown | bytes | None] +reveal_type(eve1a) # revealed: Person reveal_type(eve1b) # revealed: Person # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" eve2a: Person = {"age": 22} + # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" eve2b = Person(age=22) -# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) -reveal_type(eve2a) # revealed: dict[Unknown | str, Unknown | int] +reveal_type(eve2a) # revealed: Person reveal_type(eve2b) # revealed: Person # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" eve3a: Person = {"name": "Eve", "age": 25, "extra": True} + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" eve3b = Person(name="Eve", age=25, extra=True) -# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts) -reveal_type(eve3a) # revealed: dict[Unknown | str, Unknown | str | int] +reveal_type(eve3a) # revealed: Person reveal_type(eve3b) # revealed: Person ``` @@ -238,15 +238,19 @@ All of these are missing the required `age` field: ```py # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" alice2: Person = {"name": "Alice"} + # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" Person(name="Alice") + # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" Person({"name": "Alice"}) # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-argument-type] 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"} a_person: Person @@ -259,19 +263,25 @@ All of these have an invalid type for the `name` field: ```py # 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} + # 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) + # 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}) # 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}) -# 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} a_person: Person # 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} + # 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}) ``` @@ -281,19 +291,25 @@ All of these have an extra field that is not defined in the `TypedDict`: ```py # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" alice4: Person = {"name": "Alice", "age": 30, "extra": True} + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" Person(name="Alice", age=30, extra=True) + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" Person({"name": "Alice", "age": 30, "extra": True}) # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" +# error: [invalid-argument-type] 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} a_person: Person # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" a_person = {"name": "Alice", "age": 30, "extra": True} + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" (a_person := {"name": "Alice", "age": 30, "extra": True}) ``` @@ -490,6 +506,15 @@ dangerous(alice) 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 ### Reading @@ -977,7 +1002,7 @@ class Person(TypedDict): name: str 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}) ``` @@ -1029,8 +1054,11 @@ def write_to_non_literal_string_key(person: Person, str_key: str): person[str_key] = "Alice" # error: [invalid-key] def create_with_invalid_string_key(): - alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] - bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] + # 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: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index bdc859b095..bee767b763 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1987,11 +1987,14 @@ impl<'db> Type<'db> { ConstraintSet::from(false) } - (Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => { + (Type::TypedDict(_), _) => { // TODO: Implement assignability and subtyping for TypedDict 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__`. // 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()), diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b0a5cc1b91..d2739fa696 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3582,6 +3582,11 @@ impl<'db> BindingError<'db> { expected_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 Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else { return; diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 6ab6f2a447..5d647f108f 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -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"); } +// 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( context: &InferContext, node: AnyNodeRef, @@ -2040,15 +2054,27 @@ pub(super) fn report_invalid_assignment<'db>( target_ty: Type, 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 = DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty); - if let DefinitionKind::AnnotatedAssignment(annotated_assignment) = definition.kind(context.db()) - && let Some(value) = annotated_assignment.value(context.module()) - { + if let Some(value_expr) = value_expr { // Re-infer the RHS of the annotated assignment, ignoring the type context for more precise // 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( @@ -2070,6 +2096,11 @@ pub(super) fn report_invalid_attribute_assignment( source_ty: Type, 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( context, node, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1ad7d18482..b7608bbfff 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -5,7 +5,9 @@ use ruff_db::diagnostic::{Annotation, DiagnosticId, Severity}; use ruff_db::files::File; use ruff_db::parsed::ParsedModuleRef; 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_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -5859,15 +5861,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { 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] fn infer_expression(&mut self, expression: &ast::Expr, tcx: TypeContext<'db>) -> Type<'db> { debug_assert!( @@ -6223,7 +6216,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = list; 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(|| { KnownClass::List.to_specialized_instance(self.db(), [Type::unknown()]) }) @@ -6237,7 +6231,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } = set; 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(|| { KnownClass::Set.to_specialized_instance(self.db(), [Type::unknown()]) }) @@ -6250,12 +6245,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { items, } = dict; + let mut item_types = FxHashMap::default(); + // Validate `TypedDict` dictionary literal assignments. if let Some(tcx) = tcx.annotation && let Some(typed_dict) = tcx .filter_union(self.db(), Type::is_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; } @@ -6271,7 +6268,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .iter() .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(|| { KnownClass::Dict .to_specialized_instance(self.db(), [Type::unknown(), Type::unknown()]) @@ -6282,6 +6289,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &mut self, dict: &ast::ExprDict, typed_dict: TypedDictType<'db>, + item_types: &mut FxHashMap>, ) -> Option> { let ast::ExprDict { range: _, @@ -6293,14 +6301,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for item in items { 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())) { - self.infer_expression(&item.value, TypeContext::new(Some(field.declared_ty))); + self.infer_expression(&item.value, TypeContext::new(Some(field.declared_ty))) } 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| { @@ -6311,12 +6324,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } // 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, - elts: impl Iterator; N]>, + elts: I, tcx: TypeContext<'db>, + mut infer_elt_expression: F, collection_class: KnownClass, - ) -> Option> { + ) -> Option> + where + I: Iterator; N]>, + F: FnMut(&mut Self, &'expr ast::Expr, TypeContext<'db>) -> Type<'db>, + { // Extract the type variable `T` from `list[T]` in typeshed. let elt_tys = |collection_class: KnownClass| { 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 // custom typesheds. for elt in elts.flatten().flatten() { - self.get_or_infer_expression(elt, TypeContext::default()); + infer_elt_expression(self, elt, TypeContext::default()); } return None; @@ -6387,7 +6405,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for elts in elts { // An unpacking expression for a dictionary. 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. if let Some(specialization) = @@ -6410,7 +6428,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { 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. if let Some(elt_tcx) = elt_tcx.annotation { diff --git a/crates/ty_python_semantic/src/types/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index e29b836d8a..83b4ae946e 100644 --- a/crates/ty_python_semantic/src/types/typed_dict.rs +++ b/crates/ty_python_semantic/src/types/typed_dict.rs @@ -8,7 +8,7 @@ use ruff_text_size::Ranged; use super::class::{ClassType, CodeGeneratorKind, Field}; use super::context::InferContext; 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, }; use super::{ApplyTypeMappingVisitor, Type, TypeMapping, visitor}; @@ -213,9 +213,13 @@ pub(super) fn validate_typed_dict_key_assignment<'db, 'ast>( 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 - 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_d = typed_dict_ty.display(db); let value_d = value_ty.display(db);