diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index f1688037a6fc4b..174476828db93e 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -181,10 +181,10 @@ mod tests { --> stdlib/ty_extensions.pyi:15:1 | 13 | # Types - 14 | Unknown = object() - 15 | AlwaysTruthy = object() + 14 | Unknown: _SpecialForm + 15 | AlwaysTruthy: _SpecialForm | ------------ - 16 | AlwaysFalsy = object() + 16 | AlwaysFalsy: _SpecialForm | "); } @@ -939,10 +939,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "#); } @@ -1003,10 +1003,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "#); } @@ -1030,10 +1030,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "#); } @@ -1057,10 +1057,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "#); } @@ -1249,10 +1249,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "#); } @@ -2042,10 +2042,10 @@ def function(): --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ------- - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | "); } diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 87e40c7537a20e..126d4bbcf0fce3 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -2324,10 +2324,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ^^^^^^^ - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | info: Source --> main2.py:5:18 @@ -2664,7 +2664,7 @@ mod tests { "#, ); - assert_snapshot!(test.inlay_hints(), @r###" + assert_snapshot!(test.inlay_hints(), @r#" a[: list[int]] = [1, 2] b[: list[int | float]] = [1.0, 2.0] @@ -3164,7 +3164,7 @@ mod tests { i: list[bytes] = [b'/x01', b'/x02'] j: list[int | float] = [+1, +2.0] k: list[int | float] = [-1, -2.0] - "###); + "#); } #[test] @@ -3349,7 +3349,7 @@ mod tests { "#, ); - assert_snapshot!(test.inlay_hints(), @r###" + assert_snapshot!(test.inlay_hints(), @r#" class MyClass[T, U]: def __init__(self, x: list[T], y: tuple[U, U]): @@ -4083,7 +4083,7 @@ mod tests { y: tuple[MyClass[int, str], MyClass[int, str]] = (MyClass([42], ("a", "b")), MyClass([42], ("a", "b"))) a, b = MyClass([42], ("a", "b")), MyClass([42], ("a", "b")) c, d = (MyClass([42], ("a", "b")), MyClass([42], ("a", "b"))) - "###); + "#); } #[test] @@ -4387,7 +4387,7 @@ mod tests { foo(y[0])", ); - assert_snapshot!(test.inlay_hints(), @r###" + assert_snapshot!(test.inlay_hints(), @r#" def foo(x: int): pass x[: list[int]] = [1] @@ -4496,7 +4496,7 @@ mod tests { foo(x[0]) foo(y[0]) - "###); + "#); } #[test] @@ -5525,10 +5525,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ^^^^^^^ - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | info: Source --> main2.py:2:14 @@ -5543,10 +5543,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ^^^^^^^ - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | info: Source --> main2.py:3:17 @@ -6530,10 +6530,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ^^^^^^^ - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | info: Source --> main2.py:4:63 @@ -7260,10 +7260,10 @@ mod tests { --> stdlib/ty_extensions.pyi:14:1 | 13 | # Types - 14 | Unknown = object() + 14 | Unknown: _SpecialForm | ^^^^^^^ - 15 | AlwaysTruthy = object() - 16 | AlwaysFalsy = object() + 15 | AlwaysTruthy: _SpecialForm + 16 | AlwaysFalsy: _SpecialForm | info: Source --> main2.py:4:22 diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md index d496ee01864753..5314e79db4b076 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/invalid.md @@ -86,7 +86,9 @@ async def outer_async(): # avoid unrelated syntax errors on `yield` and `await` b: 2.3, # error: [invalid-type-form] "Float literals are not allowed in type expressions" c: 4j, # error: [invalid-type-form] "Complex literals are not allowed in type expressions" d: True, # error: [invalid-type-form] "Boolean literals are not allowed in this context in a type expression" - e: int | b"foo", # error: [invalid-type-form] "Bytes literals are not allowed in this context in a type expression" + # error: [unsupported-operator] + # error: [invalid-type-form] "Bytes literals are not allowed in this context in a type expression" + e: int | b"foo", f: 1 and 2, # error: [invalid-type-form] "Boolean operations are not allowed in type expressions" g: 1 or 2, # error: [invalid-type-form] "Boolean operations are not allowed in type expressions" h: (foo := 1), # error: [invalid-type-form] "Named expressions are not allowed in type expressions" @@ -97,7 +99,9 @@ async def outer_async(): # avoid unrelated syntax errors on `yield` and `await` m: (yield 1), # error: [invalid-type-form] "`yield` expressions are not allowed in type expressions" n: 1 < 2, # error: [invalid-type-form] "Comparison expressions are not allowed in type expressions" o: bar(), # error: [invalid-type-form] "Function calls are not allowed in type expressions" - p: int | f"foo", # error: [invalid-type-form] "F-strings are not allowed in type expressions" + # error: [unsupported-operator] + # error: [invalid-type-form] "F-strings are not allowed in type expressions" + p: int | f"foo", # error: [invalid-type-form] "Slices are not allowed in type expressions" # error: [invalid-type-form] "Invalid subscript" q: [1, 2, 3][1:2], diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/string.md b/crates/ty_python_semantic/resources/mdtest/annotations/string.md index 77131c310a98ae..ffa1df89bef23b 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/string.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/string.md @@ -35,7 +35,13 @@ def f(v: tuple[int, "str"]): def f(v: "Foo"): reveal_type(v) # revealed: Foo +def f(x: "int | 'Foo'"): ... + class Foo: ... + +f("not an int or a Foo") # error: [invalid-argument-type] +f(Foo()) # fine +f(42) # fine ``` ## Deferred (undefined) @@ -46,13 +52,145 @@ def f(v: "Foo"): reveal_type(v) # revealed: Unknown ``` -## Partial deferred +## Partially deferred annotations + +### Python less than 3.14 + +"Partially stringified" PEP-604 unions can raise `TypeError` on Python \<3.14; we try to detect this +common runtime error: + + + +```toml +[environment] +python-version = "3.13" +``` + +```py +from typing import Any, TypeVar, Callable, Protocol, TypedDict, TYPE_CHECKING + +class TD(TypedDict): ... + +class P(Protocol): + x: int + +class Meta(type): + def __or__(cls, other: str) -> Any: + return "wow, so fancy, bet type checkers can't handle this" + +class UsesMeta(metaclass=Meta): ... + +T = TypeVar("T") + +# fmt: off +def f( + # error: [unsupported-operator] + a: int | "Foo", + # error: [unsupported-operator] + b: int | "memoryview" | bytes, + # error: [unsupported-operator] + c: "TD" | None, + # error: [unsupported-operator] + d: "P" | None, + # fine: `TypeVar.__or__` accepts strings at runtime + e: T | "Foo", + # fine: _SpecialForm.__ror__` accepts strings at runtime + f: "Foo" | Callable[..., None], + # also fine due to the custom metaclass + g: UsesMeta | "Foo", + # error: [unsupported-operator] + h: None | None, + # error: [unresolved-reference] "SomethingUndefined" + # error: [unresolved-reference] "SomethingAlsoUndefined" + i: SomethingUndefined | SomethingAlsoUndefined, +): + reveal_type(a) # revealed: int | Foo + reveal_type(b) # revealed: int | memoryview[int] | bytes + reveal_type(c) # revealed: TD | None + reveal_type(d) # revealed: P | None + reveal_type(e) # revealed: T@f | Foo + reveal_type(f) # revealed: Foo | ((...) -> None) + reveal_type(g) # revealed: UsesMeta | Foo + reveal_type(h) # revealed: None + reveal_type(i) # revealed: Unknown + +# fmt: on + +class Foo: ... + +# error: [unsupported-operator] +X = list["int" | None] + +if TYPE_CHECKING: + # TODO: ideally we would not error here, since `if TYPE_CHECKING` + # blocks are not executed at runtime. Requires + # https://github.com/astral-sh/ty/issues/1553. + bar: "int" | "None" # error: [unsupported-operator] + + # TODO: same as above + # error: [unsupported-operator] + def foo(x: "int" | "None"): ... + + class Bar: + # no error because this annotation is resolved inside a scope + # fully defined inside an `if TYPE_CHECKING` block + def f(x: "int" | "None"): ... +``` + +### Python less than 3.14 in a stub file + +This error is never emitted on stub files, because they are never executed at runtime: + +```toml +[environment] +python-version = "3.13" +``` + +```pyi +# fine +def f(x: "int" | None): ... +``` + +### Python less than 3.14 with `__future__` annotations + +The errors can be avoided in type-annotation contexts by using `__future__` annotations on Python +\<3.14: + +```toml +[environment] +python-version = "3.13" +``` ```py -def f(v: int | "Foo"): +from __future__ import annotations + +def f(v: int | "Foo"): # fine reveal_type(v) # revealed: int | Foo class Foo: ... + +# error: [unsupported-operator] +X = list["int" | None] +``` + +### Python >=3.14 + +Runtime errors are also less common for partially stringified annotations if the Python version +being used is >=3.14: + +```toml +[environment] +python-version = "3.14" +``` + +```py +def f(v: int | "Foo"): # fine + reveal_type(v) # revealed: int | Foo + +class Foo: ... + +# error: [unsupported-operator] +X = list["int" | None] ``` ## `typing.Literal` diff --git a/crates/ty_python_semantic/resources/mdtest/cycle.md b/crates/ty_python_semantic/resources/mdtest/cycle.md index d2aebe0f477348..4a7fc249c03458 100644 --- a/crates/ty_python_semantic/resources/mdtest/cycle.md +++ b/crates/ty_python_semantic/resources/mdtest/cycle.md @@ -42,7 +42,7 @@ python-version = "3.12" # typing.TypeAliasType ```py from typing import Union, TypeAliasType, Sequence, Mapping -A = list["A" | None] +A = list["A | None"] def f(x: A): # TODO: should be `list[A | None]`? diff --git a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md index 51eb51d0d8a54d..858957625b3cad 100644 --- a/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md @@ -1644,10 +1644,10 @@ python-version = "3.12" ```py from typing import List, Dict -RecursiveList1 = list["RecursiveList1" | None] -RecursiveList2 = List["RecursiveList2" | None] -RecursiveDict1 = dict[str, "RecursiveDict1" | None] -RecursiveDict2 = Dict[str, "RecursiveDict2" | None] +RecursiveList1 = list["RecursiveList1 | None"] +RecursiveList2 = List["RecursiveList2 | None"] +RecursiveDict1 = dict[str, "RecursiveDict1 | None"] +RecursiveDict2 = Dict[str, "RecursiveDict2 | None"] RecursiveDict3 = dict["RecursiveDict3", int] RecursiveDict4 = Dict["RecursiveDict4", int] diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index f9579ce8cbd716..83db0404acaca0 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -246,7 +246,7 @@ Dangling calls cannot contain other dangling calls; that's an invalid type form: from ty_extensions import reveal_mro # error: [invalid-type-form] -class A(NamedTuple("B", [("x", NamedTuple("C", [("x", "A" | None)]))])): +class A(NamedTuple("B", [("x", NamedTuple("C", [("x", "A | None")]))])): pass # revealed: (, , , , , , , , typing.Protocol, typing.Generic, ) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index d8c0e891dba985..b07d929a43d5de 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -174,6 +174,8 @@ python-version = "3.9" ``` ```py +from __future__ import annotations + def _(x: int | str | bytes): # error: [unsupported-operator] if isinstance(x, int | str): diff --git a/crates/ty_python_semantic/resources/mdtest/overloads.md b/crates/ty_python_semantic/resources/mdtest/overloads.md index 985288bd52222a..c38d640f01b730 100644 --- a/crates/ty_python_semantic/resources/mdtest/overloads.md +++ b/crates/ty_python_semantic/resources/mdtest/overloads.md @@ -176,6 +176,8 @@ python-version = "3.9" ``` ```py +from __future__ import annotations + import sys from typing import overload diff --git a/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md index 8b1bb49e30d2d3..d9879a852cf0ca 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md @@ -310,13 +310,13 @@ def _(x: IntOrStr): from typing import TypeAlias, TypeVar, Union from types import UnionType -RecursiveTuple: TypeAlias = tuple[int | "RecursiveTuple", str] +RecursiveTuple: TypeAlias = tuple["int | RecursiveTuple", str] def _(rec: RecursiveTuple): # TODO should be `tuple[int | RecursiveTuple, str]` reveal_type(rec) # revealed: tuple[Divergent, str] -RecursiveHomogeneousTuple: TypeAlias = tuple[int | "RecursiveHomogeneousTuple", ...] +RecursiveHomogeneousTuple: TypeAlias = tuple["int | RecursiveHomogeneousTuple", ...] def _(rec: RecursiveHomogeneousTuple): # TODO should be `tuple[int | RecursiveHomogeneousTuple, ...]` diff --git a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md index ac6b77d51dac70..a85b6c65c0716f 100644 --- a/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md @@ -131,6 +131,31 @@ def f(x: Foo[int]): reveal_type(x.foo()) # revealed: int ``` +## Stringified values + + + +Stringifying the right-hand side of a type alias is redundant, but allowed: + +```py +type X = "int | str" + +def f(obj: X): + reveal_type(obj) # revealed: int | str +``` + +The right-hand side of a PEP-695 type alias will not usually be executed, but can be if the user +accesses the `.__value__` attribute. Normal runtime rules still therefore apply regarding partially +stringified alias values: + +```py +# error: [unsupported-operator] +type Y = "int" | str + +def g(obj: Y): + reveal_type(obj) # revealed: int | str +``` + ## In unions and intersections We can "break apart" a type alias by e.g. adding it to a union: @@ -276,7 +301,7 @@ in a tuple unpacking is not supported. from typing_extensions import TypeAliasType # error: [invalid-type-alias-type] "A `TypeAliasType` definition must be a simple variable assignment" -TypeAliasType("IntOrStr", int | str) +TypeAliasType("IntOrStr", "int | str") ``` ### Mutually recursive `TypeAliasType` definitions @@ -469,7 +494,7 @@ def f(x: A): #### With new-style union ```py -type A = list["A" | str] +type A = list[A | str] def f(x: A): reveal_type(x) # revealed: list[A | str] diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/pep695_type_aliases.\342\200\246_-_PEP_695_type_aliases_-_Stringified_values_(5d8e1185129f8ae4).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/pep695_type_aliases.\342\200\246_-_PEP_695_type_aliases_-_Stringified_values_(5d8e1185129f8ae4).snap" new file mode 100644 index 00000000000000..68965324829aca --- /dev/null +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/pep695_type_aliases.\342\200\246_-_PEP_695_type_aliases_-_Stringified_values_(5d8e1185129f8ae4).snap" @@ -0,0 +1,46 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- + +--- +mdtest name: pep695_type_aliases.md - PEP 695 type aliases - Stringified values +mdtest path: crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md +--- + +# Python source files + +## mdtest_snippet.py + +``` +1 | type X = "int | str" +2 | +3 | def f(obj: X): +4 | reveal_type(obj) # revealed: int | str +5 | # error: [unsupported-operator] +6 | type Y = "int" | str +7 | +8 | def g(obj: Y): +9 | reveal_type(obj) # revealed: int | str +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:6:10 + | +4 | reveal_type(obj) # revealed: int | str +5 | # error: [unsupported-operator] +6 | type Y = "int" | str + | -----^^^--- + | | | + | | Has type `` + | Has type `Literal["int"]` +7 | +8 | def g(obj: Y): + | +info: A type alias scope is lazy but will be executed at runtime if the `__value__` property is accessed +info: rule `unsupported-operator` is enabled by default + +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/string.md_-_String_annotations_-_Partially_deferred_a\342\200\246_-_Python_less_than_3.1\342\200\246_(5e6477d05ddea33f).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/string.md_-_String_annotations_-_Partially_deferred_a\342\200\246_-_Python_less_than_3.1\342\200\246_(5e6477d05ddea33f).snap" new file mode 100644 index 00000000000000..bb45195da772ea --- /dev/null +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/string.md_-_String_annotations_-_Partially_deferred_a\342\200\246_-_Python_less_than_3.1\342\200\246_(5e6477d05ddea33f).snap" @@ -0,0 +1,279 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- + +--- +mdtest name: string.md - String annotations - Partially deferred annotations - Python less than 3.14 +mdtest path: crates/ty_python_semantic/resources/mdtest/annotations/string.md +--- + +# Python source files + +## mdtest_snippet.py + +``` + 1 | from typing import Any, TypeVar, Callable, Protocol, TypedDict, TYPE_CHECKING + 2 | + 3 | class TD(TypedDict): ... + 4 | + 5 | class P(Protocol): + 6 | x: int + 7 | + 8 | class Meta(type): + 9 | def __or__(cls, other: str) -> Any: +10 | return "wow, so fancy, bet type checkers can't handle this" +11 | +12 | class UsesMeta(metaclass=Meta): ... +13 | +14 | T = TypeVar("T") +15 | +16 | # fmt: off +17 | def f( +18 | # error: [unsupported-operator] +19 | a: int | "Foo", +20 | # error: [unsupported-operator] +21 | b: int | "memoryview" | bytes, +22 | # error: [unsupported-operator] +23 | c: "TD" | None, +24 | # error: [unsupported-operator] +25 | d: "P" | None, +26 | # fine: `TypeVar.__or__` accepts strings at runtime +27 | e: T | "Foo", +28 | # fine: _SpecialForm.__ror__` accepts strings at runtime +29 | f: "Foo" | Callable[..., None], +30 | # also fine due to the custom metaclass +31 | g: UsesMeta | "Foo", +32 | # error: [unsupported-operator] +33 | h: None | None, +34 | # error: [unresolved-reference] "SomethingUndefined" +35 | # error: [unresolved-reference] "SomethingAlsoUndefined" +36 | i: SomethingUndefined | SomethingAlsoUndefined, +37 | ): +38 | reveal_type(a) # revealed: int | Foo +39 | reveal_type(b) # revealed: int | memoryview[int] | bytes +40 | reveal_type(c) # revealed: TD | None +41 | reveal_type(d) # revealed: P | None +42 | reveal_type(e) # revealed: T@f | Foo +43 | reveal_type(f) # revealed: Foo | ((...) -> None) +44 | reveal_type(g) # revealed: UsesMeta | Foo +45 | reveal_type(h) # revealed: None +46 | reveal_type(i) # revealed: Unknown +47 | +48 | # fmt: on +49 | +50 | class Foo: ... +51 | +52 | # error: [unsupported-operator] +53 | X = list["int" | None] +54 | +55 | if TYPE_CHECKING: +56 | # TODO: ideally we would not error here, since `if TYPE_CHECKING` +57 | # blocks are not executed at runtime. Requires +58 | # https://github.com/astral-sh/ty/issues/1553. +59 | bar: "int" | "None" # error: [unsupported-operator] +60 | +61 | # TODO: same as above +62 | # error: [unsupported-operator] +63 | def foo(x: "int" | "None"): ... +64 | +65 | class Bar: +66 | # no error because this annotation is resolved inside a scope +67 | # fully defined inside an `if TYPE_CHECKING` block +68 | def f(x: "int" | "None"): ... +``` + +# Diagnostics + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:19:8 + | +17 | def f( +18 | # error: [unsupported-operator] +19 | a: int | "Foo", + | ---^^^----- + | | | + | | Has type `Literal["Foo"]` + | Has type `` +20 | # error: [unsupported-operator] +21 | b: int | "memoryview" | bytes, + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:21:8 + | +19 | a: int | "Foo", +20 | # error: [unsupported-operator] +21 | b: int | "memoryview" | bytes, + | ---^^^------------ + | | | + | | Has type `Literal["memoryview"]` + | Has type `` +22 | # error: [unsupported-operator] +23 | c: "TD" | None, + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:23:8 + | +21 | b: int | "memoryview" | bytes, +22 | # error: [unsupported-operator] +23 | c: "TD" | None, + | ----^^^---- + | | | + | | Has type `None` + | Has type `Literal["TD"]` +24 | # error: [unsupported-operator] +25 | d: "P" | None, + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:25:8 + | +23 | c: "TD" | None, +24 | # error: [unsupported-operator] +25 | d: "P" | None, + | ---^^^---- + | | | + | | Has type `None` + | Has type `Literal["P"]` +26 | # fine: `TypeVar.__or__` accepts strings at runtime +27 | e: T | "Foo", + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:33:8 + | +31 | g: UsesMeta | "Foo", +32 | # error: [unsupported-operator] +33 | h: None | None, + | ^^^^^^^^^^^ Both operands have type `None` +34 | # error: [unresolved-reference] "SomethingUndefined" +35 | # error: [unresolved-reference] "SomethingAlsoUndefined" + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unresolved-reference]: Name `SomethingUndefined` used when not defined + --> src/mdtest_snippet.py:36:8 + | +34 | # error: [unresolved-reference] "SomethingUndefined" +35 | # error: [unresolved-reference] "SomethingAlsoUndefined" +36 | i: SomethingUndefined | SomethingAlsoUndefined, + | ^^^^^^^^^^^^^^^^^^ +37 | ): +38 | reveal_type(a) # revealed: int | Foo + | +info: rule `unresolved-reference` is enabled by default + +``` + +``` +error[unresolved-reference]: Name `SomethingAlsoUndefined` used when not defined + --> src/mdtest_snippet.py:36:29 + | +34 | # error: [unresolved-reference] "SomethingUndefined" +35 | # error: [unresolved-reference] "SomethingAlsoUndefined" +36 | i: SomethingUndefined | SomethingAlsoUndefined, + | ^^^^^^^^^^^^^^^^^^^^^^ +37 | ): +38 | reveal_type(a) # revealed: int | Foo + | +info: rule `unresolved-reference` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:53:10 + | +52 | # error: [unsupported-operator] +53 | X = list["int" | None] + | -----^^^---- + | | | + | | Has type `None` + | Has type `Literal["int"]` +54 | +55 | if TYPE_CHECKING: + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:59:10 + | +57 | # blocks are not executed at runtime. Requires +58 | # https://github.com/astral-sh/ty/issues/1553. +59 | bar: "int" | "None" # error: [unsupported-operator] + | -----^^^------ + | | | + | | Has type `Literal["None"]` + | Has type `Literal["int"]` +60 | +61 | # TODO: same as above + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` + +``` +error[unsupported-operator]: Unsupported `|` operation + --> src/mdtest_snippet.py:63:16 + | +61 | # TODO: same as above +62 | # error: [unsupported-operator] +63 | def foo(x: "int" | "None"): ... + | -----^^^------ + | | | + | | Has type `Literal["None"]` + | Has type `Literal["int"]` +64 | +65 | class Bar: + | +info: All type expressions are evaluated at runtime by default on Python <3.14 +info: Python 3.13 was assumed when inferring types because it was specified on the command line +help: Put quotes around the whole union rather than just certain elements +info: rule `unsupported-operator` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md index 70e4d2e57a105f..ccf8c918a33f70 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md +++ b/crates/ty_python_semantic/resources/mdtest/type_properties/is_subtype_of.md @@ -2025,7 +2025,7 @@ from dataclasses import dataclass @dataclass class A: - x: "A" | None + x: "A | None" static_assert(is_subtype_of(type[A], Callable[[A], A])) static_assert(is_subtype_of(type[A], Callable[[None], A])) diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index c9d892e2e45dfb..e9dc45f424fa79 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1277,11 +1277,11 @@ from ty_extensions import static_assert, is_assignable_to, is_equivalent_to class Node1(TypedDict): value: int - next: "Node1" | None + next: "Node1 | None" class Node2(TypedDict): value: int - next: "Node2" | None + next: "Node2 | None" static_assert(is_assignable_to(Node1, Node2)) static_assert(is_equivalent_to(Node1, Node2)) diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index 56e882c7c0b33a..d5f653d06686ac 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -1,13 +1,14 @@ use itertools::Either; -use ruff_python_ast as ast; +use ruff_python_ast::{self as ast, PythonVersion}; use super::{DeferredExpressionState, TypeInferenceBuilder}; -use crate::FxOrderSet; +use crate::semantic_index::scope::ScopeKind; use crate::types::diagnostic::{ - self, INVALID_TYPE_FORM, NOT_SUBSCRIPTABLE, UNBOUND_TYPE_VARIABLE, + self, INVALID_TYPE_FORM, NOT_SUBSCRIPTABLE, UNBOUND_TYPE_VARIABLE, UNSUPPORTED_OPERATOR, report_invalid_argument_number_to_special_form, report_invalid_arguments_to_callable, }; -use crate::types::infer::builder::{InferenceFlags, InnerExpressionInferenceState}; +use crate::types::infer::InferenceFlags; +use crate::types::infer::builder::{InnerExpressionInferenceState, MultiInferenceState}; use crate::types::signatures::Signature; use crate::types::special_form::{AliasSpec, LegacyStdlibAlias}; use crate::types::string_annotation::parse_string_annotation; @@ -18,6 +19,7 @@ use crate::types::{ SpecialFormType, SubclassOfType, Type, TypeAliasType, TypeContext, TypeGuardType, TypeIsType, TypeMapping, TypeVarKind, UnionBuilder, UnionType, any_over_type, todo_type, }; +use crate::{FxOrderSet, Program, add_inferred_python_version_hint_to_diagnostic}; /// Type expressions impl<'db> TypeInferenceBuilder<'db, '_> { @@ -153,6 +155,139 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ast::Operator::BitOr => { let left_ty = self.infer_type_expression(&binary.left); let right_ty = self.infer_type_expression(&binary.right); + + // Detect runtime errors from e.g. `int | "bytes"` on Python <3.14 without `__future__` annotations. + if !self.deferred_state.is_deferred() + && !self.scope.scope(self.db()).in_type_checking_block() + { + let previous_state = + self.set_multi_inference_state(MultiInferenceState::Ignore); + self.context.set_multi_inference(true); + // If the left-hand side of the union is itself a PEP-604 union, + // we'll already have checked whether it can be used with `|` in a previous inference step + // and emitted a diagnostic if it was appropriate. We should skip inferring it here to + // avoid duplicate diagnostics; just assume that the l.h.s. is a `UnionType` instance + // in that case. + let left_type_value = + self.infer_expression(&binary.left, TypeContext::default()); + let right_type_value = + self.infer_expression(&binary.right, TypeContext::default()); + self.multi_inference_state = previous_state; + self.context.set_multi_inference(false); + + let dunder_fails = Type::try_call_bin_op( + self.db(), + left_type_value, + ast::Operator::BitOr, + right_type_value, + ) + .is_err(); + + // As well as trying the normal dunder lookup, + // we also check for the case where one of the operands is a class-literal type + // and the other is a string literal. The normal dunder lookup fails to catch + // this error, since typeshed annotates `type.__(r)or__` as accepting `Any`. + let should_emit_error = dunder_fails + || matches!( + (left_type_value, right_type_value), + ( + Type::ClassLiteral(class), Type::LiteralValue(literal)) + | (Type::LiteralValue(literal), Type::ClassLiteral(class) + ) + if class.metaclass(self.db()) == KnownClass::Type.to_class_literal(self.db()) + && !literal.is_enum() + ); + + if should_emit_error + && let Some(builder) = + self.context.report_lint(&UNSUPPORTED_OPERATOR, binary) + { + let mut diagnostic = + builder.into_diagnostic("Unsupported `|` operation"); + + if left_type_value.is_equivalent_to(self.db(), right_type_value) { + diagnostic.set_primary_message(format_args!( + "Both operands have type `{}`", + left_type_value.display(self.db()) + )); + diagnostic.set_concise_message(format_args!( + "Operator `|` is unsupported between \ + two objects of type `{}`", + left_type_value.display(self.db()) + )); + } else { + for (operand, ty) in [ + (&*binary.left, left_type_value), + (&*binary.right, right_type_value), + ] { + diagnostic.annotate( + self.context.secondary(operand).message(format_args!( + "Has type `{}`", + ty.display(self.db()) + )), + ); + } + diagnostic.set_concise_message(format_args!( + "Operator `|` is unsupported between \ + objects of type `{}` and `{}`", + left_type_value.display(self.db()), + right_type_value.display(self.db()) + )); + } + + match self.scope.scope(self.db()).kind() { + ScopeKind::TypeAlias => diagnostic.info( + "A type alias scope is lazy but will be \ + executed at runtime if the `__value__` property is \ + accessed", + ), + ScopeKind::TypeParams => diagnostic.info( + "Type parameter scopes are lazy but may be \ + executed at runtime if the `__bound__`, `__value__` + or `__constraints__` property of a type parameter is \ + accessed", + ), + _ => { + let python_version = + Program::get(self.db()).python_version(self.db()); + + if python_version < PythonVersion::PY310 + && !binary.left.is_string_literal_expr() + && !binary.right.is_string_literal_expr() + { + diagnostic.info( + "PEP 604 `|` unions are only available on \ + Python 3.10+ unless they are quoted", + ); + add_inferred_python_version_hint_to_diagnostic( + self.db(), + &mut diagnostic, + "inferring types", + ); + } else if python_version < PythonVersion::PY314 { + diagnostic.info( + "All type expressions are evaluated at \ + runtime by default on Python <3.14", + ); + add_inferred_python_version_hint_to_diagnostic( + self.db(), + &mut diagnostic, + "inferring types", + ); + if binary.left.is_string_literal_expr() + || binary.right.is_string_literal_expr() + { + diagnostic.help( + "Put quotes around the whole union \ + rather than just certain elements", + ); + } + } + } + } + } + } + UnionType::from_elements_leave_aliases(self.db(), [left_ty, right_ty]) } // anything else is an invalid annotation: diff --git a/crates/ty_python_semantic/src/types/special_form.rs b/crates/ty_python_semantic/src/types/special_form.rs index 96fc2f2dceff69..7a0966c3ac4c7a 100644 --- a/crates/ty_python_semantic/src/types/special_form.rs +++ b/crates/ty_python_semantic/src/types/special_form.rs @@ -143,6 +143,9 @@ impl SpecialFormType { | Self::Bottom | Self::Intersection | Self::CallableTypeOf + | Self::Unknown + | Self::AlwaysTruthy + | Self::AlwaysFalsy | Self::TypeQualifier(_) => KnownClass::SpecialForm, // Typeshed says it's an instance of `_SpecialForm`, @@ -154,8 +157,6 @@ impl SpecialFormType { Self::LegacyStdlibAlias(_) => KnownClass::StdlibAlias, - Self::Unknown | Self::AlwaysTruthy | Self::AlwaysFalsy => KnownClass::Object, - Self::NamedTuple => KnownClass::FunctionType, } } diff --git a/crates/ty_vendored/ty_extensions/ty_extensions.pyi b/crates/ty_vendored/ty_extensions/ty_extensions.pyi index 1fb55e591dad9e..9d6313744e3276 100644 --- a/crates/ty_vendored/ty_extensions/ty_extensions.pyi +++ b/crates/ty_vendored/ty_extensions/ty_extensions.pyi @@ -11,9 +11,9 @@ from typing_extensions import LiteralString, Self # noqa: UP035 def static_assert(condition: object, msg: LiteralString | None = None) -> None: ... # Types -Unknown = object() -AlwaysTruthy = object() -AlwaysFalsy = object() +Unknown: _SpecialForm +AlwaysTruthy: _SpecialForm +AlwaysFalsy: _SpecialForm # Special forms Not: _SpecialForm