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.