Reputation: 8071
Consider the following code:
from typing import Callable, Any
TFunc = Callable[..., Any]
def get_authenticated_user(): return "John"
def require_auth() -> Callable[TFunc, TFunc]:
def decorator(func: TFunc) -> TFunc:
def wrapper(*args, **kwargs) -> Any:
user = get_authenticated_user()
if user is None:
raise Exception("Don't!")
return func(*args, **kwargs)
return wrapper
return decorator
@require_auth()
def foo(a: int) -> bool:
return bool(a % 2)
foo(2) # Type check OK
foo("no!") # Type check failing as intended
This piece of code is working as intended. Now imagine I want to extend this, and instead of just executing func(*args, **kwargs)
I want to inject the username in the arguments. Therefore, I modify the function signature.
from typing import Callable, Any
TFunc = Callable[..., Any]
def get_authenticated_user(): return "John"
def inject_user() -> Callable[TFunc, TFunc]:
def decorator(func: TFunc) -> TFunc:
def wrapper(*args, **kwargs) -> Any:
user = get_authenticated_user()
if user is None:
raise Exception("Don't!")
return func(*args, user, **kwargs) # <- call signature modified
return wrapper
return decorator
@inject_user()
def foo(a: int, username: str) -> bool:
print(username)
return bool(a % 2)
foo(2) # Type check OK
foo("no!") # Type check OK <---- UNEXPECTED
I can't figure out a correct way to type this. I know that on this example, decorated function and returned function should technically have the same signature (but even that is not detected).
Upvotes: 78
Views: 69111
Reputation: 1197
PEP 612 was accepted after the accepted answer, and we now have typing.ParamSpec
and typing.Concatenate
in Python 3.10. With these variables, we can correctly type some decorators that manipulate positional parameters.
The code in question can be typed like this:
from typing import Callable, ParamSpec, Concatenate, TypeVar
Param = ParamSpec("Param")
RetType = TypeVar("RetType")
def get_authenticated_user()->str:
return "John"
def inject_user() -> Callable[[Callable[Param, RetType]], Callable[Concatenate[str, Param], RetType]]:
def decorator(func: Callable[Param, RetType]) -> Callable[Concatenate[str, Param], RetType]:
def wrapper(user: str, *args:Param.args, **kwargs:Param.kwargs) -> RetType:
user = get_authenticated_user()
if user is None:
raise Exception("Don't!")
return func(*args, **kwargs)
return wrapper
return decorator
@inject_user()
def foo(a: int) -> bool:
return bool(a % 2)
reveal_type(foo) # # I: Revealed type is "def (builtins.str, a: builtins.int) -> builtins.bool"
foo("user", 2) # Type check OK
foo("no!") # E: Missing positional argument "a" in call to "foo" [call-arg]
foo(3) # # E: Missing positional argument "a" in call to "foo" [call-arg] # E: Argument 1 to "foo" has incompatible type "int"; expected "str" [arg-type]
Upvotes: 94
Reputation: 69
The problem is solved using the decohints
library:
pip install decohints
Here is how it will work with your code:
from decohints import decohints
def get_authenticated_user():
return "John"
@decohints
def inject_user():
def decorator(func):
def wrapper(*args, **kwargs):
user = get_authenticated_user()
if user is None:
raise Exception("Don't!")
return func(*args, user, **kwargs) # <- call signature modified
return wrapper
return decorator
@inject_user()
def foo(a: int, username: str) -> bool:
print(username)
return bool(a % 2)
If you type below foo()
in PyCharm and wait, it will show foo
function parameter hints (a: int, username: str)
.
Here is a link to the decohints
sources, there are also other options for solving this problem: https://github.com/gri-gus/decohints
Upvotes: -6
Reputation: 600
I tested this in Pyright.
from typing import Any, Callable, Type, TypeVar
T = TypeVar('T')
def typing_decorator(rtype: Type[T]) -> Callable[..., Callable[..., T]]:
"""
Useful function to typing a previously decorated func.
```
@typing_decorator(rtype = int)
@my_decorator()
def my_func(a, b, *c, **d):
...
```
In Pyright the return typing of my_func will be int.
"""
def decorator(function: Any) -> Any:
def wrapper(*args: Any, **kwargs: Any) -> Any:
return function(*args, **kwargs)
return wrapper
return decorator # type: ignore
Upvotes: 0
Reputation: 1123440
You can't use Callable
to say anything about additional arguments; they are not generic. Your only option is to say that your decorator takes a Callable
and that a different Callable
is returned.
In your case you can nail down the return type with a typevar:
RT = TypeVar('RT') # return type
def inject_user() -> Callable[[Callable[..., RT]], Callable[..., RT]]:
def decorator(func: Callable[..., RT]) -> Callable[..., RT]:
def wrapper(*args, **kwargs) -> RT:
# ...
Even then the resulting decorated foo()
function has a typing signature of def (*Any, **Any) -> builtins.bool*
when you use reveal_type()
.
Various proposals are currently being discussed to make Callable
more flexible but those have not yet come to fruition. See
Callable
to be able to specify argument names and kinds for some examples. The last one in that list is an umbrella ticket that includes your specific usecase, the decorator that alters the callable signature:
Mess with the return type or with arguments
For an arbitrary function you can't do this at all yet -- there isn't even a syntax. Here's me making up some syntax for it.
Upvotes: 64