From e13a50dc06d47bb1a5b276b5a0bb05cf2d999029 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sun, 26 Oct 2025 00:01:47 +0200 Subject: [PATCH 1/5] Add logic for ParamSpec args/kwargs in expand_type --- mypy/expandtype.py | 65 +++++++++++-- .../unit/check-parameter-specification.test | 94 +++++++++++++++++++ 2 files changed, 150 insertions(+), 9 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index e2a42317141f..e284a104c4a2 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Mapping from typing import Final, TypeVar, cast, overload -from mypy.nodes import ARG_STAR, FakeInfo, Var +from mypy.nodes import ARG_STAR, ArgKind, FakeInfo, Var from mypy.state import state from mypy.types import ( ANY_STRATEGY, @@ -270,14 +270,61 @@ def visit_param_spec(self, t: ParamSpecType) -> Type: ), ) elif isinstance(repl, Parameters): - assert t.flavor == ParamSpecFlavor.BARE - return Parameters( - self.expand_types(t.prefix.arg_types) + repl.arg_types, - t.prefix.arg_kinds + repl.arg_kinds, - t.prefix.arg_names + repl.arg_names, - variables=[*t.prefix.variables, *repl.variables], - imprecise_arg_kinds=repl.imprecise_arg_kinds, - ) + assert isinstance(t.upper_bound, ProperType) and isinstance(t.upper_bound, Instance) + if t.flavor == ParamSpecFlavor.BARE: + return Parameters( + self.expand_types(t.prefix.arg_types) + repl.arg_types, + t.prefix.arg_kinds + repl.arg_kinds, + t.prefix.arg_names + repl.arg_names, + variables=[*t.prefix.variables, *repl.variables], + imprecise_arg_kinds=repl.imprecise_arg_kinds, + ) + elif t.flavor == ParamSpecFlavor.ARGS: + assert all(k.is_positional() for k in t.prefix.arg_kinds) + required_posargs = list(t.prefix.arg_types) + optional_posargs: list[Type] = [] + for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): + if kind.is_positional() and name is None: + if optional_posargs: + # May happen following Unpack expansion + required_posargs += optional_posargs + optional_posargs = [] + required_posargs.append(type) + elif kind.is_positional(): + optional_posargs.append(type) + elif kind == ArgKind.ARG_STAR: + p_type = get_proper_type(type) + if isinstance(p_type, UnpackType): + optional_posargs.append(type) + else: + optional_posargs.append( + UnpackType(Instance(t.upper_bound.type, [type])) + ) + break + return UnionType.make_union( + [ + TupleType(required_posargs + optional_posargs[:i], fallback=t.upper_bound) + for i in range(len(optional_posargs) + 1) + ] + ) + else: + assert t.flavor == ParamSpecFlavor.KWARGS + kwargs = {} + required_names = set() + extra_items: Type = UninhabitedType() + for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): + if kind == ArgKind.ARG_NAMED and name is not None: + kwargs[name] = type + required_names.add(name) + elif kind == ArgKind.ARG_STAR2: + # Unpack[TypedDict] is normalized early, it isn't stored as Unpack + extra_items = type + elif not kind.is_star() and name is not None: + kwargs[name] = type + if not kwargs: + return Instance(t.upper_bound.type, [t.upper_bound.args[0], extra_items]) + # TODO: when PEP 728 is implemented, pass extra_items below. + return TypedDictType(kwargs, required_names, set(), fallback=t.upper_bound) else: # We could encode Any as trivial parameters etc., but it would be too verbose. # TODO: assert this is a trivial type, like Any, Never, or object. diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test index 2b4f92c7c819..8e1b3e4b801f 100644 --- a/test-data/unit/check-parameter-specification.test +++ b/test-data/unit/check-parameter-specification.test @@ -2599,3 +2599,97 @@ def run3(predicate: Callable[Concatenate[int, str, _P], None], *args: _P.args, * # E: Argument 1 has incompatible type "*tuple[Union[int, str], ...]"; expected "str" \ # E: Argument 1 has incompatible type "*tuple[Union[int, str], ...]"; expected "_P.args" [builtins fixtures/paramspec.pyi] + +[case testRevealBoundParamSpecArgs] +from typing import Callable, Generic, ParamSpec +from typing_extensions import TypeVarTuple, Unpack + +P = ParamSpec("P") +Ts = TypeVarTuple("Ts") + +class Sneaky(Generic[P]): + def __init__(self, fn: Callable[P, object], *args: P.args, **kwargs: P.kwargs) -> None: + self.fn = fn + self.args = args + self.kwargs = kwargs + +def f1() -> int: + return 0 +def f2(x: int) -> int: + return 0 +def f3(x: int, /) -> int: + return 0 +def f4(*, x: int) -> int: + return 0 +def f5(x: int, y: int = 0) -> int: + return 0 +def f6(x: int, *args: int) -> int: + return 0 +def f7(x: int, *args: Unpack[Ts]) -> int: + return 0 +def f8(x: int, *args: Unpack[tuple[str, ...]]) -> int: + return 0 +def f9(x: int, *args: Unpack[tuple[str, int]]) -> int: + return 0 + +reveal_type(Sneaky(f1).args) # N: Revealed type is "tuple[()]" +reveal_type(Sneaky(f2, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int]]" +reveal_type(Sneaky(f3, 1).args) # N: Revealed type is "tuple[builtins.int]" +reveal_type(Sneaky(f4, x=1).args) # N: Revealed type is "tuple[()]" +reveal_type(Sneaky(f5, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, builtins.int]]" +reveal_type(Sneaky(f5, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, builtins.int]]" +reveal_type(Sneaky(f6, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.int, ...]]]]" +reveal_type(Sneaky(f6, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.int, ...]]]]" +reveal_type(Sneaky(f7, 1, 2).args) # N: Revealed type is "tuple[Literal[1]?, Literal[2]?]" +reveal_type(Sneaky(f8, 1, '').args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.str, ...]]]]" +reveal_type(Sneaky(f9, 1, '', 0).args) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.int]" +[builtins fixtures/paramspec.pyi] + +[case testRevealBoundParamSpecKwargs] +from typing import Callable, Generic, ParamSpec +from typing_extensions import Unpack, NotRequired, TypedDict + +P = ParamSpec("P") + +class Sneaky(Generic[P]): + def __init__(self, fn: Callable[P, object], *args: P.args, **kwargs: P.kwargs) -> None: + self.fn = fn + self.args = args + self.kwargs = kwargs + +class Opt(TypedDict): + y: int + z: NotRequired[str] + +def f1() -> int: + return 0 +def f2(x: int) -> int: + return 0 +def f3(x: int, /) -> int: + return 0 +def f4(*, x: int) -> int: + return 0 +def f5(x: int, y: int = 0) -> int: + return 0 +def f6(**kwargs: int) -> int: + return 0 +def f7(x: int, **kwargs: str) -> int: + return 0 +def f8(x: int, /, **kwargs: str) -> int: + return 0 +def f9(x: int, **kwargs: Unpack[Opt]) -> int: + return 0 + +reveal_type(Sneaky(f1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, Never]" +reveal_type(Sneaky(f2, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int})" +reveal_type(Sneaky(f3, 1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, Never]" +reveal_type(Sneaky(f4, x=1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x': builtins.int})" +reveal_type(Sneaky(f5, 1).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int})" +reveal_type(Sneaky(f5, 1, 2).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y'?: builtins.int})" +reveal_type(Sneaky(f6, x=1).kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" +reveal_type(Sneaky(f6, x=1, y=2).kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.int]" +reveal_type(Sneaky(f7, 1, y='').kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int})" +reveal_type(Sneaky(f8, 1, y='').kwargs) # N: Revealed type is "builtins.dict[builtins.str, builtins.str]" +reveal_type(Sneaky(f9, 1, y=0).kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str})" +reveal_type(Sneaky(f9, 1, y=0, z='').kwargs) # N: Revealed type is "TypedDict('builtins.dict', {'x'?: builtins.int, 'y': builtins.int, 'z'?: builtins.str})" +[builtins fixtures/paramspec.pyi] From c08ce63adf1e0c6f1a016cb66eb893b3983d1ce8 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Sun, 26 Oct 2025 00:28:19 +0200 Subject: [PATCH 2/5] Remove get_proper_type (though it probably would be OK to increase the limit?) --- mypy/expandtype.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index e284a104c4a2..63e2c135d5d2 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -293,8 +293,8 @@ def visit_param_spec(self, t: ParamSpecType) -> Type: elif kind.is_positional(): optional_posargs.append(type) elif kind == ArgKind.ARG_STAR: - p_type = get_proper_type(type) - if isinstance(p_type, UnpackType): + # UnpackType cannot be aliased + if isinstance(type, ProperType) and isinstance(type, UnpackType): optional_posargs.append(type) else: optional_posargs.append( From 74e51ec8546b634b87417f51f7abda3ca10e988b Mon Sep 17 00:00:00 2001 From: STerliakov Date: Thu, 30 Oct 2025 16:39:03 +0100 Subject: [PATCH 3/5] Refactor into separate methods --- mypy/expandtype.py | 118 ++++++++++++------ .../unit/check-parameter-specification.test | 3 + 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 63e2c135d5d2..fa841749509e 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -281,55 +281,91 @@ def visit_param_spec(self, t: ParamSpecType) -> Type: ) elif t.flavor == ParamSpecFlavor.ARGS: assert all(k.is_positional() for k in t.prefix.arg_kinds) - required_posargs = list(t.prefix.arg_types) - optional_posargs: list[Type] = [] - for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): - if kind.is_positional() and name is None: - if optional_posargs: - # May happen following Unpack expansion - required_posargs += optional_posargs - optional_posargs = [] - required_posargs.append(type) - elif kind.is_positional(): - optional_posargs.append(type) - elif kind == ArgKind.ARG_STAR: - # UnpackType cannot be aliased - if isinstance(type, ProperType) and isinstance(type, UnpackType): - optional_posargs.append(type) - else: - optional_posargs.append( - UnpackType(Instance(t.upper_bound.type, [type])) - ) - break - return UnionType.make_union( - [ - TupleType(required_posargs + optional_posargs[:i], fallback=t.upper_bound) - for i in range(len(optional_posargs) + 1) - ] + return self._possible_callable_varargs( + repl, list(t.prefix.arg_types), t.upper_bound ) else: assert t.flavor == ParamSpecFlavor.KWARGS - kwargs = {} - required_names = set() - extra_items: Type = UninhabitedType() - for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): - if kind == ArgKind.ARG_NAMED and name is not None: - kwargs[name] = type - required_names.add(name) - elif kind == ArgKind.ARG_STAR2: - # Unpack[TypedDict] is normalized early, it isn't stored as Unpack - extra_items = type - elif not kind.is_star() and name is not None: - kwargs[name] = type - if not kwargs: - return Instance(t.upper_bound.type, [t.upper_bound.args[0], extra_items]) - # TODO: when PEP 728 is implemented, pass extra_items below. - return TypedDictType(kwargs, required_names, set(), fallback=t.upper_bound) + return self._possible_callable_kwargs(repl, t.upper_bound) else: # We could encode Any as trivial parameters etc., but it would be too verbose. # TODO: assert this is a trivial type, like Any, Never, or object. return repl + @classmethod + def _possible_callable_varargs( + cls, repl: Parameters, required_prefix: list[Type], tuple_type: Instance + ) -> ProperType: + """Given a callable, extract all parameters that can be passed as `*args`. + + This builds a union of all (possibly variadic) tuples of the shape + [*all_required, *optional_suffix] + where all_required contains args that must be passed positionally, + all_optional contains args that may be omitted, and + optional_suffix is some prefix of all_optional. + + This will grab the following argtypes of the function: + + * posonly - required unless has a default + * pos-or-kw - always optional as it may be passed by name + * vararg - converted to an Unpack suffix + """ + required_posargs = required_prefix + optional_posargs: list[Type] = [] + for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): + if kind.is_positional() and name is None: + if optional_posargs: + # May happen following Unpack expansion without kinds correction + required_posargs += optional_posargs + optional_posargs = [] + required_posargs.append(type) + elif kind.is_positional(): + optional_posargs.append(type) + elif kind == ArgKind.ARG_STAR: + # UnpackType cannot be aliased + if isinstance(type, ProperType) and isinstance(type, UnpackType): + optional_posargs.append(type) + else: + optional_posargs.append(UnpackType(Instance(tuple_type.type, [type]))) + break + return UnionType.make_union( + [ + TupleType(required_posargs + optional_posargs[:i], fallback=tuple_type) + for i in range(len(optional_posargs) + 1) + ] + ) + + @classmethod + def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> ProperType: + """Given a callable, extract all parameters that can be passed as `**kwargs`. + + If the function only accepts **kwargs, this will be a `dict[str, KwargsValueType]`. + Otherwise, this will be a `TypedDict` containing all explicit args and ignoring + `**kwargs` (until PEP 728 `extra_items` is supported). + + This will grab the following argtypes of the function: + + * kwonly - required unless has a default + * pos-or-kw - always optional as it may be passed positionally + * `**kwargs` + """ + kwargs = {} + required_names = set() + extra_items: Type = UninhabitedType() + for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types): + if kind == ArgKind.ARG_NAMED and name is not None: + kwargs[name] = type + required_names.add(name) + elif kind == ArgKind.ARG_STAR2: + # Unpack[TypedDict] is normalized early, it isn't stored as Unpack + extra_items = type + elif not kind.is_star() and name is not None: + kwargs[name] = type + if not kwargs: + return Instance(dict_type.type, [dict_type.args[0], extra_items]) + # TODO: when PEP 728 is implemented, pass extra_items below. + return TypedDictType(kwargs, required_names, set(), fallback=dict_type) + def visit_type_var_tuple(self, t: TypeVarTupleType) -> Type: # Sometimes solver may need to expand a type variable with (a copy of) itself # (usually together with other TypeVars, but it is hard to filter out TypeVarTuples). diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test index 8e1b3e4b801f..e3ccdc8b4327 100644 --- a/test-data/unit/check-parameter-specification.test +++ b/test-data/unit/check-parameter-specification.test @@ -2631,6 +2631,8 @@ def f8(x: int, *args: Unpack[tuple[str, ...]]) -> int: return 0 def f9(x: int, *args: Unpack[tuple[str, int]]) -> int: return 0 +def f10(x: int=0, *args: Unpack[tuple[str, ...]]) -> int: + return 0 reveal_type(Sneaky(f1).args) # N: Revealed type is "tuple[()]" reveal_type(Sneaky(f2, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int]]" @@ -2643,6 +2645,7 @@ reveal_type(Sneaky(f6, 1, 2).args) # N: Revealed type is "Union[tuple[()], tupl reveal_type(Sneaky(f7, 1, 2).args) # N: Revealed type is "tuple[Literal[1]?, Literal[2]?]" reveal_type(Sneaky(f8, 1, '').args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.str, ...]]]]" reveal_type(Sneaky(f9, 1, '', 0).args) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.int]" +reveal_type(Sneaky(f10, 1, '', '').args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.str, ...]]]]" [builtins fixtures/paramspec.pyi] [case testRevealBoundParamSpecKwargs] From c01514ce090fe313a25149946adeedaf2889417e Mon Sep 17 00:00:00 2001 From: STerliakov Date: Thu, 30 Oct 2025 16:52:41 +0100 Subject: [PATCH 4/5] Add test case with Concatenate --- .../unit/check-parameter-specification.test | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test index e3ccdc8b4327..175e41425ffe 100644 --- a/test-data/unit/check-parameter-specification.test +++ b/test-data/unit/check-parameter-specification.test @@ -2602,7 +2602,7 @@ def run3(predicate: Callable[Concatenate[int, str, _P], None], *args: _P.args, * [case testRevealBoundParamSpecArgs] from typing import Callable, Generic, ParamSpec -from typing_extensions import TypeVarTuple, Unpack +from typing_extensions import Concatenate, TypeVarTuple, Unpack P = ParamSpec("P") Ts = TypeVarTuple("Ts") @@ -2613,6 +2613,12 @@ class Sneaky(Generic[P]): self.args = args self.kwargs = kwargs +class SneakyPrefix(Generic[P]): + def __init__(self, fn: Callable[Concatenate[int, P], object], _: int, *args: P.args, **kwargs: P.kwargs) -> None: + self.fn = fn + self.args = args + self.kwargs = kwargs + def f1() -> int: return 0 def f2(x: int) -> int: @@ -2635,19 +2641,42 @@ def f10(x: int=0, *args: Unpack[tuple[str, ...]]) -> int: return 0 reveal_type(Sneaky(f1).args) # N: Revealed type is "tuple[()]" +reveal_type(SneakyPrefix(f1).args) # E: Missing positional argument "_" in call to "SneakyPrefix" \ + # N: Revealed type is "tuple[()]" \ + # E: Argument 1 to "SneakyPrefix" has incompatible type "Callable[[], int]"; expected "Callable[[int], object]" + reveal_type(Sneaky(f2, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int]]" +reveal_type(SneakyPrefix(f2, 1).args) # N: Revealed type is "tuple[()]" + reveal_type(Sneaky(f3, 1).args) # N: Revealed type is "tuple[builtins.int]" +reveal_type(SneakyPrefix(f3, 1).args) # N: Revealed type is "tuple[()]" + reveal_type(Sneaky(f4, x=1).args) # N: Revealed type is "tuple[()]" + reveal_type(Sneaky(f5, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, builtins.int]]" +reveal_type(SneakyPrefix(f5, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int]]" reveal_type(Sneaky(f5, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, builtins.int]]" +reveal_type(SneakyPrefix(f5, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int]]" + reveal_type(Sneaky(f6, 1).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.int, ...]]]]" +reveal_type(SneakyPrefix(f6, 1).args) # N: Revealed type is "Union[tuple[()], tuple[Unpack[builtins.tuple[builtins.int, ...]]]]" reveal_type(Sneaky(f6, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.int, ...]]]]" +reveal_type(SneakyPrefix(f6, 1, 2).args) # N: Revealed type is "Union[tuple[()], tuple[Unpack[builtins.tuple[builtins.int, ...]]]]" + reveal_type(Sneaky(f7, 1, 2).args) # N: Revealed type is "tuple[Literal[1]?, Literal[2]?]" +reveal_type(SneakyPrefix(f7, 1, 2).args) # N: Revealed type is "tuple[Literal[2]?]" + reveal_type(Sneaky(f8, 1, '').args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.str, ...]]]]" +reveal_type(SneakyPrefix(f8, 1, '').args) # N: Revealed type is "Union[tuple[()], tuple[Unpack[builtins.tuple[builtins.str, ...]]]]" + reveal_type(Sneaky(f9, 1, '', 0).args) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.int]" +reveal_type(SneakyPrefix(f9, 1, '', 0).args) # N: Revealed type is "tuple[builtins.str, builtins.int]" + reveal_type(Sneaky(f10, 1, '', '').args) # N: Revealed type is "Union[tuple[()], tuple[builtins.int], tuple[builtins.int, Unpack[builtins.tuple[builtins.str, ...]]]]" +reveal_type(SneakyPrefix(f10, 1, '', '').args) # N: Revealed type is "Union[tuple[()], tuple[Unpack[builtins.tuple[builtins.str, ...]]]]" [builtins fixtures/paramspec.pyi] + [case testRevealBoundParamSpecKwargs] from typing import Callable, Generic, ParamSpec from typing_extensions import Unpack, NotRequired, TypedDict From 2047a9618dfd153a97d498ae7bf604acbf11b9e6 Mon Sep 17 00:00:00 2001 From: STerliakov Date: Thu, 30 Oct 2025 16:57:06 +0100 Subject: [PATCH 5/5] Restart CI