Skip to content

Commit 020ff17

Browse files
authored
[ty] Add narrowing for isinstance() and issubclass() checks that use PEP-604 unions (#21334)
1 parent 09e6af1 commit 020ff17

File tree

3 files changed

+156
-3
lines changed

3 files changed

+156
-3
lines changed

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,74 @@ def _(flag: bool):
7070
reveal_type(x) # revealed: Literal["a"]
7171
```
7272

73+
## `classinfo` is a PEP-604 union of types
74+
75+
```toml
76+
[environment]
77+
python-version = "3.10"
78+
```
79+
80+
```py
81+
def _(x: int | str | bytes | memoryview | range):
82+
if isinstance(x, int | str):
83+
reveal_type(x) # revealed: int | str
84+
elif isinstance(x, bytes | memoryview):
85+
reveal_type(x) # revealed: bytes | memoryview[Unknown]
86+
else:
87+
reveal_type(x) # revealed: range
88+
```
89+
90+
Although `isinstance()` usually only works if all elements in the `UnionType` are class objects, at
91+
runtime a special exception is made for `None` so that `isinstance(x, int | None)` can work:
92+
93+
```py
94+
def _(x: int | str | bytes | range | None):
95+
if isinstance(x, int | str | None):
96+
reveal_type(x) # revealed: int | str | None
97+
else:
98+
reveal_type(x) # revealed: bytes | range
99+
```
100+
101+
## `classinfo` is an invalid PEP-604 union of types
102+
103+
Except for the `None` special case mentioned above, narrowing can only take place if all elements in
104+
the PEP-604 union are class literals. If any elements are generic aliases or other types, the
105+
`isinstance()` call may fail at runtime, so no narrowing can take place:
106+
107+
```toml
108+
[environment]
109+
python-version = "3.10"
110+
```
111+
112+
```py
113+
def _(x: int | list[int] | bytes):
114+
# TODO: this fails at runtime; we should emit a diagnostic
115+
# (requires special-casing of the `isinstance()` signature)
116+
if isinstance(x, int | list[int]):
117+
reveal_type(x) # revealed: int | list[int] | bytes
118+
else:
119+
reveal_type(x) # revealed: int | list[int] | bytes
120+
```
121+
122+
## PEP-604 unions on Python \<3.10
123+
124+
PEP-604 unions were added in Python 3.10, so attempting to use them on Python 3.9 does not lead to
125+
any type narrowing.
126+
127+
```toml
128+
[environment]
129+
python-version = "3.9"
130+
```
131+
132+
```py
133+
def _(x: int | str | bytes):
134+
# error: [unsupported-operator]
135+
if isinstance(x, int | str):
136+
reveal_type(x) # revealed: (int & Unknown) | (str & Unknown) | (bytes & Unknown)
137+
else:
138+
reveal_type(x) # revealed: (int & Unknown) | (str & Unknown) | (bytes & Unknown)
139+
```
140+
73141
## Class types
74142

75143
```py

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,74 @@ def _(flag1: bool, flag2: bool):
131131
reveal_type(t) # revealed: <class 'str'>
132132
```
133133

134+
## `classinfo` is a PEP-604 union of types
135+
136+
```toml
137+
[environment]
138+
python-version = "3.10"
139+
```
140+
141+
```py
142+
def f(x: type[int | str | bytes | range]):
143+
if issubclass(x, int | str):
144+
reveal_type(x) # revealed: type[int] | type[str]
145+
elif issubclass(x, bytes | memoryview):
146+
reveal_type(x) # revealed: type[bytes]
147+
else:
148+
reveal_type(x) # revealed: <class 'range'>
149+
```
150+
151+
Although `issubclass()` usually only works if all elements in the `UnionType` are class objects, at
152+
runtime a special exception is made for `None` so that `issubclass(x, int | None)` can work:
153+
154+
```py
155+
def _(x: type):
156+
if issubclass(x, int | str | None):
157+
reveal_type(x) # revealed: type[int] | type[str] | <class 'NoneType'>
158+
else:
159+
reveal_type(x) # revealed: type & ~type[int] & ~type[str] & ~<class 'NoneType'>
160+
```
161+
162+
## `classinfo` is an invalid PEP-604 union of types
163+
164+
Except for the `None` special case mentioned above, narrowing can only take place if all elements in
165+
the PEP-604 union are class literals. If any elements are generic aliases or other types, the
166+
`issubclass()` call may fail at runtime, so no narrowing can take place:
167+
168+
```toml
169+
[environment]
170+
python-version = "3.10"
171+
```
172+
173+
```py
174+
def _(x: type[int | list | bytes]):
175+
# TODO: this fails at runtime; we should emit a diagnostic
176+
# (requires special-casing of the `issubclass()` signature)
177+
if issubclass(x, int | list[int]):
178+
reveal_type(x) # revealed: type[int] | type[list[Unknown]] | type[bytes]
179+
else:
180+
reveal_type(x) # revealed: type[int] | type[list[Unknown]] | type[bytes]
181+
```
182+
183+
## PEP-604 unions on Python \<3.10
184+
185+
PEP-604 unions were added in Python 3.10, so attempting to use them on Python 3.9 does not lead to
186+
any type narrowing.
187+
188+
```toml
189+
[environment]
190+
python-version = "3.9"
191+
```
192+
193+
```py
194+
def _(x: type[int | str | bytes]):
195+
# error: [unsupported-operator]
196+
if issubclass(x, int | str):
197+
reveal_type(x) # revealed: (type[int] & Unknown) | (type[str] & Unknown) | (type[bytes] & Unknown)
198+
else:
199+
reveal_type(x) # revealed: (type[int] & Unknown) | (type[str] & Unknown) | (type[bytes] & Unknown)
200+
```
201+
134202
## Special cases
135203

136204
### Emit a diagnostic if the first argument is of wrong type

crates/ty_python_semantic/src/types/narrow.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ use crate::types::enums::{enum_member_literals, enum_metadata};
1111
use crate::types::function::KnownFunction;
1212
use crate::types::infer::infer_same_file_expression_type;
1313
use crate::types::{
14-
ClassLiteral, ClassType, IntersectionBuilder, KnownClass, SpecialFormType, SubclassOfInner,
15-
SubclassOfType, Truthiness, Type, TypeContext, TypeVarBoundOrConstraints, UnionBuilder,
16-
infer_expression_types,
14+
ClassLiteral, ClassType, IntersectionBuilder, KnownClass, KnownInstanceType, SpecialFormType,
15+
SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext, TypeVarBoundOrConstraints,
16+
UnionBuilder, infer_expression_types,
1717
};
1818

1919
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
@@ -212,6 +212,23 @@ impl ClassInfoConstraintFunction {
212212
)
213213
}),
214214

215+
Type::KnownInstance(KnownInstanceType::UnionType(elements)) => {
216+
UnionType::try_from_elements(
217+
db,
218+
elements.elements(db).iter().map(|element| {
219+
// A special case is made for `None` at runtime
220+
// (it's implicitly converted to `NoneType` in `int | None`)
221+
// which means that `isinstance(x, int | None)` works even though
222+
// `None` is not a class literal.
223+
if element.is_none(db) {
224+
self.generate_constraint(db, KnownClass::NoneType.to_class_literal(db))
225+
} else {
226+
self.generate_constraint(db, *element)
227+
}
228+
}),
229+
)
230+
}
231+
215232
Type::AlwaysFalsy
216233
| Type::AlwaysTruthy
217234
| Type::BooleanLiteral(_)

0 commit comments

Comments
 (0)