Extract kwargs types from a function signature (original) (raw)
September 20, 2023, 6:58pm 1
Let’s say I have some function
def do_some_stuff(
*,
foo: int,
bar: float | None = None,
baz: BazEnum = BazEnum.SPAM,
) -> int:
...
And some wrapper around it, for example to add default/error handling:
def do_some_stuff_safe(
*,
default: int = 3,
**kwargs: Any,
) -> int:
try:
return do_some_stuff(**kwargs)
except ValueError:
return default
I’d like to express that kwargs
must match do_some_stuff
signature using the static type system.
Current solutions
In Python 3.12, I see two ways to achieve this:
- Explicitly re-expose
do_some_stuff
kwargs indo_some_stuff_safe
:
def do_some_stuff_safe(
*,
foo: int,
bar: float | None = None,
baz: BazEnum = BazEnum.SPAM,
default: int = 3,
) -> int:
...
- Use
Unpack
+TypedDict
combinaison introduced by PEP 692:
class DoSomeStuffKwargs(TypedDict):
foo: int
bar: NotRequierd[float | None]
baz: NotRequierd[BazEnum]
def do_some_stuff_safe(
*,
default: int = 3,
**kwargs: Unpack[DoSomeStuffKwargs],
) -> int:
...
These solutions have the same issue: we have to duplicate do_some_stuff
’s signature information (argument names + types + default value in solution 1), with no mechanism that ensures they don’t go out of sync if we add, rename, delete… an argument in do_some_stuff
.
(some possible mechanism)
One could write something in the lines of
if TYPE_CHECKING:
kwargs = cast(DoSomeStuffKwargs,{})
do_some_stuff(**kwargs) # type error if DoSomeStuffKwargs is incompatible with do_some_stuff signature
but that feels pretty hacky, and it does not detect addition of non-required argument.
Idea
I’d like to have a way to state that those kwargs are the ones of dosomestuff
, something like
def do_some_stuff_safe(
*,
default: int = 3,
**kwargs: Unpack[Kwargs[do_some_stuff]],
) -> int:
...
This reminds somewhat of ParamSpec
in it’s usage, so we could maybe write it like ParmSpec(do_some_stuff).kwargs
— allowing to use it with *args
too!
Problem / question
My main issue with this idea is that having a function object inside a type annotation feels wrong, at it is not a type nor a special typing object— I think that’s not something done anywhere?
However, the function’s signature is a static available info, so it looks legitimate to rely on it… but I see no way to denote it other than through the function object.
In that extend, I would feel more comfortable with something ParamSpec()
-ish (with parentheses) rather than Kwargs[]
-ish (whith square brackets).
tmk (Thomas Kehrenberg) September 20, 2023, 7:13pm 2
Maybe just Parameters[do_some_stuff].kwargs
(so, similar to Parameters<>
in typescript):
def f(x: int, /, y: str, *, z: bool) -> None: ...
FParams = Parameters[f]
def g(*args: FParams.args, **kwargs: FParams.kwargs) -> None:
f(*args, **kwargs)
sirosen (Stephen Rosen) September 21, 2023, 1:55pm 3
I would like to see this solved, exact naming and syntax TBD.
The notable extension to your use case is to consider what happens if I’m defining a wrapper over some 3rd party method.
import foo
def snork(
*args: Parameters[foo.snork].args,
*kwargs: Parameters[foo.snork].kwargs,
encoding: str = "utf-8",
) -> foo.Snorker:
x = foo.snork(*args, **kwargs)
x.set_encoding(encoding)
return x
Questions I have by raising this:
- can it be extended to cover the return type too? (Too much scope creep?)
- what happens if
foo
addsencoding
to the method kwargs? I assume that’s a type error, but want to confirm
loic-simon (Loïc Simon) September 21, 2023, 2:34pm 4
I thought a bit more about that, and it’s likely a bad idea: ParamSpec
represent the parameters of a function call, which is quite different of the arguments of a function signature: notably, what “keyword argument” mean is not obvious for a signature:
Since both f(3, 4, z=5)
and f(3, y=4, z=5)
are valid function calls, what to do with y
? (can it be both in FParams.args
and FParams.kwargs
?)
Yeah, I suppose it’d make sense!
Yes, I think it’s one cool effect if this proposal: if there is a new kwargs “collision” between the function and the wrapper, type checkers would detect it!
NeilGirdhar (Neil Girdhar) September 26, 2023, 4:03am 5
I don’t think you’re allowed to use .args
without .kwargs
. The whole parameter-spec has to be dumped in.
Anyway, I like your idea. It’s related to my super-args idea, which is a common kind of parameter forwarding. If someone wants to write a PEP, it would be great to address both simultaneously. So, for the super case, we could have:
class C:
def __init__(self, *args: Parameters[super].args, **kwargs: Parameters[super].kwargs):
...
Still seems too wordy. Maybe someone can come up with something more compact? Maybe don’t annotate *args
, or annotate it with ...
?
One extension that might be worth addressing is the case where the caller wants to fill in some of the parameters. E.g.,
def f(*, x: int, y: int) -> None: ...
def g(*args: ..., **kwargs: Parameters[f, ('y')]) -> None:
return f(*args, y=2, **kwargs)
loic-simon (Loïc Simon) September 26, 2023, 6:18pm 6
That’s true for ParamSpec, but for this idea, would that mean that we would have to specify .args
even if the function has only kewford-only parameters?
To come back to my original example,
Params = Parameters[do_some_stuff]
def do_some_stuff_safe(
*,
default: int = 3,
**kwargs: Unpack[Params.kwargs],
) -> int:
...
would it be mandatory to add *args: *Params.args
? Doesn’t looks very neat
Yeah, why not! Since super()
returns the parent class, not function, I’d imagine an eventual Super
type would refer to the parent type (in the style of Self
), and so Parameters[Super]
would be the parameters of super().__init__
.
If I understood you well, it’s like omiting a key in a mapping (like TypeScript’s Omit)? If so, I guess that would be considered as a feature on it’s own and a bit out of scope for a first version of this!
gandhis1 (Siddhartha Gandhi) September 27, 2023, 12:44am 7
One relevant use case is annotating pandas functions like apply
, which accept an arbitrary function func
and separate arguments for said function’s args
and kwargs
: pandas.DataFrame.apply — pandas 2.1.1 documentation
At present, I am not aware of a way to properly annotate this function. It should be possible to statically verify that you do not pass in args or kwargs that are incompatible with the func
you passed in.
mikeshardmind (Michael H) September 27, 2023, 2:07am 8
I feel like this is a good case for ParamSpec’s behavior to be expanded on, but last I recall, attempts to do so were rejected. (I think last time it was “what if we don’t want to use both kwargs and args”)
Paramspec feels super limiting to only the case where you are transparently passing both *args
and **kwargs
through and that your function which passes them through accepts them as *args
and **kwargs
, with only minimal modifications allowed to *args via concatenate and just doesn’t mesh well with a lot of real-world code.
NeilGirdhar (Neil Girdhar) September 27, 2023, 5:54am 9
Yes, I think that’s the current state of affairs. As you and the last two comments have noted, that’s an unfortunate limitation.
Would lifting the args/kwargs limitation belong in this PEP or a different one?
I think we would want forwarding for any method. For example:
class Base:
def f(self, x: int) -> None: pass
class Derived(Base):
def f(self, *args: Parameters[Super].args, **kwargs: Parameters[Super].kwargs) -> None:
super().f(*args, **kwargs) # Type checks okay by definition.
Or perhaps Parameters[Super.f]
? Or SuperParameters
?
Yes, exactly like omit! Great comparison and great point. Definitely does not need to be in a PEP about parameters.
Also, we may want to make the parameter forwarding more succinct. Instead of:
def f(*args: Parameters[f].args, **kwargs: Parameters[f].kwargs)
maybe we should allow:
def f(*args: Parameters[f], **kwargs: ...)
flying-sheep (Philipp A.) October 22, 2024, 7:24am 10
Yeah, I think we’d need Omit
to make ParamSpec
/Concatenate
more useful too. Currently, it is intentionally hobbled by only being able to add or remove a single positional argument, which I rarely do in practice. Keyword arguments are much more useful for designing good APIs.
I’d love to see both of these typing APIs.
Beware: small rant.
Every time I have trouble typing something in TypeScript, there is some big-brained solution I just haven’t thought about, but that I can learn and apply to create elegant abstractions that are perfectly encapsulated by the type system.
Every time I have trouble typing something in Python, it’s something others have needed before me, but hasn’t been implemented or designed yet (or has been implemented in a underpowered proto-state for years), and it’s impossible to correctly type my API without hundreds of lines of repetition or writing a code generator.
Actually, I would like to defend Params.args
for kwargs-only usecases, it explicitly futureproofs your library code. Even if .args
is effectively “never”, it both simplifies typecheckers and improves readability.
I appreciate the back and forth in this thread already, a lot of ground has already been covered. Something I’d like to highlight library code returning instances of Protocol, Generic[P, R]
to reduce the maintenance burden on library consumers when upstreams add new features.
Issue: `@traceable` decorator breaks typing · Issue #551 · langchain-ai/langsmith-sdk · GitHub attempts to do this by way of:
@runtime_checkable
class SupportsLangsmithExtra(Protocol, Generic[P, R]):
def __call__(
self,
*args: P.args,
langsmith_extra: Optional[LangSmithExtra] = None,
**kwargs: P.kwargs,
) -> R: ...
pyright correctly identifies that the langsmith_extra
parameter is unsound, according to the docs:
- A function declared as
def inner(a: A, b: B, *args: P.args, **kwargs: P.kwargs) -> R
has typeCallable[Concatenate[A, B, P], R]
. Placing keyword-only parameters between the*args
and**kwargs
is forbidden.
This restriction make sense, as without being able to scrutinize P.kwargs
(or P.args
!) both library maintainers and consumers could inadvertently introduce name/position collisions. Unfortunately, the result of this analysis seems to be to just abandon typechecking in calling code, since the error is in the library code. I admittedly haven’t dug into the particulars of why pyright does this, but it highlights a need for clarity here.
While Unpack[P.kwargs]
seems to be the closest to what I would want when writing a wrapper function, it doesn’t address positionality of kwarg’d arguments.
It would be nice to support the following:
@runtime_checkable
class SupportsLangsmithExtra(Protocol, Generic[P, R]):
def __call__(
self,
*args: P.args,
**kwargs: P.kwargs, # or **kwargs: Unpack[P.kwargs],
/, *,
langsmith_extra: Optional[LangSmithExtra] = None,
) -> R: ...
Requiring /, *,
after P.kwargs
and before subsequent keyword-only parameters would communicate both intent and make it clear to the reader what issues we are attempting to prevent. This also would codify a longstanding tradition in untyped python of being able to extend wrapped functions, but doing so in a safe way.
alwaysmpe (Marc Edwards) December 23, 2024, 9:52pm 12
If your function is kw only, it’s possible (but a little clunky) to create a mechanism:
from typing import TYPE_CHECKING, TypedDict, NotRequired
class KwargsBase(TypedDict):
foo: int
baz: str
class Kwargs(KwargsBase):
bar: NotRequired[float | None]
def takes_kwargs(*, foo: int, bar: float | None= None, baz: str) -> None:
...
if TYPE_CHECKING:
from typing import Protocol, Unpack, Callable
class _TakesAllKwargs(Protocol):
def __call__[**Pa](self, fn: Callable[Pa, object]) -> Callable[[Callable[Pa, object]], object]:
...
def takes_kwargs_td(**_kwargs: Unpack[Kwargs]) -> None:
...
def takes_kwargs_base_td(**_kwargs: Unpack[KwargsBase]) -> None:
...
def check_san_compatible(fn: _TakesAllKwargs) -> None:
fn(takes_kwargs_td)(takes_kwargs) # no type error
fn(takes_kwargs)(takes_kwargs_td) # no type error
# if optional keys are only missing on one side
# both calls are needed to raise a type error
fn(takes_kwargs_base_td)(takes_kwargs) # no type error
fn(takes_kwargs)(takes_kwargs_base_td) # type error
which type checks correctly in pyright
With a bit of re-jigging it could be:
from collections.abc import Callable
from typing import Any, Literal
def apply[**P](
func: Callable[P, Any],
axis: int=0,
raw: bool=False,
result_type: str | None=None,
by_row:Literal[False, "compat"] ='compat',
engine: Literal["python", "numba"]='python',
engine_kwargs: dict[str, Any] = None,
*args: P.args,
**kwargs: P.kwargs,
) -> None:
...
but doesn’t help for engine kwargs (but that could probably be done with overload/Typeddict)
tstefan (Stefan) December 24, 2024, 12:48pm 13
Why not using functools.wraps
?
alwaysmpe (Marc Edwards) December 24, 2024, 8:35pm 14
That copies attributes like __doc__
and __annotations__
from one function to another so that at runtime the wrapper looks like the wrapped but it doesn’t give an error if they’re incompatible until you try to use it.
tstefan (Stefan) December 25, 2024, 12:12pm 15
Yes, that is how functools.wraps
works right now. However, that this is inconsistent to the inspect
module. Consider
import functools
import inspect
def foo(i: int): print(i)
@functools.wraps(foo)
def bar(*args, **kwargs):
print("bar")
return foo(*args, **kwargs)
print(inspect.signature(bar)) # line 11
bar("1") # line 12
Line 11 prints (i: int)
, i.e., the signature of foo
. However, functools.pyi in typeshed works differently and preserves the signature of bar
. By changing line 88 in functools.pyi
- def __call__(self, *args: _PWrapper.args, **kwargs: _PWrapper.kwargs) -> _RWrapper: ...
+ def __call__(self, *args: _PWrapped.args, **kwargs: _PWrapped.kwargs) -> _RWrapped: ...
functools.wraps
behaves consistent to inspect
and as requested in the original post. I have tested this with Python 3.13 and mypy 1.14 and got:
test.py:12: error: Argument 1 to "__call__" of "_Wrapped" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)