From 5ca9c15fc80070ff99f101af591a29cf1b1e11c2 Mon Sep 17 00:00:00 2001 From: David Peter Date: Tue, 18 Nov 2025 14:31:04 +0100 Subject: [PATCH] [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 image ### After image ## 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 --- .github/workflows/ty-ecosystem-analyzer.yaml | 2 +- .github/workflows/ty-ecosystem-report.yaml | 2 +- crates/ty/docs/rules.md | 136 +++++++++--------- crates/ty/tests/cli/main.rs | 10 +- .../mdtest/diagnostics/invalid_assignment.md | 52 +++++++ ...-_Annotated_assignment_(4b799ca1eeb857b9).snap | 31 ++++ ..._Multiline_expression…_(f316976ffe72c6c7).snap | 44 ++++++ ...…_-_Multiple_targets_(e20ddfd7a91affb0).snap | 55 +++++++ ...…_-_Named_expression_(35c120b3bd9929f8).snap | 35 +++++ ...-_Unannotated_assignme…_(67e4b9239d5681a).snap | 33 +++++ ...licit_class_shado…_(c8ff9e3a079e8bd5).snap | 4 +- ...licit_function_sh…_(a1515328b775ebc1).snap | 4 +- .../resources/mdtest/stubs/ellipsis.md | 6 +- .../mdtest/suppressions/ty_ignore.md | 37 +++++ .../src/types/diagnostic.rs | 99 ++++++++++--- .../src/types/infer/builder.rs | 2 +- 16 files changed, 450 insertions(+), 102 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Annotated_assignment_(4b799ca1eeb857b9).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiline_expression…_(f316976ffe72c6c7).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiple_targets_(e20ddfd7a91affb0).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Named_expression_(35c120b3bd9929f8).snap create mode 100644 crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Unannotated_assignme…_(67e4b9239d5681a).snap diff --git a/.github/workflows/ty-ecosystem-analyzer.yaml b/.github/workflows/ty-ecosystem-analyzer.yaml index dccc9d1bb9..a1640e5974 100644 --- a/.github/workflows/ty-ecosystem-analyzer.yaml +++ b/.github/workflows/ty-ecosystem-analyzer.yaml @@ -67,7 +67,7 @@ jobs: cd .. - uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@11aa5472cf9d6b9e019c401505a093112942d7bf" + uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@0aff03414da5d242e97a9f43fb502e085637a4a1" ecosystem-analyzer \ --repository ruff \ diff --git a/.github/workflows/ty-ecosystem-report.yaml b/.github/workflows/ty-ecosystem-report.yaml index 5a2521b02c..c9df4f1b28 100644 --- a/.github/workflows/ty-ecosystem-report.yaml +++ b/.github/workflows/ty-ecosystem-report.yaml @@ -52,7 +52,7 @@ jobs: cd .. - uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@11aa5472cf9d6b9e019c401505a093112942d7bf" + uv tool install "git+https://github.com/astral-sh/ecosystem-analyzer@0aff03414da5d242e97a9f43fb502e085637a4a1" ecosystem-analyzer \ --verbose \ diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 58b1db584c..ab6973d5aa 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -39,7 +39,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -95,7 +95,7 @@ f(int) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -126,7 +126,7 @@ a = 1 Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -158,7 +158,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -190,7 +190,7 @@ class B(A): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -217,7 +217,7 @@ class B(A, A): ... Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -329,7 +329,7 @@ def test(): -> "Literal[5]": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -359,7 +359,7 @@ class C(A, B): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -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 @@ -756,7 +756,7 @@ class C[U](Generic[T]): ... Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -795,7 +795,7 @@ carol = Person(name="Carol", age=25) # typo! Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -830,7 +830,7 @@ def f(t: TypeVar("U")): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -864,7 +864,7 @@ class B(metaclass=f): ... Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -896,7 +896,7 @@ TypeError: can only inherit from a NamedTuple type and Generic Default level: error · Preview (since 1.0.0) · Related issues · -View source +View source @@ -926,7 +926,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -976,7 +976,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1002,7 +1002,7 @@ def f(a: int = ''): ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1033,7 +1033,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1067,7 +1067,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 @@ -1116,7 +1116,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1141,7 +1141,7 @@ def func() -> int: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1199,7 +1199,7 @@ TODO #14889 Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -1226,7 +1226,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 @@ -1256,7 +1256,7 @@ TYPE_CHECKING = '' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1286,7 +1286,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 @@ -1320,7 +1320,7 @@ f(10) # Error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -1354,7 +1354,7 @@ class C: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1389,7 +1389,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 @@ -1414,7 +1414,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 @@ -1447,7 +1447,7 @@ alice["age"] # KeyError Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1476,7 +1476,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1500,7 +1500,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 @@ -1526,7 +1526,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 @@ -1553,7 +1553,7 @@ f(1, x=2) # Error raised here Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -1611,7 +1611,7 @@ def test(): -> "int": Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1641,7 +1641,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 @@ -1670,7 +1670,7 @@ class B(A): ... # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1697,7 +1697,7 @@ f("foo") # Error raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1725,7 +1725,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1771,7 +1771,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1798,7 +1798,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 @@ -1826,7 +1826,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 @@ -1851,7 +1851,7 @@ import foo # ModuleNotFoundError: No module named 'foo' Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1876,7 +1876,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 @@ -1913,7 +1913,7 @@ b1 < b2 < b1 # exception raised here Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1941,7 +1941,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 @@ -1966,7 +1966,7 @@ l[1:10:0] # ValueError: slice step cannot be zero Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -2007,7 +2007,7 @@ class SubProto(BaseProto, Protocol): Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -2095,7 +2095,7 @@ a = 20 / 0 # type: ignore Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2123,7 +2123,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2155,7 +2155,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 @@ -2187,7 +2187,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 @@ -2214,7 +2214,7 @@ cast(int, f()) # Redundant Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2238,7 +2238,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 @@ -2296,7 +2296,7 @@ def g(): Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -2335,7 +2335,7 @@ class D(C): ... # error: [unsupported-base] Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -2398,7 +2398,7 @@ def foo(x: int | str) -> int | str: Default level: ignore · Preview (since 0.0.1-alpha.1) · Related issues · -View source +View source @@ -2422,7 +2422,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/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 0edbc1c414..6640c4f598 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -41,22 +41,24 @@ fn test_quiet_output() -> anyhow::Result<()> { let case = CliTest::with_file("test.py", "x: int = 'foo'")?; // By default, we emit a diagnostic - assert_cmd_snapshot!(case.command(), @r###" + assert_cmd_snapshot!(case.command(), @r#" success: false exit_code: 1 ----- stdout ----- error[invalid-assignment]: Object of type `Literal["foo"]` is not assignable to `int` - --> test.py:1:1 + --> test.py:1:4 | 1 | x: int = 'foo' - | ^ + | --- ^^^^^ Incompatible value of type `Literal["foo"]` + | | + | Declared type | info: rule `invalid-assignment` is enabled by default Found 1 diagnostic ----- stderr ----- - "###); + "#); // With `quiet`, the diagnostic is not displayed, just the summary message assert_cmd_snapshot!(case.command().arg("--quiet"), @r" diff --git a/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md new file mode 100644 index 0000000000..066a110f7d --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/diagnostics/invalid_assignment.md @@ -0,0 +1,52 @@ +# Invalid assignment 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). diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Annotated_assignment_(4b799ca1eeb857b9).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Annotated_assignment_(4b799ca1eeb857b9).snap new file mode 100644 index 0000000000..b4d14282da --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Annotated_assignment_(4b799ca1eeb857b9).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiline_expression…_(f316976ffe72c6c7).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiline_expression…_(f316976ffe72c6c7).snap new file mode 100644 index 0000000000..9c908b4a3e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiline_expression…_(f316976ffe72c6c7).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiple_targets_(e20ddfd7a91affb0).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiple_targets_(e20ddfd7a91affb0).snap new file mode 100644 index 0000000000..8ac6d47663 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Multiple_targets_(e20ddfd7a91affb0).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Named_expression_(35c120b3bd9929f8).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Named_expression_(35c120b3bd9929f8).snap new file mode 100644 index 0000000000..df49bebbc9 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Named_expression_(35c120b3bd9929f8).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Unannotated_assignme…_(67e4b9239d5681a).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Unannotated_assignme…_(67e4b9239d5681a).snap new file mode 100644 index 0000000000..03cbb5f6e2 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/invalid_assignment.m…_-_Invalid_assignment_d…_-_Unannotated_assignme…_(67e4b9239d5681a).snap @@ -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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_class_shado…_(c8ff9e3a079e8bd5).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_class_shado…_(c8ff9e3a079e8bd5).snap index 5109c05d9a..9c2fa5e925 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_class_shado…_(c8ff9e3a079e8bd5).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_class_shado…_(c8ff9e3a079e8bd5).snap @@ -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 `` | info: Annotate to make it explicit if this is intentional info: rule `invalid-assignment` is enabled by default diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_function_sh…_(a1515328b775ebc1).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_function_sh…_(a1515328b775ebc1).snap index c9e3b2b326..e642105997 100644 --- a/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_function_sh…_(a1515328b775ebc1).snap +++ b/crates/ty_python_semantic/resources/mdtest/snapshots/shadowing.md_-_Shadowing_-_Implicit_function_sh…_(a1515328b775ebc1).snap @@ -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 diff --git a/crates/ty_python_semantic/resources/mdtest/stubs/ellipsis.md b/crates/ty_python_semantic/resources/mdtest/stubs/ellipsis.md index a29277516d..a8077eba1f 100644 --- a/crates/ty_python_semantic/resources/mdtest/stubs/ellipsis.md +++ b/crates/ty_python_semantic/resources/mdtest/stubs/ellipsis.md @@ -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: ... ``` diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md index 765d4f9e65..f9c2f9b5d2 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/ty_ignore.md @@ -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 +) +``` diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 07ad837655..42b621dcab 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -31,8 +31,11 @@ use crate::{ }; use itertools::Itertools; use ruff_db::diagnostic::{Annotation, Diagnostic, Span, SubDiagnostic, SubDiagnosticSeverity}; +use ruff_db::source::source_text; use ruff_python_ast::name::Name; +use ruff_python_ast::parenthesize::parentheses_iterator; use ruff_python_ast::{self as ast, AnyNodeRef, Identifier}; +use ruff_python_trivia::CommentRanges; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::fmt::Formatter; @@ -2068,15 +2071,13 @@ pub(crate) fn is_invalid_typed_dict_literal( && matches!(source, AnyNodeRef::ExprDict(_)) } -fn report_invalid_assignment_with_message( - context: &InferContext, - node: AnyNodeRef, - target_ty: Type, +fn report_invalid_assignment_with_message<'db, 'ctx: 'db, T: Ranged>( + context: &'ctx InferContext, + node: T, + target_ty: Type<'db>, message: std::fmt::Arguments, -) { - let Some(builder) = context.report_lint(&INVALID_ASSIGNMENT, node) else { - return; - }; +) -> Option> { + let builder = context.report_lint(&INVALID_ASSIGNMENT, node)?; match target_ty { Type::ClassLiteral(class) => { let mut diag = builder.into_diagnostic(format_args!( @@ -2084,6 +2085,7 @@ fn report_invalid_assignment_with_message( class.name(context.db()), )); diag.info("Annotate to make it explicit if this is intentional"); + Some(diag) } Type::FunctionLiteral(function) => { let mut diag = builder.into_diagnostic(format_args!( @@ -2091,53 +2093,106 @@ fn report_invalid_assignment_with_message( function.name(context.db()), )); diag.info("Annotate to make it explicit if this is intentional"); + Some(diag) } + _ => { - builder.into_diagnostic(message); + let diag = builder.into_diagnostic(message); + Some(diag) } } } pub(super) fn report_invalid_assignment<'db>( context: &InferContext<'db, '_>, - node: AnyNodeRef, + target_node: AnyNodeRef, definition: Definition<'db>, target_ty: Type, - mut source_ty: Type<'db>, + mut value_ty: Type<'db>, ) { - let value_expr = match definition.kind(context.db()) { + let definition_kind = definition.kind(context.db()); + let value_node = match definition_kind { 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()) + if let Some(value_node) = value_node + && is_invalid_typed_dict_literal(context.db(), target_ty, value_node.into()) { return; } let settings = - DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty); + DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, value_ty); - if let Some(value_expr) = value_expr { + if let Some(value_node) = value_node { // 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_expr); + value_ty = + infer_isolated_expression(context.db(), definition.scope(context.db()), value_node); } - report_invalid_assignment_with_message( + let diagnostic_range = if let Some(value_node) = value_node { + // Expand the range to include parentheses around the value, if any. This allows + // invalid-assignment diagnostics to be suppressed on the opening or closing parenthesis: + // ```py + // x: str = ( # ty: ignore <- here + // 1 + 2 + 3 + // ) # ty: ignore <- or here + // ``` + + let comment_ranges = CommentRanges::from(context.module().tokens()); + let source = source_text(context.db(), context.file()); + parentheses_iterator(value_node.into(), None, &comment_ranges, &source) + .last() + .unwrap_or(value_node.range()) + } else { + target_node.range() + }; + + let Some(mut diag) = report_invalid_assignment_with_message( context, - node, + diagnostic_range, target_ty, format_args!( "Object of type `{}` is not assignable to `{}`", - source_ty.display_with(context.db(), settings.clone()), + value_ty.display_with(context.db(), settings.clone()), target_ty.display_with(context.db(), settings) ), - ); + ) else { + return; + }; + + if value_node.is_some() { + match definition_kind { + DefinitionKind::AnnotatedAssignment(assignment) => { + // For annotated assignments, just point to the annotation in the source code. + diag.annotate( + context + .secondary(assignment.annotation(context.module())) + .message("Declared type"), + ); + } + _ => { + // Otherwise, annotate the target with its declared type. + diag.annotate(context.secondary(target_node).message(format_args!( + "Declared type `{}`", + target_ty.display(context.db()), + ))); + } + } + + diag.set_primary_message(format_args!( + "Incompatible value of type `{}`", + value_ty.display(context.db()), + )); + + // Overwrite the concise message to avoid showing the value type twice + let message = diag.primary_message().to_string(); + diag.set_concise_message(message); + } } pub(super) fn report_invalid_attribute_assignment( diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index a99d9dfe21..43f569b9f2 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -7761,7 +7761,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_expression(target, TypeContext::default()); - self.add_binding(named.into(), definition, |builder, tcx| { + self.add_binding(named.target.as_ref().into(), definition, |builder, tcx| { builder.infer_expression(value, tcx) }) }