Skip to content

Commit 23b7f84

Browse files
committed
Promote unions of homogenously-typed tuples of differing lengths to a single, variable size tuple
1 parent 810cab3 commit 23b7f84

6 files changed

Lines changed: 387 additions & 9 deletions

File tree

crates/ty_python_semantic/resources/mdtest/literal/collections/dictionary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ reveal_type(x) # revealed: dict[int, (_: int) -> int]
6565
## Mixed dict
6666

6767
```py
68-
# revealed: dict[str, int | tuple[int, int] | tuple[int, int, int]]
68+
# revealed: dict[str, int | tuple[int, ...]]
6969
reveal_type({"a": 1, "b": (1, 2), "c": (1, 2, 3)})
7070
```
7171

crates/ty_python_semantic/resources/mdtest/literal/collections/list.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ reveal_type(x[0].__name__) # revealed: str
3434
## Mixed list
3535

3636
```py
37-
# revealed: list[int | tuple[int, int] | tuple[int, int, int]]
37+
# revealed: list[int | tuple[int, ...]]
3838
reveal_type([1, (1, 2), (1, 2, 3)])
3939
```
4040

crates/ty_python_semantic/resources/mdtest/literal/collections/set.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ reveal_type(x) # revealed: set[(_: int) -> int]
2828
## Mixed set
2929

3030
```py
31-
# revealed: set[int | tuple[int, int] | tuple[int, int, int]]
31+
# revealed: set[int | tuple[int, ...]]
3232
reveal_type({1, (1, 2), (1, 2, 3)})
3333
```
3434

