[ty] Filter overloads using variadic parameters (#20547)

## Summary

Closes: https://github.com/astral-sh/ty/issues/551

This PR adds support for step 4 of the overload call evaluation
algorithm which states that:

> If the argument list is compatible with two or more overloads,
determine whether one or more of the overloads has a variadic parameter
(either `*args` or `**kwargs`) that maps to a corresponding argument
that supplies an indeterminate number of positional or keyword
arguments. If so, eliminate overloads that do not have a variadic
parameter.

And, with that, the overload call evaluation algorithm has been
implemented completely end to end as stated in the typing spec.

## Test Plan

Expand the overload call test suite.
This commit is contained in:
Dhruv Manilawala 2025-09-25 20:28:00 +05:30 committed by GitHub
parent b0bdf0334e
commit 35ed55ec8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 238 additions and 16 deletions

View file

@ -159,6 +159,8 @@ def _(args: list[int]) -> None:
takes_zero(*args)
takes_one(*args)
takes_two(*args)
takes_two(*b"ab")
takes_two(*b"abc") # error: [too-many-positional-arguments]
takes_two_positional_only(*args)
takes_two_different(*args) # error: [invalid-argument-type]
takes_two_different_positional_only(*args) # error: [invalid-argument-type]

View file

@ -931,6 +931,134 @@ def _(t: tuple[int, str] | tuple[int, str, int]) -> None:
f(*t) # error: [no-matching-overload]
```
## Filtering based on variaidic arguments
This is step 4 of the overload call evaluation algorithm which specifies that:
> If the argument list is compatible with two or more overloads, determine whether one or more of
> the overloads has a variadic parameter (either `*args` or `**kwargs`) that maps to a corresponding
> argument that supplies an indeterminate number of positional or keyword arguments. If so,
> eliminate overloads that do not have a variadic parameter.
This is only performed if the previous step resulted in more than one matching overload.
### Simple `*args`
`overloaded.pyi`:
```pyi
from typing import overload
@overload
def f(x1: int) -> tuple[int]: ...
@overload
def f(x1: int, x2: int) -> tuple[int, int]: ...
@overload
def f(*args: int) -> int: ...
```
```py
from overloaded import f
def _(x1: int, x2: int, args: list[int]):
reveal_type(f(x1)) # revealed: tuple[int]
reveal_type(f(x1, x2)) # revealed: tuple[int, int]
reveal_type(f(*(x1, x2))) # revealed: tuple[int, int]
# Step 4 should filter out all but the last overload.
reveal_type(f(*args)) # revealed: int
```
### Variable `*args`
```toml
[environment]
python-version = "3.11"
```
`overloaded.pyi`:
```pyi
from typing import overload
@overload
def f(x1: int) -> tuple[int]: ...
@overload
def f(x1: int, x2: int) -> tuple[int, int]: ...
@overload
def f(x1: int, *args: int) -> tuple[int, ...]: ...
```
```py
from overloaded import f
def _(x1: int, x2: int, args1: list[int], args2: tuple[int, *tuple[int, ...]]):
reveal_type(f(x1, x2)) # revealed: tuple[int, int]
reveal_type(f(*(x1, x2))) # revealed: tuple[int, int]
# Step 4 should filter out all but the last overload.
reveal_type(f(x1, *args1)) # revealed: tuple[int, ...]
reveal_type(f(*args2)) # revealed: tuple[int, ...]
```
### Simple `**kwargs`
`overloaded.pyi`:
```pyi
from typing import overload
@overload
def f(*, x1: int) -> int: ...
@overload
def f(*, x1: int, x2: int) -> tuple[int, int]: ...
@overload
def f(**kwargs: int) -> int: ...
```
```py
from overloaded import f
def _(x1: int, x2: int, kwargs: dict[str, int]):
reveal_type(f(x1=x1)) # revealed: int
reveal_type(f(x1=x1, x2=x2)) # revealed: tuple[int, int]
# Step 4 should filter out all but the last overload.
reveal_type(f(**{"x1": x1, "x2": x2})) # revealed: int
reveal_type(f(**kwargs)) # revealed: int
```
### `TypedDict`
The keys in a `TypedDict` are static so there's no variable part to it, so step 4 shouldn't filter
out any overloads.
`overloaded.pyi`:
```pyi
from typing import TypedDict, overload
@overload
def f(*, x: int) -> int: ...
@overload
def f(*, x: int, y: int) -> tuple[int, int]: ...
@overload
def f(**kwargs: int) -> tuple[int, ...]: ...
```
```py
from typing import TypedDict
from overloaded import f
class Foo(TypedDict):
x: int
y: int
def _(foo: Foo, kwargs: dict[str, int]):
reveal_type(f(**foo)) # revealed: tuple[int, int]
reveal_type(f(**kwargs)) # revealed: tuple[int, ...]
```
## Filtering based on `Any` / `Unknown`
This is the step 5 of the overload call evaluation algorithm which specifies that: