Skip to content

Commit 48dcdd0

Browse files
authored
[ty] Restrict range implications from gradual equivalence (astral-sh#29577)
Fix false `invalid-argument-type` errors when inferring constrained type variables from callbacks that return an invariant generic with a gradual type argument. A callback returning `Box[Any]` can satisfy `Callable[[], T | Box[T]]` when `T` is constrained to `str` or `bytes`. The solver treated equivalence to a gradual type as exact substitution, allowing a valid union alternative to imply an incompatible bound. Require a static equivalence bound before deriving either lower- or upper-bound implications, including when gradual types are nested inside a bound. Prerequisite to avoid regressions in astral-sh#29236.
1 parent 734d75a commit 48dcdd0

5 files changed

Lines changed: 146 additions & 10 deletions

File tree

‎crates/ty_python_semantic/resources/mdtest/generics/legacy/functions.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2102,6 +2102,44 @@ reveal_type(infer_any_bound(list[str]())) # revealed: str
21022102
reveal_type(infer_list_bound(list[list[str]]())) # revealed: list[str]
21032103
```
21042104

2105+
## Gradual specializations in callable union returns
2106+
2107+
A callable's return type only needs to match one member of an expected union, even when the match
2108+
relies on a gradual type argument. If several declared constraints remain possible, inference
2109+
preserves the gradual type argument.
2110+
2111+
```py
2112+
from typing import Any, Callable, Generic, TypeVar
2113+
2114+
T = TypeVar("T", str, bytes)
2115+
2116+
class Box(Generic[T]):
2117+
value: T
2118+
2119+
def infer(callback: Callable[[], T | Box[T]]) -> T:
2120+
raise NotImplementedError
2121+
2122+
unknown = Box()
2123+
any_ = Box[Any]()
2124+
2125+
unknown_result = infer(lambda: unknown) # no diagnostic
2126+
reveal_type(unknown_result) # revealed: Unknown
2127+
any_result = infer(lambda: any_) # no diagnostic
2128+
reveal_type(any_result) # revealed: Any
2129+
```
2130+
2131+
Other arguments must still satisfy the type variable's declared constraints. Gradual evidence from a
2132+
callback does not make an incompatible argument valid:
2133+
2134+
```py
2135+
def consume(callback: Callable[[], Box[T]], other: T) -> None: ...
2136+
2137+
# The callback returns a box, as required by `Box[T]`. The second argument must instead
2138+
# be `str` or `bytes`, but is also a box: only its type argument is gradual.
2139+
consume(lambda: unknown, unknown) # error: [invalid-argument-type] "does not satisfy constraints"
2140+
consume(lambda: any_, any_) # error: [invalid-argument-type] "does not satisfy constraints"
2141+
```
2142+
21052143
## Invalid bounds in generic union members
21062144

21072145
An argument that violates a type variable's bound is rejected even when another union member is not

‎crates/ty_python_semantic/resources/mdtest/generics/pep695/functions.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3764,6 +3764,42 @@ reveal_type(infer_any_bound(list[str]())) # revealed: str
37643764
reveal_type(infer_list_bound(list[list[str]]())) # revealed: list[str]
37653765
```
37663766

3767+
## Gradual specializations in callable union returns
3768+
3769+
A callable's return type only needs to match one member of an expected union, even when the match
3770+
relies on a gradual type argument. If several declared constraints remain possible, inference
3771+
preserves the gradual type argument.
3772+
3773+
```py
3774+
from typing import Any, Callable
3775+
3776+
class Box[T: (str, bytes)]:
3777+
value: T
3778+
3779+
def infer[T: (str, bytes)](callback: Callable[[], T | Box[T]]) -> T:
3780+
raise NotImplementedError
3781+
3782+
unknown = Box()
3783+
any_ = Box[Any]()
3784+
3785+
unknown_result = infer(lambda: unknown) # no diagnostic
3786+
reveal_type(unknown_result) # revealed: Unknown
3787+
any_result = infer(lambda: any_) # no diagnostic
3788+
reveal_type(any_result) # revealed: Any
3789+
```
3790+
3791+
Other arguments must still satisfy the type variable's declared constraints. Gradual evidence from a
3792+
callback does not make an incompatible argument valid:
3793+
3794+
```py
3795+
def consume[T: (str, bytes)](callback: Callable[[], Box[T]], other: T) -> None: ...
3796+
3797+
# The callback returns a box, as required by `Box[T]`. The second argument must instead
3798+
# be `str` or `bytes`, but is also a box: only its type argument is gradual.
3799+
consume(lambda: unknown, unknown) # error: [invalid-argument-type] "does not satisfy constraints"
3800+
consume(lambda: any_, any_) # error: [invalid-argument-type] "does not satisfy constraints"
3801+
```
3802+
37673803
## Invalid bounds in generic union members
37683804

37693805
An argument that violates a type variable's bound is rejected even when another union member is not

‎crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,8 @@ def lower_bound[T, U]():
747747
### Substituting gradual equivalences into nested bounds
748748

749749
Substituting a gradual equivalence into a lower bound preserves assignability, but does not
750-
establish subtyping.
750+
establish subtyping. This also holds for equivalence bounds: separate occurrences of `Any` in a
751+
subtype comparison can materialize to different types.
751752

752753
```py
753754
from typing import Any
@@ -765,8 +766,9 @@ def equivalence_into_lower[T, U]():
765766

766767
def equivalence_into_equality[T, U]():
767768
constraints = ConstraintSet.equality(T, Any) & ConstraintSet.equality(U, Covariant[T])
768-
static_assert(constraints.implies_subtype_of(Covariant[Any], U))
769-
static_assert(constraints.implies_subtype_of(U, Covariant[Any]))
769+
static_assert(constraints.satisfies(ConstraintSet.equality(U, Covariant[Any])))
770+
static_assert(not constraints.implies_subtype_of(Covariant[Any], U))
771+
static_assert(not constraints.implies_subtype_of(U, Covariant[Any]))
770772
```
771773

772774
### Gradual range substitutions do not preserve correlation

‎crates/ty_python_semantic/src/types/constraints.rs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5703,6 +5703,60 @@ class E: ...
57035703
Ok(())
57045704
}
57055705