crates/ty_python_semantic/resources/mdtest/promotion.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,96 @@ reveal_type((1, 2, 3)) # revealed: tuple[Literal[1], Literal[2], Literal[3]]
9090
reveal_type(frozenset((1, 2, 3))) # revealed: frozenset[Literal[1, 2, 3]]
9191
```
9292

93+
## Unions of differently-sized but homogenously-typed tuples are promoted to a single, variably-sized tuple when they appear in an invariant position
94+
95+
When a union of homogeneously-typed tuples of different lengths appears in a promotable position,
96+
the union is replaced with a single tuple of variable length after promotion:
97+
98+
```py
99+
from typing import Sequence
100+
101+
class Invariant[T]:
102+
x: T
103+
104+
def __init__(self, value: T): ...
105+
106+
def promote[T](x: T) -> list[T]:
107+
return [x]
108+
109+
def make_invariant[T](x: T) -> Invariant[T]:
110+
return Invariant(x)
111+
112+
reveal_type([(1, 2), (3, 4, 5)]) # revealed: list[tuple[int, ...]]
113+
reveal_type({".py": (".py", ".pyi"), ".js": (".js", ".jsx", ".ts", ".tsx")}) # revealed: dict[str, tuple[str, ...]]
114+
reveal_type({(1, 2), (3, 4, 5)}) # revealed: set[tuple[int, ...]]
115+
116+
languages = {
117+
"python": (".py", ".pyi"),
118+
"javascript": (".js", ".jsx", ".ts", ".tsx"),
119+
}
120+
reveal_type(languages) # revealed: dict[str, tuple[str, ...]]
121+
languages["ruby"] = (".rb",)
122+
123+
covariant_segments: Sequence[tuple[int, ...]] = [(1, 2), (3, 4, 5)]
124+
reveal_type(covariant_segments) # revealed: list[tuple[int, ...]]
125+
```
126+
127+
Tuple-size promotion only applies when a promotable position sees differently-sized homogeneous
128+
tuples. Plain tuple literals stay fixed and literal-precise, same-sized homogeneous tuples stay
129+
fixed, heterogeneous tuples stay fixed, and empty tuples are not promoted on their own:
130+
131+
```py
132+
reveal_type((1, 2)) # revealed: tuple[Literal[1], Literal[2]]
133+
reveal_type(promote((1, 2))) # revealed: list[tuple[int, int]]
134+
reveal_type(make_invariant((1, 2))) # revealed: Invariant[tuple[int, int]]
135+
reveal_type([(1, "a"), (2, "b")]) # revealed: list[tuple[int, str]]
136+
reveal_type([(1, 2), ("a", "b", "c")]) # revealed: list[tuple[int, int] | tuple[str, str, str]]
137+
reveal_type([()]) # revealed: list[tuple[()]]
138+
139+
places_of_interest = {
140+
"home": (0, 0),
141+
"palm-tree": (10, 8),
142+
}
143+
reveal_type(places_of_interest) # revealed: dict[str, tuple[int, int]]
144+
places_of_interest["treasure"] = (5, 6, -10) # error: [invalid-assignment]
145+
```
146+
147+
Tuple-size promotion is based on tuple literal elements in the collection. Explicit finite tuple
148+
unions are not widened just because they are inferred into a collection:
149+
150+
```py
151+
def get_padding() -> int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]:
152+
return (0, 1)
153+
154+
def get_segment() -> tuple[int] | tuple[int, int, int, int] | tuple[int, int, int, int, int]:
155+
return (0,)
156+
157+
reveal_type([get_padding()]) # revealed: list[int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]]
158+
reveal_type([get_segment()]) # revealed: list[tuple[int] | tuple[int, int, int, int] | tuple[int, int, int, int, int]]
159+
160+
mixed_segments = [get_segment(), (1, 2), (3, 4, 5)]
161+
mixed_segments.append((6, 7, 8, 9, 10))
162+
mixed_segments.append((6, 7, 8, 9, 10, 11)) # error: [invalid-argument-type]
163+
164+
def get_overlapping_segment() -> tuple[int, int] | tuple[int, int, int]:
165+
return (0, 1)
166+
167+
overlapping_segments = [get_overlapping_segment(), (1, 2), (3, 4, 5)]
168+
reveal_type(overlapping_segments) # revealed: list[tuple[int, int] | tuple[int, int, int]]
169+
overlapping_segments.append((6, 7, 8, 9)) # error: [invalid-argument-type]
170+
171+
def accepts_padding(padding: int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]) -> None: ...
172+
173+
class UsesPadding:
174+
def __init__(self, padding: int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]):
175+
self.padding = padding
176+
177+
def check(self) -> None:
178+
accepts_padding(self.padding)
179+
180+
UsesPadding(get_padding()).check()
181+
```
182+
93183
## Invariant and contravariant return types are promoted
94184

95185
We promote in non-covariant position in the return type of a generic function, or constructor of a
@@ -254,6 +344,9 @@ reveal_type(x13) # revealed: list[tuple[Literal[1], Literal[2], Literal[3]]]
254344
x14: list[tuple[int, str, int]] = [(1, "2", 3), (4, "5", 6)]
255345
reveal_type(x14) # revealed: list[tuple[int, str, int]]
256346

347+
x14a: list[tuple[int, int]] = [(1, 2), (3, 4)]
348+
reveal_type(x14a) # revealed: list[tuple[int, int]]
349+
257350
x15: list[tuple[Literal[1], ...]] = [(1, 1, 1)]
258351
reveal_type(x15) # revealed: list[tuple[Literal[1], ...]]
259352

@@ -543,6 +636,7 @@ any errors from clearly incorrect code like this:
543636
`module1.py`:
544637

545638
```py
639+
546640
```
547641

548642
`main.py`:

crates/ty_python_semantic/src/types.rs

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use compact_str::ToCompactString;
22
use itertools::Itertools;
33
use ruff_diagnostics::{Edit, Fix};
4-
use rustc_hash::FxHashMap;
4+
use rustc_hash::{FxHashMap, FxHashSet};
55

66
use std::borrow::Cow;
77
use std::cell::OnceCell;
@@ -862,6 +862,84 @@ fn recursive_type_normalize_type_guard_like<'db, T: TypeGuardLike<'db>>(
862862
};
863863
Some(guard.with_type(db, ty))
864864
}
865+
// Represents the members of a union that are all homogeneously-typed, fixed-length tuples and that
866+
// share the same element type.
867+
//
868+
// EXAMPLE:
869+
//
870+
// Given a union like `str | tuple[int] | tuple[int, int] | tuple[str, str]`, we would build the
871+
// following instances of this struct:
872+
//
873+
// HomogenousTupleGroup{
874+
// element_type: int,
875+
// original_tuple_types: [tuple[int], tuple[int, int]]
876+
// }
877+
//
878+
// HomogenousTupleGroup{
879+
// element_type: str,
880+
// original_tuple_types: [tuple[str, str]]
881+
// }
882+
struct HomogeneousTupleGroup<'db> {
883+
element_type: Type<'db>,
884+
original_tuple_types: Vec<Type<'db>>,
885+
}
886+
887+
impl<'db> HomogeneousTupleGroup<'db> {
888+
fn new(element_type: Type<'db>, original_tuple_type: Type<'db>) -> Self {
889+
Self {
890+
element_type,
891+
original_tuple_types: vec![original_tuple_type],
892+
}
893+
}
894+
895+
// Add a new tuple to this group.
896+
fn add(&mut self, original_tuple_type: Type<'db>) {
897+
self.original_tuple_types.push(original_tuple_type);
898+
}
899+
900+
// Does the group have tuples of different lengths?
901+
//
902+
// This determines whether or not we should promote the types in this group to a single tuple
903+
// of variable-length.
904+
fn has_multiple_lengths(&self, db: &'db dyn Db) -> bool {
905+
let mut lengths = self.original_tuple_types.iter().filter_map(|tuple_type| {
906+
tuple_type
907+
.homogeneous_fixed_length_tuple_instance(db)
908+
.map(|(_, length)| length)
909+
});
910+
911+
let Some(first_length) = lengths.next() else {
912+
return false;
913+
};
914+
915+
lengths.any(|length| length != first_length)
916+
}
917+
}
918+
919+
fn partition_homogeneous_fixed_length_tuple_union_elements<'db>(
920+
db: &'db dyn Db,
921+
elements: impl IntoIterator<Item = Type<'db>>,
922+
) -> (Vec<Type<'db>>, Vec<HomogeneousTupleGroup<'db>>) {
923+
let mut other_union_elements = Vec::new();
924+
let mut tuple_groups: Vec<HomogeneousTupleGroup<'db>> = Vec::new();
925+
926+
for element in elements {
927+
if let Some((tuple_element_type, _)) = element.homogeneous_fixed_length_tuple_instance(db) {
928+
if let Some(group) = tuple_groups
929+
.iter_mut()
930+
.find(|group| group.element_type.is_equivalent_to(db, tuple_element_type))
931+
{
932+
group.add(element);
933+
} else {
934+
tuple_groups.push(HomogeneousTupleGroup::new(tuple_element_type, element));
935+
}
936+
} else {
937+
other_union_elements.push(element);
938+
}
939+
}
940+
941+
(other_union_elements, tuple_groups)
942+
}
865943

866944
#[derive(Debug, Clone, Copy)]
867945
#[expect(clippy::struct_field_names)]
@@ -1297,6 +1375,32 @@ impl<'db> Type<'db> {
12971375
.and_then(|instance| instance.own_tuple_spec(db))
12981376
}
12991377

1378+
/// Detects whether or not this type is a homogeneously-typed tuple of fixed length
1379+
/// e.g. `tuple[str, str]` but NOT `tuple[str, int]` or `tuple[str, ...]`.
1380+
///
1381+
/// If this type is indeed a homogeneously-typed, fixed-length tuple, then returns the
1382+
/// tuple's homogeneous element type and its length.
1383+
pub(crate) fn homogeneous_fixed_length_tuple_instance(
1384+
self,
1385+
db: &'db dyn Db,
1386+
) -> Option<(Type<'db>, usize)> {
1387+
let tuple_spec = self.exact_tuple_instance_spec(db)?;
1388+
let TupleSpec::Fixed(tuple) = tuple_spec.as_ref() else {
1389+
return None;
1390+
};
1391+
1392+
let length = tuple.len();
1393+
if length == 0 {
1394+
return None;
1395+
}
1396+
1397+
let mut elements = tuple.iter_all_elements();
1398+
let element_type = elements.next()?;
1399+
elements
1400+
.all(|element| element.is_equivalent_to(db, element_type))
1401+
.then_some((element_type, length))
1402+
}
1403+
13001404
/// Returns the materialization of this type depending on the given `variance`.
13011405
///
13021406
/// More concretely, `T'`, the materialization of `T`, is the type `T` with all occurrences of
@@ -1925,6 +2029,78 @@ impl<'db> Type<'db> {
19252029
)
19262030
}
19272031

