From 2520bbc0aaf652b32adf6e5a95f2afaae2cc8450 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 6 Feb 2026 11:04:29 -0800 Subject: [PATCH 1/9] [ty] Fix union *args binding for optional positional parameters --- .../resources/mdtest/call/function.md | 18 ++++ .../ty_python_semantic/src/types/call/bind.rs | 93 +++++++++++++++++-- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index c4325a9fe2da53..ecbbc2425eaeeb 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -786,6 +786,24 @@ for tup in my_other_args: f4(*tup, e=None) ``` +Regression test for . + +```py +def f5(x: int | None = None, y: str = "") -> None: ... +def f6(flag: bool) -> None: + args = () if flag else (1,) + f5(*args) + +def f7(x: int | None = None, y: str = "") -> None: ... +def f8(flag: bool) -> None: + args = () if flag else ("bad",) + f7(*args) # error: [invalid-argument-type] + +def f11(*args: int) -> None: ... +def f12(args: tuple[int] | int) -> None: + f11(*args) # error: [not-iterable] +``` + ### Mixed argument and parameter containing variadic ```toml diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 5014349f05fb03..282b585bb51b39 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -24,6 +24,7 @@ use super::{Argument, CallArguments, CallError, CallErrorKind, InferContext, Sig use crate::db::Db; use crate::dunder_all::dunder_all_names; use crate::place::{DefinedPlace, Definedness, Place, known_module_symbol}; +use crate::subscript::PyIndex; use crate::types::call::arguments::{Expansion, is_expandable_type}; use crate::types::constraints::{ConstraintSet, ConstraintSetBuilder}; use crate::types::diagnostic::{ @@ -3328,13 +3329,91 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { // `*args: P.args` (another ParamSpec). Some(argument_type) => match argument_type.as_paramspec_typevar(db) { Some(paramspec) => VariadicArgumentType::ParamSpec(paramspec), - // TODO: `Type::iterate` internally handles unions, but in a lossy way. - // It might be superior here to manually map over the union and call `try_iterate` - // on each element, similar to the way that `unpacker.rs` does in the `unpack_inner` method. - // It might be a bit of a refactor, though. - // See - // for more details. --Alex - None => VariadicArgumentType::Other(argument_type.iterate(db)), + None => match argument_type { + // `Type::iterate` unions tuple specs in a way that can invent additional + // arities. For signatures where all remaining positional parameters are + // defaulted, map each possible tuple index from the union to the + // corresponding optional positional parameter. + Type::Union(union) + if self.parameters.variadic().is_none() + && self + .parameters + .positional() + .skip(self.next_positional) + .all(|parameter| parameter.default_type().is_some()) => + { + let tuple_specs = union + .elements(db) + .iter() + .map(|ty| { + ty.try_iterate(db).unwrap_or_else(|err| { + Cow::Owned(TupleSpec::homogeneous( + err.fallback_element_type(db), + )) + }) + }) + .collect_vec(); + + let mut tuple_index = 0usize; + + while self + .parameters + .get_positional(self.next_positional) + .is_some() + { + let Ok(tuple_index_i32) = i32::try_from(tuple_index) else { + break; + }; + + let positional_types = tuple_specs + .iter() + .filter_map(|tuple| tuple.py_index(db, tuple_index_i32).ok()) + .collect_vec(); + + if positional_types.is_empty() { + break; + } + + let argument_type = + UnionType::from_elements_leave_aliases(db, positional_types); + + self.match_positional( + argument_index, + argument, + Some(argument_type), + false, + )?; + tuple_index += 1; + } + + // If any union element can still provide another positional argument, + // preserve the "too-many-positional-arguments" diagnostic. + let excess_positional_types: Vec> = + i32::try_from(tuple_index).map_or_else( + |_| Vec::new(), + |tuple_index_i32| { + tuple_specs + .iter() + .filter_map(|tuple| tuple.py_index(db, tuple_index_i32).ok()) + .collect() + }, + ); + + if !excess_positional_types.is_empty() { + let argument_type = + UnionType::from_elements_leave_aliases(db, excess_positional_types); + let _ = self.match_positional( + argument_index, + argument, + Some(argument_type), + false, + ); + } + + return Ok(()); + } + _ => VariadicArgumentType::Other(argument_type.iterate(db)), + }, }, None => VariadicArgumentType::None, }; From 0311dff6227e7e8f0adfa5fc8229a56affbb749a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 6 Feb 2026 11:18:15 -0800 Subject: [PATCH 2/9] Avoid false positive --- .../resources/mdtest/call/function.md | 10 ++++++++ .../ty_python_semantic/src/types/call/bind.rs | 24 ------------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index ecbbc2425eaeeb..9ed9db966748b3 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -802,6 +802,16 @@ def f8(flag: bool) -> None: def f11(*args: int) -> None: ... def f12(args: tuple[int] | int) -> None: f11(*args) # error: [not-iterable] + +def f13(a: int, b: int, c: str) -> None: ... +def f14(a: int, b: int, c: str, d: list[float], e: list[float]) -> None: ... +def f15(profile: bool, line: str) -> None: + matcher = f13 + timings = [] + if profile: + matcher = f14 + timings = [[0.0], [1.0], [2.0], [3.0]] + matcher(1, 2, line, *timings[:2]) ``` ### Mixed argument and parameter containing variadic diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 282b585bb51b39..14429de387554a 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3386,30 +3386,6 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { tuple_index += 1; } - // If any union element can still provide another positional argument, - // preserve the "too-many-positional-arguments" diagnostic. - let excess_positional_types: Vec> = - i32::try_from(tuple_index).map_or_else( - |_| Vec::new(), - |tuple_index_i32| { - tuple_specs - .iter() - .filter_map(|tuple| tuple.py_index(db, tuple_index_i32).ok()) - .collect() - }, - ); - - if !excess_positional_types.is_empty() { - let argument_type = - UnionType::from_elements_leave_aliases(db, excess_positional_types); - let _ = self.match_positional( - argument_index, - argument, - Some(argument_type), - false, - ); - } - return Ok(()); } _ => VariadicArgumentType::Other(argument_type.iterate(db)), From 6fb529e8ba4cc6507300afcc95bd4c4c157c790d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 6 Feb 2026 11:41:36 -0800 Subject: [PATCH 3/9] Add regression tests --- .../resources/mdtest/call/builtins.md | 13 ++++ .../resources/mdtest/call/function.md | 73 +++++++++++++++++++ .../mdtest/generics/legacy/callables.md | 27 +++++++ 3 files changed, 113 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 286411aae6ea53..fe732e6c738063 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -191,3 +191,16 @@ isinstance("", (int, t.Any)) # error: [invalid-argument-type] raise NotImplemented() # error: [call-non-callable] raise NotImplemented("this module is not implemented yet!!!") # error: [call-non-callable] ``` + +## TODO regression: `map` with `Unknown | str` + +```py +import re +from ty_extensions import Unknown + +def get_search_text() -> Unknown | str: ... +def _() -> None: + # TODO: No `invalid-argument-type` diagnostic should be emitted here. + # error: [invalid-argument-type] + ".*".join(map(re.escape, get_search_text())) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index 9ed9db966748b3..eb9c734130edb7 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -1540,3 +1540,76 @@ def _(arg: int): # error: [not-iterable] "Object of type `int` is not iterable" foo(*arg) ``` + +## TODO regressions: false positives from variadic binding + +```py +from datetime import datetime, timezone +from ty_extensions import Unknown + +# TODO: No `parameter-already-assigned` diagnostics should be emitted here. +def _scipy_regression() -> None: + class Dist: + def rvs(self, mu=0, lmbda=1, a=1, b=1, size=None, random_state=None): ... + + class Frozen: + def __init__(self) -> None: + self._dist: Unknown | Dist = Dist() + self._shapes = (0, 1, 1, 1) + + def rvs(self, size=None, random_state=None): + # error: [parameter-already-assigned] + # error: [parameter-already-assigned] + self._dist.rvs(*self._shapes, size=size, random_state=random_state) + +# TODO: No `parameter-already-assigned` diagnostic should be emitted here. +def _cki_regression() -> None: + cases = [ + ("*/5 * * * *", [(0, 5, 0), (0, 10, 0), (0, 15, 0)]), + ("*/10 * * * *", [(0, 10, 0), (0, 20, 0), (0, 30, 0)]), + ("0 */2 * * *", [(2, 0, 0), (4, 0, 0), (6, 0, 0)]), + ] + + for _schedule, times in cases: + for expected_next in times: + # error: [parameter-already-assigned] + datetime(2010, 1, 2, *expected_next, tzinfo=timezone.utc) + expected_next = datetime(2010, 1, 2, 0, 0, 0, tzinfo=timezone.utc) + +# TODO: No diagnostics should be emitted here. +def _apprise_regression() -> None: + def validate_regex( + value: str, + regex: str = r"[^\s]+", + flags: str | int = 0, + strip: object = True, + fmt: str | None = None, + ) -> str: + return value + + class NotifyBase: + template_tokens: dict[str, dict[str, str | bool | tuple[str, str]]] = { + "default": {"name": "x", "required": False, "regex": ("x", "i")}, + } + + class NotifyTelegram(NotifyBase): + template_tokens = dict( + NotifyBase.template_tokens, + **{ + "bot_token": { + "name": "Bot Token", + "required": True, + "regex": (r"^(bot)?(?P[0-9]+:[a-z0-9_-]+)$", "i"), + } + }, + ) + + def __init__(self, bot_token: str) -> None: + self.bot_token = validate_regex( + bot_token, + # error: [not-iterable] + *self.template_tokens["bot_token"]["regex"], + # error: [parameter-already-assigned] + fmt="{key}", + ) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md index b305eeb333cbe5..c2669998c0af9d 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md @@ -397,3 +397,30 @@ class IPolys(Protocol[T]): @overload def __getitem__(self, key: slice) -> IPolys[T] | Domain[T]: ... ``` + +## TODO regression: `compose(identity)` should specialize under assignment + +```py +from collections.abc import Callable +from typing import Any, TypeVar, overload + +_A = TypeVar("_A") +_B = TypeVar("_B") + +@overload +def compose() -> Callable[[_A], _A]: ... +@overload +def compose(__fn1: Callable[[_A], _B]) -> Callable[[_A], _B]: ... +def compose(*fns: Callable[[Any], Any]) -> Callable[[Any], Any]: + def _compose(source: Any) -> Any: + for fn in fns: + source = fn(source) + return source + return _compose + +def identity(value: _A) -> _A: + return value + +# TODO: No `invalid-assignment` diagnostic should be emitted here. +fn: Callable[[int], int] = compose(identity) # error: [invalid-assignment] +``` From 82b51d5fc1000e07f72bea1554904dc403777108 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 8 Feb 2026 20:09:34 -0500 Subject: [PATCH 4/9] Fix regression --- .../resources/mdtest/call/function.md | 16 ++++++++-------- crates/ty_python_semantic/src/types/call/bind.rs | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index eb9c734130edb7..50ef64f5a448f5 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -1541,13 +1541,17 @@ def _(arg: int): foo(*arg) ``` -## TODO regressions: false positives from variadic binding +## Regression: union variadic unpacking with explicit keyword arguments + +When a union type containing variable-length elements (like `Unknown` or `str`) is unpacked as +`*args`, the variadic expansion should not greedily consume optional positional parameters that are +also provided as explicit keyword arguments. ```py from datetime import datetime, timezone from ty_extensions import Unknown -# TODO: No `parameter-already-assigned` diagnostics should be emitted here. +# Regression inspired by scipy: `self._dist.rvs(*self._shapes, size=size, random_state=random_state)` def _scipy_regression() -> None: class Dist: def rvs(self, mu=0, lmbda=1, a=1, b=1, size=None, random_state=None): ... @@ -1558,11 +1562,9 @@ def _scipy_regression() -> None: self._shapes = (0, 1, 1, 1) def rvs(self, size=None, random_state=None): - # error: [parameter-already-assigned] - # error: [parameter-already-assigned] self._dist.rvs(*self._shapes, size=size, random_state=random_state) -# TODO: No `parameter-already-assigned` diagnostic should be emitted here. +# Regression inspired by cki-lib: `datetime(2010, 1, 2, *expected_next, tzinfo=timezone.utc)` def _cki_regression() -> None: cases = [ ("*/5 * * * *", [(0, 5, 0), (0, 10, 0), (0, 15, 0)]), @@ -1572,11 +1574,10 @@ def _cki_regression() -> None: for _schedule, times in cases: for expected_next in times: - # error: [parameter-already-assigned] datetime(2010, 1, 2, *expected_next, tzinfo=timezone.utc) expected_next = datetime(2010, 1, 2, 0, 0, 0, tzinfo=timezone.utc) -# TODO: No diagnostics should be emitted here. +# Regression inspired by apprise: `validate_regex(bot_token, *x, fmt="{key}")` def _apprise_regression() -> None: def validate_regex( value: str, @@ -1609,7 +1610,6 @@ def _apprise_regression() -> None: bot_token, # error: [not-iterable] *self.template_tokens["bot_token"]["regex"], - # error: [parameter-already-assigned] fmt="{key}", ) ``` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 14429de387554a..30592e392dd720 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3354,6 +3354,8 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { }) .collect_vec(); + let any_variable_length = + tuple_specs.iter().any(|spec| spec.len().is_variable()); let mut tuple_index = 0usize; while self @@ -3361,6 +3363,14 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { .get_positional(self.next_positional) .is_some() { + if any_variable_length + && self + .explicit_keyword_parameters + .contains(&self.next_positional) + { + break; + } + let Ok(tuple_index_i32) = i32::try_from(tuple_index) else { break; }; From 0205c4023dd71f6cac8472c21cb396c7818ebabc Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 8 Feb 2026 20:23:29 -0500 Subject: [PATCH 5/9] Simplify --- .../resources/mdtest/call/builtins.md | 13 ---- .../resources/mdtest/call/function.md | 73 ++----------------- 2 files changed, 7 insertions(+), 79 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index fe732e6c738063..286411aae6ea53 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -191,16 +191,3 @@ isinstance("", (int, t.Any)) # error: [invalid-argument-type] raise NotImplemented() # error: [call-non-callable] raise NotImplemented("this module is not implemented yet!!!") # error: [call-non-callable] ``` - -## TODO regression: `map` with `Unknown | str` - -```py -import re -from ty_extensions import Unknown - -def get_search_text() -> Unknown | str: ... -def _() -> None: - # TODO: No `invalid-argument-type` diagnostic should be emitted here. - # error: [invalid-argument-type] - ".*".join(map(re.escape, get_search_text())) -``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index 50ef64f5a448f5..513acb33228f32 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -1541,75 +1541,16 @@ def _(arg: int): foo(*arg) ``` -## Regression: union variadic unpacking with explicit keyword arguments +## Union variadic unpacking with explicit keyword arguments -When a union type containing variable-length elements (like `Unknown` or `str`) is unpacked as -`*args`, the variadic expansion should not greedily consume optional positional parameters that are -also provided as explicit keyword arguments. +When a union type containing variable-length elements (like `Unknown`) is unpacked as `*args`, the +variadic expansion should not greedily consume optional positional parameters that are also provided +as explicit keyword arguments. ```py -from datetime import datetime, timezone from ty_extensions import Unknown -# Regression inspired by scipy: `self._dist.rvs(*self._shapes, size=size, random_state=random_state)` -def _scipy_regression() -> None: - class Dist: - def rvs(self, mu=0, lmbda=1, a=1, b=1, size=None, random_state=None): ... - - class Frozen: - def __init__(self) -> None: - self._dist: Unknown | Dist = Dist() - self._shapes = (0, 1, 1, 1) - - def rvs(self, size=None, random_state=None): - self._dist.rvs(*self._shapes, size=size, random_state=random_state) - -# Regression inspired by cki-lib: `datetime(2010, 1, 2, *expected_next, tzinfo=timezone.utc)` -def _cki_regression() -> None: - cases = [ - ("*/5 * * * *", [(0, 5, 0), (0, 10, 0), (0, 15, 0)]), - ("*/10 * * * *", [(0, 10, 0), (0, 20, 0), (0, 30, 0)]), - ("0 */2 * * *", [(2, 0, 0), (4, 0, 0), (6, 0, 0)]), - ] - - for _schedule, times in cases: - for expected_next in times: - datetime(2010, 1, 2, *expected_next, tzinfo=timezone.utc) - expected_next = datetime(2010, 1, 2, 0, 0, 0, tzinfo=timezone.utc) - -# Regression inspired by apprise: `validate_regex(bot_token, *x, fmt="{key}")` -def _apprise_regression() -> None: - def validate_regex( - value: str, - regex: str = r"[^\s]+", - flags: str | int = 0, - strip: object = True, - fmt: str | None = None, - ) -> str: - return value - - class NotifyBase: - template_tokens: dict[str, dict[str, str | bool | tuple[str, str]]] = { - "default": {"name": "x", "required": False, "regex": ("x", "i")}, - } - - class NotifyTelegram(NotifyBase): - template_tokens = dict( - NotifyBase.template_tokens, - **{ - "bot_token": { - "name": "Bot Token", - "required": True, - "regex": (r"^(bot)?(?P[0-9]+:[a-z0-9_-]+)$", "i"), - } - }, - ) - - def __init__(self, bot_token: str) -> None: - self.bot_token = validate_regex( - bot_token, - # error: [not-iterable] - *self.template_tokens["bot_token"]["regex"], - fmt="{key}", - ) +def f(a: int = 0, b: int = 0, c: int = 0, fmt: str | None = None) -> None: ... +def _(args: "Unknown | tuple[int, int, int]"): + f(*args, fmt="{key}") # fine ``` From 0fb55924bd086349bd7562949fa73403cf74b600 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 8 Feb 2026 21:07:07 -0500 Subject: [PATCH 6/9] Revert tests --- .../mdtest/generics/legacy/callables.md | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md index c2669998c0af9d..b305eeb333cbe5 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/callables.md @@ -397,30 +397,3 @@ class IPolys(Protocol[T]): @overload def __getitem__(self, key: slice) -> IPolys[T] | Domain[T]: ... ``` - -## TODO regression: `compose(identity)` should specialize under assignment - -```py -from collections.abc import Callable -from typing import Any, TypeVar, overload - -_A = TypeVar("_A") -_B = TypeVar("_B") - -@overload -def compose() -> Callable[[_A], _A]: ... -@overload -def compose(__fn1: Callable[[_A], _B]) -> Callable[[_A], _B]: ... -def compose(*fns: Callable[[Any], Any]) -> Callable[[Any], Any]: - def _compose(source: Any) -> Any: - for fn in fns: - source = fn(source) - return source - return _compose - -def identity(value: _A) -> _A: - return value - -# TODO: No `invalid-assignment` diagnostic should be emitted here. -fn: Callable[[int], int] = compose(identity) # error: [invalid-assignment] -``` From 4dd6300c02915a482a8a5dc1f288380e28e97a62 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 15 Feb 2026 15:47:34 -0500 Subject: [PATCH 7/9] Use VariadicArgumentType::Union --- .../resources/mdtest/call/function.md | 19 +++ .../ty_python_semantic/src/types/call/bind.rs | 131 +++++++++++------- 2 files changed, 97 insertions(+), 53 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index 513acb33228f32..4f2e9c7139bffb 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -812,6 +812,25 @@ def f15(profile: bool, line: str) -> None: matcher = f14 timings = [[0.0], [1.0], [2.0], [3.0]] matcher(1, 2, line, *timings[:2]) + +def f9(x: int = 0, y: str = "") -> None: ... +def f10(args: tuple[int, ...] | tuple[int, str]) -> None: + # The variable-length element `int` from `tuple[int, ...]` unions with `str` + # from `tuple[int, str]` at position 1, giving `int | str` for `y: str`. + f9(*args) # error: [invalid-argument-type] + +def f18(x: int = 0, y: int = 0) -> None: ... +def f19(args: tuple[int, ...] | tuple[int, int]) -> None: + f18(*args) + +# TODO: Union variadic unpacking should also work when the non-defaulted parameters +# are covered by all union elements, even if not all remaining parameters are defaulted. +# Currently we only apply per-element iteration when all remaining positional parameters +# have defaults, so this falls back to `iterate()` which produces `tuple[int, ...]` and +# greedily matches `c: str` with `int`. +def f16(a: int, b: int = 0, c: str = "") -> None: ... +def f17(x: tuple[int] | tuple[int, int]) -> None: + f16(*x) # error: [invalid-argument-type] # TODO: false positive ``` ### Mixed argument and parameter containing variadic diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 30592e392dd720..7db7a1348bd565 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3314,6 +3314,14 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { ) -> Result<(), ()> { enum VariadicArgumentType<'db> { ParamSpec(Type<'db>), + /// A union type where each element has been individually iterated into a tuple spec. + /// We pre-compute the per-position union types, length bounds, and variable element + /// so the rest of the matching logic can handle unions without special-casing. + Union { + argument_types: Vec>, + length: TupleLength, + variable_element: Option>, + }, Other(Cow<'db, TupleSpec<'db>>), None, } @@ -3331,9 +3339,19 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { Some(paramspec) => VariadicArgumentType::ParamSpec(paramspec), None => match argument_type { // `Type::iterate` unions tuple specs in a way that can invent additional - // arities. For signatures where all remaining positional parameters are - // defaulted, map each possible tuple index from the union to the - // corresponding optional positional parameter. + // arities. Iterate each union element individually and compute per-position + // union types, length bounds, and variable element so that the rest of the + // matching logic handles unions correctly. + // + // We restrict this to cases where all remaining positional parameters are + // defaulted and there is no variadic parameter, because the per-position + // union loses the correlation between element lengths and per-position types. + // In overloaded contexts, this loss would prevent the expansion step from + // correctly splitting the union into separate argument lists. + // + // TODO: This is overly conservative. We could also apply this when all + // non-defaulted parameters are covered by the shortest union element, + // e.g. `f(a: int, b: int = 0)` with `*x` where `x: tuple[int] | tuple[int, int]`. Type::Union(union) if self.parameters.variadic().is_none() && self @@ -3342,61 +3360,58 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { .skip(self.next_positional) .all(|parameter| parameter.default_type().is_some()) => { - let tuple_specs = union - .elements(db) - .iter() - .map(|ty| { - ty.try_iterate(db).unwrap_or_else(|err| { - Cow::Owned(TupleSpec::homogeneous( - err.fallback_element_type(db), - )) - }) - }) - .collect_vec(); + let tuple_specs: Vec<_> = + union.elements(db).iter().map(|ty| ty.iterate(db)).collect(); - let any_variable_length = - tuple_specs.iter().any(|spec| spec.len().is_variable()); - let mut tuple_index = 0usize; + let min_len = tuple_specs + .iter() + .map(|s| s.len().minimum()) + .min() + .unwrap_or(0); + let any_variable = tuple_specs.iter().any(|s| s.len().is_variable()); + let max_elements = tuple_specs + .iter() + .map(|s| s.all_elements().len()) + .max() + .unwrap_or(0); - while self - .parameters - .get_positional(self.next_positional) - .is_some() - { - if any_variable_length - && self - .explicit_keyword_parameters - .contains(&self.next_positional) - { - break; + let variable_element = { + let var_types: Vec<_> = tuple_specs + .iter() + .filter_map(|s| s.variable_element().copied()) + .collect(); + if var_types.is_empty() { + None + } else { + Some(UnionType::from_elements_leave_aliases(db, var_types)) } + }; - let Ok(tuple_index_i32) = i32::try_from(tuple_index) else { - break; - }; - - let positional_types = tuple_specs + let max_elements = i32::try_from(max_elements).unwrap_or(i32::MAX); + let mut argument_types_vec = Vec::new(); + for index in 0..max_elements { + let positional_types: Vec<_> = tuple_specs .iter() - .filter_map(|tuple| tuple.py_index(db, tuple_index_i32).ok()) - .collect_vec(); - + .filter_map(|s| s.py_index(db, index).ok()) + .collect(); if positional_types.is_empty() { break; } + argument_types_vec + .push(UnionType::from_elements_leave_aliases(db, positional_types)); + } - let argument_type = - UnionType::from_elements_leave_aliases(db, positional_types); + let length = if any_variable || argument_types_vec.len() > min_len { + TupleLength::Variable(min_len, 0) + } else { + TupleLength::Fixed(min_len) + }; - self.match_positional( - argument_index, - argument, - Some(argument_type), - false, - )?; - tuple_index += 1; + VariadicArgumentType::Union { + argument_types: argument_types_vec, + length, + variable_element, } - - return Ok(()); } _ => VariadicArgumentType::Other(argument_type.iterate(db)), }, @@ -3408,6 +3423,11 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { VariadicArgumentType::ParamSpec(paramspec) => { ([].as_slice(), TupleLength::unknown(), Some(*paramspec)) } + VariadicArgumentType::Union { + argument_types, + length, + variable_element, + } => (argument_types.as_slice(), *length, *variable_element), VariadicArgumentType::Other(tuple) => ( tuple.all_elements(), tuple.len(), @@ -3419,6 +3439,12 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { let mut argument_types = argument_types.iter().copied(); let is_variable = length.is_variable(); + // For union types with no variable element, we know the exact set of possible argument + // positions. When argument types are exhausted, we should stop matching rather than + // continuing with unknown types. + let bounded = variable_element.is_none() + && matches!(&variadic_type, VariadicArgumentType::Union { .. }); + // We must be able to match up the fixed-length portion of the argument with positional // parameters, so we pass on any errors that occur. for _ in 0..length.minimum() { @@ -3445,12 +3471,11 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { { break; } - self.match_positional( - argument_index, - argument, - argument_types.next().or(variable_element), - is_variable, - )?; + let arg_type = argument_types.next().or(variable_element); + if bounded && arg_type.is_none() { + break; + } + self.match_positional(argument_index, argument, arg_type, is_variable)?; } } From 188ce1357d58f5cb1fef15d1b9cc24a6fc9c617b Mon Sep 17 00:00:00 2001 From: Carl Meyer Date: Tue, 3 Mar 2026 13:14:53 -0800 Subject: [PATCH 8/9] add tests and clarify 'bounded' case --- .../resources/mdtest/call/function.md | 23 +++++++++++++++++++ .../ty_python_semantic/src/types/call/bind.rs | 15 ++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/function.md b/crates/ty_python_semantic/resources/mdtest/call/function.md index 4f2e9c7139bffb..6d2d367c8e436f 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/function.md +++ b/crates/ty_python_semantic/resources/mdtest/call/function.md @@ -1573,3 +1573,26 @@ def f(a: int = 0, b: int = 0, c: int = 0, fmt: str | None = None) -> None: ... def _(args: "Unknown | tuple[int, int, int]"): f(*args, fmt="{key}") # fine ``` + +## Variadic unpacking should stop at max known arity + +When unpacking (a union of) fixed-length tuples, variadic matching should stop once the known +positions are exhausted. Otherwise, optional positional parameters can be incorrectly treated as +already assigned, causing false positives for `**kwargs`. + +(This test uses `**kwargs` unpacking of a `TypedDict` instead of the simpler `c=1` keyword argument, +because `c=1` is a known keyword argument and we always prevent unpacking `*args` over an +explicitly-provided keyword argument. The case shown here, without the explicit keyword argument, +requires instead that we use our knowledge of the tuple length to prevent over-unpacking.) + +```py +from typing import TypedDict + +class CKwargs(TypedDict): + c: int + +def f(a: int = 0, b: int = 0, c: int = 0) -> None: ... +def _(args_tuple: tuple[int, int], args_union: tuple[int] | tuple[int, int], kwargs: CKwargs) -> None: + f(*args_tuple, **kwargs) # fine + f(*args_union, **kwargs) # fine +``` diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 7db7a1348bd565..c69742eb04b864 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3437,14 +3437,11 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { }; let mut argument_types = argument_types.iter().copied(); + // This can be true either if we have a true variable-length tuple (in which case + // `variable_element.is_some()`) or if we have a union of different fixed-length tuples (in + // which case `variable_element.is_none()`). let is_variable = length.is_variable(); - // For union types with no variable element, we know the exact set of possible argument - // positions. When argument types are exhausted, we should stop matching rather than - // continuing with unknown types. - let bounded = variable_element.is_none() - && matches!(&variadic_type, VariadicArgumentType::Union { .. }); - // We must be able to match up the fixed-length portion of the argument with positional // parameters, so we pass on any errors that occur. for _ in 0..length.minimum() { @@ -3458,7 +3455,9 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { // If the tuple is variable-length, we assume that it will soak up all remaining positional // parameters, stopping only when we reach a parameter that has an explicit keyword argument - // or a parameter that can only be provided via keyword argument. + // or a parameter that can only be provided via keyword argument, or if we run out of + // `argument_types` and have no `variable_element`. (The combination of `is_variable` with + // no `variable_element` can only happen with a union of different-fixed-length tuples.) if is_variable { while self .parameters @@ -3472,7 +3471,7 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { break; } let arg_type = argument_types.next().or(variable_element); - if bounded && arg_type.is_none() { + if arg_type.is_none() { break; } self.match_positional(argument_index, argument, arg_type, is_variable)?; From cce3195f7f807811df9961e92486045f35852505 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 3 Mar 2026 21:01:15 -0500 Subject: [PATCH 9/9] Add comment --- crates/ty_python_semantic/src/types/call/bind.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index c69742eb04b864..9095aadf181446 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3346,8 +3346,10 @@ impl<'a, 'db> ArgumentMatcher<'a, 'db> { // We restrict this to cases where all remaining positional parameters are // defaulted and there is no variadic parameter, because the per-position // union loses the correlation between element lengths and per-position types. - // In overloaded contexts, this loss would prevent the expansion step from - // correctly splitting the union into separate argument lists. + // For example, given overloads `f(x: int, y: int)` and `f(x: int, y: str, z: int)` + // with `t: tuple[int, str] | tuple[int, str, int]`, the per-position union + // would collapse the two arities, preventing the expansion step from correctly + // splitting the union into separate argument lists per overload. // // TODO: This is overly conservative. We could also apply this when all // non-defaulted parameters are covered by the shortest union element,