[ty] Better invalid-assignment diagnostics (#21476)

## Summary

Improve the diagnostic range for `invalid-assignment` diagnostics, and
add source annotations for the value and target type.

closes https://github.com/astral-sh/ty/issues/1556

### Before

<img width="836" height="601" alt="image"
src="https://github.com/user-attachments/assets/a48219bb-58a8-4a83-b290-d09ef50ce5f0"
/>

### After

<img width="857" height="742" alt="image"
src="https://github.com/user-attachments/assets/cfcaa4f4-94fb-459e-8d64-97050dfecb50"
/>

## Ecosystem impact

Very good! Due to the wider diagnostic range, we now pick up more `#
type: ignore` directives that were supposed to suppress an invalid
assignment diagnostic.

## Test Plan

New snapshot tests
This commit is contained in:
David Peter
2025-11-18 14:31:04 +01:00
committed by GitHub
parent 7a739d6b76
commit 5ca9c15fc8
16 changed files with 450 additions and 102 deletions

View File

@@ -0,0 +1,52 @@
# Invalid assignment diagnostics
<!-- snapshot-diagnostics -->
## Annotated assignment
```py
x: int = "three" # error: [invalid-assignment]
```
## Unannotated assignment
```py
x: int
x = "three" # error: [invalid-assignment]
```
## Named expression
```py
x: int
(x := "three") # error: [invalid-assignment]
```
## Multiline expressions
```py
# fmt: off
# error: [invalid-assignment]
x: str = (
1 + 2 + (
3 + 4 + 5
)
)
```
## Multiple targets
```py
x: int
y: str
x, y = ("a", "b") # error: [invalid-assignment]
x, y = (0, 0) # error: [invalid-assignment]
```
## Shadowing of classes and functions
See [shadowing.md](./shadowing.md).

View File

@@ -0,0 +1,31 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Annotated assignment
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:1:4
|
1 | x: int = "three" # error: [invalid-assignment]
| --- ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,44 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Multiline expressions
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | # fmt: off
2 |
3 | # error: [invalid-assignment]
4 | x: str = (
5 | 1 + 2 + (
6 | 3 + 4 + 5
7 | )
8 | )
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal[15]` is not assignable to `str`
--> src/mdtest_snippet.py:4:4
|
3 | # error: [invalid-assignment]
4 | x: str = (
| ____---___^
| | |
| | Declared type
5 | | 1 + 2 + (
6 | | 3 + 4 + 5
7 | | )
8 | | )
| |_^ Incompatible value of type `Literal[15]`
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,55 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Multiple targets
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int
2 | y: str
3 |
4 | x, y = ("a", "b") # error: [invalid-assignment]
5 |
6 | x, y = (0, 0) # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `tuple[Literal["a"], Literal["b"]]` is not assignable to `int`
--> src/mdtest_snippet.py:4:1
|
2 | y: str
3 |
4 | x, y = ("a", "b") # error: [invalid-assignment]
| - ^^^^^^^^^^ Incompatible value of type `tuple[Literal["a"], Literal["b"]]`
| |
| Declared type `int`
5 |
6 | x, y = (0, 0) # error: [invalid-assignment]
|
info: rule `invalid-assignment` is enabled by default
```
```
error[invalid-assignment]: Object of type `tuple[Literal[0], Literal[0]]` is not assignable to `str`
--> src/mdtest_snippet.py:6:4
|
4 | x, y = ("a", "b") # error: [invalid-assignment]
5 |
6 | x, y = (0, 0) # error: [invalid-assignment]
| - ^^^^^^ Incompatible value of type `tuple[Literal[0], Literal[0]]`
| |
| Declared type `str`
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,35 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Named expression
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int
2 |
3 | (x := "three") # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:3:2
|
1 | x: int
2 |
3 | (x := "three") # error: [invalid-assignment]
| - ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type `int`
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -0,0 +1,33 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: invalid_assignment.md - Invalid assignment diagnostics - Unannotated assignment
mdtest path: crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md
---
# Python source files
## mdtest_snippet.py
```
1 | x: int
2 | x = "three" # error: [invalid-assignment]
```
# Diagnostics
```
error[invalid-assignment]: Object of type `Literal["three"]` is not assignable to `int`
--> src/mdtest_snippet.py:2:1
|
1 | x: int
2 | x = "three" # error: [invalid-assignment]
| - ^^^^^^^ Incompatible value of type `Literal["three"]`
| |
| Declared type `int`
|
info: rule `invalid-assignment` is enabled by default
```

View File

@@ -26,7 +26,9 @@ error[invalid-assignment]: Implicit shadowing of class `C`
1 | class C: ...
2 |
3 | C = 1 # error: [invalid-assignment]
| ^
| - ^ Incompatible value of type `Literal[1]`
| |
| Declared type `<class 'C'>`
|
info: Annotate to make it explicit if this is intentional
info: rule `invalid-assignment` is enabled by default

View File

@@ -26,7 +26,9 @@ error[invalid-assignment]: Implicit shadowing of function `f`
1 | def f(): ...
2 |
3 | f = 1 # error: [invalid-assignment]
| ^
| - ^ Incompatible value of type `Literal[1]`
| |
| Declared type `def f() -> Unknown`
|
info: Annotate to make it explicit if this is intentional
info: rule `invalid-assignment` is enabled by default

View File

@@ -59,10 +59,10 @@ In a non-stub file, there's no special treatment of ellipsis literals. An ellips
be assigned if `EllipsisType` is actually assignable to the annotated type.
```py
# error: 7 [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
# error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
def f(x: int = ...) -> None: ...
# error: 1 [invalid-assignment] "Object of type `EllipsisType` is not assignable to `int`"
# error: [invalid-assignment] "Object of type `EllipsisType` is not assignable to `int`"
a: int = ...
b = ...
reveal_type(b) # revealed: EllipsisType
@@ -73,6 +73,6 @@ reveal_type(b) # revealed: EllipsisType
There is no special treatment of the builtin name `Ellipsis` in stubs, only of `...` literals.
```pyi
# error: 7 [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
# error: [invalid-parameter-default] "Default value of type `EllipsisType` is not assignable to annotated parameter type `int`"
def f(x: int = Ellipsis) -> None: ...
```

View File

@@ -189,3 +189,40 @@ a = 10 + 4 # ty: ignore[division-by-zer]
# error: [division-by-zero]
a = 10 / 0 # ty: ignore[lint:division-by-zero]
```
## Suppression of specific diagnostics
In this section, we make sure that specific diagnostics can be suppressed in various forms that
users might expect to work.
### Invalid assignment
An invalid assignment can be suppressed in the following ways:
```py
# fmt: off
x1: str = 1 + 2 + 3 # ty: ignore
x2: str = ( # ty: ignore
1 + 2 + 3
)
x4: str = (
1 + 2 + 3
) # ty: ignore
```
It can *not* be suppressed by putting the `# ty: ignore` on the inner expression. The range targeted
by the suppression comment needs to overlap with one of the boundaries of the value range (the outer
parentheses in this case):
```py
# fmt: off
# error: [invalid-assignment]
x4: str = (
# error: [unused-ignore-comment]
1 + 2 + 3 # ty: ignore
)
```