2032+
/// Promote unions of homogeneously-typed, fixed-length tuples with different lengths to a
2033+
/// single variable-length tuple when every tuple in that homogeneous group came from a tuple
2034+
/// literal in the collection literal currently being inferred.
2035+
///
2036+
/// This deliberately only applies to unions; a standalone fixed-length tuple keeps its shape,
2037+
/// and groups that include non-literal tuple members remain unchanged.
2038+
///
2039+
/// EXAMPLE:
2040+
///
2041+
/// In the code below, we promote `dict[str, tuple[str, str] | tuple[str, str, str, str]]`
2042+
/// to `dict[str, tuple[str,...]]`.
2043+
///
2044+
/// languages = {
2045+
/// "python": (".py", ".pyi"),
2046+
/// "javascript": (".js", ".jsx", ".ts", ".tsx"),
2047+
/// }
2048+
/// reveal_type(languages) # revealed: dict[str, tuple[str, ...]]
2049+
///
2050+
pub(crate) fn promote_tuple_literal_unions(
2051+
self,
2052+
db: &'db dyn Db,
2053+
tuple_literal_candidates: &FxHashSet<Type<'db>>,
2054+
non_literal_tuple_candidates: &FxHashSet<Type<'db>>,
2055+
) -> Type<'db> {
2056+
let Type::Union(union) = self else {
2057+
return self;
2058+
};
2059+
2060+
let (other_union_elements, tuple_groups) =
2061+
partition_homogeneous_fixed_length_tuple_union_elements(
2062+
db,
2063+
union.elements(db).iter().copied(),
2064+
);
2065+
2066+
if !tuple_groups.iter().any(|group| {
2067+
group.has_multiple_lengths(db)
2068+
&& group.original_tuple_types.iter().all(|tuple_type| {
2069+
tuple_literal_candidates.contains(tuple_type)
2070+
&& !non_literal_tuple_candidates.contains(tuple_type)
2071+
})
2072+
}) {
2073+
// No tuple group consisted entirely of tuple-literal members with differing lengths,
2074+
// so there is nothing to promote. Return early to avoid rebuilding the union type.
2075+
return self;
2076+
}
2077+
2078+
let mut builder = UnionBuilder::new(db)
2079+
.unpack_aliases(false)
2080+
.recursively_defined(union.recursively_defined(db));
2081+
2082+
for element in other_union_elements {
2083+
builder = builder.add(element);
2084+
}
2085+
2086+
for group in tuple_groups {
2087+
if group.has_multiple_lengths(db)
2088+
&& group.original_tuple_types.iter().all(|tuple_type| {
2089+
tuple_literal_candidates.contains(tuple_type)
2090+
&& !non_literal_tuple_candidates.contains(tuple_type)
2091+
})
2092+
{
2093+
builder = builder.add(Type::homogeneous_tuple(db, group.element_type));
2094+
} else {
2095+
for element in group.original_tuple_types {
2096+
builder = builder.add(element);
2097+
}
2098+
}
2099+
}
2100+
2101+
builder.build()
2102+
}
2103+
19282104
/// Promote a top-level singleton type (like `None`, `EllipsisType`) to `T | Unknown`.
19292105
pub(crate) fn promote_singletons(self, db: &'db dyn Db) -> Type<'db> {
19302106
self.promote_singletons_impl(db)

0 commit comments

Comments
 (0)