Skip to content

Commit 6ad151a

Browse files
authored
[ty] Recurse into tuples and nested tuples when applying special-cased validation of isinstance() and issubclass() (#23607)
## Summary Refactor the validation logic for `isinstance()` and `issubclass()` calls to support checking tuples (both literal and non-literal) that contain invalid types like protocol classes, TypedDicts, `typing.Any`, and invalid `UnionType` instances. Previously, validation only worked when these invalid types were passed directly as the second argument. Now we recursively validate each element in tuples, enabling detection of errors in cases like: - `isinstance(obj, (int, SomeProtocol))` - `isinstance(obj, (int, SomeTypedDict))` - `isinstance(obj, (int, typing.Any))` - `isinstance(obj, (int, list[int] | bytes))` This fixes astral-sh/ty#1600 and improves our typing conformance score ## Test Plan mdtests and snapshots extended and updated
1 parent e30a40e commit 6ad151a

10 files changed

Lines changed: 746 additions & 146 deletions

crates/ty_python_semantic/resources/mdtest/annotations/any.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,19 @@ And `Any` cannot be used in `isinstance()` checks:
185185
isinstance("", Any)
186186
```
187187

188+
The same applies when `Any` is nested inside a tuple, including non-literal tuples:
189+
190+
```py
191+
isinstance("", (int, Any)) # error: [invalid-argument-type]
192+
isinstance("", (int, (str, Any))) # error: [invalid-argument-type]
193+
classes = (int, Any)
194+
isinstance("", classes) # error: [invalid-argument-type]
195+
```
196+
188197
But `issubclass()` checks are fine:
189198

190199
```py
191200
issubclass(object, Any) # no error!
201+
issubclass(object, (int, Any)) # no error!
202+
issubclass(object, (int, (str, Any))) # no error!
192203
```

crates/ty_python_semantic/resources/mdtest/call/builtins.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ isinstance("", t.Callable | t.Deque)
164164
# `Any` is valid in `issubclass()` calls but not `isinstance()` calls
165165
issubclass(list, t.Any)
166166
issubclass(list, t.Any | t.Dict)
167+
168+
# The same works in tuples
169+
isinstance("", (int, t.Dict))
170+
isinstance("", (int, t.Callable))
171+
issubclass(list, (int, t.Any))
167172
```
168173

169174
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]
173178
isinstance("", t.ClassVar) # error: [invalid-argument-type]
174179
isinstance("", t.Final) # error: [invalid-argument-type]
175180
isinstance("", t.Any) # error: [invalid-argument-type]
181+
182+
# The same applies when `Any` is nested inside a tuple
183+
isinstance("", (int, t.Any)) # error: [invalid-argument-type]
176184
```
177185

178186
## The builtin `NotImplemented` constant is not callable

crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,41 @@ def _(x: int | list[int] | bytes):
128128
reveal_type(x) # revealed: int | list[int] | bytes
129129
```
130130

131+
The same validation also applies when an invalid `UnionType` is nested inside a tuple:
132+
133+
```py
134+
def _(x: int | list[int] | bytes):
135+
# error: [invalid-argument-type]
136+
if isinstance(x, (int, list[int] | bytes)):
137+
reveal_type(x) # revealed: int | list[int] | bytes
138+
else:
139+
reveal_type(x) # revealed: int | list[int] | bytes
140+
```
141+
142+
Including nested tuples:
143+
144+
```py
145+
def _(x: int | list[int] | bytes):
146+
# error: [invalid-argument-type]
147+
if isinstance(x, (int, (str, list[int] | bytes))):
148+
reveal_type(x) # revealed: int | list[int] | bytes
149+
else:
150+
reveal_type(x) # revealed: int | list[int] | bytes
151+
```
152+
153+
And non-literal tuples:
154+
155+
```py
156+
classes = (int, list[int] | bytes)
157+
158+
def _(x: int | list[int] | bytes):
159+
# error: [invalid-argument-type]
160+
if isinstance(x, classes):
161+
reveal_type(x) # revealed: int | list[int] | bytes
162+
else:
163+
reveal_type(x) # revealed: int | list[int] | bytes
164+
```
165+
131166
## PEP-604 unions on Python \<3.10
132167

