Skip to content

Commit 9f6146a

Browse files
AlexWaygoodntBre
andauthored
[ty] Add precise inference for indexing, slicing and unpacking NamedTuple instances (#19560)
Co-authored-by: Brent Westbrook <[email protected]>
1 parent 11d2cb6 commit 9f6146a

File tree

7 files changed

+102
-36
lines changed

7 files changed

+102
-36
lines changed

crates/ty_python_semantic/resources/mdtest/named_tuple.md

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ name, and not just by its numeric position within the tuple:
99

1010
```py
1111
from typing import NamedTuple
12+
from ty_extensions import static_assert, is_subtype_of, is_assignable_to
1213

1314
class Person(NamedTuple):
1415
id: int
@@ -24,10 +25,45 @@ reveal_type(alice.id) # revealed: int
2425
reveal_type(alice.name) # revealed: str
2526
reveal_type(alice.age) # revealed: int | None
2627

27-
# TODO: These should reveal the types of the fields
28-
reveal_type(alice[0]) # revealed: Unknown
29-
reveal_type(alice[1]) # revealed: Unknown
30-
reveal_type(alice[2]) # revealed: Unknown
28+
# revealed: tuple[<class 'Person'>, <class 'tuple[int, str, int | None]'>, <class 'Sequence[int | str | None]'>, <class 'Reversible[int | str | None]'>, <class 'Collection[int | str | None]'>, <class 'Iterable[int | str | None]'>, <class 'Container[int | str | None]'>, typing.Protocol, typing.Generic, <class 'object'>]
29+
reveal_type(Person.__mro__)
30+
31+
static_assert(is_subtype_of(Person, tuple[int, str, int | None]))
32+
static_assert(is_subtype_of(Person, tuple[object, ...]))
33+
static_assert(not is_assignable_to(Person, tuple[int, str, int]))
34+
static_assert(not is_assignable_to(Person, tuple[int, str]))
35+
36+
reveal_type(len(alice)) # revealed: Literal[3]
37+
reveal_type(bool(alice)) # revealed: Literal[True]
38+
39+
reveal_type(alice[0]) # revealed: int
40+
reveal_type(alice[1]) # revealed: str
41+
reveal_type(alice[2]) # revealed: int | None
42+
43+
# error: [index-out-of-bounds] "Index 3 is out of bounds for tuple `Person` with length 3"
44+
reveal_type(alice[3]) # revealed: Unknown
45+
46+
reveal_type(alice[-1]) # revealed: int | None
47+
reveal_type(alice[-2]) # revealed: str
48+
reveal_type(alice[-3]) # revealed: int
49+
50+
# error: [index-out-of-bounds] "Index -4 is out of bounds for tuple `Person` with length 3"
51+
reveal_type(alice[-4]) # revealed: Unknown
52+
53+
reveal_type(alice[1:]) # revealed: tuple[str, int | None]
54+
reveal_type(alice[::-1]) # revealed: tuple[int | None, str, int]
55+
56+
alice_id, alice_name, alice_age = alice
57+
reveal_type(alice_id) # revealed: int
58+
reveal_type(alice_name) # revealed: str
59+
reveal_type(alice_age) # revealed: int | None
60+
61+
# error: [invalid-assignment] "Not enough values to unpack: Expected 4"
62+
a, b, c, d = alice
63+
# error: [invalid-assignment] "Too many values to unpack: Expected 2"
64+
a, b = alice
65+
*_, age = alice
66+
reveal_type(age) # revealed: int | None
3167

3268
# error: [missing-argument]
3369
Person(3)

crates/ty_python_semantic/resources/primer/bad.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
Tanjun # too many iterations
2+
altair # too many iterations (uses packaging)
23
antidote # hangs / slow (single threaded)
34
artigraph # cycle panics (value_type_)
45
arviz # too many iterations on versions of arviz newer than https://github.com/arviz-devs/arviz/commit/3205b82bb4d6097c31f7334d7ac51a6de37002d0
56
core # cycle panics (value_type_)
67
cpython # too many cycle iterations
8+
graphql-core # stack overflow
79
hydpy # too many iterations
810
ibis # too many iterations
911
jax # too many iterations
1012
mypy # too many iterations (self-recursive type alias)
13+
nox # too many iterations (uses packaging)
1114
packaging # too many iterations
1215
pandas # slow (9s)
1316
pandas-stubs # panics on versions of pandas-stubs newer than https://github.com/pandas-dev/pandas-stubs/commit/bf1221eb7ea0e582c30fe233d1f4f5713fce376b
@@ -21,4 +24,5 @@ setuptools # vendors packaging, see above
2124
spack # slow, success, but mypy-primer hangs processing the output
2225
spark # too many iterations
2326
steam.py # hangs (single threaded)
27+
streamlit # too many iterations (uses packaging)
2428
xarray # too many iterations

crates/ty_python_semantic/resources/primer/good.txt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ aioredis
1010
aiortc
1111
alectryon
1212
alerta
13-
altair
1413
anyio
1514
apprise
1615
async-utils
@@ -41,7 +40,6 @@ flake8
4140
flake8-pyi
4241
freqtrade
4342
git-revise
44-
graphql-core
4543
httpx-caching
4644
hydra-zen
4745
ignite
@@ -64,7 +62,6 @@ more-itertools
6462
mypy-protobuf
6563
mypy_primer
6664
nionutils
67-
nox
6865
openlibrary
6966
operator
7067
optuna
@@ -107,7 +104,6 @@ starlette
107104
static-frame
108105
stone
109106
strawberry
110-
streamlit
111107
svcs
112108
sympy
113109
tornado

crates/ty_python_semantic/src/types.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9389,7 +9389,19 @@ impl<'db> BoundSuperType<'db> {
93899389
));
93909390
}
93919391

9392-
let pivot_class = ClassBase::try_from_type(db, pivot_class_type).ok_or({
9392+
// TODO: having to get a class-literal just to pass it in here is silly.
9393+
// `BoundSuperType` should probably not be using `ClassBase::try_from_type` here;
9394+
// this also leads to false negatives in some cases. See discussion in
9395+
// <https://github.com/astral-sh/ruff/pull/19560#discussion_r2271570071>.
9396+
let pivot_class = ClassBase::try_from_type(
9397+
db,
9398+
pivot_class_type,
9399+
KnownClass::Object
9400+
.to_class_literal(db)
9401+
.into_class_literal()
9402+
.expect("`object` should always exist in typeshed"),
9403+
)
9404+
.ok_or({
93939405
BoundSuperError::InvalidPivotClassType {
93949406
pivot_class: pivot_class_type,
93959407
}

crates/ty_python_semantic/src/types/class.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2240,7 +2240,7 @@ impl<'db> ClassLiteral<'db> {
22402240
/// y: str = "a"
22412241
/// ```
22422242
/// we return a map `{"x": (int, None), "y": (str, Some(Literal["a"]))}`.
2243-
fn own_fields(
2243+
pub(super) fn own_fields(
22442244
self,
22452245
db: &'db dyn Db,
22462246
specialization: Option<Specialization<'db>>,

crates/ty_python_semantic/src/types/class_base.rs

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::Db;
22
use crate::types::generics::Specialization;
3+
use crate::types::tuple::TupleType;
34
use crate::types::{
4-
ClassType, DynamicType, KnownClass, KnownInstanceType, MroError, MroIterator, SpecialFormType,
5-
Type, TypeMapping, TypeTransformer, todo_type,
5+
ClassLiteral, ClassType, DynamicType, KnownClass, KnownInstanceType, MroError, MroIterator,
6+
SpecialFormType, Type, TypeMapping, TypeTransformer, todo_type,
67
};
78

89
/// Enumeration of the possible kinds of types we allow in class bases.
@@ -68,15 +69,28 @@ impl<'db> ClassBase<'db> {
6869
/// Attempt to resolve `ty` into a `ClassBase`.
6970
///
7071
/// Return `None` if `ty` is not an acceptable type for a class base.
71-
pub(super) fn try_from_type(db: &'db dyn Db, ty: Type<'db>) -> Option<Self> {
72+
pub(super) fn try_from_type(
73+
db: &'db dyn Db,
74+
ty: Type<'db>,
75+
subclass: ClassLiteral<'db>,
76+
) -> Option<Self> {
7277
match ty {
7378
Type::Dynamic(dynamic) => Some(Self::Dynamic(dynamic)),
7479
Type::ClassLiteral(literal) => {
7580
if literal.is_known(db, KnownClass::Any) {
7681
Some(Self::Dynamic(DynamicType::Any))
7782
} else if literal.is_known(db, KnownClass::NamedTuple) {
78-
// TODO: Figure out the tuple spec for the named tuple
79-
Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db))
83+
let fields = subclass.own_fields(db, None);
84+
Self::try_from_type(
85+
db,
86+
TupleType::heterogeneous(
87+
db,
88+
fields.values().map(|field| field.declared_ty),
89+
)?
90+
.to_class_type(db)
91+
.into(),
92+
subclass,
93+
)
8094
} else {
8195
Some(Self::Class(literal.default_specialization(db)))
8296
}
@@ -85,7 +99,7 @@ impl<'db> ClassBase<'db> {
8599
Type::NominalInstance(instance)
86100
if instance.class(db).is_known(db, KnownClass::GenericAlias) =>
87101
{
88-
Self::try_from_type(db, todo_type!("GenericAlias instance"))
102+
Self::try_from_type(db, todo_type!("GenericAlias instance"), subclass)
89103
}
90104
Type::SubclassOf(subclass_of) => subclass_of
91105
.subclass_of()
@@ -95,7 +109,7 @@ impl<'db> ClassBase<'db> {
95109
let valid_element = inter
96110
.positive(db)
97111
.iter()
98-
.find_map(|elem| ClassBase::try_from_type(db, *elem))?;
112+
.find_map(|elem| ClassBase::try_from_type(db, *elem, subclass))?;
99113

100114
if ty.is_disjoint_from(db, KnownClass::Type.to_instance(db)) {
101115
None
@@ -122,7 +136,7 @@ impl<'db> ClassBase<'db> {
122136
if union
123137
.elements(db)
124138
.iter()
125-
.all(|elem| ClassBase::try_from_type(db, *elem).is_some())
139+
.all(|elem| ClassBase::try_from_type(db, *elem, subclass).is_some())
126140
{
127141
Some(ClassBase::Dynamic(*dynamic))
128142
} else {
@@ -135,7 +149,7 @@ impl<'db> ClassBase<'db> {
135149
// in which case we want to treat `Never` in a forgiving way and silence diagnostics
136150
Type::Never => Some(ClassBase::unknown()),
137151

138-
Type::TypeAlias(alias) => Self::try_from_type(db, alias.value_type(db)),
152+
Type::TypeAlias(alias) => Self::try_from_type(db, alias.value_type(db), subclass),
139153

140154
Type::PropertyInstance(_)
141155
| Type::BooleanLiteral(_)
@@ -202,42 +216,44 @@ impl<'db> ClassBase<'db> {
202216

203217
// TODO: Classes inheriting from `typing.Type` et al. also have `Generic` in their MRO
204218
SpecialFormType::Dict => {
205-
Self::try_from_type(db, KnownClass::Dict.to_class_literal(db))
219+
Self::try_from_type(db, KnownClass::Dict.to_class_literal(db), subclass)
206220
}
207221
SpecialFormType::List => {
208-
Self::try_from_type(db, KnownClass::List.to_class_literal(db))
222+
Self::try_from_type(db, KnownClass::List.to_class_literal(db), subclass)
209223
}
210224
SpecialFormType::Type => {
211-
Self::try_from_type(db, KnownClass::Type.to_class_literal(db))
225+
Self::try_from_type(db, KnownClass::Type.to_class_literal(db), subclass)
212226
}
213227
SpecialFormType::Tuple => {
214-
Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db))
228+
Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db), subclass)
215229
}
216230
SpecialFormType::Set => {
217-
Self::try_from_type(db, KnownClass::Set.to_class_literal(db))
231+
Self::try_from_type(db, KnownClass::Set.to_class_literal(db), subclass)
218232
}
219233
SpecialFormType::FrozenSet => {
220-
Self::try_from_type(db, KnownClass::FrozenSet.to_class_literal(db))
234+
Self::try_from_type(db, KnownClass::FrozenSet.to_class_literal(db), subclass)
221235
}
222236
SpecialFormType::ChainMap => {
223-
Self::try_from_type(db, KnownClass::ChainMap.to_class_literal(db))
237+
Self::try_from_type(db, KnownClass::ChainMap.to_class_literal(db), subclass)
224238
}
225239
SpecialFormType::Counter => {
226-
Self::try_from_type(db, KnownClass::Counter.to_class_literal(db))
240+
Self::try_from_type(db, KnownClass::Counter.to_class_literal(db), subclass)
227241
}
228242
SpecialFormType::DefaultDict => {
229-
Self::try_from_type(db, KnownClass::DefaultDict.to_class_literal(db))
243+
Self::try_from_type(db, KnownClass::DefaultDict.to_class_literal(db), subclass)
230244
}
231245
SpecialFormType::Deque => {
232-
Self::try_from_type(db, KnownClass::Deque.to_class_literal(db))
246+
Self::try_from_type(db, KnownClass::Deque.to_class_literal(db), subclass)
233247
}
234248
SpecialFormType::OrderedDict => {
235-
Self::try_from_type(db, KnownClass::OrderedDict.to_class_literal(db))
249+
Self::try_from_type(db, KnownClass::OrderedDict.to_class_literal(db), subclass)
236250
}
237251
SpecialFormType::TypedDict => Some(Self::TypedDict),
238-
SpecialFormType::Callable => {
239-
Self::try_from_type(db, todo_type!("Support for Callable as a base class"))
240-
}
252+
SpecialFormType::Callable => Self::try_from_type(
253+
db,
254+
todo_type!("Support for Callable as a base class"),
255+
subclass,
256+
),
241257
},
242258
}
243259
}

crates/ty_python_semantic/src/types/mro.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ impl<'db> Mro<'db> {
151151
)
152152
) =>
153153
{
154-
ClassBase::try_from_type(db, *single_base).map_or_else(
154+
ClassBase::try_from_type(db, *single_base, class.class_literal(db).0).map_or_else(
155155
|| Err(MroErrorKind::InvalidBases(Box::from([(0, *single_base)]))),
156156
|single_base| {
157157
if single_base.has_cyclic_mro(db) {
@@ -186,7 +186,7 @@ impl<'db> Mro<'db> {
186186
&original_bases[i + 1..],
187187
);
188188
} else {
189-
match ClassBase::try_from_type(db, *base) {
189+
match ClassBase::try_from_type(db, *base, class.class_literal(db).0) {
190190
Some(valid_base) => resolved_bases.push(valid_base),
191191
None => invalid_bases.push((i, *base)),
192192
}
@@ -253,7 +253,9 @@ impl<'db> Mro<'db> {
253253
// `inconsistent-mro` diagnostic (which would be accurate -- but not nearly as
254254
// precise!).
255255
for (index, base) in original_bases.iter().enumerate() {
256-
let Some(base) = ClassBase::try_from_type(db, *base) else {
256+
let Some(base) =
257+
ClassBase::try_from_type(db, *base, class.class_literal(db).0)
258+
else {
257259
continue;
258260
};
259261
base_to_indices.entry(base).or_default().push(index);

0 commit comments

Comments
 (0)