Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ reveal_type(x) # revealed: dict[int, (_: int) -> int]
## Mixed dict

```py
# revealed: dict[str, int | tuple[int, int] | tuple[int, int, int]]
# revealed: dict[str, int | tuple[int, ...]]
reveal_type({"a": 1, "b": (1, 2), "c": (1, 2, 3)})
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ reveal_type(x[0].__name__) # revealed: str
## Mixed list

```py
# revealed: list[int | tuple[int, int] | tuple[int, int, int]]
# revealed: list[int | tuple[int, ...]]
reveal_type([1, (1, 2), (1, 2, 3)])
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ reveal_type(x) # revealed: set[(_: int) -> int]
## Mixed set

```py
# revealed: set[int | tuple[int, int] | tuple[int, int, int]]
# revealed: set[int | tuple[int, ...]]
reveal_type({1, (1, 2), (1, 2, 3)})
```

Expand Down
78 changes: 78 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,80 @@ reveal_type((1, 2, 3)) # revealed: tuple[Literal[1], Literal[2], Literal[3]]
reveal_type(frozenset((1, 2, 3))) # revealed: frozenset[Literal[1, 2, 3]]
```

## Unions of differently-sized but homogenously-typed tuples are promoted to a single, variably-sized tuple when they appear in an invariant position

When a union of homogeneously-typed tuples of different lengths appears in a promotable position,
the union is replaced with a single tuple of variable length after promotion:

```py
class Invariant[T]:
x: T

def __init__(self, value: T): ...

def promote[T](x: T) -> list[T]:
return [x]

def make_invariant[T](x: T) -> Invariant[T]:
return Invariant(x)

reveal_type([(1, 2), (3, 4, 5)]) # revealed: list[tuple[int, ...]]
reveal_type({".py": (".py", ".pyi"), ".js": (".js", ".jsx", ".ts", ".tsx")}) # revealed: dict[str, tuple[str, ...]]
reveal_type({(1, 2), (3, 4, 5)}) # revealed: set[tuple[int, ...]]

languages = {
"python": (".py", ".pyi"),
"javascript": (".js", ".jsx", ".ts", ".tsx"),
}
reveal_type(languages) # revealed: dict[str, tuple[str, ...]]
languages["ruby"] = (".rb",)
```

Tuple-size promotion only applies when a promotable position sees differently-sized homogeneous
tuples. Plain tuple literals stay fixed and literal-precise, same-sized homogeneous tuples stay
fixed, heterogeneous tuples stay fixed, and empty tuples are not promoted on their own:

```py
reveal_type((1, 2)) # revealed: tuple[Literal[1], Literal[2]]
reveal_type(promote((1, 2))) # revealed: list[tuple[int, int]]
reveal_type(make_invariant((1, 2))) # revealed: Invariant[tuple[int, int]]
reveal_type([(1, "a"), (2, "b")]) # revealed: list[tuple[int, str]]
reveal_type([(1, 2), ("a", "b", "c")]) # revealed: list[tuple[int, int] | tuple[str, str, str]]
reveal_type([()]) # revealed: list[tuple[()]]

places_of_interest = {
"home": (0, 0),
"palm-tree": (10, 8),
}
reveal_type(places_of_interest) # revealed: dict[str, tuple[int, int]]
places_of_interest["treasure"] = (5, 6, -10) # error: [invalid-assignment]
```

Tuple-size promotion is based on tuple literal elements in the collection. Explicit finite tuple
unions are not widened just because they are inferred into a collection:

```py
def get_padding() -> int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]:
return (0, 1)

def get_segment() -> tuple[int] | tuple[int, int, int, int] | tuple[int, int, int, int, int]:
return (0,)

reveal_type([get_padding()]) # revealed: list[int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]]
reveal_type([get_segment()]) # revealed: list[tuple[int] | tuple[int, int, int, int] | tuple[int, int, int, int, int]]

def accepts_padding(padding: int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]) -> None: ...

class UsesPadding:
def __init__(self, padding: int | tuple[int] | tuple[int, int] | tuple[int, int, int, int]):
self.padding = padding

def check(self) -> None:
accepts_padding(self.padding)

UsesPadding(get_padding()).check()
```

## Invariant and contravariant return types are promoted

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

x14a: list[tuple[int, int]] = [(1, 2), (3, 4)]
reveal_type(x14a) # revealed: list[tuple[int, int]]

x15: list[tuple[Literal[1], ...]] = [(1, 1, 1)]
reveal_type(x15) # revealed: list[tuple[Literal[1], ...]]

Expand Down Expand Up @@ -543,6 +620,7 @@ any errors from clearly incorrect code like this:
`module1.py`:

```py

```

`main.py`:
Expand Down
91 changes: 91 additions & 0 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,12 @@ fn recursive_type_normalize_type_guard_like<'db, T: TypeGuardLike<'db>>(
Some(guard.with_type(db, ty))
}

struct HomogeneousTuplePromotionGroup<'db> {
element_type: Type<'db>,
lengths: Vec<usize>,
elements: Vec<Type<'db>>,
}

#[derive(Debug, Clone, Copy)]
#[expect(clippy::struct_field_names)]
struct GeneratorTypes<'db> {
Expand Down Expand Up @@ -1297,6 +1303,27 @@ impl<'db> Type<'db> {
.and_then(|instance| instance.own_tuple_spec(db))
}

pub(crate) fn homogeneous_fixed_length_tuple_instance(
self,
db: &'db dyn Db,
) -> Option<(Type<'db>, usize)> {
let tuple_spec = self.exact_tuple_instance_spec(db)?;
let TupleSpec::Fixed(tuple) = tuple_spec.as_ref() else {
return None;
};

let length = tuple.len();
if length == 0 {
return None;
}

let mut elements = tuple.iter_all_elements();
let element_type = elements.next()?;
elements
.all(|element| element.is_equivalent_to(db, element_type))
.then_some((element_type, length))
}

/// Returns the materialization of this type depending on the given `variance`.
///
/// More concretely, `T'`, the materialization of `T`, is the type `T` with all occurrences of
Expand Down Expand Up @@ -1925,6 +1952,70 @@ impl<'db> Type<'db> {
)
}

