Open cdce8p opened 2 years ago
@pradeep90 @mrahtz
Thanks for the thoughtful use case. Yeah, we didn't address this in the PEP.
For the above case, Pyre currently uses all the parameters, default or not. So, it expects 3 arguments in call_later(func2, 1, 2, 3)
. [Repro](https://pyre-check.org/play?input=%23%20Pyre%20is%20being%20run%20in%20strict%20mode%3A%20https%3A%2F%2Fwww.internalfb.com%2Fintern%2Fstaticdocs%2Fpyre%2Fdocs%2Ftypes-in-python%23strict-mode%0A%23%20Use%20the%20pyre-unsafe%20header%20to%20run%20in%20unsafe%20mode.%0A%0Afrom%20typing%20import%20*%0Afrom%20pyre_extensions%20import%20TypeVarTuple%2C%20Unpack%0A%0ATs%20%3D%20TypeVarTuple(%22Ts%22)%0A%0Adef%20func1(cb1%3A%20Callable%5B%5BUnpack%5BTs%5D%5D%2C%20None%5D)%20-%3E%20tuple%5BUnpack%5BTs%5D%5D%3A%0A%20%20%20%20...%0A%0Adef%20func2(x%3A%20int%2C%20y%3A%20int%20%3D%200%2C%20z%3A%20int%20%3D%200)%20-%3E%20None%3A%20...%0A%20%20%20%20%0Adef%20apply(f%3A%20Callable%5B%5BUnpack%5BTs%5D%5D%2C%20None%5D%2C%20*args%3A%20Unpack%5BTs%5D)%20-%3E%20tuple%5BUnpack%5BTs%5D%5D%3A%20...%0A%0Adef%20main()%20-%3E%20None%3A%0A%20%20%20%20f%20%3D%20func1(func2)%0A%20%20%20%20x%20%3D%20apply(func2%2C%201)%0A%20%20%20%20y%20%3D%20apply(func2%2C%201%2C%202)%0A%20%20%20%20z%20%3D%20apply(func2%2C%201%2C%202%2C%203)%0A%20%20%20%20reveal_locals()).
So, only one of the possible ways of calling func2
will be accepted right now (i.e., passing all three arguments). The alternative is to track the various possible solutions for Ts
and, as Eric pointed out, that makes this complicated feature even more complicated. We could revisit that if users run into this very often.
A workaround is to assign the function to a variable of type Callable[[int], None]
, so that the type checker knows which signature you intend to use.
@mrahtz If that sounds good to you too, we could mention this limitation in the PEP.
Thanks for the response @pradeep90!
We could revisit that if users run into this very often.
From my experience so far, if a codebase uses functions like call_later(cb, *args)
, they are relied upon all over the place. About 1/3 of the calls in my case didn't pass all arguments (and thus throw errors). At that scale adding type aliases becomes impractical quickly and adds additional complexity to the code. I would prefer if we could find a solution to make it work.
Regarding the issues with an implementation, Eric did first mention that TypeVarTuples
don't have a concept of default arguments. I originally though of WithDefault
(#1232) in the context of callable types, but I think it could be used here too.
The other case would be
def func1(cb1: Callable[[*Ts], None]) -> tuple[*Ts]:
...
def func2(x: int, y: int = 0, z: int = 0) -> None: ...
func1(func2) # Type is ambiguous; could be tuple[int] or tuple[int, int]
If Ts
would resolve to [int, WithDefault[int]]
and we define that WithDefault[int]
means int
if evaluated, tuple[*Ts]
would always be tuple[int, int]
. Doing so could remove the ambiguity and allow a path forward.
I think I might be missing something here for no one to have brought it up already, but isn't this what ParamSpec
is for? This seems to type-check correctly in Pyright:
from typing import Any, Callable, TypeVar, ParamSpec
P = ParamSpec('P')
def call_later(
cb: Callable[P, Any],
*args: P.args,
**kwargs: P.kwargs,
) -> None: ...
def func1(x: int, y: str, z: float) -> None: ...
call_later(func1, 0, '0', 0.0) # Ok
def func2(x: int, y: str = '0', z: float = 0.0) -> None: ...
call_later(func2, 0) # Ok
call_later(func2, 0, '0') # Ok
call_later(func2, 0, '0', 0.0) # Ok
call_later(func2, 0, 0) # Not ok
Or is the point that, sure, it's doable with ParamSpec
- but it'd sort of be nice if it worked with TypeVarTuple
too?
I think I might be missing something here for no one to have brought it up already, but isn't this what
ParamSpec
is for?
This is only possible if call_later
also accepts **kwargs
. However, this isn't always the case. Especially with asyncio
methods, it's a common pattern to only allow *args
. E.g. call_soon
, call_later
, call_at
, ... https://docs.python.org/3/library/asyncio-eventloop.html#scheduling-callbacks
A while back I suggested allowing only P.args
in a function signature #1000, but it was basically rejected with the reference to TypeVarTuples as alternative: https://github.com/python/typing/issues/1000#issuecomment-1004459692.
This is only possible if call_later also accepts
**kwargs
.
Oh huh, I didn't realise.
Mulling this over, even with potential future extensions, I don't think TypeVarTuple
would be the right construct to handle this situation in general. One of Eric's examples is particular instructive:
def func1(cb1: Callable[[*Ts], None], cb2: Callable[[*Ts], None]) -> tuple[*Ts]:
...
The question of whether cb1
and cb2
have the same call signature when default arguments are involved is complicated enough that I think we'd want some mechanism for specifying the desired behaviour more precisely. It seems like one could reasonably want any of the following behaviours:
cb1
and cb2
is the same ignoring default argumentscb1
and cb2
is exactly the same, including the same default argumentscb1
and cb2
share the same base signature (without default arguments), and cb2
's default arguments are a superset of cb1
's, or vice-versa(And as Eric points out, there's still the issue of what the *Ts
in the return type should mean.)
Even if we did implement some way of using TypeVarTuple
s for this, there are so many options for how to interpret the resulting type constraints that I think it would be unintuitive to the reader.
But for the call_later
example in particular
def call_later(cb: Callable[[*Ts], None], *args: *Ts) -> None: ...
I'm wondering whether it might be possible to specifying the exact behaviour desired manually with something like:
ArgType = *Ts | *tuple[*Ts, T1] | *tuple[*Ts, T1, T2] # | Etc
def call_later(cb: Callable[[ArgType], None], *args: ArgType) -> None: ...
So if we want to use that with
def func2(x: int, y: str = '0', z: float = 0.0) -> None: ...
We could do:
*args: *tuple[int]
cb: Callable[[*tuple[*Ts, T1, T2]], None], *args: *Ts
*args: *tuple[int, str]
cb: Callable[[*tuple[*Ts, T1, T2]], None, *args: *tuple[*Ts, T1]
*args: *tuple[int, str, float]
cb: Callable[[*Ts], None], *args: *Ts
@pradeep90 Am I crazy, or could this work?
I'm wondering whether it might be possible to specifying the exact behaviour desired manually with something like:
ArgType = *Ts | *tuple[*Ts, T1] | *tuple[*Ts, T1, T2] # | Etc def call_later(cb: Callable[[ArgType], None], *args: ArgType) -> None: ...
Am I crazy, or could this work?
Not fully certain, but wouldn't we need a constraint and nested TypeVarTuple for that?
T1 = TypeVar("T1")
T2 = TypeVar("T2")
Ts = TypeVarTuple("Ts")
Tu = TypeVarTuple("Tu", *Ts, *tuple[*Ts, T1], *tuple[*Ts, T1, T2])
def call_later(cb: Callable[[*Tu], None], *args: *Tu) -> None: ...
Without Tu
, there isn't any connection between the ArgType
for Callable
and the one for *args
.
I think this complexity strongly leans me towards support P.args only / P.kwargs only solution. I'd rather make this legal,
def foo(func: Callable[P, object], *args: P.args):
...
then work out remaining implications of typevartuple with defaults. Rules here I'd expect is func must not have any required keyword only arguments and that when foo is called it can not have any keyword arguments passed (besides func to allow foo(func=f).
Previous discussion: https://github.com/microsoft/pyright/issues/3775
I've been playing around with TypeVarTuples recently, in particular together with Callable and args. https://peps.python.org/pep-0646/#type-variable-tuples-with-callable
One issue became obvious early on which isn't defined in the PEP itself.
A common pattern, especially in async code, is to pass a callable and it's args to a function
That works well if all arguments are required
However, what should happen if
y
andz
have default arguments?Instinctively, I would think that it should work, too. As @erictraut did point out though, there are at least a few cases where the behavior would need to be further specified / or explicitly forbidden.