mirror of
https://github.com/astral-sh/ruff
synced 2026-01-21 05:20:49 -05:00
[ty] Fix contravariant type variable bound checking in specialization inference (#22488)
## Summary Correctly handle upper bounds for contravariant type variables during specialization inference. Previously, the type checker incorrectly applied covariant subtyping rules, requiring the actual type to directly satisfy the bound rather than checking for a valid intersection. In contravariant positions, subtyping relationships are inverted. The bug caused valid code like `f(x: Contra[str])` where `f` expects `Contra[T: int]` to be incorrectly rejected, when it should solve `T` to `Never` (the intersection of `int` and `str`). Closes https://github.com/astral-sh/ty/issues/2427 ## Details - Added `is_contravariant()` helper to `TypeVarVariance` in `variance.rs` - Updated `SpecializationBuilder::infer_map_impl` in `generics.rs` to treat bounds and constraints differently based on variance: * Skip immediate `ty <: bound` check for contravariant upper bounds * Flip constraint check to `constraint <: ty` for contravariant positions - Added test case for bounded contravariant type variables in `variance.md` - All 308 mdtest cases pass & 150 ty_python_semantic unit tests pass --------- Co-authored-by: Douglas Creager <dcreager@dcreager.net>
This commit is contained in:
@@ -171,6 +171,27 @@ static_assert(not is_equivalent_to(D[Any], C[Any]))
|
||||
static_assert(not is_equivalent_to(D[Any], C[Unknown]))
|
||||
```
|
||||
|
||||
## Bounded typevars in contravariant positions
|
||||
|
||||
When a bounded typevar appears in a contravariant position, the actual type doesn't need to satisfy
|
||||
the bound directly. The typevar can be solved to the intersection of the actual type and the bound
|
||||
(e.g., `Never` when disjoint).
|
||||
|
||||
```py
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T", contravariant=True)
|
||||
T_int = TypeVar("T_int", bound=int)
|
||||
|
||||
class Contra(Generic[T]): ...
|
||||
|
||||
def f(x: Contra[T_int]) -> T_int:
|
||||
raise NotImplementedError
|
||||
|
||||
def _(x: Contra[str]):
|
||||
reveal_type(f(x)) # revealed: Never
|
||||
```
|
||||
|
||||
## Invariance
|
||||
|
||||
With an invariant typevar, only equivalent specializations of the generic class are subtypes of or
|
||||
|
||||
@@ -172,6 +172,23 @@ static_assert(not is_equivalent_to(D[Any], C[Any]))
|
||||
static_assert(not is_equivalent_to(D[Any], C[Unknown]))
|
||||
```
|
||||
|
||||
## Bounded typevars in contravariant positions
|
||||
|
||||
When a bounded typevar appears in a contravariant position, the actual type doesn't need to satisfy
|
||||
the bound directly. The typevar can be solved to the intersection of the actual type and the bound
|
||||
(e.g., `Never` when disjoint).
|
||||
|
||||
```py
|
||||
class Contra[T]:
|
||||
def append(self, x: T): ...
|
||||
|
||||
def f[T: int](x: Contra[T]) -> T:
|
||||
raise NotImplementedError
|
||||
|
||||
def _(x: Contra[str]):
|
||||
reveal_type(f(x)) # revealed: Never
|
||||
```
|
||||
|
||||
## Invariance
|
||||
|
||||
With an invariant typevar, only equivalent specializations of the generic class are subtypes of or
|
||||
|
||||
@@ -1878,6 +1878,21 @@ impl<'db> SpecializationBuilder<'db> {
|
||||
{
|
||||
match bound_typevar.typevar(self.db).bound_or_constraints(self.db) {
|
||||
Some(TypeVarBoundOrConstraints::UpperBound(bound)) => {
|
||||
if polarity.is_contravariant() {
|
||||
// In a contravariant position, the formal type variable is a subtype of
|
||||
// the actual type (`T <: ty`). Since we also have the upper bound
|
||||
// constraint `T <: bound`, we just need to ensure that the intersection
|
||||
// of `ty` and `bound` is non-empty. Since `Never` is always a valid
|
||||
// intersection if the types are disjoint, we don't need to perform any
|
||||
// check here.
|
||||
self.add_type_mapping(
|
||||
bound_typevar,
|
||||
IntersectionType::from_elements(self.db, [bound, ty]),
|
||||
polarity,
|
||||
f,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !ty
|
||||
.when_assignable_to(self.db, bound, self.inferable)
|
||||
.is_always_satisfied(self.db)
|
||||
@@ -1899,10 +1914,16 @@ impl<'db> SpecializationBuilder<'db> {
|
||||
}
|
||||
|
||||
for constraint in constraints.elements(self.db) {
|
||||
if ty
|
||||
.when_assignable_to(self.db, *constraint, self.inferable)
|
||||
.is_always_satisfied(self.db)
|
||||
{
|
||||
let is_satisfied = if polarity.is_contravariant() {
|
||||
constraint
|
||||
.when_assignable_to(self.db, ty, self.inferable)
|
||||
.is_always_satisfied(self.db)
|
||||
} else {
|
||||
ty.when_assignable_to(self.db, *constraint, self.inferable)
|
||||
.is_always_satisfied(self.db)
|
||||
};
|
||||
|
||||
if is_satisfied {
|
||||
self.add_type_mapping(bound_typevar, *constraint, polarity, f);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ impl TypeVarVariance {
|
||||
TypeVarVariance::Covariant | TypeVarVariance::Bivariant
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const fn is_contravariant(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeVarVariance::Contravariant | TypeVarVariance::Bivariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::FromIterator<Self> for TypeVarVariance {
|
||||
|
||||
Reference in New Issue
Block a user