[ty] Further improve subscript assignment diagnostics (#21411)

## Summary

Further improve subscript assignment diagnostics, especially for
`dict`s:

```py
config: dict[str, int] = {}

config["retries"] = "three"
```

<img width="1276" height="274" alt="image"
src="https://github.com/user-attachments/assets/9762c733-8d1c-4a57-8c8a-99825071dc7d"
/>

I have many more ideas, but this looks like a reasonable first step.
Thank you @AlexWaygood for some of the suggestions here.

## Test Plan

Update tests
This commit is contained in:
David Peter 2025-11-13 13:31:14 +01:00 committed by GitHub
parent 12e74ae894
commit 04ab9170d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 147 additions and 76 deletions

View file

@ -19,12 +19,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal[0]` and a value of type `Literal[3]` on object of type `dict[str, int]`
error[invalid-assignment]: Invalid subscript assignment with key of type `Literal[0]` and value of type `Literal[3]` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:2:1
|
1 | config: dict[str, int] = {}
2 | config[0] = 3 # error: [invalid-assignment]
| ^^^^^^
| ^^^^^^^-^^^^^
| |
| Expected key of type `str`, got `Literal[0]`
|
info: rule `invalid-assignment` is enabled by default

View file

@ -24,7 +24,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-key]: Cannot access `Config` with a key of type `Literal[0]`. Only string literals are allowed as keys on TypedDicts.
error[invalid-key]: TypedDict `Config` can only be subscripted with a string literal key, got key of type `Literal[0]`.
--> src/mdtest_snippet.py:7:12
|
6 | def _(config: Config) -> None:

View file

@ -19,12 +19,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `Literal["three"]` on object of type `dict[str, int]`
error[invalid-assignment]: Invalid subscript assignment with key of type `Literal["retries"]` and value of type `Literal["three"]` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:2:1
|
1 | config: dict[str, int] = {}
2 | config["retries"] = "three" # error: [invalid-assignment]
| ^^^^^^
| ^^^^^^^^^^^^^^^^^^^^-------
| |
| Expected value of type `int`, got `Literal["three"]`
|
info: rule `invalid-assignment` is enabled by default

View file

@ -23,13 +23,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Cannot assign to a subscript on an object of type `ReadOnlyDict` with no `__setitem__` method
error[invalid-assignment]: Cannot assign to a subscript on an object of type `ReadOnlyDict`
--> src/mdtest_snippet.py:6:1
|
5 | config = ReadOnlyDict()
6 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
| ^^^^^^^^^^^^^^^^^
|
help: Consider adding a `__setitem__` method to `ReadOnlyDict`.
info: rule `invalid-assignment` is enabled by default
```

View file

@ -19,14 +19,15 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Cannot assign to a subscript on an object of type `None` with no `__setitem__` method
error[invalid-assignment]: Cannot assign to a subscript on an object of type `None`
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | None) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
| ^^^^^^^^^^^^^^^^^
|
info: The full type of the subscripted object is `dict[str, int] | None`
info: `None` does not have a `__setitem__` method.
info: rule `invalid-assignment` is enabled by default
```

View file

@ -19,12 +19,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, str].__setitem__(key: str, value: str, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `Literal[3]` on object of type `dict[str, str]`
error[invalid-assignment]: Invalid subscript assignment with key of type `Literal["retries"]` and value of type `Literal[3]` on object of type `dict[str, str]`
--> src/mdtest_snippet.py:2:5
|
1 | def _(config: dict[str, int] | dict[str, str]) -> None:
2 | config["retries"] = 3 # error: [invalid-assignment]
| ^^^^^^
| ^^^^^^^^^^^^^^^^^^^^-
| |
| Expected value of type `str`, got `Literal[3]`
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default

View file

@ -21,13 +21,15 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/subscript/assignment_dia
# Diagnostics
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, int].__setitem__(key: str, value: int, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `float` on object of type `dict[str, int]`
error[invalid-assignment]: Invalid subscript assignment with key of type `Literal["retries"]` and value of type `float` on object of type `dict[str, int]`
--> src/mdtest_snippet.py:4:5
|
2 | # error: [invalid-assignment]
3 | # error: [invalid-assignment]
4 | config["retries"] = 3.0
| ^^^^^^
| ^^^^^^^^^^^^^^^^^^^^---
| |
| Expected value of type `int`, got `float`
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default
@ -35,13 +37,15 @@ info: rule `invalid-assignment` is enabled by default
```
```
error[invalid-assignment]: Method `__setitem__` of type `bound method dict[str, str].__setitem__(key: str, value: str, /) -> None` cannot be called with a key of type `Literal["retries"]` and a value of type `float` on object of type `dict[str, str]`
error[invalid-assignment]: Invalid subscript assignment with key of type `Literal["retries"]` and value of type `float` on object of type `dict[str, str]`
--> src/mdtest_snippet.py:4:5
|
2 | # error: [invalid-assignment]
3 | # error: [invalid-assignment]
4 | config["retries"] = 3.0
| ^^^^^^
| ^^^^^^^^^^^^^^^^^^^^---
| |
| Expected value of type `str`, got `float`
|
info: The full type of the subscripted object is `dict[str, int] | dict[str, str]`
info: rule `invalid-assignment` is enabled by default

View file

@ -89,7 +89,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Invalid key of type `str` for TypedDict `Person`
error[invalid-key]: TypedDict `Person` can only be subscripted with string literal keys, got key of type `str`
--> src/mdtest_snippet.py:16:12
|
15 | def access_with_str_key(person: Person, str_key: str):
@ -146,7 +146,7 @@ info: rule `invalid-key` is enabled by default
```
```
error[invalid-key]: Cannot access `Person` with a key of type `str`. Only string literals are allowed as keys on TypedDicts.
error[invalid-key]: TypedDict `Person` can only be subscripted with a string literal key, got key of type `str`.
--> src/mdtest_snippet.py:25:12
|
24 | def write_to_non_literal_string_key(person: Person, str_key: str):

View file

@ -76,7 +76,7 @@ a[0] = 0
class NoSetitem: ...
a = NoSetitem()
a[0] = 0 # error: "Cannot assign to a subscript on an object of type `NoSetitem` with no `__setitem__` method"
a[0] = 0 # error: "Cannot assign to a subscript on an object of type `NoSetitem`"
```
## `__setitem__` not callable

