mirror of
https://github.com/astral-sh/ruff.git
synced 2025-11-19 12:16:43 +00:00
[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.
This commit is contained in:
parent
172e8d4ae0
commit
3179b05221
5 changed files with 155 additions and 99 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue