Skip to content

ParamSpec: Allow only P.args #1000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
cdce8p opened this issue Jan 2, 2022 · 14 comments
Closed

ParamSpec: Allow only P.args #1000

cdce8p opened this issue Jan 2, 2022 · 14 comments
Labels
topic: feature Discussions about new features for Python's type annotations

Comments

@cdce8p
Copy link
Contributor

cdce8p commented Jan 2, 2022

I would like to use ParamSpec to type a function that accepts a Callable[P, Any] and *args which will be passed to the callable. The function itself does not accept keyword arguments. As an example, AbstractEventLoop.call_soon behaves similar. This is the current Typeshed definition

    def call_soon(self, callback: Callable[..., Any], *args: Any) -> Handle: ...

typeshed -> stdlib/asyncio/events.pyi -> AbstractEventLoop

P = ParamSpec("P")

    def call_soon(self, callback: Callable[P, Any], *args: P.args) -> Handle: ...

Intuitively adding a ParamSpec variable like this would make sense: "Only except *args. If any arguments are passed, they need to match those of the callback." I would also expect that if callback has required arguments, they need to be passed with *args to call_soon.

The issue here is that PEP 612 explicitly forbids specifying P.args or P.kwargs alone. They always need to be together.
https://www.python.org/dev/peps/pep-0612/#id2

Furthermore, because the default kind of parameter in Python ((x: int)) may be addressed
both positionally and through its name, two valid invocations of a (*args: P.args, **kwargs: P.kwargs)
function may give different partitions of the same set of parameters. Therefore, we need to make
sure that these special types are only brought into the world together, and are used together,
so that our usage is valid for all possible partitions.

I do wonder if this strict limitation makes sense or if there are other ways to work around it so the case described above could be supported. After all it's similar to adding **kwargs to the function signature without ever passing keyword arguments.

    def call_soon(self, callback: Callable[P, Any], *args: P.args, **kwargs: P.kwargs) -> Handle: ...
@cdce8p cdce8p added the topic: feature Discussions about new features for Python's type annotations label Jan 2, 2022
@TeamSpen210
Copy link

Perhaps this could require having ParamSpec("Name", positional_only=True)? Then it would be clear you don't care about keyword arguments, and any callable with required keyword args would be invalid.

@JelleZijlstra
Copy link
Member

I believe PEP 646 would allow this: https://www.python.org/dev/peps/pep-0646/#type-variable-tuples-with-callable.

@erictraut
Copy link
Collaborator

@JelleZijlstra is correct. This is supported by PEP 646.

Ts = TypeVarTuple("Ts")

def call_soon(self, callback: Callable[[*Ts], Any], *args: *Ts) -> Handle:
    ...

@cdce8p
Copy link
Contributor Author

cdce8p commented Jan 8, 2022

This is supported by PEP 646.

Thanks for the responses! This certainly looks promising. I do wonder though why it wouldn't work with ParamSpec, too. The PEP says it's forbidden, but is there an actual limitation? I would have assumed that only specifying one would just set an additional constraint for the other to be empty. E.g. if only P.args is given, no kwargs should be accepted.

There is also the question about kwargs only calls. Something like this here seems reasonable to require keyword arguments for external functions.

P = ParamSpec("P")
R = TypeVar("R")

def decorator(
    func: Callable[P, R]
) -> Callable[P, R]:
    def wrapper(**kwargs: P.kwargs) -> R:
        return func(**kwargs)
    return wrapper

@srittau
Copy link
Collaborator

srittau commented Jan 10, 2022

I have a similar use case with Ariadne middleware functions, but with kwargs. In Ariadne, a "resolver" function takes two positional arguments and a varying number of keyword arguments that depend on the API the resolver implements. As the name implies, a middleware function sits between Ariadne and the resolver function and forwards arguments.

Ideally, I would like to type a middleware function as follows:

_P = ParamSpec("_P")
_R = TypeVar("_R")
_T = TypeVar("_T")

def foo_middleware(
    resolver: Callable[Concatenate[_T, GraphQLResolveInfo, _P], _R],
    obj: _T,
    info: GraphQLResolveInfo,
    /,
    **kwargs: _P.kwargs,
) -> _R:
    ...
    return resolver(obj, info, **kwargs)

But currently this isn't possible, because _P.args is "missing".

@JelleZijlstra
Copy link
Member

Closing as PEP 646 provides support for @cdce8p's use case.

