diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/any.md b/crates/ty_python_semantic/resources/mdtest/annotations/any.md index eef66d6b74c6ba..4ad5749372b8b0 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/any.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/any.md @@ -185,8 +185,19 @@ And `Any` cannot be used in `isinstance()` checks: isinstance("", Any) ``` +The same applies when `Any` is nested inside a tuple, including non-literal tuples: + +```py +isinstance("", (int, Any)) # error: [invalid-argument-type] +isinstance("", (int, (str, Any))) # error: [invalid-argument-type] +classes = (int, Any) +isinstance("", classes) # error: [invalid-argument-type] +``` + But `issubclass()` checks are fine: ```py issubclass(object, Any) # no error! +issubclass(object, (int, Any)) # no error! +issubclass(object, (int, (str, Any))) # no error! ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 5d783a93d34265..286411aae6ea53 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -164,6 +164,11 @@ isinstance("", t.Callable | t.Deque) # `Any` is valid in `issubclass()` calls but not `isinstance()` calls issubclass(list, t.Any) issubclass(list, t.Any | t.Dict) + +# The same works in tuples +isinstance("", (int, t.Dict)) +isinstance("", (int, t.Callable)) +issubclass(list, (int, t.Any)) ``` But for other special forms that are not permitted as the second argument, we still emit an error: @@ -173,6 +178,9 @@ isinstance("", t.TypeGuard) # error: [invalid-argument-type] isinstance("", t.ClassVar) # error: [invalid-argument-type] isinstance("", t.Final) # error: [invalid-argument-type] isinstance("", t.Any) # error: [invalid-argument-type] + +# The same applies when `Any` is nested inside a tuple +isinstance("", (int, t.Any)) # error: [invalid-argument-type] ``` ## The builtin `NotImplemented` constant is not callable diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index b3fcd35aef69f6..b44f28efde8ac3 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -128,6 +128,41 @@ def _(x: int | list[int] | bytes): reveal_type(x) # revealed: int | list[int] | bytes ``` +The same validation also applies when an invalid `UnionType` is nested inside a tuple: + +```py +def _(x: int | list[int] | bytes): + # error: [invalid-argument-type] + if isinstance(x, (int, list[int] | bytes)): + reveal_type(x) # revealed: int | list[int] | bytes + else: + reveal_type(x) # revealed: int | list[int] | bytes +``` + +Including nested tuples: + +```py +def _(x: int | list[int] | bytes): + # error: [invalid-argument-type] + if isinstance(x, (int, (str, list[int] | bytes))): + reveal_type(x) # revealed: int | list[int] | bytes + else: + reveal_type(x) # revealed: int | list[int] | bytes +``` + +And non-literal tuples: + +```py +classes = (int, list[int] | bytes) + +def _(x: int | list[int] | bytes): + # error: [invalid-argument-type] + if isinstance(x, classes): + reveal_type(x) # revealed: int | list[int] | bytes + else: + reveal_type(x) # revealed: int | list[int] | bytes +``` + ## PEP-604 unions on Python \<3.10 PEP-604 unions were added in Python 3.10, so attempting to use them on Python 3.9 does not lead to @@ -312,6 +347,15 @@ def _(flag: bool): reveal_type(x) # revealed: Literal[1, "a"] ``` +## Splatted calls with invalid `classinfo` + +Diagnostics are still emitted for invalid `classinfo` types when the arguments are splatted: + +```py +args = (object(), int | list[str]) +isinstance(*args) # error: [invalid-argument-type] +``` + ## Generic aliases are not supported as second argument The `classinfo` argument cannot be a generic alias: diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index ff6d116da58b3b..6199073bc1a3b4 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -181,6 +181,41 @@ def _(x: type[int | list | bytes]): reveal_type(x) # revealed: type[int | list[Unknown] | bytes] ``` +The same validation also applies when an invalid `UnionType` is nested inside a tuple: + +```py +def _(x: type[int | list | bytes]): + # error: [invalid-argument-type] + if issubclass(x, (int, list[int] | bytes)): + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + else: + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +``` + +Including nested tuples: + +```py +def _(x: type[int | list | bytes]): + # error: [invalid-argument-type] + if issubclass(x, (int, (str, list[int] | bytes))): + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + else: + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +``` + +And non-literal tuples: + +```py +classes = (int, list[int] | bytes) + +def _(x: type[int | list | bytes]): + # error: [invalid-argument-type] + if issubclass(x, classes): + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + else: + reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +``` + ## PEP-604 unions on Python \<3.10 PEP-604 unions were added in Python 3.10, so attempting to use them on Python 3.9 does not lead to diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index cdc5bfd1da28cc..cee9b8eec3c794 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2601,6 +2601,41 @@ def f(arg1: type): reveal_type(arg1) # revealed: type & ~type[OnlyClassmethodMembers] ``` +The same diagnostics are also emitted when protocol classes appear inside a tuple passed as the +second argument to `isinstance()` or `issubclass()`: + +```py +def g(arg: object, arg2: type): + isinstance(arg, (HasX, RuntimeCheckableHasX)) # error: [isinstance-against-protocol] + isinstance(arg, (HasX, int)) # error: [isinstance-against-protocol] + + # error: [isinstance-against-protocol] + # error: [isinstance-against-protocol] + issubclass(arg2, (HasX, RuntimeCheckableHasX)) + + issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] +``` + +This includes nested tuples: + +```py +def g2(arg: object, arg2: type): + isinstance(arg, (int, (HasX, str))) # error: [isinstance-against-protocol] + + # error: [isinstance-against-protocol] + # error: [isinstance-against-protocol] + issubclass(arg2, (int, (HasX, RuntimeCheckableHasX))) +``` + +This also works when the tuple is not a literal in the source: + +```py +classes = (HasX, int) + +def h(arg: object): + isinstance(arg, classes) # error: [isinstance-against-protocol] +``` + ## Match class patterns and protocols @@ -3049,11 +3084,13 @@ static_assert(not is_disjoint_from(Proto, Nominal)) This snippet caused us to panic on an early version of the implementation for protocols. ```py -from typing import Protocol +from typing import Protocol, runtime_checkable +@runtime_checkable class A(Protocol): def x(self) -> "B | A": ... +@runtime_checkable class B(Protocol): def y(self): ... diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/isinstance.md_-_Narrowing_for_`isins\342\200\246_-_`classinfo`_is_an_in\342\200\246_(eeef56c0ef87a30b).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/isinstance.md_-_Narrowing_for_`isins\342\200\246_-_`classinfo`_is_an_in\342\200\246_(eeef56c0ef87a30b).snap" index 73ce5e0bd9cd78..a6c462f3f7403c 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/isinstance.md_-_Narrowing_for_`isins\342\200\246_-_`classinfo`_is_an_in\342\200\246_(eeef56c0ef87a30b).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/isinstance.md_-_Narrowing_for_`isins\342\200\246_-_`classinfo`_is_an_in\342\200\246_(eeef56c0ef87a30b).snap" @@ -27,6 +27,26 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md 12 | reveal_type(x) # revealed: int | list[int] | bytes 13 | else: 14 | reveal_type(x) # revealed: int | list[int] | bytes +15 | def _(x: int | list[int] | bytes): +16 | # error: [invalid-argument-type] +17 | if isinstance(x, (int, list[int] | bytes)): +18 | reveal_type(x) # revealed: int | list[int] | bytes +19 | else: +20 | reveal_type(x) # revealed: int | list[int] | bytes +21 | def _(x: int | list[int] | bytes): +22 | # error: [invalid-argument-type] +23 | if isinstance(x, (int, (str, list[int] | bytes))): +24 | reveal_type(x) # revealed: int | list[int] | bytes +25 | else: +26 | reveal_type(x) # revealed: int | list[int] | bytes +27 | classes = (int, list[int] | bytes) +28 | +29 | def _(x: int | list[int] | bytes): +30 | # error: [invalid-argument-type] +31 | if isinstance(x, classes): +32 | reveal_type(x) # revealed: int | list[int] | bytes +33 | else: +34 | reveal_type(x) # revealed: int | list[int] | bytes ``` # Diagnostics @@ -87,3 +107,58 @@ info: Element `` in the union, and 2 more elements, a info: rule `invalid-argument-type` is enabled by default ``` + +``` +error[invalid-argument-type]: Invalid second argument to `isinstance` + --> src/mdtest_snippet.py:17:8 + | +15 | def _(x: int | list[int] | bytes): +16 | # error: [invalid-argument-type] +17 | if isinstance(x, (int, list[int] | bytes)): + | ^^^^^^^^^^^^^^^^^^^^-----------------^^ + | | + | This `UnionType` instance contains non-class elements +18 | reveal_type(x) # revealed: int | list[int] | bytes +19 | else: + | +info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects +info: Element `` in the union is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Invalid second argument to `isinstance` + --> src/mdtest_snippet.py:23:8 + | +21 | def _(x: int | list[int] | bytes): +22 | # error: [invalid-argument-type] +23 | if isinstance(x, (int, (str, list[int] | bytes))): + | ^^^^^^^^^^^^^^^^^^^^^^^^^^-----------------^^^ + | | + | This `UnionType` instance contains non-class elements +24 | reveal_type(x) # revealed: int | list[int] | bytes +25 | else: + | +info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects +info: Element `` in the union is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Invalid second argument to `isinstance` + --> src/mdtest_snippet.py:31:8 + | +29 | def _(x: int | list[int] | bytes): +30 | # error: [invalid-argument-type] +31 | if isinstance(x, classes): + | ^^^^^^^^^^^^^^^^^^^^^^ +32 | reveal_type(x) # revealed: int | list[int] | bytes +33 | else: + | +info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects +info: Element `` in the union `list[int] | bytes` is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/issubclass.md_-_Narrowing_for_`issub\342\200\246_-_`classinfo`_is_an_in\342\200\246_(7bb66a0f412caac1).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/issubclass.md_-_Narrowing_for_`issub\342\200\246_-_`classinfo`_is_an_in\342\200\246_(7bb66a0f412caac1).snap" index f98df2862d2e1b..ebcb7cda0e93ff 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/issubclass.md_-_Narrowing_for_`issub\342\200\246_-_`classinfo`_is_an_in\342\200\246_(7bb66a0f412caac1).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/issubclass.md_-_Narrowing_for_`issub\342\200\246_-_`classinfo`_is_an_in\342\200\246_(7bb66a0f412caac1).snap" @@ -13,12 +13,32 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md ## mdtest_snippet.py ``` -1 | def _(x: type[int | list | bytes]): -2 | # error: [invalid-argument-type] -3 | if issubclass(x, int | list[int]): -4 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] -5 | else: -6 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + 1 | def _(x: type[int | list | bytes]): + 2 | # error: [invalid-argument-type] + 3 | if issubclass(x, int | list[int]): + 4 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + 5 | else: + 6 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] + 7 | def _(x: type[int | list | bytes]): + 8 | # error: [invalid-argument-type] + 9 | if issubclass(x, (int, list[int] | bytes)): +10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +11 | else: +12 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +13 | def _(x: type[int | list | bytes]): +14 | # error: [invalid-argument-type] +15 | if issubclass(x, (int, (str, list[int] | bytes))): +16 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +17 | else: +18 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +19 | classes = (int, list[int] | bytes) +20 | +21 | def _(x: type[int | list | bytes]): +22 | # error: [invalid-argument-type] +23 | if issubclass(x, classes): +24 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +25 | else: +26 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] ``` # Diagnostics @@ -41,3 +61,58 @@ info: Element `` in the union is not a class object info: rule `invalid-argument-type` is enabled by default ``` + +``` +error[invalid-argument-type]: Invalid second argument to `issubclass` + --> src/mdtest_snippet.py:9:8 + | + 7 | def _(x: type[int | list | bytes]): + 8 | # error: [invalid-argument-type] + 9 | if issubclass(x, (int, list[int] | bytes)): + | ^^^^^^^^^^^^^^^^^^^^-----------------^^ + | | + | This `UnionType` instance contains non-class elements +10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +11 | else: + | +info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects +info: Element `` in the union is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Invalid second argument to `issubclass` + --> src/mdtest_snippet.py:15:8 + | +13 | def _(x: type[int | list | bytes]): +14 | # error: [invalid-argument-type] +15 | if issubclass(x, (int, (str, list[int] | bytes))): + | ^^^^^^^^^^^^^^^^^^^^^^^^^^-----------------^^^ + | | + | This `UnionType` instance contains non-class elements +16 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +17 | else: + | +info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects +info: Element `` in the union is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` + +``` +error[invalid-argument-type]: Invalid second argument to `issubclass` + --> src/mdtest_snippet.py:23:8 + | +21 | def _(x: type[int | list | bytes]): +22 | # error: [invalid-argument-type] +23 | if issubclass(x, classes): + | ^^^^^^^^^^^^^^^^^^^^^^ +24 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +25 | else: + | +info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects +info: Element `` in the union `list[int] | bytes` is not a class object +info: rule `invalid-argument-type` is enabled by default + +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco\342\200\246_(98257e7c2300373).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco\342\200\246_(98257e7c2300373).snap" index b4aab670b07f85..c98c2072ac520e 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco\342\200\246_(98257e7c2300373).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/protocols.md_-_Protocols_-_Narrowing_of_protoco\342\200\246_(98257e7c2300373).snap" @@ -74,6 +74,25 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 59 | reveal_type(arg1) # revealed: type[OnlyClassmethodMembers] 60 | else: 61 | reveal_type(arg1) # revealed: type & ~type[OnlyClassmethodMembers] +62 | def g(arg: object, arg2: type): +63 | isinstance(arg, (HasX, RuntimeCheckableHasX)) # error: [isinstance-against-protocol] +64 | isinstance(arg, (HasX, int)) # error: [isinstance-against-protocol] +65 | +66 | # error: [isinstance-against-protocol] +67 | # error: [isinstance-against-protocol] +68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) +69 | +70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] +71 | def g2(arg: object, arg2: type): +72 | isinstance(arg, (int, (HasX, str))) # error: [isinstance-against-protocol] +73 | +74 | # error: [isinstance-against-protocol] +75 | # error: [isinstance-against-protocol] +76 | issubclass(arg2, (int, (HasX, RuntimeCheckableHasX))) +77 | classes = (HasX, int) +78 | +79 | def h(arg: object): +80 | isinstance(arg, classes) # error: [isinstance-against-protocol] ``` # Diagnostics @@ -179,3 +198,229 @@ info: `MultipleNonMethodMembers` has non-method members `a` and `b` info: rule `isinstance-against-protocol` is enabled by default ``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `isinstance` + --> src/mdtest_snippet.py:63:5 + | +61 | reveal_type(arg1) # revealed: type & ~type[OnlyClassmethodMembers] +62 | def g(arg: object, arg2: type): +63 | isinstance(arg, (HasX, RuntimeCheckableHasX)) # error: [isinstance-against-protocol] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +64 | isinstance(arg, (HasX, int)) # error: [isinstance-against-protocol] + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `isinstance` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `isinstance` + --> src/mdtest_snippet.py:64:5 + | +62 | def g(arg: object, arg2: type): +63 | isinstance(arg, (HasX, RuntimeCheckableHasX)) # error: [isinstance-against-protocol] +64 | isinstance(arg, (HasX, int)) # error: [isinstance-against-protocol] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +65 | +66 | # error: [isinstance-against-protocol] + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `isinstance` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `issubclass` + --> src/mdtest_snippet.py:68:5 + | +66 | # error: [isinstance-against-protocol] +67 | # error: [isinstance-against-protocol] +68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +69 | +70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `issubclass` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `RuntimeCheckableHasX` cannot be used as the second argument to `issubclass` + --> src/mdtest_snippet.py:68:5 + | +66 | # error: [isinstance-against-protocol] +67 | # error: [isinstance-against-protocol] +68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +69 | +70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] + | +info: A protocol class cannot be used in `issubclass` checks if it has non-method members + --> src/mdtest_snippet.py:20:5 + | +18 | @runtime_checkable +19 | class RuntimeCheckableHasX(Protocol): +20 | x: int + | ^ Non-method member `x` declared here +21 | +22 | def f(arg: object): + | +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `issubclass` + --> src/mdtest_snippet.py:70:5 + | +68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) +69 | +70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +71 | def g2(arg: object, arg2: type): +72 | isinstance(arg, (int, (HasX, str))) # error: [isinstance-against-protocol] + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `issubclass` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `isinstance` + --> src/mdtest_snippet.py:72:5 + | +70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] +71 | def g2(arg: object, arg2: type): +72 | isinstance(arg, (int, (HasX, str))) # error: [isinstance-against-protocol] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +73 | +74 | # error: [isinstance-against-protocol] + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `isinstance` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `issubclass` + --> src/mdtest_snippet.py:76:5 + | +74 | # error: [isinstance-against-protocol] +75 | # error: [isinstance-against-protocol] +76 | issubclass(arg2, (int, (HasX, RuntimeCheckableHasX))) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +77 | classes = (HasX, int) + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `issubclass` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `RuntimeCheckableHasX` cannot be used as the second argument to `issubclass` + --> src/mdtest_snippet.py:76:5 + | +74 | # error: [isinstance-against-protocol] +75 | # error: [isinstance-against-protocol] +76 | issubclass(arg2, (int, (HasX, RuntimeCheckableHasX))) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +77 | classes = (HasX, int) + | +info: A protocol class cannot be used in `issubclass` checks if it has non-method members + --> src/mdtest_snippet.py:20:5 + | +18 | @runtime_checkable +19 | class RuntimeCheckableHasX(Protocol): +20 | x: int + | ^ Non-method member `x` declared here +21 | +22 | def f(arg: object): + | +info: rule `isinstance-against-protocol` is enabled by default + +``` + +``` +error[isinstance-against-protocol]: Class `HasX` cannot be used as the second argument to `isinstance` + --> src/mdtest_snippet.py:80:5 + | +79 | def h(arg: object): +80 | isinstance(arg, classes) # error: [isinstance-against-protocol] + | ^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime + | +info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable + --> src/mdtest_snippet.py:3:7 + | +1 | from typing_extensions import Protocol +2 | +3 | class HasX(Protocol): + | ^^^^^^^^^^^^^^ `HasX` declared here +4 | x: int + | +info: A protocol class can only be used in `isinstance` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable` +info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable +info: rule `isinstance-against-protocol` is enabled by default + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index c1123dd3c024a0..9fa17eddf669bc 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1917,6 +1917,20 @@ def _(obj: object, obj2: type): issubclass(obj2, Person) ``` +The same applies when a `TypedDict` class appears inside a tuple, including non-literal tuples: + +```py +def _(obj: object, obj2: type): + isinstance(obj, (int, Person)) # error: [isinstance-against-typed-dict] + issubclass(obj2, (int, Person)) # error: [isinstance-against-typed-dict] + isinstance(obj, (int, (str, Person))) # error: [isinstance-against-typed-dict] + +classes = (int, Person) + +def _(obj: object): + isinstance(obj, classes) # error: [isinstance-against-typed-dict] +``` + They also cannot be used in class patterns for `match` statements: ```py diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 9e14bf373e3c0f..d6b1f4ef8b8fb6 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1260,6 +1260,186 @@ impl<'db> FunctionType<'db> { } } +/// Check the second argument to `isinstance()` or `issubclass()` for types that cannot be used +/// at runtime (protocol classes, typed dicts, `typing.Any` in `isinstance`, and invalid +/// `UnionType` elements). Handles class literals, tuples (including nested tuples), and +/// recursively validates each element. +/// +/// `classinfo_expr` is the AST expression corresponding to `classinfo`, if available. It is +/// used for precise annotation spans (e.g., highlighting just the `UnionType` inside a tuple +/// rather than the whole tuple). It may be `None` when the tuple is not a literal in the AST +/// (e.g., when it's stored in a variable). +fn check_classinfo_in_isinstance<'db>( + db: &'db dyn Db, + context: &InferContext<'db, '_>, + call_expression: &ast::ExprCall, + function: KnownFunction, + classinfo: Type<'db>, + classinfo_expr: Option<&ast::Expr>, +) { + match classinfo { + Type::ClassLiteral(class) => { + if class.is_typed_dict(db) { + report_runtime_check_against_typed_dict(context, call_expression, class, function); + } else if let Some(protocol_class) = class.into_protocol_class(db) { + if !protocol_class.is_runtime_checkable(db) { + report_runtime_check_against_non_runtime_checkable_protocol( + context, + call_expression, + protocol_class, + function, + ); + } else if function == KnownFunction::IsSubclass { + let non_method_members = protocol_class.interface(db).non_method_members(db); + if !non_method_members.is_empty() { + report_issubclass_check_against_protocol_with_non_method_members( + context, + call_expression, + protocol_class, + &non_method_members, + ); + } + } + } + } + Type::SpecialForm(SpecialFormType::Any) if function == KnownFunction::IsInstance => { + let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, call_expression) else { + return; + }; + let mut diagnostic = builder.into_diagnostic(format_args!( + "`typing.Any` cannot be used with `isinstance()`" + )); + diagnostic.set_primary_message("This call will raise `TypeError` at runtime"); + } + Type::KnownInstance(KnownInstanceType::UnionType(_)) => { + report_invalid_union_type_elements( + db, + context, + call_expression, + function, + classinfo, + classinfo_expr, + ); + } + Type::NominalInstance(nominal) => { + if let Some(tuple_spec) = nominal.tuple_spec(db) { + let element_exprs = match classinfo_expr { + Some(ast::Expr::Tuple(tuple_expr)) => Some(&tuple_expr.elts), + _ => None, + }; + for (index, element) in tuple_spec.iter_all_elements().enumerate() { + let element_expr = element_exprs.and_then(|elts| elts.get(index)); + check_classinfo_in_isinstance( + db, + context, + call_expression, + function, + element, + element_expr, + ); + } + } + } + _ => {} + } +} + +/// Report an error if a `types.UnionType` instance passed to `isinstance()`/`issubclass()` +/// contains elements that are not class objects. +fn report_invalid_union_type_elements<'db>( + db: &'db dyn Db, + context: &InferContext<'db, '_>, + call_expression: &ast::ExprCall, + function: KnownFunction, + union_type: Type<'db>, + union_type_expr: Option<&ast::Expr>, +) { + fn find_invalid_elements<'db>( + db: &'db dyn Db, + function: KnownFunction, + ty: Type<'db>, + invalid_elements: &mut Vec>, + ) { + match ty { + Type::ClassLiteral(_) => {} + Type::NominalInstance(instance) + if instance.has_known_class(db, KnownClass::NoneType) => {} + Type::SpecialForm(special_form) if special_form.is_valid_isinstance_target() => {} + // `Any` can be used in `issubclass()` calls but not `isinstance()` calls + Type::SpecialForm(SpecialFormType::Any) if function == KnownFunction::IsSubclass => {} + Type::KnownInstance(KnownInstanceType::UnionType(instance)) => { + match instance.value_expression_types(db) { + Ok(value_expression_types) => { + for element in value_expression_types { + find_invalid_elements(db, function, element, invalid_elements); + } + } + Err(_) => { + invalid_elements.push(ty); + } + } + } + _ => invalid_elements.push(ty), + } + } + + let mut invalid_elements = vec![]; + find_invalid_elements(db, function, union_type, &mut invalid_elements); + + let Some((first_invalid_element, other_invalid_elements)) = invalid_elements.split_first() + else { + return; + }; + + let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, call_expression) else { + return; + }; + + let function_name: &str = function.into(); + + let mut diagnostic = + builder.into_diagnostic(format_args!("Invalid second argument to `{function_name}`")); + diagnostic.info(format_args!( + "A `UnionType` instance can only be used as the second argument to \ + `{function_name}` if all elements are class objects" + )); + if let Some(union_type_expr) = union_type_expr { + diagnostic.annotate( + Annotation::secondary(context.span(union_type_expr)) + .message("This `UnionType` instance contains non-class elements"), + ); + } + + // When we have a secondary annotation pointing at the UnionType expression, + // "the union" is unambiguous. Otherwise, spell out the union type in the message. + let union_suffix = match (&union_type_expr, union_type) { + (None, Type::KnownInstance(KnownInstanceType::UnionType(instance))) => { + match instance.union_type(db) { + Ok(ty) => format!(" `{}`", ty.display(db)), + Err(_) => String::new(), + } + } + _ => String::new(), + }; + + match other_invalid_elements { + [] => diagnostic.info(format_args!( + "Element `{}` in the union{union_suffix} is not a class object", + first_invalid_element.display(db) + )), + [single] => diagnostic.info(format_args!( + "Elements `{}` and `{}` in the union{union_suffix} are not class objects", + first_invalid_element.display(db), + single.display(db), + )), + _ => diagnostic.info(format_args!( + "Element `{}` in the union{union_suffix}, and {} more elements, are not class objects", + first_invalid_element.display(db), + other_invalid_elements.len(), + )), + } +} + /// Evaluate an `isinstance` call. Return `Truthiness::AlwaysTrue` if we can definitely infer that /// this will return `True` at runtime, `Truthiness::AlwaysFalse` if we can definitely infer /// that this will return `False` at runtime, or `Truthiness::Ambiguous` if we should infer `bool` @@ -1980,145 +2160,21 @@ impl KnownFunction { return; }; - match second_argument { - Type::ClassLiteral(class) => { - if class.is_typed_dict(db) { - report_runtime_check_against_typed_dict( - context, - call_expression, - *class, - self, - ); - } else if let Some(protocol_class) = class.into_protocol_class(db) { - if !protocol_class.is_runtime_checkable(db) { - report_runtime_check_against_non_runtime_checkable_protocol( - context, - call_expression, - protocol_class, - self, - ); - } else if self == KnownFunction::IsSubclass { - let non_method_members = - protocol_class.interface(db).non_method_members(db); - if !non_method_members.is_empty() { - report_issubclass_check_against_protocol_with_non_method_members( - context, - call_expression, - protocol_class, - &non_method_members, - ); - } - } - } - - if self == KnownFunction::IsInstance { - overload.set_return_type( - is_instance_truthiness(db, *first_arg, *class).into_type(db), - ); - } - } - // The special-casing here is necessary because we recognise the symbol `typing.Any` as an - // instance of `type` at runtime. Even once we understand typeshed's annotation for - // `isinstance()`, we'd continue to accept calls such as `isinstance(x, typing.Any)` without - // emitting a diagnostic if we didn't have this branch. - Type::SpecialForm(SpecialFormType::Any) - if self == KnownFunction::IsInstance => - { - let Some(builder) = - context.report_lint(&INVALID_ARGUMENT_TYPE, call_expression) - else { - return; - }; - let mut diagnostic = builder.into_diagnostic(format_args!( - "`typing.Any` cannot be used with `isinstance()`" - )); - diagnostic - .set_primary_message("This call will raise `TypeError` at runtime"); - } - - Type::KnownInstance(KnownInstanceType::UnionType(_)) => { - fn find_invalid_elements<'db>( - db: &'db dyn Db, - function: KnownFunction, - ty: Type<'db>, - invalid_elements: &mut Vec>, - ) { - match ty { - Type::ClassLiteral(_) => {} - Type::NominalInstance(instance) - if instance.has_known_class(db, KnownClass::NoneType) => {} - Type::SpecialForm(special_form) - if special_form.is_valid_isinstance_target() => {} - // `Any` can be used in `issubclass()` calls but not `isinstance()` calls - Type::SpecialForm(SpecialFormType::Any) - if function == KnownFunction::IsSubclass => {} - Type::KnownInstance(KnownInstanceType::UnionType(instance)) => { - match instance.value_expression_types(db) { - Ok(value_expression_types) => { - for element in value_expression_types { - find_invalid_elements( - db, - function, - element, - invalid_elements, - ); - } - } - Err(_) => { - invalid_elements.push(ty); - } - } - } - _ => invalid_elements.push(ty), - } - } - - let mut invalid_elements = vec![]; - find_invalid_elements(db, self, *second_argument, &mut invalid_elements); - - let Some((first_invalid_element, other_invalid_elements)) = - invalid_elements.split_first() - else { - return; - }; - - let Some(builder) = - context.report_lint(&INVALID_ARGUMENT_TYPE, call_expression) - else { - return; - }; - - let function_name: &str = self.into(); - - let mut diagnostic = builder.into_diagnostic(format_args!( - "Invalid second argument to `{function_name}`" - )); - diagnostic.info(format_args!( - "A `UnionType` instance can only be used as the second argument to \ - `{function_name}` if all elements are class objects" - )); - diagnostic.annotate( - Annotation::secondary(context.span(&call_expression.arguments.args[1])) - .message("This `UnionType` instance contains non-class elements"), - ); - match other_invalid_elements { - [] => diagnostic.info(format_args!( - "Element `{}` in the union is not a class object", - first_invalid_element.display(db) - )), - [single] => diagnostic.info(format_args!( - "Elements `{}` and `{}` in the union are not class objects", - first_invalid_element.display(db), - single.display(db), - )), - _ => diagnostic.info(format_args!( - "Element `{}` in the union, and {} more elements, are not class objects", - first_invalid_element.display(db), - other_invalid_elements.len(), - )) - } - } - _ => {} + check_classinfo_in_isinstance( + db, + context, + call_expression, + self, + *second_argument, + call_expression.arguments.args.get(1), + ); + + if let Type::ClassLiteral(class) = second_argument + && self == KnownFunction::IsInstance + { + overload.set_return_type( + is_instance_truthiness(db, *first_arg, *class).into_type(db), + ); } }