Skip to content

Commit 3bf4988

Browse files
authored
[ty] Avoid widening compatible generic return alternatives (astral-sh#29578)
Since astral-sh#28303, when a generic call inference has multiple valid solutions, we will intersect the return types resulting from specializing with each valid solution. This approach has various caveats discussed in the description of that PR. One of them is that sometimes a call with an invariant return type (e.g. `list[T]`) can be validly inferred as returning e.g. either `list[int]` or `list[str]`, but not both simultaneously (consider if it actually returns `[]` -- it can be viewed as either kind of list, but not both at the same time). Intersecting `list[int]` and `list[str]` would result in `Never`, which is wrong: the call can return. For this reason, we skip intersecting return types if they vary in an invariant type. But in some cases one specialized return type is a subtype of all the other ones. In this case it's safe to go ahead and prefer the narrower type, even if they vary in invariant position and thus we wouldn't otherwise intersect them. This avoids a regression in astral-sh#29236.
1 parent 6f06c4a commit 3bf4988

3 files changed

Lines changed: 192 additions & 14 deletions

File tree

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

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3793,7 +3793,8 @@ make independently valid specializations incompatible: a fresh `[]` can be typed
37933793
`Never`, which would incorrectly imply that allocating the list cannot return.
37943794

37953795
We handle this conservatively: if an invariant component differs across valid specializations, we do
3796-
not intersect their return types.
3796+
not intersect their return types. (But if one valid specialization results in a return type that is
3797+
a subtype of all others, we select that one.)
37973798

37983799
### Mutable returns from intersection arguments
37993800

@@ -4000,6 +4001,36 @@ def _(
40004001
reveal_type(invoke_all(reordered, All())) # revealed: tuple[B, list[int]] | tuple[A, list[int]] | int
40014002
```
40024003

4004+
The call to `invoke_all` below can infer three different types for `R`, one from each callback
4005+
signature. Each choice gives a possible return type for the call. Inference selects `list[B]`
4006+
because it is a subtype of both other return types, `list[A] | list[B]` and `list[B] | list[C]`:
4007+
4008+
```py
4009+
def _(
4010+
callback: Intersection[
4011+
Callable[[A], list[A] | list[B]],
4012+
Callable[[B], list[B] | list[C]],
4013+
Callable[[C], list[B]],
4014+
],
4015+
) -> None:
4016+
reveal_type(invoke_all(callback, All())) # revealed: list[B]
4017+
```
4018+
4019+
Being a subtype of just one other return type is insufficient. Here `list[A]` and `list[B]` are both
4020+
subtypes of `list[A] | list[B]`, but neither is a subtype of the other. Inference retains
4021+
`list[A] | list[B]`:
4022+
4023+
```py
4024+
def _(
4025+
callback: Intersection[
4026+
Callable[[A], list[A] | list[B]],
4027+
Callable[[B], list[A]],
4028+
Callable[[C], list[B]],
4029+
],
4030+
) -> None:
4031+
reveal_type(invoke_all(callback, All())) # revealed: list[A] | list[B]
4032+
```
4033+
40034034
For generic classes with both covariant and invariant parameters, refinement depends on which
40044035
parameters vary. `Wrapper` only produces `T`, while its writable `value` attribute makes `U`
40054036
invariant:
@@ -4029,6 +4060,55 @@ def _(
40294060
reveal_type(invoke(varying, object())) # revealed: Wrapper[A, int] | Wrapper[B, str]
40304061
```
40314062

4063+
### Returning values from mixed-variance unions
4064+
4065+
Inference finds two valid specializations for the call to `first_value` below, returning `V@forward`
4066+
and `V@forward | Invariant[V@forward]`. The first return type is a subtype of the second, so
4067+
inference selects `V@forward`:
4068+
4069+
```py
4070+
from typing import Callable, Generic, TypeVar
4071+
4072+
V = TypeVar("V")
4073+
V_co = TypeVar("V_co", covariant=True)
4074+
4075+
class Covariant(Generic[V_co]):
4076+
def get(self) -> V_co:
4077+
raise NotImplementedError
4078+
4079+
class Invariant(Generic[V]):
4080+
value: V
4081+
4082+
def first_value(mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> V:
4083+
raise NotImplementedError
4084+
4085+
def forward(mapping: V | Invariant[V] | Covariant[V]) -> V:
4086+
value = first_value(lambda: mapping)
4087+
reveal_type(value) # revealed: V@forward
4088+
return value
4089+
```
4090+
4091+
The comparison uses the complete return types after substituting the inferred type arguments. For
4092+
`make_list`, the two choices produce `list[V]` and `list[V | Invariant[V]]`. Neither is a subtype of
4093+
the other, because lists are invariant, so inference keeps the merged result below.
4094+
4095+
For `make_sink`, a callable accepting `V | Invariant[V]` is a subtype of one accepting only `V`: it
4096+
accepts every input the latter accepts. The wider parameter type therefore produces the narrower
4097+
callable type, which inference selects:
4098+
4099+
```py
4100+
def make_list(mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> list[V]:
4101+
return []
4102+
4103+
def make_sink(mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> Callable[[V], None]:
4104+
return lambda value: None
4105+
4106+
def wrapped_returns(mapping: V | Invariant[V] | Covariant[V]) -> None:
4107+
# revealed: list[V@wrapped_returns | Covariant[V@wrapped_returns] | Invariant[V@wrapped_returns]]
4108+
reveal_type(make_list(lambda: mapping))
4109+
reveal_type(make_sink(lambda: mapping)) # revealed: (Invariant[V@wrapped_returns] | V@wrapped_returns, /) -> None
4110+
```
4111+
40324112
### Inferred TypedDict return alternatives
40334113

40344114
Writable `TypedDict` fields are invariant even when the dictionaries have no type parameters. A

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1354,7 +1354,8 @@ make independently valid specializations incompatible: a fresh `[]` can be typed
13541354
`Never`, which would incorrectly imply that allocating the list cannot return.
13551355

13561356
We handle this conservatively: if an invariant component differs across valid specializations, we do
1357-
not intersect their return types.
1357+
not intersect their return types. (But if one valid specialization results in a return type that is
1358+
a subtype of all others, we select that one.)
13581359

13591360
### Mutable returns from intersection arguments
13601361

@@ -1540,6 +1541,36 @@ def _(
15401541
reveal_type(invoke_all(reordered, All())) # revealed: tuple[B, list[int]] | tuple[A, list[int]] | int
15411542
```
15421543

1544+
The call to `invoke_all` below can infer three different types for `R`, one from each callback
1545+
signature. Each choice gives a possible return type for the call. Inference selects `list[B]`
1546+
because it is a subtype of both other return types, `list[A] | list[B]` and `list[B] | list[C]`:
1547+
1548+
```py
1549+
def _(
1550+
callback: Intersection[
1551+
Callable[[A], list[A] | list[B]],
1552+
Callable[[B], list[B] | list[C]],
1553+
Callable[[C], list[B]],
1554+
],
1555+
) -> None:
1556+
reveal_type(invoke_all(callback, All())) # revealed: list[B]
1557+
```
1558+
1559+
Being a subtype of just one other return type is insufficient. Here `list[A]` and `list[B]` are both
1560+
subtypes of `list[A] | list[B]`, but neither is a subtype of the other. Inference retains
1561+
`list[A] | list[B]`:
1562+
1563+
```py
1564+
def _(
1565+
callback: Intersection[
1566+
Callable[[A], list[A] | list[B]],
1567+
Callable[[B], list[A]],
1568+
Callable[[C], list[B]],
1569+
],
1570+
) -> None:
1571+
reveal_type(invoke_all(callback, All())) # revealed: list[A] | list[B]
1572+
```
1573+
15431574
For generic classes with both covariant and invariant parameters, refinement depends on which
15441575
parameters vary. `Wrapper` only produces `T`, while its writable `value` attribute makes `U`
15451576
invariant:
@@ -1564,6 +1595,52 @@ def _(
15641595
reveal_type(invoke(varying, object())) # revealed: Wrapper[A, int] | Wrapper[B, str]
15651596
```
15661597

1598+
### Returning values from mixed-variance unions
1599+
1600+
Inference finds two valid specializations for the call to `first_value` below, returning `V@forward`
1601+
and `V@forward | Invariant[V@forward]`. The first return type is a subtype of the second, so
1602+
inference selects `V@forward`:
1603+
1604+
```py
1605+
from typing import Callable
1606+
1607+
class Covariant[V]:
1608+
def get(self) -> V:
1609+
raise NotImplementedError
1610+
1611+
class Invariant[V]:
1612+
value: V
1613+
1614+
def first_value[V](mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> V:
1615+
raise NotImplementedError
1616+
1617+
def forward[V](mapping: V | Invariant[V] | Covariant[V]) -> V:
1618+
value = first_value(lambda: mapping)
1619+
reveal_type(value) # revealed: V@forward
1620+
return value
1621+
```
1622+
1623+
The comparison uses the complete return types after substituting the inferred type arguments. For
1624+
`make_list`, the two choices produce `list[V]` and `list[V | Invariant[V]]`. Neither is a subtype of
1625+
the other, because lists are invariant, so inference keeps the merged result below.
1626+
1627+
For `make_sink`, a callable accepting `V | Invariant[V]` is a subtype of one accepting only `V`: it
1628+
accepts every input the latter accepts. The wider parameter type therefore produces the narrower
1629+
callable type, which inference selects:
1630+
1631+
```py
1632+
def make_list[V](mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> list[V]:
1633+
return []
1634+
1635+
def make_sink[V](mapping: Callable[[], V | Invariant[V] | Covariant[V]]) -> Callable[[V], None]:
1636+
return lambda value: None
1637+
1638+
def wrapped_returns[V](mapping: V | Invariant[V] | Covariant[V]) -> None:
1639+
# revealed: list[V@wrapped_returns | Covariant[V@wrapped_returns] | Invariant[V@wrapped_returns]]
1640+
reveal_type(make_list(lambda: mapping))
1641+
reveal_type(make_sink(lambda: mapping)) # revealed: (Invariant[V@wrapped_returns] | V@wrapped_returns, /) -> None
1642+
```
1643+
15671644
### Inferred TypedDict return alternatives
15681645

15691646
Writable `TypedDict` fields are invariant even when the dictionaries have no type parameters. A

‎crates/ty_python_semantic/src/types/call/bind.rs‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6384,6 +6384,39 @@ impl<'db> CallInference<'_, 'db> {
63846384
let db = self.db;
63856385
let env = self.env;
63866386

6387+
let mut returns = Vec::with_capacity(specializations.len());
6388+
let mut budget = ProjectionTypeBudget::new(SolutionBudget::default().type_terms);
6389+
for &specialization in specializations {
6390+
let return_ty = self.return_ty.apply_specialization(db, specialization);
6391+
budget.charge_type(db, return_ty).ok()?;
6392+
// Expose aliased unions to the intersection constructor's expansion budget.
6393+
returns.push(match return_ty.resolve_type_alias(db) {
6394+
Type::Union(union) => union.expand_aliases(db, env),
6395+
ty => ty,
6396+
});
6397+
}
6398+
6399+
// If one valid specialization's return type is a subtype of all the others, use it
6400+
// directly. For example, `int` can be selected over `int | list[int]`, even though the
6401+
// invariant-type check below would otherwise prevent intersecting them. Compare complete
6402+
// return types: substituting a narrower type argument can produce an incomparable
6403+
// (`list[T]`) or wider (`Callable[[T], None]`) return type.
6404+
//
6405+
// Find a candidate in one pass, then check it against every return type; two
6406+
// incomparable returns may still have a later return that is a subtype of both.
6407+
let mut candidate = *returns.first()?;
6408+
for &return_ty in returns.iter().skip(1) {
6409+
if return_ty != candidate && return_ty.is_subtype_of(db, env, candidate) {
6410+
candidate = return_ty;
6411+
}
6412+
}
6413+
if returns
6414+
.iter()
6415+
.all(|&return_ty| candidate == return_ty || candidate.is_subtype_of(db, env, return_ty))
6416+
{
6417+
return Some(candidate);
6418+
}
6419+
63876420
// A fresh invariant container can receive its specialization from context: the same
63886421
// expression `[]` is valid as either `list[A]` or `list[B]`. These typings make different,
63896422
// mutually exclusive choices for the allocated list's static element type, rather than
@@ -6423,18 +6456,6 @@ impl<'db> CallInference<'_, 'db> {
64236456
})
64246457
});
64256458

6426-
let mut returns = Vec::with_capacity(specializations.len());
6427-
let mut budget = ProjectionTypeBudget::new(SolutionBudget::default().type_terms);
6428-
for &specialization in specializations {
6429-
let return_ty = self.return_ty.apply_specialization(db, specialization);
6430-
budget.charge_type(db, return_ty).ok()?;
6431-
// Expose aliased unions to the intersection constructor's expansion budget.
6432-
returns.push(match return_ty.resolve_type_alias(db) {
6433-
Type::Union(union) => union.expand_aliases(db, env),
6434-
ty => ty,
6435-
});
6436-
}
6437-
64386459
if has_invariant_difference {
64396460
let merged = inference.merged_specialization(db);
64406461
// Preserve the existing merged return when that specialization accepts the call.

0 commit comments

Comments
 (0)