@cdce8p
Copy link
Contributor Author

cdce8p commented Nov 30, 2023

Closing as PEP 646 provides support for @cdce8p's use case.

Unfortunately, it only fully works for Mypy. Pyright doesn't support callables with defaults.

from typing import Callable, TypeVarTuple

Ts = TypeVarTuple("Ts")

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

def call_soon(func: Callable[[*Ts], None], *args: *Ts) -> None:
    return func(*args)


call_soon(f, 1)
call_soon(g, 1)  # pyright error
call_soon(g, 1, "Hello")

Whereas mypy can decide if Ts should be expanded to (int,) or (int, str), pyright always assumes the latter which results in an error for call_soon(g, 1).

  test.py:13:1 - error: Argument of type "*tuple[int]" cannot be assigned to parameter "args" of type "*Ts@call_soon" in function "call_soon"
    Type "*tuple[int]" cannot be assigned to type "*tuple[int, str]"
      "*tuple[int]" is incompatible with "*tuple[int, str]"
        Tuple size mismatch; expected 2 but received 1 (reportGeneralTypeIssues)

@erictraut mentioned in microsoft/pyright#3775 that this was be design. If so, TypeVarTuple can't be used to annotated functions like these.

@AlexWaygood mentioned a few other examples for TypeVarTuple in the typeshed tracking issue which wouldn't work then: python/typeshed#8708 (comment)

--
mypy playground
pyright playground
pyre doesn't seem to work at all? pyre playground

@AlexWaygood
Copy link
Member

AlexWaygood commented Nov 30, 2023

@erictraut mentioned in microsoft/pyright#3775 that this was be design. If so, TypeVarTuple can't be used to annotated functions like these.

@AlexWaygood mentioned a few other examples for TypeVarTuple in the typeshed tracking issue which wouldn't work then: python/typeshed#8708 (comment)

I think it would be unfortunate if this interpretation of the spec meant we had to stick with the (significantly more unsafe) signatures indefinitely for the many functions in asyncio that follow this pattern. I had been hoping we were close to being able to significantly increase the type safety of asyncio's functions: see python/typeshed#11015.

If it is decided that pyright's current interpretation of the spec is correct, I think the outcome will be that there will be essentially nowhere in typeshed that we are able to make use of TypeVarTuple.

@JelleZijlstra JelleZijlstra reopened this Nov 30, 2023
@erictraut
Copy link
Collaborator

If mypy figured out a way to implement this, I'll take another look at adding support in pyright. I'm not sure how mypy is capturing the value of Ts in the example above when g is passed as an argument. The captured type could be either tuple[int] or tuple[int, str]. Pyright's implementation chooses tuple[int, str]. It looks like mypy makes the same choice by default.

def g(a: int, b: str = "") -> None: ...
def func(func: Callable[[*Ts], None]) -> tuple[*Ts]: ...
reveal_type(func(g))  # mypy and pyright both reveal "tuple[int, str]"

@JelleZijlstra, do you happen to know who implemented this in mypy? Was it Ivan? I could use some advice on how this works.

@JelleZijlstra
Copy link
Member

Yes, Ivan did most of the PEP 646 work, though Jared Hance worked on it earlier.

@erictraut
Copy link
Collaborator

Good news — I figured out how to make this work in pyright without too much hackery. This support will be included in the next release of pyright.

It's not clear from a reading of PEP 646 that this case is meant to be supported — and if so, what the intended behavior is. If typeshed is going to depend on this behavior, we should work to mandate this behavior in the typing spec and include it in the typing conformance test suite. I've added this to a long list that I've been compiling.

@AlexWaygood
Copy link
Member

Thanks Eric! Yes, agreed, let's formalise this in the spec.

@cdce8p
Copy link
Contributor Author

cdce8p commented Dec 6, 2023

Now that both mypy and pyright support using TypeVarTuples for it, I guess the only thing left would be to eventually formalize this in the typing spec. Would be fine with closing this issue if nobody has objections.

https://github.com/microsoft/pyright/releases/tag/1.1.339

--
There is still the **kwargs case @srittau mentioned earlier but that might need a new kind of TypeVar if we don't want to touch ParamSpec. Might be best to track that separately though.

@srittau
Copy link
Collaborator

srittau commented Dec 6, 2023

I split the kwargs case into #1524.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: feature Discussions about new features for Python's type annotations
Projects
None yet
Development

No branches or pull requests

6 participants