5706+
#[test]
5707+
fn gradual_equivalence_preserves_alternative_bounds() {
5708+
let db = setup_db();
5709+
let db = &db;
5710+
let env = db.program_environment();
5711+
let t = create_typevar(db, "T");
5712+
let int = known_instance(db, KnownClass::Int);
5713+
let str = known_instance(db, KnownClass::Str);
5714+
let list = |ty| KnownClass::List.to_specialized_instance(db, &env, &[ty]);
5715+
5716+
for dynamic in [Type::any(), Type::unknown()] {
5717+
for (gradual, bound, witness) in
5718+
[(dynamic, int, str), (list(dynamic), list(int), list(str))]
5719+
{
5720+
for (lower, upper) in [(Some(bound), None), (None, Some(bound))] {
5721+
let builder = ConstraintSetBuilder::new();
5722+
let equivalent = ConstraintSet::constrain_typevar_equivalence_bound(
5723+
db,
5724+
&env,
5725+
&builder,
5726+
ConstraintProvenance::Evidence,
5727+
t,
5728+
gradual,
5729+
);
5730+
let range =
5731+
create_constraint_set_with_bounds(db, &env, &builder, t, lower, upper);
5732+
let witness = ConstraintSet::constrain_typevar_equivalence_bound(
5733+
db,
5734+
&env,
5735+
&builder,
5736+
ConstraintProvenance::Evidence,
5737+
t,
5738+
witness,
5739+
);
5740+
5741+
// `T = Any` permits `T = str`, even though `int ≤ T` (or `T ≤ int`)
5742+
// does not. The same holds inside an invariant container.
5743+
let alternatives =
5744+
equivalent
5745+
.or(db, &builder, || range)
5746+
.and(db, &builder, || witness);
5747+
assert!(!alternatives.is_never_satisfied(db, &env, TypeVarSet::None));
5748+
5749+
// Requiring both bounds still rules out that specialization.
5750+
let conjunction =
5751+
equivalent
5752+
.and(db, &builder, || range)
5753+
.and(db, &builder, || witness);
5754+
assert!(conjunction.is_never_satisfied(db, &env, TypeVarSet::None));
5755+
}
5756+
}
5757+
}
5758+
}
5759+
57065760
#[test]
57075761
fn trivial_satisfaction_only_recognizes_terminals() {
57085762
let db = setup_db();

‎crates/ty_python_semantic/src/types/constraints/sequents.rs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,9 +1547,12 @@ impl<'db> ConcreteLowerBound<'db> {
15471547
}
15481548

15491549
// (α ≤ β) ⇒ ((T = β) ⇒ (α ≤ T))
1550-
if self
1551-
.bound
1552-
.is_constraint_set_assignable_to(db, env, other.bound)
1550+
// This requires a static equivalence bound: `T = Any` permits `T = str`, so
1551+
// assignability of `int` to `Any` does not imply `int ≤ T`.
1552+
if other.bound.is_static_sequent_eligible(db, env)
1553+
&& self
1554+
.bound
1555+
.is_constraint_set_assignable_to(db, env, other.bound)
15531556
{
15541557
map.add_single_implication(other.into(), self.into());
15551558
}
@@ -1728,14 +1731,17 @@ impl<'db> ConcreteUpperBound<'db> {
17281731
}
17291732

17301733
// (β ≤ α) ⇒ ((T = β) ⇒ (T ≤ α))
1731-
if other
1732-
.bound
1733-
.is_constraint_set_assignable_to(db, env, self.bound)
1734+
// This requires a static equivalence bound: `T = Any` permits `T = str`, so
1735+
// assignability of `Any` to `int` does not imply `T ≤ int`.
1736+
if other.bound.is_static_sequent_eligible(db, env)
1737+
&& other
1738+
.bound
1739+
.is_constraint_set_assignable_to(db, env, self.bound)
17341740
{
17351741
map.add_single_implication(other.into(), self.into());
17361742
}
17371743

1738-
// Given constraints `T ≤ α` and `T = β`, `α ≤ β` must also hold. If those bounds contain
1744+
// Given constraints `T ≤ α` and `T = β`, `β ≤ α` must also hold. If those bounds contain
17391745
// other typevars, we can infer additional constraints.
17401746
AtomicConstraint::add_sequents_for_range(db, env, map, other, self);
17411747
}

0 commit comments

Comments
 (0)