View file

@ -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] "Invalid key of type `str` for TypedDict `Person`"
# error: [invalid-key] "TypedDict `Person` can only be subscripted with string literal keys, got key of type `str`"
reveal_type(carol[non_literal()]) # revealed: Unknown
reveal_type(carol[name_or_age()]) # revealed: str | int | None
@ -553,7 +553,7 @@ def _(
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "non_existing""
reveal_type(person["non_existing"]) # revealed: Unknown
# error: [invalid-key] "Invalid key of type `str` for TypedDict `Person`"
# error: [invalid-key] "TypedDict `Person` can only be subscripted with string literal keys, got key of type `str`"
reveal_type(person[str_key]) # revealed: Unknown
# No error here:
@ -631,10 +631,10 @@ def _(person: Person, union_of_keys: Literal["name", "age"], unknown_value: Any)
person[union_of_keys] = None
def _(person: Person, str_key: str, literalstr_key: LiteralString):
# error: [invalid-key] "Cannot access `Person` with a key of type `str`. Only string literals are allowed as keys on TypedDicts."
# error: [invalid-key] "TypedDict `Person` can only be subscripted with a string literal key, got key of type `str`."
person[str_key] = None
# error: [invalid-key] "Cannot access `Person` with a key of type `LiteralString`. Only string literals are allowed as keys on TypedDicts."
# error: [invalid-key] "TypedDict `Person` can only be subscripted with a string literal key, got key of type `LiteralString`."
person[literalstr_key] = None
def _(person: Person, unknown_key: Any):