From 71c14c96d7a1e77a16d76a4c873fc038a24fd329 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Sun, 2 Nov 2025 20:20:21 -0500 Subject: [PATCH 1/2] `dict` is not assignable to `TypedDict` --- .../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 | 69 ++++++++++++------- .../resources/mdtest/typed_dict.md | 64 +++++++++++++---- crates/ty_python_semantic/src/types.rs | 5 +- .../src/types/infer/builder.rs | 66 +++++++++++------- 8 files changed, 149 insertions(+), 67 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 3fee0513ed47d..6b908737282bb 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 726d74a630335..e6ef48276ad56 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 69695c3f5c417..7bb4e02044c95 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 254ac03d73d68..5fac394404e57 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 155b4ea618b0d..fec44f0656df6 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,20 @@ 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 +28 | # error: [invalid-key] +29 | # error: [invalid-assignment] +30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} 31 | -32 | class Employee(TypedDict): -33 | id: ReadOnly[int] -34 | name: str +32 | # error: [invalid-key] +33 | bob = Person(name="Bob", age=25, unknown="Bar") +34 | from typing_extensions import ReadOnly 35 | -36 | def write_to_readonly_key(employee: Employee): -37 | employee["id"] = 42 # error: [invalid-assignment] +36 | class Employee(TypedDict): +37 | id: ReadOnly[int] +38 | name: str +39 | +40 | def write_to_readonly_key(employee: Employee): +41 | employee["id"] = 42 # error: [invalid-assignment] ``` # Diagnostics @@ -156,18 +160,34 @@ info: rule `invalid-key` is enabled by default ``` +``` +error[invalid-assignment]: Object of type `dict[Unknown | str, Unknown | str | int]` is not assignable to `Person` + --> src/mdtest_snippet.py:30:5 + | +28 | # error: [invalid-key] +29 | # error: [invalid-assignment] +30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} + | ^^^^^ +31 | +32 | # error: [invalid-key] + | +info: rule `invalid-assignment` is enabled by default + +``` + ``` error[invalid-key]: Invalid key for TypedDict `Person` - --> src/mdtest_snippet.py:28:21 + --> src/mdtest_snippet.py:30: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 | # error: [invalid-assignment] +30 | 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 +31 | +32 | # error: [invalid-key] | info: rule `invalid-key` is enabled by default @@ -175,13 +195,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:33: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] +32 | # error: [invalid-key] +33 | bob = Person(name="Bob", age=25, unknown="Bar") | ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown" -30 | from typing_extensions import ReadOnly +34 | from typing_extensions import ReadOnly | info: rule `invalid-key` is enabled by default @@ -189,21 +208,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:41:5 | -36 | def write_to_readonly_key(employee: Employee): -37 | employee["id"] = 42 # error: [invalid-assignment] +40 | def write_to_readonly_key(employee: Employee): +41 | 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:37:5 | -32 | class Employee(TypedDict): -33 | id: ReadOnly[int] +36 | class Employee(TypedDict): +37 | id: ReadOnly[int] | ----------------- Read-only item declared here -34 | name: str +38 | 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 14142020a2385..f405f5f1d71db 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -83,6 +83,7 @@ CAPITALIZED_NAME = "Name" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "Name" - did you mean "name"?" # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" +# error: [invalid-assignment] dave: Person = {CAPITALIZED_NAME: "Dave", "age": 20} def age() -> Literal["age"] | None: @@ -95,30 +96,33 @@ 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`" +# error: [invalid-assignment] 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" +# error: [invalid-assignment] 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"" +# error: [invalid-assignment] 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 ``` @@ -201,6 +205,8 @@ reveal_type(alice["inner"]["age"]) # revealed: int | None reveal_type(alice["inner"]["non_existing"]) # revealed: Unknown # error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "extra"" +# error: [invalid-argument-type] +# error: [invalid-assignment] alice: Person = {"inner": {"name": "Alice", "age": 30, "extra": 1}} ``` @@ -237,20 +243,26 @@ All of these are missing the required `age` field: ```py # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-assignment] 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 # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" +# error: [invalid-assignment] a_person = {"name": "Alice"} ``` @@ -258,21 +270,30 @@ 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`" +# error: [invalid-assignment] 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`" +# error: [invalid-assignment] 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-assignment] (a_person := {"name": None, "age": 30}) ``` @@ -280,21 +301,30 @@ 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"" +# error: [invalid-assignment] 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"" +# error: [invalid-assignment] a_person = {"name": "Alice", "age": 30, "extra": True} + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" +# error: [invalid-assignment] (a_person := {"name": "Alice", "age": 30, "extra": True}) ``` @@ -760,6 +790,7 @@ class Employee(Person): alice: Employee = {"name": "Alice", "employee_id": 1} # error: [missing-typed-dict-key] "Missing required key 'employee_id' in TypedDict `Employee` constructor" +# error: [invalid-assignment] eve: Employee = {"name": "Eve"} def combine(p: Person, e: Employee): @@ -861,6 +892,7 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} # error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" +# error: [invalid-assignment] p3: TaggedData[int] = {"data": "not a number", "tag": "number"} class Items(TypedDict, Generic[T]): @@ -894,6 +926,7 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} # error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" +# error: [invalid-assignment] p3: TaggedData[int] = {"data": "not a number", "tag": "number"} class Items[T](TypedDict): @@ -928,6 +961,9 @@ grandchild: Node = {"name": "grandchild", "parent": child} nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", "parent": None}}} # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Node`: value of type `Literal[3]`" +# error: [invalid-assignment] +# error: [invalid-argument-type] +# error: [invalid-argument-type] nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}} ``` @@ -977,7 +1013,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 +1065,12 @@ 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] + # error: [invalid-assignment] + 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 b20f3329996d4..abd3a4f4a026c 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1975,11 +1975,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/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index b74ff75404ca0..4e65ae80b52f0 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}; @@ -5833,15 +5835,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!( @@ -6197,7 +6190,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()]) }) @@ -6211,7 +6205,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()]) }) @@ -6224,12 +6219,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; } @@ -6245,7 +6242,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()]) @@ -6256,6 +6263,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: _, @@ -6267,14 +6275,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| { @@ -6285,12 +6298,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())?; @@ -6306,7 +6324,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; @@ -6361,7 +6379,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) = @@ -6384,7 +6402,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 { From 2aa777ec119655dc917d6937dc256fdac05bfddf Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Mon, 3 Nov 2025 10:34:40 -0500 Subject: [PATCH 2/2] avoid duplicated `TypedDict` assignment diagnostics --- ...ict`_-_Diagnostics_(e5289abf5c570c29).snap | 72 ++++++++----------- .../resources/mdtest/typed_dict.md | 30 +++----- .../ty_python_semantic/src/types/call/bind.rs | 5 ++ .../src/types/diagnostic.rs | 39 ++++++++-- .../src/types/typed_dict.rs | 10 ++- 5 files changed, 84 insertions(+), 72 deletions(-) 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 fec44f0656df6..a5b9456acd81c 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 @@ -40,19 +40,18 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md 26 | 27 | def create_with_invalid_string_key(): 28 | # error: [invalid-key] -29 | # error: [invalid-assignment] -30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} -31 | -32 | # error: [invalid-key] -33 | bob = Person(name="Bob", age=25, unknown="Bar") -34 | from typing_extensions import ReadOnly -35 | -36 | class Employee(TypedDict): -37 | id: ReadOnly[int] -38 | name: str -39 | -40 | def write_to_readonly_key(employee: Employee): -41 | employee["id"] = 42 # error: [invalid-assignment] +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 @@ -160,34 +159,19 @@ info: rule `invalid-key` is enabled by default ``` -``` -error[invalid-assignment]: Object of type `dict[Unknown | str, Unknown | str | int]` is not assignable to `Person` - --> src/mdtest_snippet.py:30:5 - | -28 | # error: [invalid-key] -29 | # error: [invalid-assignment] -30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} - | ^^^^^ -31 | -32 | # error: [invalid-key] - | -info: rule `invalid-assignment` is enabled by default - -``` - ``` error[invalid-key]: Invalid key for TypedDict `Person` - --> src/mdtest_snippet.py:30:21 + --> src/mdtest_snippet.py:29:21 | +27 | def create_with_invalid_string_key(): 28 | # error: [invalid-key] -29 | # error: [invalid-assignment] -30 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} +29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} | -----------------------------^^^^^^^^^-------- | | | | | Unknown key "unknown" | TypedDict `Person` -31 | -32 | # error: [invalid-key] +30 | +31 | # error: [invalid-key] | info: rule `invalid-key` is enabled by default @@ -195,12 +179,12 @@ info: rule `invalid-key` is enabled by default ``` error[invalid-key]: Invalid key for TypedDict `Person` - --> src/mdtest_snippet.py:33:11 + --> src/mdtest_snippet.py:32:11 | -32 | # error: [invalid-key] -33 | bob = Person(name="Bob", age=25, unknown="Bar") +31 | # error: [invalid-key] +32 | bob = Person(name="Bob", age=25, unknown="Bar") | ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown" -34 | from typing_extensions import ReadOnly +33 | from typing_extensions import ReadOnly | info: rule `invalid-key` is enabled by default @@ -208,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:41:5 + --> src/mdtest_snippet.py:40:5 | -40 | def write_to_readonly_key(employee: Employee): -41 | 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:37:5 + --> src/mdtest_snippet.py:36:5 | -36 | class Employee(TypedDict): -37 | id: ReadOnly[int] +35 | class Employee(TypedDict): +36 | id: ReadOnly[int] | ----------------- Read-only item declared here -38 | 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 f405f5f1d71db..04987a262cc61 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -83,7 +83,6 @@ CAPITALIZED_NAME = "Name" # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "Name" - did you mean "name"?" # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" -# error: [invalid-assignment] dave: Person = {CAPITALIZED_NAME: "Dave", "age": 20} def age() -> Literal["age"] | None: @@ -96,7 +95,6 @@ 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`" -# error: [invalid-assignment] eve1a: Person = {"name": b"Eve", "age": None} # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`" @@ -106,7 +104,6 @@ reveal_type(eve1a) # revealed: Person reveal_type(eve1b) # revealed: Person # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" -# error: [invalid-assignment] eve2a: Person = {"age": 22} # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" @@ -116,7 +113,6 @@ reveal_type(eve2a) # revealed: Person reveal_type(eve2b) # revealed: Person # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" -# error: [invalid-assignment] eve3a: Person = {"name": "Eve", "age": 25, "extra": True} # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" @@ -205,8 +201,6 @@ reveal_type(alice["inner"]["age"]) # revealed: int | None reveal_type(alice["inner"]["non_existing"]) # revealed: Unknown # error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "extra"" -# error: [invalid-argument-type] -# error: [invalid-assignment] alice: Person = {"inner": {"name": "Alice", "age": 30, "extra": 1}} ``` @@ -243,7 +237,6 @@ All of these are missing the required `age` field: ```py # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" -# error: [invalid-assignment] alice2: Person = {"name": "Alice"} # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" @@ -262,7 +255,6 @@ house.owner = {"name": "Alice"} a_person: Person # error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor" -# error: [invalid-assignment] a_person = {"name": "Alice"} ``` @@ -270,7 +262,6 @@ 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`" -# error: [invalid-assignment] 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`" @@ -289,11 +280,9 @@ 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`" -# error: [invalid-assignment] 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-assignment] (a_person := {"name": None, "age": 30}) ``` @@ -301,7 +290,6 @@ 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"" -# error: [invalid-assignment] alice4: Person = {"name": "Alice", "age": 30, "extra": True} # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" @@ -320,11 +308,9 @@ house.owner = {"name": "Alice", "age": 30, "extra": True} a_person: Person # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" -# error: [invalid-assignment] a_person = {"name": "Alice", "age": 30, "extra": True} # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" -# error: [invalid-assignment] (a_person := {"name": "Alice", "age": 30, "extra": True}) ``` @@ -520,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 @@ -790,7 +785,6 @@ class Employee(Person): alice: Employee = {"name": "Alice", "employee_id": 1} # error: [missing-typed-dict-key] "Missing required key 'employee_id' in TypedDict `Employee` constructor" -# error: [invalid-assignment] eve: Employee = {"name": "Eve"} def combine(p: Person, e: Employee): @@ -892,7 +886,6 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} # error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" -# error: [invalid-assignment] p3: TaggedData[int] = {"data": "not a number", "tag": "number"} class Items(TypedDict, Generic[T]): @@ -926,7 +919,6 @@ p1: TaggedData[int] = {"data": 42, "tag": "number"} p2: TaggedData[str] = {"data": "Hello", "tag": "text"} # error: [invalid-argument-type] "Invalid argument to key "data" with declared type `int` on TypedDict `TaggedData`: value of type `Literal["not a number"]`" -# error: [invalid-assignment] p3: TaggedData[int] = {"data": "not a number", "tag": "number"} class Items[T](TypedDict): @@ -961,9 +953,6 @@ grandchild: Node = {"name": "grandchild", "parent": child} nested: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": "n3", "parent": None}}} # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Node`: value of type `Literal[3]`" -# error: [invalid-assignment] -# error: [invalid-argument-type] -# error: [invalid-argument-type] nested_invalid: Node = {"name": "n1", "parent": {"name": "n2", "parent": {"name": 3, "parent": None}}} ``` @@ -1066,7 +1055,6 @@ def write_to_non_literal_string_key(person: Person, str_key: str): def create_with_invalid_string_key(): # error: [invalid-key] - # error: [invalid-assignment] alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index b0a5cc1b91a80..d2739fa69692b 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 2dd75e57aaba1..fcbc79f68ef48 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 can not 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/typed_dict.rs b/crates/ty_python_semantic/src/types/typed_dict.rs index e29b836d8a8fd..83b4ae946e21b 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);