133168
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):
312347
reveal_type(x) # revealed: Literal[1, "a"]
313348
```
314349

350+
## Splatted calls with invalid `classinfo`
351+
352+
Diagnostics are still emitted for invalid `classinfo` types when the arguments are splatted:
353+
354+
```py
355+
args = (object(), int | list[str])
356+
isinstance(*args) # error: [invalid-argument-type]
357+
```
358+
315359
## Generic aliases are not supported as second argument
316360

317361
The `classinfo` argument cannot be a generic alias:

crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,41 @@ def _(x: type[int | list | bytes]):
181181
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
182182
```
183183

184+
The same validation also applies when an invalid `UnionType` is nested inside a tuple:
185+
186+
```py
187+
def _(x: type[int | list | bytes]):
188+
# error: [invalid-argument-type]
189+
if issubclass(x, (int, list[int] | bytes)):
190+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
191+
else:
192+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
193+
```
194+
195+
Including nested tuples:
196+
197+
```py
198+
def _(x: type[int | list | bytes]):
199+
# error: [invalid-argument-type]
200+
if issubclass(x, (int, (str, list[int] | bytes))):
201+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
202+
else:
203+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
204+
```
205+
206+
And non-literal tuples:
207+
208+
```py
209+
classes = (int, list[int] | bytes)
210+
211+
def _(x: type[int | list | bytes]):
212+
# error: [invalid-argument-type]
213+
if issubclass(x, classes):
214+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
215+
else:
216+
reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
217+
```
218+
184219
## PEP-604 unions on Python \<3.10
185220

186221
PEP-604 unions were added in Python 3.10, so attempting to use them on Python 3.9 does not lead to

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2601,6 +2601,41 @@ def f(arg1: type):
26012601
reveal_type(arg1) # revealed: type & ~type[OnlyClassmethodMembers]
26022602
```
26032603

2604+
The same diagnostics are also emitted when protocol classes appear inside a tuple passed as the
2605+
second argument to `isinstance()` or `issubclass()`:
2606+
2607+
```py
2608+
def g(arg: object, arg2: type):
2609+
isinstance(arg, (HasX, RuntimeCheckableHasX)) # error: [isinstance-against-protocol]
2610+
isinstance(arg, (HasX, int)) # error: [isinstance-against-protocol]
2611+
2612+
# error: [isinstance-against-protocol]
2613+
# error: [isinstance-against-protocol]
2614+
issubclass(arg2, (HasX, RuntimeCheckableHasX))
2615+
2616+
issubclass(arg2, (HasX, OnlyMethodMembers)) # error: [isinstance-against-protocol]
2617+
```
2618+
2619+
This includes nested tuples:
2620+
2621+
```py
2622+
def g2(arg: object, arg2: type):
2623+
isinstance(arg, (int, (HasX, str))) # error: [isinstance-against-protocol]
2624+
2625+
# error: [isinstance-against-protocol]
2626+
# error: [isinstance-against-protocol]
2627+
issubclass(arg2, (int, (HasX, RuntimeCheckableHasX)))
2628+
```
2629+
2630+
This also works when the tuple is not a literal in the source:
2631+
2632+
```py
2633+
classes = (HasX, int)
2634+
2635+
def h(arg: object):
2636+
isinstance(arg, classes) # error: [isinstance-against-protocol]
2637+
```
2638+
26042639
## Match class patterns and protocols
26052640

26062641
<!-- snapshot-diagnostics -->
@@ -3049,11 +3084,13 @@ static_assert(not is_disjoint_from(Proto, Nominal))
30493084
This snippet caused us to panic on an early version of the implementation for protocols.
30503085

30513086
```py
3052-
from typing import Protocol
3087+
from typing import Protocol, runtime_checkable
30533088

3089+
@runtime_checkable
30543090
class A(Protocol):
30553091
def x(self) -> "B | A": ...
30563092

