Skip to content

Commit 905b9d7

Browse files
authored
[ty] Reachability analysis for isinstance(…) branches (#19503)
## Summary Add more precise type inference for a limited set of `isinstance(…)` calls, i.e. return `Literal[True]` if we can be sure that this is the correct result. This improves exhaustiveness checking / reachability analysis for if-elif-else chains with `isinstance` checks. For example: ```py def is_number(x: int | str) -> bool: # no "can implicitly return `None` error here anymore if isinstance(x, int): return True elif isinstance(x, str): return False # code here is now detected as being unreachable ``` This PR also adds a new test suite for exhaustiveness checking. ## Test Plan New Markdown tests ### Ecosystem analysis The removed diagnostics look good. There's [one case](https://github.com/pytorch/vision/blob/f52c4f1afd7dec25cbe7b98bcf1cbc840298e8da/torchvision/io/video_reader.py#L125-L143) where a "true positive" is removed in unreachable code. `src` is annotated as being of type `str`, but there is an `elif isinstance(src, bytes)` branch, which we now detect as unreachable. And so the diagnostic inside that branch is silenced. I don't think this is a problem, especially once we have a "graying out" feature, or a lint that warns about unreachable code.
1 parent b605c3e commit 905b9d7

File tree

3 files changed

+404
-14
lines changed

3 files changed

+404
-14
lines changed

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,59 @@ str("Müsli", "utf-8")
105105
# error: [invalid-argument-type] "Argument to class `str` is incorrect: Expected `str`, found `Literal[b"utf-8"]`"
106106
str(b"M\xc3\xbcsli", b"utf-8")
107107
```
108+
109+
## Calls to `isinstance`
110+
111+
We infer `Literal[True]` for a limited set of cases where we can be sure that the answer is correct,
112+
but fall back to `bool` otherwise.
113+
114+
```py
115+
from enum import Enum
116+
from types import FunctionType
117+
118+
class Answer(Enum):
119+
NO = 0
120+
YES = 1
121+
122+
reveal_type(isinstance(True, bool)) # revealed: Literal[True]
123+
reveal_type(isinstance(True, int)) # revealed: Literal[True]
124+
reveal_type(isinstance(True, object)) # revealed: Literal[True]
125+
reveal_type(isinstance("", str)) # revealed: Literal[True]
126+
reveal_type(isinstance(1, int)) # revealed: Literal[True]
127+
reveal_type(isinstance(b"", bytes)) # revealed: Literal[True]
128+
reveal_type(isinstance(Answer.NO, Answer)) # revealed: Literal[True]
129+
130+
reveal_type(isinstance((1, 2), tuple)) # revealed: Literal[True]
131+
132+
def f(): ...
133+
134+
reveal_type(isinstance(f, FunctionType)) # revealed: Literal[True]
135+
136+
reveal_type(isinstance("", int)) # revealed: bool
137+
138+
class A: ...
139+
class SubclassOfA(A): ...
140+
class B: ...
141+
142+
reveal_type(isinstance(A, type)) # revealed: Literal[True]
143+
144+
a = A()
145+
146+
reveal_type(isinstance(a, A)) # revealed: Literal[True]
147+
reveal_type(isinstance(a, object)) # revealed: Literal[True]
148+
reveal_type(isinstance(a, SubclassOfA)) # revealed: bool
149+
reveal_type(isinstance(a, B)) # revealed: bool
150+
151+
s = SubclassOfA()
152+
reveal_type(isinstance(s, SubclassOfA)) # revealed: Literal[True]
153+
reveal_type(isinstance(s, A)) # revealed: Literal[True]
154+
155+
def _(x: A | B):
156+
reveal_type(isinstance(x, A)) # revealed: bool
157+
158+
if isinstance(x, A):
159+
pass
160+
else:
161+
reveal_type(x) # revealed: B & ~A
162+
reveal_type(isinstance(x, B)) # revealed: Literal[True]
163+
```
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Exhaustiveness checking
2+
3+
```toml
4+
[environment]
5+
python-version = "3.11"
6+
```
7+
8+
## Checks on literals
9+
10+
```py
11+
from typing import Literal, assert_never
12+
13+
def if_else_exhaustive(x: Literal[0, 1, "a"]):
14+
if x == 0:
15+
pass
16+
elif x == 1:
17+
pass
18+
elif x == "a":
19+
pass
20+
else:
21+
no_diagnostic_here
22+
23+
assert_never(x)
24+
25+
def if_else_exhaustive_no_assertion(x: Literal[0, 1, "a"]) -> int:
26+
if x == 0:
27+
return 0
28+
elif x == 1:
29+
return 1
30+
elif x == "a":
31+
return 2
32+
33+
def if_else_non_exhaustive(x: Literal[0, 1, "a"]):
34+
if x == 0:
35+
pass
36+
elif x == "a":
37+
pass
38+
else:
39+
this_should_be_an_error # error: [unresolved-reference]
40+
41+
# this diagnostic is correct: the inferred type of `x` is `Literal[1]`
42+
assert_never(x) # error: [type-assertion-failure]
43+
44+
def match_exhaustive(x: Literal[0, 1, "a"]):
45+
match x:
46+
case 0:
47+
pass
48+
case 1:
49+
pass
50+
case "a":
51+
pass
52+
case _:
53+
# TODO: this should not be an error
54+
no_diagnostic_here # error: [unresolved-reference]
55+
56+
assert_never(x)
57+
58+
# TODO: there should be no error here
59+
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`"
60+
def match_exhaustive_no_assertion(x: Literal[0, 1, "a"]) -> int:
61+
match x:
62+
case 0:
63+
return 0
64+
case 1:
65+
return 1
66+
case "a":
67+
return 2
68+
69+
def match_non_exhaustive(x: Literal[0, 1, "a"]):
70+
match x:
71+
case 0:
72+
pass
73+
case "a":
74+
pass
75+
case _:
76+
this_should_be_an_error # error: [unresolved-reference]
77+
78+
# this diagnostic is correct: the inferred type of `x` is `Literal[1]`
79+
assert_never(x) # error: [type-assertion-failure]
80+
```
81+
82+
## Checks on enum literals
83+
84+
```py
85+
from enum import Enum
86+
from typing import assert_never
87+
88+
class Color(Enum):
89+
RED = 1
90+
GREEN = 2
91+
BLUE = 3
92+
93+
def if_else_exhaustive(x: Color):
94+
if x == Color.RED:
95+
pass
96+
elif x == Color.GREEN:
97+
pass
98+
elif x == Color.BLUE:
99+
pass
100+
else:
101+
no_diagnostic_here
102+
103+
assert_never(x)
104+
105+
def if_else_exhaustive_no_assertion(x: Color) -> int:
106+
if x == Color.RED:
107+
return 1
108+
elif x == Color.GREEN:
109+
return 2
110+
elif x == Color.BLUE:
111+
return 3
112+
113+
def if_else_non_exhaustive(x: Color):
114+
if x == Color.RED:
115+
pass
116+
elif x == Color.BLUE:
117+
pass
118+
else:
119+
this_should_be_an_error # error: [unresolved-reference]
120+
121+
# this diagnostic is correct: inferred type of `x` is `Literal[Color.GREEN]`
122+
assert_never(x) # error: [type-assertion-failure]
123+
124+
def match_exhaustive(x: Color):
125+
match x:
126+
case Color.RED:
127+
pass
128+
case Color.GREEN:
129+
pass
130+
case Color.BLUE:
131+
pass
132+
case _:
133+
# TODO: this should not be an error
134+
no_diagnostic_here # error: [unresolved-reference]
135+
136+
assert_never(x)
137+
138+
# TODO: there should be no error here
139+
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`"
140+
def match_exhaustive_no_assertion(x: Color) -> int:
141+
match x:
142+
case Color.RED:
143+
return 1
144+
case Color.GREEN:
145+
return 2
146+
case Color.BLUE:
147+
return 3
148+
149+
def match_non_exhaustive(x: Color):
150+
match x:
151+
case Color.RED:
152+
pass
153+
case Color.BLUE:
154+
pass
155+
case _:
156+
this_should_be_an_error # error: [unresolved-reference]
157+
158+
# this diagnostic is correct: inferred type of `x` is `Literal[Color.GREEN]`
159+
assert_never(x) # error: [type-assertion-failure]
160+
```
161+
162+
## `isinstance` checks
163+
164+
```py
165+
from typing import assert_never
166+
167+
class A: ...
168+
class B: ...
169+
class C: ...
170+
171+
def if_else_exhaustive(x: A | B | C):
172+
if isinstance(x, A):
173+
pass
174+
elif isinstance(x, B):
175+
pass
176+
elif isinstance(x, C):
177+
pass
178+
else:
179+
no_diagnostic_here
180+
181+
assert_never(x)
182+
183+
def if_else_exhaustive_no_assertion(x: A | B | C) -> int:
184+
if isinstance(x, A):
185+
return 0
186+
elif isinstance(x, B):
187+
return 1
188+
elif isinstance(x, C):
189+
return 2
190+
191+
def if_else_non_exhaustive(x: A | B | C):
192+
if isinstance(x, A):
193+
pass
194+
elif isinstance(x, C):
195+
pass
196+
else:
197+
this_should_be_an_error # error: [unresolved-reference]
198+
199+
# this diagnostic is correct: the inferred type of `x` is `B & ~A & ~C`
200+
assert_never(x) # error: [type-assertion-failure]
201+
202+
def match_exhaustive(x: A | B | C):
203+
match x:
204+
case A():
205+
pass
206+
case B():
207+
pass
208+
case C():
209+
pass
210+
case _:
211+
# TODO: this should not be an error
212+
no_diagnostic_here # error: [unresolved-reference]
213+
214+
assert_never(x)
215+
216+
# TODO: there should be no error here
217+
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `int`"
218+
def match_exhaustive_no_assertion(x: A | B | C) -> int:
219+
match x:
220+
case A():
221+
return 0
222+
case B():
223+
return 1
224+
case C():
225+
return 2
226+
227+
def match_non_exhaustive(x: A | B | C):
228+
match x:
229+
case A():
230+
pass
231+
case C():
232+
pass
233+
case _:
234+
this_should_be_an_error # error: [unresolved-reference]
235+
236+
# this diagnostic is correct: the inferred type of `x` is `B & ~A & ~C`
237+
assert_never(x) # error: [type-assertion-failure]
238+
```

0 commit comments

Comments
 (0)