/// Promote unions of non-empty homogeneous fixed-length tuples with different lengths to a
/// single variable-length tuple.
///
/// This deliberately only applies to unions; a standalone fixed-length tuple keeps its shape.
pub(crate) fn promote_differently_sized_homogeneous_tuple_unions(
self,
db: &'db dyn Db,
) -> Type<'db> {
let Type::Union(union) = self else {
return self;
};

let mut other_elements = Vec::new();
let mut groups: Vec<HomogeneousTuplePromotionGroup<'db>> = Vec::new();

for element in union.elements(db).iter().copied() {
if let Some((element_type, length)) =
element.homogeneous_fixed_length_tuple_instance(db)
{
if let Some(group) = groups
.iter_mut()
.find(|group| group.element_type.is_equivalent_to(db, element_type))
{
group.elements.push(element);
if !group.lengths.contains(&length) {
group.lengths.push(length);
}
} else {
groups.push(HomogeneousTuplePromotionGroup {
element_type,
lengths: vec![length],
elements: vec![element],
});
}
} else {
other_elements.push(element);
}
}

if groups.iter().all(|group| group.lengths.len() == 1) {
return self;
}

let mut builder = UnionBuilder::new(db)
.unpack_aliases(false)
.recursively_defined(union.recursively_defined(db));

for element in other_elements {
builder = builder.add(element);
}

for group in groups {
if group.lengths.len() > 1 {
builder = builder.add(Type::homogeneous_tuple(db, group.element_type));
} else {
for element in group.elements {
builder = builder.add(element);
}
}
}

builder.build()
}

/// Promote a top-level singleton type (like `None`, `EllipsisType`) to `T | Unknown`.
pub(crate) fn promote_singletons(self, db: &'db dyn Db) -> Type<'db> {
self.promote_singletons_impl(db)
Expand Down
78 changes: 76 additions & 2 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ use crate::types::special_form::TypeQualifier;
use crate::types::subclass_of::SubclassOfInner;
use crate::types::tuple::{Tuple, TupleLength, TupleSpecBuilder, TupleType};
use crate::types::type_alias::{ManualPEP695TypeAliasType, PEP695TypeAliasType};
use crate::types::typevar::{BoundTypeVarIdentity, TypeVarConstraints, TypeVarIdentity};
use crate::types::typevar::{
BoundTypeVarIdentity, BoundTypeVarInstance, TypeVarConstraints, TypeVarIdentity,
};
use crate::types::{
CallDunderError, CallableBinding, CallableType, CallableTypes, ClassType, DynamicType,
InferenceFlags, InternedConstraintSet, InternedType, IntersectionBuilder, IntersectionType,
Expand Down Expand Up @@ -5808,6 +5810,74 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_collection_literal_impl(collection_class, elts, infer_elt_expression, tcx)
}

fn promote_differently_sized_homogeneous_tuple_unions<'expr, const N: usize, I>(
&self,
typevar: BoundTypeVarInstance<'db>,
lower: Type<'db>,
elts: &[[Option<&'expr ast::Expr>; N]],
elt_tys: &I,
) -> Type<'db>
where
I: Iterator<Item = BoundTypeVarInstance<'db>> + Clone,
{
let db = self.db();
let typevar_identity = typevar.identity(db);
let mut candidates: Vec<(Type<'db>, usize, bool)> = Vec::new();

for elts in elts {
for (elt, elt_ty) in elts.iter().zip((*elt_tys).clone()) {
if elt_ty.identity(db) != typevar_identity {
continue;
}

let Some(elt) = *elt else { continue };
if elt.is_starred_expr() {
continue;
}

let ast::Expr::Tuple(tuple) = elt else {
continue;
};
if tuple.iter().any(ast::Expr::is_starred_expr) {
continue;
}

let Some((element_type, length)) = self
.try_expression_type(elt)
.map(|ty| ty.promote(db))
.and_then(|ty| ty.homogeneous_fixed_length_tuple_instance(db))
else {
continue;
};

if tuple.len() != length {
continue;
}

if let Some((_, first_length, has_different_lengths)) =
candidates
.iter_mut()
.find(|(candidate_element_type, _, _)| {
(*candidate_element_type).is_equivalent_to(db, element_type)
})
{
*has_different_lengths |= *first_length != length;
} else {
candidates.push((element_type, length, false));
}
}
}

if candidates
.iter()
.any(|(_, _, has_different_lengths)| *has_different_lengths)
{
lower.promote_differently_sized_homogeneous_tuple_unions(db)
} else {
lower
}
}

// Infer the type of a collection literal expression.
fn infer_collection_literal_impl<'expr, const N: usize>(
&mut self,
Expand Down Expand Up @@ -6086,10 +6156,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let class_type = collection_alias
.origin(self.db())
.apply_specialization(self.db(), |_| {
builder.build_with(generic_context, |_, lower, _| {
builder.build_with(generic_context, |typevar, lower, _| {
// Promote singleton types to `T | Unknown` in inferred type parameters,
// so that e.g. `[None]` is inferred as `list[None | Unknown]`.
if elt_tcx_constraints.is_empty() {
let lower = self.promote_differently_sized_homogeneous_tuple_unions(
typevar, lower, elts, &elt_tys,
);

return Some(lower.promote_singletons_recursively(self.db()));
}
None
Expand Down
Loading