From 3179b052215260e1c573b67d7d48afe20135c994 Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Fri, 31 Oct 2025 10:49:59 -0400 Subject: [PATCH] [ty] don't assume in diagnostic messages that a TypedDict key error is about subscript access (#21166) ## Summary Before this PR, we would emit diagnostics like "Invalid key access" for a TypedDict literal with invalid key, which doesn't make sense since there's no "access" in that case. This PR just adjusts the wording to be more general, and adjusts the documentation of the lint rule too. I noticed this in the playground and thought it would be a quick fix. As usual, it turned out to be a bit more subtle than I expected, but for now I chose to punt on the complexity. We may ultimately want to have different rules for invalid subscript vs invalid TypedDict literal, because an invalid key in a TypedDict literal is low severity: it's a typo detector, but not actually a type error. But then there's another wrinkle there: if the TypedDict is `closed=True`, then it _is_ a type error. So would we want to separate the open and closed cases into separate rules, too? I decided to leave this as a question for future. If we wanted to use separate rules, or use specific wording for each case instead of the generalized wording I chose here, that would also involve a bit of extra work to distinguish the cases, since we use a generic set of functions for reporting these errors. ## Test Plan Added and updated mdtests. --- crates/ty/docs/rules.md | 110 ++++++++++-------- ...ict`_-_Diagnostics_(e5289abf5c570c29).snap | 74 +++++++++--- .../resources/mdtest/typed_dict.md | 48 ++++---- .../src/types/diagnostic.rs | 18 ++- ty.schema.json | 4 +- 5 files changed, 155 insertions(+), 99 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 858f1f0c7c..4218eee1af 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -474,7 +474,7 @@ an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -529,7 +529,7 @@ a: int = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -599,7 +599,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -650,7 +650,7 @@ with 1: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -679,7 +679,7 @@ a: str Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -723,7 +723,7 @@ except ZeroDivisionError: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -762,11 +762,15 @@ Added in 0 **What it does** -Checks for subscript accesses with invalid keys. +Checks for subscript accesses with invalid keys and `TypedDict` construction with an +unknown key. **Why is this bad?** -Using an invalid key will raise a `KeyError` at runtime. +Subscripting with an invalid key will raise a `KeyError` at runtime. + +Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is +`closed=true` it also violates the expectations of the type. **Examples** @@ -779,6 +783,10 @@ class Person(TypedDict): alice = Person(name="Alice", age=30) alice["height"] # KeyError: 'height' + +bob: Person = { "name": "Bob", "age": 30 } # typo! + +carol = Person(name="Carol", age=25) # typo! ``` ## `invalid-legacy-type-variable` @@ -787,7 +795,7 @@ alice["height"] # KeyError: 'height' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -822,7 +830,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -888,7 +896,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -938,7 +946,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -998,7 +1006,7 @@ TypeError: Protocols can only inherit from other protocols, got Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1047,7 +1055,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1072,7 +1080,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1130,7 +1138,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1157,7 +1165,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1187,7 +1195,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1217,7 +1225,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1251,7 +1259,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1285,7 +1293,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1320,7 +1328,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1345,7 +1353,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -1378,7 +1386,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1407,7 +1415,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1431,7 +1439,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1457,7 +1465,7 @@ for i in 34: # TypeError: 'int' object is not iterable Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1484,7 +1492,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1542,7 +1550,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1572,7 +1580,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1601,7 +1609,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1628,7 +1636,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1656,7 +1664,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1702,7 +1710,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1729,7 +1737,7 @@ f(x=1, y=2) # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1757,7 +1765,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1782,7 +1790,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1807,7 +1815,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1844,7 +1852,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1872,7 +1880,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2026,7 +2034,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2086,7 +2094,7 @@ A()[0] # TypeError: 'A' object is not subscriptable Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2118,7 +2126,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2145,7 +2153,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2169,7 +2177,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -2227,7 +2235,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2266,7 +2274,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2353,7 +2361,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source 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 b80700fa08..155b4ea618 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 @@ -37,20 +37,24 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md 23 | 24 | def write_to_non_literal_string_key(person: Person, str_key: str): 25 | person[str_key] = "Alice" # error: [invalid-key] -26 | from typing_extensions import ReadOnly -27 | -28 | class Employee(TypedDict): -29 | id: ReadOnly[int] -30 | name: str +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 | def write_to_readonly_key(employee: Employee): -33 | employee["id"] = 42 # error: [invalid-assignment] +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] ``` # Diagnostics ``` -error[invalid-key]: Invalid key access on TypedDict `Person` +error[invalid-key]: Invalid key for TypedDict `Person` --> src/mdtest_snippet.py:8:5 | 7 | def access_invalid_literal_string_key(person: Person): @@ -66,7 +70,7 @@ info: rule `invalid-key` is enabled by default ``` ``` -error[invalid-key]: Invalid key access on TypedDict `Person` +error[invalid-key]: Invalid key for TypedDict `Person` --> src/mdtest_snippet.py:13:5 | 12 | def access_invalid_key(person: Person): @@ -82,7 +86,7 @@ info: rule `invalid-key` is enabled by default ``` ``` -error[invalid-key]: TypedDict `Person` cannot be indexed with a key of type `str` +error[invalid-key]: Invalid key for TypedDict `Person` of type `str` --> src/mdtest_snippet.py:16:12 | 15 | def access_with_str_key(person: Person, str_key: str): @@ -123,7 +127,7 @@ info: rule `invalid-assignment` is enabled by default ``` ``` -error[invalid-key]: Invalid key access on TypedDict `Person` +error[invalid-key]: Invalid key for TypedDict `Person` --> src/mdtest_snippet.py:22:5 | 21 | def write_to_non_existing_key(person: Person): @@ -145,7 +149,39 @@ error[invalid-key]: Cannot access `Person` with a key of type `str`. Only string 24 | def write_to_non_literal_string_key(person: Person, str_key: str): 25 | person[str_key] = "Alice" # error: [invalid-key] | ^^^^^^^ -26 | from typing_extensions import ReadOnly +26 | +27 | def create_with_invalid_string_key(): + | +info: rule `invalid-key` is enabled by default + +``` + +``` +error[invalid-key]: Invalid key for TypedDict `Person` + --> src/mdtest_snippet.py:28:21 + | +27 | def create_with_invalid_string_key(): +28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key] + | -----------------------------^^^^^^^^^-------- + | | | + | | Unknown key "unknown" + | TypedDict `Person` +29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key] +30 | from typing_extensions import ReadOnly + | +info: rule `invalid-key` is enabled by default + +``` + +``` +error[invalid-key]: Invalid key for TypedDict `Person` + --> src/mdtest_snippet.py:29: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] + | ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown" +30 | from typing_extensions import ReadOnly | info: rule `invalid-key` is enabled by default @@ -153,21 +189,21 @@ info: rule `invalid-key` is enabled by default ``` error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee` - --> src/mdtest_snippet.py:33:5 + --> src/mdtest_snippet.py:37:5 | -32 | def write_to_readonly_key(employee: Employee): -33 | employee["id"] = 42 # error: [invalid-assignment] +36 | def write_to_readonly_key(employee: Employee): +37 | employee["id"] = 42 # error: [invalid-assignment] | -------- ^^^^ key is marked read-only | | | TypedDict `Employee` | info: Item declaration - --> src/mdtest_snippet.py:29:5 + --> src/mdtest_snippet.py:33:5 | -28 | class Employee(TypedDict): -29 | id: ReadOnly[int] +32 | class Employee(TypedDict): +33 | id: ReadOnly[int] | ----------------- Read-only item declared here -30 | name: str +34 | 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 d810a79efe..042d6317a2 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -29,7 +29,7 @@ alice: Person = {"name": "Alice", "age": 30} reveal_type(alice["name"]) # revealed: str reveal_type(alice["age"]) # revealed: int | None -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing"" reveal_type(alice["non_existing"]) # revealed: Unknown ``` @@ -41,7 +41,7 @@ bob = Person(name="Bob", age=25) reveal_type(bob["name"]) # revealed: str reveal_type(bob["age"]) # revealed: int | None -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing"" reveal_type(bob["non_existing"]) # revealed: Unknown ``` @@ -69,7 +69,7 @@ def name_or_age() -> Literal["name", "age"]: carol: Person = {NAME: "Carol", AGE: 20} reveal_type(carol[NAME]) # revealed: str -# error: [invalid-key] "TypedDict `Person` cannot be indexed with a key of type `str`" +# error: [invalid-key] "Invalid key for TypedDict `Person` of type `str`" reveal_type(carol[non_literal()]) # revealed: Unknown reveal_type(carol[name_or_age()]) # revealed: str | int | None @@ -81,7 +81,7 @@ def _(): CAPITALIZED_NAME = "Name" -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "Name" - did you mean "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" dave: Person = {CAPITALIZED_NAME: "Dave", "age": 20} @@ -104,9 +104,9 @@ eve2a: Person = {"age": 22} # error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor" eve2b = Person(age=22) -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" eve3a: Person = {"name": "Eve", "age": 25, "extra": True} -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" eve3b = Person(name="Eve", age=25, extra=True) ``` @@ -157,10 +157,10 @@ bob["name"] = None Assignments to non-existing keys are disallowed: ```py -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" alice["extra"] = True -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" bob["extra"] = True ``` @@ -185,10 +185,10 @@ alice: Person = {"inner": {"name": "Alice", "age": 30}} reveal_type(alice["inner"]["name"]) # revealed: str reveal_type(alice["inner"]["age"]) # revealed: int | None -# error: [invalid-key] "Invalid key access on TypedDict `Inner`: Unknown key "non_existing"" +# error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "non_existing"" reveal_type(alice["inner"]["non_existing"]) # revealed: Unknown -# error: [invalid-key] "Invalid key access on TypedDict `Inner`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Inner`: Unknown key "extra"" alice: Person = {"inner": {"name": "Alice", "age": 30, "extra": 1}} ``` @@ -267,22 +267,22 @@ a_person = {"name": None, "age": 30} All of these have an extra field that is not defined in the `TypedDict`: ```py -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" alice4: Person = {"name": "Alice", "age": 30, "extra": True} -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" Person(name="Alice", age=30, extra=True) -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" Person({"name": "Alice", "age": 30, "extra": True}) -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" accepts_person({"name": "Alice", "age": 30, "extra": True}) # TODO: this should be an error house.owner = {"name": "Alice", "age": 30, "extra": True} a_person: Person -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" a_person = {"name": "Alice", "age": 30, "extra": True} -# error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra"" (a_person := {"name": "Alice", "age": 30, "extra": True}) ``` @@ -323,7 +323,7 @@ user2 = User({"name": "Bob"}) # error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `User`: value of type `None`" user3 = User({"name": None, "age": 25}) -# error: [invalid-key] "Invalid key access on TypedDict `User`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `User`: Unknown key "extra"" user4 = User({"name": "Charlie", "age": 30, "extra": True}) ``` @@ -360,7 +360,7 @@ invalid = OptionalPerson(name=123) Extra fields are still not allowed, even with `total=False`: ```py -# error: [invalid-key] "Invalid key access on TypedDict `OptionalPerson`: Unknown key "extra"" +# error: [invalid-key] "Invalid key for TypedDict `OptionalPerson`: Unknown key "extra"" invalid_extra = OptionalPerson(name="George", extra=True) ``` @@ -503,10 +503,10 @@ def _(person: Person, literal_key: Literal["age"], union_of_keys: Literal["age", reveal_type(person[union_of_keys]) # revealed: int | None | str - # error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "non_existing"" + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing"" reveal_type(person["non_existing"]) # revealed: Unknown - # error: [invalid-key] "TypedDict `Person` cannot be indexed with a key of type `str`" + # error: [invalid-key] "Invalid key for TypedDict `Person` of type `str`" reveal_type(person[str_key]) # revealed: Unknown # No error here: @@ -530,7 +530,7 @@ def _(person: Person): person["name"] = "Alice" person["age"] = 30 - # error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "naem" - did you mean "name"?" + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "naem" - did you mean "name"?" person["naem"] = "Alice" def _(person: Person): @@ -646,7 +646,7 @@ def _(p: Person) -> None: reveal_type(p.setdefault("name", "Alice")) # revealed: str reveal_type(p.setdefault("extra", "default")) # revealed: str - # error: [invalid-key] "Invalid key access on TypedDict `Person`: Unknown key "extraz" - did you mean "extra"?" + # error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extraz" - did you mean "extra"?" reveal_type(p.setdefault("extraz", "value")) # revealed: Unknown ``` @@ -1015,6 +1015,10 @@ def write_to_non_existing_key(person: Person): 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] ``` Assignment to `ReadOnly` keys: diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 7db83b9b88..2dd75e57aa 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -572,10 +572,14 @@ declare_lint! { // Added in #19763. declare_lint! { /// ## What it does - /// Checks for subscript accesses with invalid keys. + /// Checks for subscript accesses with invalid keys and `TypedDict` construction with an + /// unknown key. /// /// ## Why is this bad? - /// Using an invalid key will raise a `KeyError` at runtime. + /// Subscripting with an invalid key will raise a `KeyError` at runtime. + /// + /// Creating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is + /// `closed=true` it also violates the expectations of the type. /// /// ## Examples /// ```python @@ -587,9 +591,13 @@ declare_lint! { /// /// alice = Person(name="Alice", age=30) /// alice["height"] # KeyError: 'height' + /// + /// bob: Person = { "name": "Bob", "age": 30 } # typo! + /// + /// carol = Person(name="Carol", age=25) # typo! /// ``` pub(crate) static INVALID_KEY = { - summary: "detects invalid subscript accesses", + summary: "detects invalid subscript accesses or TypedDict literal keys", status: LintStatus::stable("0.0.1-alpha.17"), default_level: Level::Error, } @@ -2966,7 +2974,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>( let typed_dict_name = typed_dict_ty.display(db); let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid key access on TypedDict `{typed_dict_name}`", + "Invalid key for TypedDict `{typed_dict_name}`", )); diagnostic.annotate( @@ -2989,7 +2997,7 @@ pub(crate) fn report_invalid_key_on_typed_dict<'db>( diagnostic } _ => builder.into_diagnostic(format_args!( - "TypedDict `{}` cannot be indexed with a key of type `{}`", + "Invalid key for TypedDict `{}` of type `{}`", typed_dict_ty.display(db), key_ty.display(db), )), diff --git a/ty.schema.json b/ty.schema.json index 270241fb28..55d5bdf996 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -584,8 +584,8 @@ ] }, "invalid-key": { - "title": "detects invalid subscript accesses", - "description": "## What it does\nChecks for subscript accesses with invalid keys.\n\n## Why is this bad?\nUsing an invalid key will raise a `KeyError` at runtime.\n\n## Examples\n```python\nfrom typing import TypedDict\n\nclass Person(TypedDict):\n name: str\n age: int\n\nalice = Person(name=\"Alice\", age=30)\nalice[\"height\"] # KeyError: 'height'\n```", + "title": "detects invalid subscript accesses or TypedDict literal keys", + "description": "## What it does\nChecks for subscript accesses with invalid keys and `TypedDict` construction with an\nunknown key.\n\n## Why is this bad?\nSubscripting with an invalid key will raise a `KeyError` at runtime.\n\nCreating a `TypedDict` with an unknown key is likely a mistake; if the `TypedDict` is\n`closed=true` it also violates the expectations of the type.\n\n## Examples\n```python\nfrom typing import TypedDict\n\nclass Person(TypedDict):\n name: str\n age: int\n\nalice = Person(name=\"Alice\", age=30)\nalice[\"height\"] # KeyError: 'height'\n\nbob: Person = { \"name\": \"Bob\", \"age\": 30 } # typo!\n\ncarol = Person(name=\"Carol\", age=25) # typo!\n```", "default": "error", "oneOf": [ {