3093+
@runtime_checkable
30573094
class B(Protocol):
30583095
def y(self): ...
30593096

crates/ty_python_semantic/resources/mdtest/snapshots/isinstance.md_-_Narrowing_for_`isins…_-_`classinfo`_is_an_in…_(eeef56c0ef87a30b).snap

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/isinstance.md
2727
12 | reveal_type(x) # revealed: int | list[int] | bytes
2828
13 | else:
2929
14 | reveal_type(x) # revealed: int | list[int] | bytes
30+
15 | def _(x: int | list[int] | bytes):
31+
16 | # error: [invalid-argument-type]
32+
17 | if isinstance(x, (int, list[int] | bytes)):
33+
18 | reveal_type(x) # revealed: int | list[int] | bytes
34+
19 | else:
35+
20 | reveal_type(x) # revealed: int | list[int] | bytes
36+
21 | def _(x: int | list[int] | bytes):
37+
22 | # error: [invalid-argument-type]
38+
23 | if isinstance(x, (int, (str, list[int] | bytes))):
39+
24 | reveal_type(x) # revealed: int | list[int] | bytes
40+
25 | else:
41+
26 | reveal_type(x) # revealed: int | list[int] | bytes
42+
27 | classes = (int, list[int] | bytes)
43+
28 |
44+
29 | def _(x: int | list[int] | bytes):
45+
30 | # error: [invalid-argument-type]
46+
31 | if isinstance(x, classes):
47+
32 | reveal_type(x) # revealed: int | list[int] | bytes
48+
33 | else:
49+
34 | reveal_type(x) # revealed: int | list[int] | bytes
3050
```
3151

3252
# Diagnostics
@@ -87,3 +107,58 @@ info: Element `<special-form 'typing.Any'>` in the union, and 2 more elements, a
87107
info: rule `invalid-argument-type` is enabled by default
88108

89109
```
110+
111+
```
112+
error[invalid-argument-type]: Invalid second argument to `isinstance`
113+
--> src/mdtest_snippet.py:17:8
114+
|
115+
15 | def _(x: int | list[int] | bytes):
116+
16 | # error: [invalid-argument-type]
117+
17 | if isinstance(x, (int, list[int] | bytes)):
118+
| ^^^^^^^^^^^^^^^^^^^^-----------------^^
119+
| |
120+
| This `UnionType` instance contains non-class elements
121+
18 | reveal_type(x) # revealed: int | list[int] | bytes
122+
19 | else:
123+
|
124+
info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects
125+
info: Element `<class 'list[int]'>` in the union is not a class object
126+
info: rule `invalid-argument-type` is enabled by default
127+
128+
```
129+
130+
```
131+
error[invalid-argument-type]: Invalid second argument to `isinstance`
132+
--> src/mdtest_snippet.py:23:8
133+
|
134+
21 | def _(x: int | list[int] | bytes):
135+
22 | # error: [invalid-argument-type]
136+
23 | if isinstance(x, (int, (str, list[int] | bytes))):
137+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^-----------------^^^
138+
| |
139+
| This `UnionType` instance contains non-class elements
140+
24 | reveal_type(x) # revealed: int | list[int] | bytes
141+
25 | else:
142+
|
143+
info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects
144+
info: Element `<class 'list[int]'>` in the union is not a class object
145+
info: rule `invalid-argument-type` is enabled by default
146+
147+
```
148+
149+
```
150+
error[invalid-argument-type]: Invalid second argument to `isinstance`
151+
--> src/mdtest_snippet.py:31:8
152+
|
153+
29 | def _(x: int | list[int] | bytes):
154+
30 | # error: [invalid-argument-type]
155+
31 | if isinstance(x, classes):
156+
| ^^^^^^^^^^^^^^^^^^^^^^
157+
32 | reveal_type(x) # revealed: int | list[int] | bytes
158+
33 | else:
159+
|
160+
info: A `UnionType` instance can only be used as the second argument to `isinstance` if all elements are class objects
161+
info: Element `<class 'list[int]'>` in the union `list[int] | bytes` is not a class object
162+
info: rule `invalid-argument-type` is enabled by default
163+
164+
```

crates/ty_python_semantic/resources/mdtest/snapshots/issubclass.md_-_Narrowing_for_`issub…_-_`classinfo`_is_an_in…_(7bb66a0f412caac1).snap

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,32 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/narrow/issubclass.md
1313
## mdtest_snippet.py
1414

