ParamSpec

October 3, 2021 (3y 7mo ago)

Archive

PEP 612 introduced ParamSpec, which you may have encountered. This blog post provides a concise refresher over of how to use ParamSpec for typing decorators. This will be very short, so I won't explain what it is, as the information already exists in the official docs

Here's a simple decorator

1from functools import wraps 
2
3def decorator(f):
4    @wraps(f)
5    def wrap(*args, **kwargs) -> _T:
6        return f(*args, **kwargs)
7    return wrap
8
9@decorator
10def foo(*args, **kwargs):
11    ...
12

How would you type that ?

1from typing import  Callable, Any
2from functools import wraps 
3
4def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
5    @wraps(f)
6    def wrap(*args: Any, **kwargs: Any) -> Any:
7        return f(*args, **kwargs)
8    return wrap
9

But I'm highly allergic to Any this callable can take any arbitrary number of arguments we do not specify args nor kwargs, it retruns anything, this anything is not bound to any type, this is so confusing.

I'll use a generic T as the return type of the function f, where f takes an arbitrary number of arguments and keyword arguments typed as _PSpec , this allows us to capture the structure of both args and kwargs.

1from typing import ParamSpec, TypeVar, Callable
2from functools import wraps 
3
4
5_T = TypeVar("_T")
6_PSpec = ParamSpec("_PSpec")
7
8def decorator(f: Callable[_PSpec, _T]) -> Callable[_PSpec, _T]:
9    @wraps(f)
10    def wrap(*args: _PSpec.args, **kwargs: _PSpec.kwargs) -> _T:
11        return f(*args, **kwargs)
12    return wrap
13
14@decorator
15def foo(*args,**kwargs):
16    ...
17

But what if the foo function is not so broad and perhaps takes a known positional typed argument maybe bar like:

class Bar:
	...
	
@decorator
def foo(bar: Bar, /, *args: bool, **kwargs: str) -> None:
    pass

How would you type that ? you'll need concatenate the argument type with the rest of the args and kwargs, where f as in foo in this context is typed as follows

f: Callable[Concatenate[Bar, _PSpec], _T]) 

So the decorator function becomes

def decorator(f: Callable[Concatenate[Bar, _PSpec], _T]) -> Callable[_PSpec, _T]:
    @wraps(f)
    def wrap(*args: _PSpec.args, **kwargs: _PSpec.kwargs) -> _T:
        return f(Bar(), *args, **kwargs)
    return wrap

Between the wrapper and the return function you can use the params as you like, log an action , send metrics, anything. But what if your function requires more parameters ? simply add it inside Concatenate

 class X:
    ...
def decorator(f: Callable[Concatenate[X,Bar, _PSpec], _T]) -> Callable[_PSpec, _T]:
    @wraps(f)
    def wrap(*args: _PSpec.args, **kwargs: _PSpec.kwargs) -> _T:
        x = X()
        bar = Bar()
        print(f'here is x:{x}')
        return f(x,bar, *args, **kwargs)
    return wrap

So f becomes

@decorator
def foo(x: X,bar: Bar,/, *args: bool, **kwargs: str) -> None:
    pass

Might be called with.

foo(X(),Bar(),True,kwarg1='...')

Pytest allows us to define fixtures, which are functions that set up or provide resources for our tests, so we don't keep redundant logic for each test case. In this case we only have one fixture.

Btw, for Pytest, when dealing with asynchronous test cases, you have to mark them as @pytest.mark.asyncio and also install an async plugin, for me I'm using pytest-asyncio.

Say I have a library called playground where the project structure is as follows (I use poetry btw).

The foo module has a class that contains another class which has a method that uses httpx to request API data asynchronously, this is a dummy example of course but you get the picture, we're trying to test the functionality of an object (function) that's two classes deep.

But if it's a Callable that you're mocking that's not exactly a @property, then you must provide a function for it, you can use a lambda if the function is simple, or define a normal function and assign it, if it's complex.

Subscribe to my newsletter. The extension of these thoughts and more.