ParamSpec for a pre-defined function, without using generic Callable[P]

Question:

I want to write a wrapper function for a known function, like

    def wrapper(*args, **kwargs)
         foo()
         return known_function(*args, **kwargs)

How can i add type-annotations to wrapper, such that it exactly follows the type annotations of known_function


I have looked at ParamSpec, but it appears to only work when the wrapper-function is generic and takes the inner function as argument.

    P = ParamSpec("P")
    T = TypeVar('T')
    def wrapper(func_arg_that_i_dont_want: Callable[P,T], *args: P.args, **kwargs: P.kwargs)
         foo()
         return known_function(*args, **kwargs)

Can i force the P to only be valid for known_function, without linking it to a Callable-argument?

Asked By: HTE

||

Answers:

PEP 612 as well as the documentation of ParamSpec.args and ParamSpec.kwargs are pretty clear on this:


These “properties” can only be used as the annotated types for *args and **kwargs, accessed from a ParamSpec already in scope.

– Source: PEP 612 ("The components of a ParamSpec" -> "Valid use locations")


Both attributes require the annotated parameter to be in scope.

– Source: python.typing module documentation (class typing.ParamSpec -> args/kwargs)


They [parameter specifications] are only valid when used in Concatenate, or as the first argument to Callable, or as parameters for user-defined Generics.

– Source: python.typing module documentation (class typing.ParamSpec, second paragraph)


So no, you cannot use parameter specification args/kwargs, without binding it a concrete Callable in the scope you want to use them in.

I question why you would even want that. If you know that wrapper will always call known_function and you want it to (as you said) have the exact same arguments, then you just annotate it with the same arguments. Example:

def known_function(x: int, y: str) -> bool:
    return str(x) == y


def wrapper(x: int, y: str) -> bool:
    # other things...
    return known_function(x, y)

If you do want wrapper to accept additional arguments aside from those passed on to known_function, then you just include those as well:

def known_function(x: int, y: str) -> bool:
    return str(x) == y


def wrapper(a: float, x: int, y: str) -> bool:
    print(a ** 2)
    return known_function(x, y)

If your argument is that you don’t want to repeat yourself because known_function has 42 distinct and complexly typed parameters, then (with all due respect) the design of known_function should be covered in copious amounts gasoline and set ablaze.


If you insist to dynamically associate the parameter specifications (or are curious about possible workarounds for academic reasons), the following is the best thing I can think of.

You write a protected decorator that is only intended to be used on known_function. (You could even raise an exception, if it is called with anything else to protect your own sanity.) You define your wrapper inside that decorator (and add any additional arguments, if you want any). Thus, you’ll be able to annotate its *args/**kwargs with the ParamSpecArgs/ParamSpecKwargs of the decorated function. In this case you probably don’t want to use functools.wraps because the function you receive out of that decorator is probably intended not to replace known_function, but stand alongside it.

Here is a full working example:

from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar


P = ParamSpec("P")
T = TypeVar("T")


def known_function(x: int, y: str) -> bool:
    """Does thing XY"""
    return str(x) == y


def _decorate(f: Callable[P, T]) -> Callable[Concatenate[float, P], T]:
    if f is not known_function:  # type: ignore[comparison-overlap]
        raise RuntimeError("This is an exclusive decorator.")

    def _wrapper(a: float, /, *args: P.args, **kwargs: P.kwargs) -> T:
        """Also does thing XY, but first does something else."""
        print(a ** 2)
        return f(*args, **kwargs)
    return _wrapper


wrapper = _decorate(known_function)


if __name__ == "__main__":
    print(known_function(1, "2"))
    print(wrapper(3.14, 10, "10"))

Output as expected:

False
9.8596
True

Adding reveal_type(wrapper) to the script and running mypy gives the following:

Revealed type is "def (builtins.float, x: builtins.int, y: builtins.str) -> builtins.bool"

PyCharm also gives the relevant suggestions regarding the function signature, which it infers from having known_function passed into _decorate.

But again, just to be clear, I don’t think this is good design. If your "wrapper" is not generic, but instead always calls the same function, you should explicitly annotate it, so that its parameters correspond to that function. After all:

Explicit is better than implicit.

Zen of Python, line 2

Answered By: Daniil Fajnberg

if the parameters match exactly you can use this 1-1, if there’s a mismatch you would need to also use Concatenate and modify it a bit, i have yet to find a generic solution for that case. this is a modified version of the above answer from @Daniil Fajnberg

from typing import TypeVar, ParamSpec, Callable

P = ParamSpec("P")
T = TypeVar("T")
S = TypeVar("S")

def paramspec_from(_: Callable[P, T]) -> Callable[[Callable[P, S]], Callable[P, S]]:
    def _fnc(fnc: Callable[P, S]) -> Callable[P, S]:
        return fnc
    return _fnc

then you can use it like this:

@paramspec_from(known_function)
def wrapper(*args, **kwargs) -> string:
    foo()
    known_function(*args, **kwargs)
    return "whatever"

The problem is that mypy only looks at the function header to resolve the ParamSpec, not the function body, so it cannot deduce the types of args and kwargs. but if we lift the function into the header, like by passing it into the decorator as an argument, mypy can deduce the parameters correctly. As you see we don’t even use the function, we marked it with _. we only use it for its typehints.

Answered By: Martin Zihlmann
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.