1515
```
16-
1 | def _(x: type[int | list | bytes]):
17-
2 | # error: [invalid-argument-type]
18-
3 | if issubclass(x, int | list[int]):
19-
4 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
20-
5 | else:
21-
6 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
16+
1 | def _(x: type[int | list | bytes]):
17+
2 | # error: [invalid-argument-type]
18+
3 | if issubclass(x, int | list[int]):
19+
4 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
20+
5 | else:
21+
6 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
22+
7 | def _(x: type[int | list | bytes]):
23+
8 | # error: [invalid-argument-type]
24+
9 | if issubclass(x, (int, list[int] | bytes)):
25+
10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
26+
11 | else:
27+
12 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
28+
13 | def _(x: type[int | list | bytes]):
29+
14 | # error: [invalid-argument-type]
30+
15 | if issubclass(x, (int, (str, list[int] | bytes))):
31+
16 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
32+
17 | else:
33+
18 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
34+
19 | classes = (int, list[int] | bytes)
35+
20 |
36+
21 | def _(x: type[int | list | bytes]):
37+
22 | # error: [invalid-argument-type]
38+
23 | if issubclass(x, classes):
39+
24 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
40+
25 | else:
41+
26 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
2242
```
2343

2444
# Diagnostics
@@ -41,3 +61,58 @@ info: Element `<class 'list[int]'>` in the union is not a class object
4161
info: rule `invalid-argument-type` is enabled by default
4262

4363
```
64+
65+
```
66+
error[invalid-argument-type]: Invalid second argument to `issubclass`
67+
--> src/mdtest_snippet.py:9:8
68+
|
69+
7 | def _(x: type[int | list | bytes]):
70+
8 | # error: [invalid-argument-type]
71+
9 | if issubclass(x, (int, list[int] | bytes)):
72+
| ^^^^^^^^^^^^^^^^^^^^-----------------^^
73+
| |
74+
| This `UnionType` instance contains non-class elements
75+
10 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
76+
11 | else:
77+
|
78+
info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects
79+
info: Element `<class 'list[int]'>` in the union is not a class object
80+
info: rule `invalid-argument-type` is enabled by default
81+
82+
```
83+
84+
```
85+
error[invalid-argument-type]: Invalid second argument to `issubclass`
86+
--> src/mdtest_snippet.py:15:8
87+
|
88+
13 | def _(x: type[int | list | bytes]):
89+
14 | # error: [invalid-argument-type]
90+
15 | if issubclass(x, (int, (str, list[int] | bytes))):
91+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^-----------------^^^
92+
| |
93+
| This `UnionType` instance contains non-class elements
94+
16 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
95+
17 | else:
96+
|
97+
info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects
98+
info: Element `<class 'list[int]'>` in the union is not a class object
99+
info: rule `invalid-argument-type` is enabled by default
100+
101+
```
102+
103+
```
104+
error[invalid-argument-type]: Invalid second argument to `issubclass`
105+
--> src/mdtest_snippet.py:23:8
106+
|
107+
21 | def _(x: type[int | list | bytes]):
108+
22 | # error: [invalid-argument-type]
109+
23 | if issubclass(x, classes):
110+
| ^^^^^^^^^^^^^^^^^^^^^^
111+
24 | reveal_type(x) # revealed: type[int | list[Unknown] | bytes]
112+
25 | else:
113+
|
114+
info: A `UnionType` instance can only be used as the second argument to `issubclass` if all elements are class objects
115+
info: Element `<class 'list[int]'>` in the union `list[int] | bytes` is not a class object
116+
info: rule `invalid-argument-type` is enabled by default
117+
118+
```

0 commit comments

Comments
 (0)