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:

def do_some_stuff_safe(  
    *,  
    foo: int,  
    bar: float | None = None,  
    baz: BazEnum = BazEnum.SPAM,  
    default: int = 3,  
) -> int:  
  ...  
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:

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:

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)