mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Promote tuple lengths based on the outer element types (#29147)
## Summary
We normally infer `list[tuple[int, ...]]` for `[(1,), (1, 2)]`, allowing
tuples of other lengths to be added later. This tuple-size promotion
applies to tuple literals; tuple types supplied by annotations or other
expressions retain their existing lengths.
The check that preserves those lengths currently searches for tuples
anywhere inside an element's type. This incorrectly blocks promotion
when the tuple appears inside a generic type argument, a callable's
return type, or a protocol member:
```python
class Box[T]: ...
def example(box: Box[tuple[int, str]]) -> None:
values = [(1,), (1, 2), box]
reveal_type(values)
# Before: list[tuple[int] | tuple[int, int] | Box[tuple[int, str]]]
# After: list[Box[tuple[int, str]] | tuple[int, ...]]
```
The `Box` is not a tuple, so its type argument should not prevent us
from widening the two tuple literals. We replace this use of
`any_over_type` with a check for whether the element itself may be a
tuple. The check still follows aliases, unions, type-variable bounds,
and `NewType` bases that can describe a tuple value, while skipping
nested type arguments and members.
Co-authored-by: Ibraheem Ahmed <ibraheem@ibraheem.ca>
This commit is contained in:
1 parent
345535cd95
commit
61888b1cc6
5 files changed
+295
-12
No files matched your search
@@ -306,6 +306,162 @@ segments: Mapping[str, Any] = {"start": (1, 2), "end": (3, 4, 5)}
|
||||
reveal_type(segments) # revealed: dict[str, tuple[int, ...]]
|
||||
```
|
||||
|
||||
## Non-tuple elements do not block tuple-size promotion
|
||||
|
||||
A non-literal tuple blocks tuple-size promotion because its shape must be preserved. A tuple nested
|
||||
inside a non-tuple element's type arguments does not:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
class Box[T]: ...
|
||||
|
||||
def _(box: Box[tuple[int, str]]) -> None:
|
||||
# revealed: list[Box[tuple[int, str]] | tuple[int, ...]]
|
||||
reveal_type([(1,), (1, 2), box])
|
||||
```
|
||||
|
||||
A tuple in a callable's return type does not prevent promotion either:
|
||||
|
||||
```py
|
||||
from collections.abc import Callable
|
||||
|
||||
def _(callback: Callable[[], tuple[int, str]]) -> None:
|
||||
# revealed: list[(() -> tuple[int, str]) | tuple[int, ...]]
|
||||
reveal_type([(1,), (1, 2), callback])
|
||||
```
|
||||
|
||||
Protocol members can also contain tuples without blocking promotion:
|
||||
|
||||
```py
|
||||
from typing import Protocol
|
||||
|
||||
class Proto(Protocol):
|
||||
pair: tuple[int, str]
|
||||
|
||||
def _(protocol: Proto) -> None:
|
||||
reveal_type([(1,), (1, 2), protocol]) # revealed: list[Proto | tuple[int, ...]]
|
||||
```
|
||||
|
||||
The same applies to `TypedDict` fields:
|
||||
|
||||
```py
|
||||
from typing import TypedDict
|
||||
|
||||
class Payload(TypedDict):
|
||||
pair: tuple[int, str]
|
||||
|
||||
def _(payload: Payload) -> None:
|
||||
reveal_type([(1,), (1, 2), payload]) # revealed: list[Payload | tuple[int, ...]]
|
||||
```
|
||||
|
||||
## Recursive members do not block tuple-size promotion
|
||||
|
||||
Tuple-valued and recursive protocol members do not prevent neighboring tuple literals from being
|
||||
promoted:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
class Recursive[T](Protocol):
|
||||
next: Recursive[list[T]]
|
||||
pair: tuple[int, str]
|
||||
|
||||
def _(x: Recursive[int]) -> None:
|
||||
reveal_type([(1,), (1, 2), x]) # revealed: list[Recursive[int] | tuple[int, ...]]
|
||||
```
|
||||
|
||||
The same applies to recursive `TypedDict` fields:
|
||||
|
||||
```py
|
||||
from typing import TypedDict
|
||||
|
||||
class Payload[T](TypedDict):
|
||||
child: Payload[list[T]]
|
||||
pair: tuple[int, str]
|
||||
|
||||
def _(x: Payload[int]) -> None:
|
||||
reveal_type([(1,), (1, 2), x]) # revealed: list[Payload[int] | tuple[int, ...]]
|
||||
```
|
||||
|
||||
## Type parameter declarations do not block tuple-size promotion
|
||||
|
||||
A class parameter's bound or default does not describe the shape of instances of that class. A tuple
|
||||
in either declaration does not prevent promotion of neighboring tuple literals.
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.13"
|
||||
```
|
||||
|
||||
```py
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T", bound=int | tuple[int, str])
|
||||
|
||||
class Bounded(Generic[T]): ...
|
||||
|
||||
def _(x: Bounded[int]) -> None:
|
||||
# revealed: list[Bounded[int] | tuple[int, ...]]
|
||||
reveal_type([(1,), (1, 2), x])
|
||||
```
|
||||
|
||||
The supplied type argument also takes precedence over a default:
|
||||
|
||||
```py
|
||||
U = TypeVar("U", default=tuple[int, str])
|
||||
|
||||
class Defaulted(Generic[U]): ...
|
||||
|
||||
def _(x: Defaulted[int]) -> None:
|
||||
# revealed: list[Defaulted[int] | tuple[int, ...]]
|
||||
reveal_type([(1,), (1, 2), x])
|
||||
```
|
||||
|
||||
A default also does not fix the type of a value annotated with the type variable:
|
||||
|
||||
```py
|
||||
def _(x: U) -> None:
|
||||
reveal_type([(1,), (1, 2), x]) # revealed: list[U@_ | tuple[int, ...]]
|
||||
```
|
||||
|
||||
## Tuple-size promotion uses alias values
|
||||
|
||||
Tuple-size promotion inspects alias values. `Alias[Alias[str]]` resolves to `str`, so it does not
|
||||
block promotion:
|
||||
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.12"
|
||||
```
|
||||
|
||||
```py
|
||||
type Alias[T] = T
|
||||
|
||||
def _(x: Alias[Alias[str]]) -> None:
|
||||
reveal_type([(1,), (1, 2), x]) # revealed: list[str | tuple[int, ...]]
|
||||
```
|
||||
|
||||
Even a recursively specialized alias permits promotion when its value is a `list`:
|
||||
|
||||
```py
|
||||
type Recursive[T] = list[Recursive[list[T]]]
|
||||
|
||||
def _(x: Recursive[int]) -> None:
|
||||
# revealed: list[list[Recursive[list[int]]] | tuple[int, ...]]
|
||||
reveal_type([(1,), (1, 2), x])
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -538,6 +538,23 @@ def expanded_tuples(values: list[str]):
|
||||
reveal_type(last) # revealed: Literal[False]
|
||||
```
|
||||
|
||||
A protocol's tuple-valued member does not prevent promotion of tuple literals collected alongside
|
||||
the protocol instance.
|
||||
|
||||
```py
|
||||
from typing import Protocol
|
||||
|
||||
class P(Protocol):
|
||||
pair: tuple[int, int]
|
||||
|
||||
def _(value: P) -> None:
|
||||
first, *rest = [0, (1,), (2, 3), value]
|
||||
reveal_type(rest) # revealed: list[P | tuple[int, ...]]
|
||||
|
||||
first, *rest = (0, (1,), (2, 3), value)
|
||||
reveal_type(rest) # revealed: list[P | tuple[int, ...]]
|
||||
```
|
||||
|
||||
### Starred expressions on the right-hand side
|
||||
|
||||
A starred element can contribute an unknown number of values. The literal's AST length does not
|
||||
|
||||
@@ -274,6 +274,18 @@ pub(crate) fn walk_newtype_instance_type<'db, V: visitor::TypeVisitor<'db> + ?Si
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate and visit the instance type of a newtype's base.
|
||||
///
|
||||
/// The caller must guard against recursive newtypes.
|
||||
pub(super) fn walk_newtype_base<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
|
||||
db: &'db dyn Db,
|
||||
newtype: NewType<'db>,
|
||||
visitor: &V,
|
||||
) {
|
||||
let base = newtype.base(db);
|
||||
visitor.visit_type(db, base.instance_type(db, visitor.program_environment()));
|
||||
}
|
||||
|
||||
/// `typing.NewType` typically wraps a class type, but it can also wrap another newtype.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)]
|
||||
pub enum NewTypeBase<'db> {
|
||||
|
||||
@@ -1,13 +1,117 @@
|
||||
use crate::Db;
|
||||
use crate::ProgramEnvironment;
|
||||
use rustc_hash::FxHashSet;
|
||||
use std::cell::Cell;
|
||||
|
||||
use ruff_python_ast::{self as ast};
|
||||
|
||||
use crate::types::cyclic::{ActiveRecursionDetector, TypeIdentity};
|
||||
use crate::types::newtype::{NewType, walk_newtype_base};
|
||||
use crate::types::tuple::TupleSpec;
|
||||
use crate::types::typevar::BoundTypeVarIdentity;
|
||||
use crate::types::visitor::{any_over_type, contains_growing_type};
|
||||
use crate::types::{Type, UnionBuilder};
|
||||
use crate::types::typevar::{BoundTypeVarIdentity, TypeVarInstance, walk_type_var_bounds};
|
||||
use crate::types::visitor::{TypeCollector, TypeVisitor, walk_type_with_recursion_guard};
|
||||
use crate::types::{
|
||||
IntersectionType, KnownInstanceType, RecursiveType, Type, TypeAliasType, UnionBuilder,
|
||||
};
|
||||
|
||||
/// Return whether the outer type may be a tuple, without searching nested arguments or members.
|
||||
/// A recursion cutoff conservatively counts as a possible tuple.
|
||||
fn may_be_tuple<'db>(db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>) -> bool {
|
||||
struct TupleShapeVisitor<'a, 'db> {
|
||||
env: &'a ProgramEnvironment<'db>,
|
||||
recursion_guard: TypeCollector<'db>,
|
||||
active_types: ActiveRecursionDetector<TypeIdentity<'db>>,
|
||||
may_be_tuple: Cell<bool>,
|
||||
}
|
||||
|
||||
impl<'db> TupleShapeVisitor<'_, 'db> {
|
||||
fn visit_guarded(&self, db: &'db dyn Db, ty: Type<'db>, visit: impl FnOnce()) {
|
||||
self.active_types.visit(
|
||||
&ty.to_type_identity(db),
|
||||
|| self.may_be_tuple.set(true),
|
||||
visit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'db> TypeVisitor<'db> for TupleShapeVisitor<'_, 'db> {
|
||||
fn program_environment(&self) -> &ProgramEnvironment<'db> {
|
||||
self.env
|
||||
}
|
||||
|
||||
fn should_visit_lazy_type_attributes(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn visit_type(&self, db: &'db dyn Db, ty: Type<'db>) {
|
||||
if self.may_be_tuple.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ty.tuple_instance_spec(db, self.env).is_some() {
|
||||
self.may_be_tuple.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
ty,
|
||||
Type::Union(_)
|
||||
| Type::Intersection(_)
|
||||
| Type::TypeAlias(_)
|
||||
| Type::Recursive(_)
|
||||
| Type::NewTypeInstance(_)
|
||||
| Type::TypeVar(_)
|
||||
) {
|
||||
walk_type_with_recursion_guard(db, ty, self, &self.recursion_guard);
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_intersection_type(&self, db: &'db dyn Db, intersection: IntersectionType<'db>) {
|
||||
for element in intersection.iter_positive(db) {
|
||||
self.visit_type(db, element);
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_type_alias_type(&self, db: &'db dyn Db, alias: TypeAliasType<'db>) {
|
||||
self.visit_guarded(db, Type::TypeAlias(alias), || {
|
||||
self.visit_type(db, alias.value_type(db));
|
||||
});
|
||||
}
|
||||
|
||||
fn visit_recursive_type(&self, db: &'db dyn Db, recursive: RecursiveType<'db>) {
|
||||
self.visit_guarded(db, Type::Recursive(recursive), || {
|
||||
self.visit_type(db, recursive.unfold(db, self.env).into_type());
|
||||
});
|
||||
}
|
||||
|
||||
fn visit_newtype_instance_type(&self, db: &'db dyn Db, newtype: NewType<'db>) {
|
||||
self.visit_guarded(db, Type::NewTypeInstance(newtype), || {
|
||||
walk_newtype_base(db, newtype, self);
|
||||
});
|
||||
}
|
||||
|
||||
fn visit_type_var_type(&self, db: &'db dyn Db, typevar: TypeVarInstance<'db>) {
|
||||
self.visit_guarded(
|
||||
db,
|
||||
Type::KnownInstance(KnownInstanceType::TypeVar(typevar)),
|
||||
|| {
|
||||
if let Some(bounds) = typevar.bound_or_constraints(db, self.env) {
|
||||
walk_type_var_bounds(db, bounds, self);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let visitor = TupleShapeVisitor {
|
||||
env,
|
||||
recursion_guard: TypeCollector::default(),
|
||||
active_types: ActiveRecursionDetector::default(),
|
||||
may_be_tuple: Cell::new(false),
|
||||
};
|
||||
visitor.visit_type(db, ty);
|
||||
visitor.may_be_tuple.get()
|
||||
}
|
||||
|
||||
/// Tracks the typevars of a collection to which tuple size promotion should **not** apply.
|
||||
#[derive(Default)]
|
||||
@@ -35,8 +139,7 @@ impl<'db> TupleSizePromotionConstraints<'db> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records that a typevar is ineligible for tuple size promotion if the given type contains
|
||||
/// a tuple type.
|
||||
/// Block tuple size promotion if this type variable is assigned a possible tuple type.
|
||||
pub(crate) fn record_unpromotable_type(
|
||||
&mut self,
|
||||
db: &'db dyn Db,
|
||||
@@ -73,12 +176,7 @@ impl<'db> TupleSizePromotionConstraints<'db> {
|
||||
) -> bool {
|
||||
expression
|
||||
.is_some_and(|expression| Self::is_promotable_tuple_literal(db, env, expression, ty))
|
||||
// An unbounded unfolding cannot establish the absence of annotated tuples.
|
||||
// Preserve their lengths unless the complete finite scan permits promotion.
|
||||
|| (!contains_growing_type(db, env, ty)
|
||||
&& !any_over_type(db, env, ty, true, |ty| {
|
||||
ty.tuple_instance_spec(db, env).is_some()
|
||||
}))
|
||||
|| !may_be_tuple(db, env, ty)
|
||||
}
|
||||
|
||||
/// Returns true if the given expression is either a non-starred homogeneous tuple literal or the
|
||||
|
||||
@@ -2276,7 +2276,7 @@ pub enum TypeVarBoundOrConstraints<'db> {
|
||||
Constraints(TypeVarConstraints<'db>),
|
||||
}
|
||||
|
||||
fn walk_type_var_bounds<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
|
||||
pub(super) fn walk_type_var_bounds<'db, V: visitor::TypeVisitor<'db> + ?Sized>(
|
||||
db: &'db dyn Db,
|
||||
bounds: TypeVarBoundOrConstraints<'db>,
|
||||
visitor: &V,
|
||||
|
||||
Reference in new issue
Block a user