Should `ParamSpec.args` be allowed inside `Unpack`?

It seems that for historical reasons – PEP 612 – Parameter Specification Variables became a part of the language one major version before PEP 646 – Variadic Generics – we have been annotating ParamSpec like *args: P.args rather than *args: *P.args, which would logically make more sense, seeing that P.args is a kind of variadic tuple type.

Consequently, type-checkers generally do not expect P.args inside an Unpack. However, this leads to cases where perfectly good runtime code gets rejected unnecessarily: mypy-playground, pyright-playground

from typing import Callable, Concatenate

def test[**P](
    fn: Callable[Concatenate[int, P], int],
    *args: P.args,
    **kwargs: P.kwargs,
) -> None:
    fn(1, *args, **kwargs)     # ✅
    fn(*(1, *args), **kwargs)  # ❌
    # mypy:
    # incompatible type "*tuple[object, ...]"; expected "int"  [arg-type]
    # incompatible type "*tuple[object, ...]"; expected "P.args"  [arg-type]
    # pyright: error: Arguments for ParamSpec "P@test" are missing

What’s stopping us, other than convention, to simply interpret the star argument in the last call as tuple[int, *P.args], and mapping this successfully onto Concatenate[int, P]?

2 Likes

Is there a use case? The currently working code looks nicer in your example.

That’s not really true, right? You always have to combine P.args with P.kwargs, because for most Python functions, arguments can be passed in positionally or with a keyword. If you have a function

def f (a: int, b: str = "") -> None: ...

then what does tuple[int, *P.args] mean? It seems to depend on how the function was called.

Yes, P.args depends on P.kwargs. Types depending on other types is a very common thing, e.g. def f[T](x: T) -> T: .... Why would that mean that it cannot be variadic tuple type?

I believe that ParamSpec was only ever designed to provide type safety of a signature, treating args and kwargs as inseparable parts of a conceptually-whole signature object.

In an alternate universe, if syntax design were a little more adventurous, we may have seen ParamSpec and type parameters been introduced like this instead:

def test[***P](
    fn: Callable[[int, ***P], int],
    ***both_args_and_kwargs: ***P
) -> None:
    ...

This makes no room for the user to think that it’s even possible to wrestle with type checkers to somehow achieve type safety by doing something individually with args or kwargs within the function body (test).

1 Like

Slightly related, PEP 677 with an easier-to-parse and more expressive syntax was a discussion to improve how to annotate callables, not sure what came of it though. You might want to look at that and see how your idea would work with the proposal there (sorry if you’ve already seen it).

There were probably more problems that would ultimately crop up, but I ran into the problem of the inability to do this (even though I expected I should be able to) while attempting to make a generalized decorator to pass-through args & kwargs.