From 7870b3b6997fae127a7d9431224f7a67327180c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 12:59:47 +0000 Subject: [PATCH 1/7] [ty] Emit isinstance/issubclass protocol diagnostics for tuple arguments Previously, diagnostics for non-runtime-checkable protocols and issubclass checks against protocols with non-method members were only emitted when a single class literal was passed as the second argument. When a tuple of classes was passed (e.g. `isinstance(x, (P1, P2))`), the individual elements were not checked for protocol-related issues. Extract the class-literal checking logic into a recursive helper `check_classinfo_in_isinstance` that handles both single class literals and tuples (including nested tuples), checking each element for protocol and typed dict issues. https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- .../resources/mdtest/protocols.md | 17 +++ ...rotoco\342\200\246_(98257e7c2300373).snap" | 135 ++++++++++++++++++ .../ty_python_semantic/src/types/function.rs | 77 ++++++---- 3 files changed, 200 insertions(+), 29 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index cdc5bfd1da28c..e519ab786f399 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2601,6 +2601,21 @@ 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] +``` + ## Match class patterns and protocols @@ -3059,6 +3074,8 @@ class B(Protocol): obj = something_unresolvable # error: [unresolved-reference] reveal_type(obj) # revealed: Unknown +# error: [isinstance-against-protocol] +# error: [isinstance-against-protocol] if isinstance(obj, (B, A)): reveal_type(obj) # revealed: (Unknown & B) | (Unknown & A) ``` 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 b4aab670b07f8..30ca463d97878 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,15 @@ 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] ``` # Diagnostics @@ -179,3 +188,129 @@ 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 + | +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 + +``` diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 9e14bf373e3c0..0a93e4fda9c51 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1260,6 +1260,52 @@ impl<'db> FunctionType<'db> { } } +/// Check the second argument to `isinstance()` or `issubclass()` for protocol classes and typed +/// dicts that cannot be used at runtime. Handles class literals, tuples (including nested tuples), +/// and recursively validates each element. +fn check_classinfo_in_isinstance<'db>( + db: &'db dyn Db, + context: &InferContext<'db, '_>, + call_expression: &ast::ExprCall, + function: KnownFunction, + classinfo: Type<'db>, +) { + 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::NominalInstance(nominal) => { + if let Some(tuple_spec) = nominal.tuple_spec(db) { + for element in tuple_spec.iter_all_elements() { + check_classinfo_in_isinstance(db, context, call_expression, function, element); + } + } + } + _ => {} + } +} + /// 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,37 +2026,10 @@ impl KnownFunction { return; }; + check_classinfo_in_isinstance(db, context, call_expression, self, *second_argument); + 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), From f3d9e87141f44877eecbf21e99b66f245b9e704b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 13:19:24 +0000 Subject: [PATCH 2/7] [ty] Validate all classinfo elements in isinstance/issubclass recursively Expand check_classinfo_in_isinstance to handle all validation that should apply recursively through tuple elements: - Protocol checks (non-runtime-checkable, non-method members) - TypedDict checks - typing.Any in isinstance() calls - Invalid elements in types.UnionType instances Previously, only protocol/TypedDict checks were recursive; the typing.Any and UnionType validations were only checked at the top level. Now all four validations work correctly when nested inside tuple arguments like isinstance(x, (int, Any)) or issubclass(y, (int, list[int] | str)). Also refactors the recursive protocols regression test to use @runtime_checkable instead of error annotations, so the test stays focused on what it's demonstrating (no stack overflow). https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- .../resources/mdtest/annotations/any.md | 7 + .../resources/mdtest/call/builtins.md | 8 + .../resources/mdtest/narrow/isinstance.md | 11 + .../resources/mdtest/narrow/issubclass.md | 11 + .../resources/mdtest/protocols.md | 6 +- ...an_in\342\200\246_(eeef56c0ef87a30b).snap" | 25 +++ ...an_in\342\200\246_(7bb66a0f412caac1).snap" | 37 ++- .../resources/mdtest/typed_dict.md | 8 + .../ty_python_semantic/src/types/function.rs | 211 +++++++++--------- 9 files changed, 204 insertions(+), 120 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/any.md b/crates/ty_python_semantic/resources/mdtest/annotations/any.md index eef66d6b74c6b..8d2165109af32 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/any.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/any.md @@ -185,8 +185,15 @@ And `Any` cannot be used in `isinstance()` checks: isinstance("", Any) ``` +The same applies when `Any` is nested inside a tuple: + +```py +isinstance("", (int, Any)) # error: [invalid-argument-type] +``` + But `issubclass()` checks are fine: ```py issubclass(object, Any) # no error! +issubclass(object, (int, 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 5d783a93d3426..286411aae6ea5 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 b3fcd35aef69f..b2173fd58719d 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -128,6 +128,17 @@ 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 +``` + ## 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/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index ff6d116da58b3..12437f6e8ff40 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -181,6 +181,17 @@ 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] +``` + ## 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 e519ab786f399..bf4a6e87d2fcb 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -3064,18 +3064,18 @@ 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): ... obj = something_unresolvable # error: [unresolved-reference] reveal_type(obj) # revealed: Unknown -# error: [isinstance-against-protocol] -# error: [isinstance-against-protocol] if isinstance(obj, (B, A)): reveal_type(obj) # revealed: (Unknown & B) | (Unknown & A) ``` 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 73ce5e0bd9cd7..90cba3896368a 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,12 @@ 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 ``` # Diagnostics @@ -87,3 +93,22 @@ 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 + +``` 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 f98df2862d2e1..dc3e8768ee145 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,18 @@ 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] ``` # Diagnostics @@ -41,3 +47,22 @@ 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 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index c1123dd3c024a..94c99d5af8e94 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1917,6 +1917,14 @@ def _(obj: object, obj2: type): issubclass(obj2, Person) ``` +The same applies when a `TypedDict` class appears inside a tuple: + +```py +def _(obj: object, obj2: type): + isinstance(obj, (int, Person)) # error: [isinstance-against-typed-dict] + issubclass(obj2, (int, Person)) # 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 0a93e4fda9c51..fb549e686a136 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1260,9 +1260,10 @@ impl<'db> FunctionType<'db> { } } -/// Check the second argument to `isinstance()` or `issubclass()` for protocol classes and typed -/// dicts that cannot be used at runtime. Handles class literals, tuples (including nested tuples), -/// and recursively validates each element. +/// 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. fn check_classinfo_in_isinstance<'db>( db: &'db dyn Db, context: &InferContext<'db, '_>, @@ -1295,6 +1296,18 @@ fn check_classinfo_in_isinstance<'db>( } } } + 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); + } Type::NominalInstance(nominal) => { if let Some(tuple_spec) = nominal.tuple_spec(db) { for element in tuple_spec.iter_all_elements() { @@ -1306,6 +1319,86 @@ fn check_classinfo_in_isinstance<'db>( } } +/// 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>, +) { + 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" + )); + 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(), + )), + } +} + /// 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` @@ -2028,116 +2121,12 @@ impl KnownFunction { check_classinfo_in_isinstance(db, context, call_expression, self, *second_argument); - match second_argument { - Type::ClassLiteral(class) => { - 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"), + if let Type::ClassLiteral(class) = second_argument { + if self == KnownFunction::IsInstance { + overload.set_return_type( + is_instance_truthiness(db, *first_arg, *class).into_type(db), ); - 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(), - )) - } } - _ => {} } } From 01f0511db76f4605464bdb20bc1115bd63affbf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 13:45:51 +0000 Subject: [PATCH 3/7] [ty] Precise annotation spans for UnionType nested in tuples, use let chains Thread the classinfo AST expression through check_classinfo_in_isinstance so that when a UnionType is nested inside a tuple, the secondary annotation highlights just the UnionType expression rather than the entire tuple. Also use let chains for the isinstance truthiness check at the call site and the tuple recursion guard. https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- ...an_in\342\200\246_(eeef56c0ef87a30b).snap" | 6 +- ...an_in\342\200\246_(7bb66a0f412caac1).snap" | 6 +- .../ty_python_semantic/src/types/function.rs | 56 ++++++++++++++----- 3 files changed, 49 insertions(+), 19 deletions(-) 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 90cba3896368a..3d24f29516c92 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" @@ -101,9 +101,9 @@ error[invalid-argument-type]: Invalid second argument to `isinstance` 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 + | ^^^^^^^^^^^^^^^^^^^^-----------------^^ + | | + | This `UnionType` instance contains non-class elements 18 | reveal_type(x) # revealed: int | list[int] | bytes 19 | else: | 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 dc3e8768ee145..1de34cd322d42 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" @@ -55,9 +55,9 @@ error[invalid-argument-type]: Invalid second argument to `issubclass` 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 + | ^^^^^^^^^^^^^^^^^^^^-----------------^^ + | | + | This `UnionType` instance contains non-class elements 10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] 11 | else: | diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index fb549e686a136..2fc47cf17a1f4 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1264,12 +1264,17 @@ impl<'db> FunctionType<'db> { /// 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`, used for precise +/// annotation spans (e.g., highlighting just the `UnionType` inside a tuple rather than +/// the whole tuple). fn check_classinfo_in_isinstance<'db>( db: &'db dyn Db, context: &InferContext<'db, '_>, call_expression: &ast::ExprCall, function: KnownFunction, classinfo: Type<'db>, + classinfo_expr: &ast::Expr, ) { match classinfo { Type::ClassLiteral(class) => { @@ -1306,12 +1311,29 @@ fn check_classinfo_in_isinstance<'db>( 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); + 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) { - for element in tuple_spec.iter_all_elements() { - check_classinfo_in_isinstance(db, context, call_expression, function, element); + if let Some(tuple_spec) = nominal.tuple_spec(db) + && let ast::Expr::Tuple(tuple_expr) = classinfo_expr + { + for (element, element_expr) in tuple_spec.iter_all_elements().zip(&tuple_expr.elts) + { + check_classinfo_in_isinstance( + db, + context, + call_expression, + function, + element, + element_expr, + ); } } } @@ -1327,6 +1349,7 @@ fn report_invalid_union_type_elements<'db>( call_expression: &ast::ExprCall, function: KnownFunction, union_type: Type<'db>, + union_type_expr: &ast::Expr, ) { fn find_invalid_elements<'db>( db: &'db dyn Db, @@ -1378,7 +1401,7 @@ fn report_invalid_union_type_elements<'db>( `{function_name}` if all elements are class objects" )); diagnostic.annotate( - Annotation::secondary(context.span(&call_expression.arguments.args[1])) + Annotation::secondary(context.span(union_type_expr)) .message("This `UnionType` instance contains non-class elements"), ); match other_invalid_elements { @@ -2119,14 +2142,21 @@ impl KnownFunction { return; }; - check_classinfo_in_isinstance(db, context, call_expression, self, *second_argument); - - if let Type::ClassLiteral(class) = second_argument { - if self == KnownFunction::IsInstance { - overload.set_return_type( - is_instance_truthiness(db, *first_arg, *class).into_type(db), - ); - } + check_classinfo_in_isinstance( + db, + context, + call_expression, + self, + *second_argument, + &call_expression.arguments.args[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), + ); } } From f1086490ebdae3a13d4bf2d4fa0830fb11380bae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 13:56:31 +0000 Subject: [PATCH 4/7] [ty] Validate classinfo elements even for non-literal tuples Make classinfo_expr optional so that check_classinfo_in_isinstance still validates all tuple elements even when the expression isn't a literal tuple in the AST (e.g., when the tuple is stored in a variable). When classinfo_expr is a literal tuple, we zip AST elements with type elements for precise secondary annotations. When it's not (or is None), we still perform all validation but skip the secondary annotation on UnionType diagnostics. Add tests for non-literal tuples across all validation types: protocols, TypedDict, typing.Any, and invalid UnionType elements. https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- .../resources/mdtest/annotations/any.md | 4 ++- .../resources/mdtest/narrow/isinstance.md | 13 +++++++ .../resources/mdtest/narrow/issubclass.md | 13 +++++++ .../resources/mdtest/protocols.md | 9 +++++ ...an_in\342\200\246_(eeef56c0ef87a30b).snap" | 25 +++++++++++++ ...an_in\342\200\246_(7bb66a0f412caac1).snap" | 25 +++++++++++++ ...rotoco\342\200\246_(98257e7c2300373).snap" | 28 +++++++++++++++ .../resources/mdtest/typed_dict.md | 7 +++- .../ty_python_semantic/src/types/function.rs | 35 +++++++++++-------- 9 files changed, 142 insertions(+), 17 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/any.md b/crates/ty_python_semantic/resources/mdtest/annotations/any.md index 8d2165109af32..cddbde7010190 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/any.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/any.md @@ -185,10 +185,12 @@ And `Any` cannot be used in `isinstance()` checks: isinstance("", Any) ``` -The same applies when `Any` is nested inside a tuple: +The same applies when `Any` is nested inside a tuple, including non-literal tuples: ```py isinstance("", (int, Any)) # error: [invalid-argument-type] +classes = (int, Any) +isinstance("", classes) # error: [invalid-argument-type] ``` But `issubclass()` checks are fine: diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index b2173fd58719d..0d15360a79828 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -139,6 +139,19 @@ def _(x: int | list[int] | bytes): reveal_type(x) # revealed: int | list[int] | bytes ``` +Including 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 diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index 12437f6e8ff40..28dd26b0fc1f8 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -192,6 +192,19 @@ def _(x: type[int | list | bytes]): reveal_type(x) # revealed: type[int | list[Unknown] | bytes] ``` +Including 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 bf4a6e87d2fcb..adf6a8be6d8ce 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2616,6 +2616,15 @@ def g(arg: object, arg2: type): issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] ``` +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 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 3d24f29516c92..e5d9bd02f0e08 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" @@ -33,6 +33,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md 18 | reveal_type(x) # revealed: int | list[int] | bytes 19 | else: 20 | reveal_type(x) # revealed: int | list[int] | bytes +21 | classes = (int, list[int] | bytes) +22 | +23 | def _(x: int | list[int] | bytes): +24 | # error: [invalid-argument-type] +25 | if isinstance(x, classes): +26 | reveal_type(x) # revealed: int | list[int] | bytes +27 | else: +28 | reveal_type(x) # revealed: int | list[int] | bytes ``` # Diagnostics @@ -112,3 +120,20 @@ 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:25:8 + | +23 | def _(x: int | list[int] | bytes): +24 | # error: [invalid-argument-type] +25 | if isinstance(x, classes): + | ^^^^^^^^^^^^^^^^^^^^^^ +26 | reveal_type(x) # revealed: int | list[int] | bytes +27 | 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 + +``` 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 1de34cd322d42..8c8f7f19b604b 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" @@ -25,6 +25,14 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md 10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] 11 | else: 12 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +13 | classes = (int, list[int] | bytes) +14 | +15 | def _(x: type[int | list | bytes]): +16 | # error: [invalid-argument-type] +17 | if issubclass(x, classes): +18 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +19 | else: +20 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] ``` # Diagnostics @@ -66,3 +74,20 @@ 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:17:8 + | +15 | def _(x: type[int | list | bytes]): +16 | # error: [invalid-argument-type] +17 | if issubclass(x, classes): + | ^^^^^^^^^^^^^^^^^^^^^^ +18 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] +19 | 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 + +``` 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 30ca463d97878..0815b58a7543f 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" @@ -83,6 +83,10 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) 69 | 70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] +71 | classes = (HasX, int) +72 | +73 | def h(arg: object): +74 | isinstance(arg, classes) # error: [isinstance-against-protocol] ``` # Diagnostics @@ -299,6 +303,7 @@ error[isinstance-against-protocol]: Class `HasX` cannot be used as the second ar 69 | 70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime +71 | 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 @@ -314,3 +319,26 @@ 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:74:5 + | +73 | def h(arg: object): +74 | 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 94c99d5af8e94..2be28d0859517 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1917,12 +1917,17 @@ def _(obj: object, obj2: type): issubclass(obj2, Person) ``` -The same applies when a `TypedDict` class appears inside a tuple: +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] + +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: diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 2fc47cf17a1f4..0906ab9670567 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1265,16 +1265,17 @@ impl<'db> FunctionType<'db> { /// `UnionType` elements). Handles class literals, tuples (including nested tuples), and /// recursively validates each element. /// -/// `classinfo_expr` is the AST expression corresponding to `classinfo`, used for precise -/// annotation spans (e.g., highlighting just the `UnionType` inside a tuple rather than -/// the whole tuple). +/// `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: &ast::Expr, + classinfo_expr: Option<&ast::Expr>, ) { match classinfo { Type::ClassLiteral(class) => { @@ -1321,11 +1322,13 @@ fn check_classinfo_in_isinstance<'db>( ); } Type::NominalInstance(nominal) => { - if let Some(tuple_spec) = nominal.tuple_spec(db) - && let ast::Expr::Tuple(tuple_expr) = classinfo_expr - { - for (element, element_expr) in tuple_spec.iter_all_elements().zip(&tuple_expr.elts) - { + 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, @@ -1349,7 +1352,7 @@ fn report_invalid_union_type_elements<'db>( call_expression: &ast::ExprCall, function: KnownFunction, union_type: Type<'db>, - union_type_expr: &ast::Expr, + union_type_expr: Option<&ast::Expr>, ) { fn find_invalid_elements<'db>( db: &'db dyn Db, @@ -1400,10 +1403,12 @@ fn report_invalid_union_type_elements<'db>( "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(union_type_expr)) - .message("This `UnionType` instance contains non-class elements"), - ); + 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"), + ); + } match other_invalid_elements { [] => diagnostic.info(format_args!( "Element `{}` in the union is not a class object", @@ -2148,7 +2153,7 @@ impl KnownFunction { call_expression, self, *second_argument, - &call_expression.arguments.args[1], + Some(&call_expression.arguments.args[1]), ); if let Type::ClassLiteral(class) = second_argument From 6b31d2a0e608d4c655ac39ff3ce2597440e74749 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:07:20 +0000 Subject: [PATCH 5/7] [ty] Spell out union type in info diagnostic when secondary annotation is absent When an invalid UnionType is nested inside a non-literal tuple, we don't have an AST expression to use for the secondary annotation. In that case, include the union type in the info message so the user knows which union contains the invalid element. With annotation: "Element `list[int]` in the union is not a class object" Without annotation: "Element `list[int]` in the union `list[int] | bytes` is not a class object" https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- ...an_in\342\200\246_(eeef56c0ef87a30b).snap" | 2 +- ...an_in\342\200\246_(7bb66a0f412caac1).snap" | 2 +- .../ty_python_semantic/src/types/function.rs | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) 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 e5d9bd02f0e08..35fb8fd69f28d 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" @@ -133,7 +133,7 @@ error[invalid-argument-type]: Invalid second argument to `isinstance` 27 | 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: 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 8c8f7f19b604b..ac36115785f23 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" @@ -87,7 +87,7 @@ error[invalid-argument-type]: Invalid second argument to `issubclass` 19 | 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: 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/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 0906ab9670567..56514b5004b33 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1409,18 +1409,31 @@ fn report_invalid_union_type_elements<'db>( .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 is not a class object", + "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 are not class objects", + "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, and {} more elements, are not class objects", + "Element `{}` in the union{union_suffix}, and {} more elements, are not class objects", first_invalid_element.display(db), other_invalid_elements.len(), )), From 10c5d16fef127b66007081e1d7bfbd9f4fe78d2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:11:15 +0000 Subject: [PATCH 6/7] [ty] Add tests for nested tuple arguments in isinstance/issubclass Add test coverage for nested tuples like `isinstance(x, (int, (str, P)))` across all validation types: protocols, TypedDict, typing.Any, and invalid UnionType elements. The secondary annotation correctly highlights just the problematic expression within the nested tuple. https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- .../resources/mdtest/annotations/any.md | 2 + .../resources/mdtest/narrow/isinstance.md | 13 ++- .../resources/mdtest/narrow/issubclass.md | 13 ++- .../resources/mdtest/protocols.md | 11 +++ ...an_in\342\200\246_(eeef56c0ef87a30b).snap" | 51 +++++++--- ...an_in\342\200\246_(7bb66a0f412caac1).snap" | 51 +++++++--- ...rotoco\342\200\246_(98257e7c2300373).snap" | 98 +++++++++++++++++-- .../resources/mdtest/typed_dict.md | 1 + 8 files changed, 204 insertions(+), 36 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/any.md b/crates/ty_python_semantic/resources/mdtest/annotations/any.md index cddbde7010190..4ad5749372b8b 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/any.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/any.md @@ -189,6 +189,7 @@ The same applies when `Any` is nested inside a tuple, including non-literal tupl ```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] ``` @@ -198,4 +199,5 @@ 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/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index 0d15360a79828..c57d96fb0edcc 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -139,7 +139,18 @@ def _(x: int | list[int] | bytes): reveal_type(x) # revealed: int | list[int] | bytes ``` -Including non-literal tuples: +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) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md index 28dd26b0fc1f8..6199073bc1a3b 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md @@ -192,7 +192,18 @@ def _(x: type[int | list | bytes]): reveal_type(x) # revealed: type[int | list[Unknown] | bytes] ``` -Including non-literal tuples: +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) diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index adf6a8be6d8ce..cee9b8eec3c79 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -2616,6 +2616,17 @@ def g(arg: object, arg2: type): 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 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 35fb8fd69f28d..a6c462f3f7403 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" @@ -33,14 +33,20 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md 18 | reveal_type(x) # revealed: int | list[int] | bytes 19 | else: 20 | reveal_type(x) # revealed: int | list[int] | bytes -21 | classes = (int, list[int] | bytes) -22 | -23 | def _(x: int | list[int] | bytes): -24 | # error: [invalid-argument-type] -25 | if isinstance(x, classes): +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 | else: -28 | 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 @@ -123,14 +129,33 @@ info: rule `invalid-argument-type` is enabled by default ``` error[invalid-argument-type]: Invalid second argument to `isinstance` - --> src/mdtest_snippet.py:25:8 + --> src/mdtest_snippet.py:23:8 | -23 | def _(x: int | list[int] | bytes): -24 | # error: [invalid-argument-type] -25 | if isinstance(x, classes): +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): | ^^^^^^^^^^^^^^^^^^^^^^ -26 | reveal_type(x) # revealed: int | list[int] | bytes -27 | else: +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 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 ac36115785f23..ebcb7cda0e93f 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" @@ -25,14 +25,20 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md 10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] 11 | else: 12 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] -13 | classes = (int, list[int] | bytes) -14 | -15 | def _(x: type[int | list | bytes]): -16 | # error: [invalid-argument-type] -17 | if issubclass(x, classes): +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 | else: -20 | 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 @@ -77,14 +83,33 @@ info: rule `invalid-argument-type` is enabled by default ``` error[invalid-argument-type]: Invalid second argument to `issubclass` - --> src/mdtest_snippet.py:17:8 + --> src/mdtest_snippet.py:15:8 | -15 | def _(x: type[int | list | bytes]): -16 | # error: [invalid-argument-type] -17 | if issubclass(x, classes): +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): | ^^^^^^^^^^^^^^^^^^^^^^ -18 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes] -19 | else: +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 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 0815b58a7543f..c98c2072ac520 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" @@ -83,10 +83,16 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/protocols.md 68 | issubclass(arg2, (HasX, RuntimeCheckableHasX)) 69 | 70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] -71 | classes = (HasX, int) -72 | -73 | def h(arg: object): -74 | isinstance(arg, classes) # 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 @@ -303,7 +309,8 @@ error[isinstance-against-protocol]: Class `HasX` cannot be used as the second ar 69 | 70 | issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime -71 | classes = (HasX, int) +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 @@ -322,10 +329,85 @@ 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:74:5 + --> src/mdtest_snippet.py:72:5 | -73 | def h(arg: object): -74 | isinstance(arg, classes) # error: [isinstance-against-protocol] +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 diff --git a/crates/ty_python_semantic/resources/mdtest/typed_dict.md b/crates/ty_python_semantic/resources/mdtest/typed_dict.md index 2be28d0859517..9fa17eddf669b 100644 --- a/crates/ty_python_semantic/resources/mdtest/typed_dict.md +++ b/crates/ty_python_semantic/resources/mdtest/typed_dict.md @@ -1923,6 +1923,7 @@ The same applies when a `TypedDict` class appears inside a tuple, including non- 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) From 06510e558aab2ce6e48eb8f757c2325a10bd1468 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:45:12 +0000 Subject: [PATCH 7/7] [ty] Fix panic on splatted isinstance/issubclass calls When `isinstance(*args)` is called, the AST only has one argument node (`*args`), but `parameter_types` resolves to two types after unpacking. The code was using `call_expression.arguments.args[1]` which panics with an index-out-of-bounds error. Fix by using `.get(1)` instead, which returns `None` when the second AST argument doesn't exist. The `check_classinfo_in_isinstance` function already handles `classinfo_expr: None` gracefully. https://claude.ai/code/session_01DRvdW91iMBPcFW5rjuZzab --- .../resources/mdtest/narrow/isinstance.md | 9 +++++++++ crates/ty_python_semantic/src/types/function.rs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md index c57d96fb0edcc..b44f28efde8ac 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md @@ -347,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/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 56514b5004b33..d6b1f4ef8b8fb 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -2166,7 +2166,7 @@ impl KnownFunction { call_expression, self, *second_argument, - Some(&call_expression.arguments.args[1]), + call_expression.arguments.args.get(1), ); if let Type::ClassLiteral(class) = second_argument