Advanced Usage¶
Stacks¶
We're not limited to a single decorator per function, we can stack how many we want.
Let's see how that works:
def decorator1(func):
def wrapper(*args, **kwargs):
print("Decorator 1")
return func(*args, **kwargs)
return wrapper
def decorator2(func):
def wrapper(*args, **kwargs):
print("Decorator 2")
return func(*args, **kwargs)
return wrapper
Remark that the order in which we stack decorators matter:
Wraps¶
functools.wraps
is a utility function in the Python standard library that is often used in decorators to preserve the original function's metadata (such as its name, docstring, and annotations) in the wrapper function.
Here's an example of how functools.wraps
can be used in a decorator, and how the metadata's differ:
from functools import wraps
def dec_with_wraps(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Do something before the function is called
result = func(*args, **kwargs)
# Do something after the function is called
return result
return wrapper
@dec_with_wraps
def my_func():
"""This is my function"""
return "Hello, world!"
print(f"Name = '{my_func.__name__}'", f"Docs = '{my_func.__doc__}'", sep="\n")
# Name = 'my_func'
# Docs = 'This is my function'
def dec_no_wraps(func):
def wrapper(*args, **kwargs):
# Do something before the function is called
result = func(*args, **kwargs)
# Do something after the function is called
return result
return wrapper
@dec_no_wraps
def my_func():
"""This is my function"""
return "Hello, world!"
print(f"Name = '{my_func.__name__}'", f"Docs = '{my_func.__doc__}'", sep="\n")
# Name = 'wrapper'
# Docs = 'None'
As you can see, the two decorators dec_with_wraps
and dec_no_wraps
are identical; the only difference between the two cases is the use of @wraps
decorator in the former to preserve the metadata of the original function.
When we print the __name__
and __doc__
attributes of the function in the two different scenarios, we obtain completely different results! In particular, in the first case the metadata of the decorated my_func
are maintained, in the latter the metadata we obtain are those of the wrapper
function inside the decorator.
Decorators with arguments¶
Sometimes we have more complexity to model and to achieve that we need to be able to pass arguments to our decorator.
Let's assume that we want to run a function twice, or 3-times, or 4-times and so on.
Instead of writing different decorators that run the input function N times, we can go one level deeper, and define a function that takes the decorator arguments and returns the actual decorator function.
from functools import wraps
from typing import Callable
def repeat_n_times(n: int) -> Callable:
"""Gets as input the arguments to be used in the actual decorator"""
def decorator(func: Callable) -> Callable:
"""This is the actual decorator!"""
@wraps(func)
def wrapper(*args, **kwargs):
"""Returns a list with N func results"""
return [func(*args, **kwargs) for _ in range(n)]
return wrapper
return decorator
@repeat_n_times(n=2)
def say_hello(name: str) -> str:
return f"Hello {name}!"
print(say_hello("Fra"))
# ['Hello Fra!', 'Hello Fra!']
Do you feel confused? If the answer is yes, it is because it is kinda confusing!
A decorator with arguments is a function that takes arguments and returns another function that acts as the actual decorator.
This decorator function takes a function as an argument and returns a new function that modifies the original function in some way.
The key difference between a decorator with arguments and a regular decorator is that the decorator with arguments has an extra layer of nested functions. The outer function takes the arguments and returns the actual decorator function, while the inner function takes the original function as an argument and returns the modified function.
Remark that even if we define repeat_n_times
to have a default value for n
, when we decorate a function we need to call the decorator, since that returns the actual decorator that we want, namely we need to:
def repeat_n_times(n: int = 3) -> Callable:
...
@repeat_n_times()
def say_hello(name: str) -> str:
return f"Hello {name}!"
@repeat_n_times
def say_goodbye(name: str) -> str:
return f"Goodbye {name}!"
print(say_hello("Fra"))
# ['Hello Fra!', 'Hello Fra!', 'Hello Fra!']
print(say_goodbye("Fra"))
# <function __main__.repeat_n_times.<locals>.decorator.<locals>.wrapper>
Which is not really what we want for the say_goodbye
function!
Can we do it differently??? Sure we can! And that's how all decorators in deczoo are implemented.
Decorators with arguments, and a trick!¶
In the introduction we saw how a decorator is defined, let's stuck to such implementation but let's see how to add additional parameters and control flow without the need to have more level of indentation.
Here is a different implementation of repeat_n_times
, this time without a triple level of indentation:
from functools import wraps, partial
from typing import Callable
def repeat_n_times(func: Callable = None, n: int = 2) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
results = [func(*args, **kwargs) for _ in range(n)]
return results
if func is None:
return partial(repeat_n_times, n=n)
else:
return wrapper
Let's see what happens here:
repeat_n_times
takes as input the function to decorate (func
) as first argument, and any additional input right after.- To use this trick, every additional argument must have a default value (which can be
None
). - Within the decorator we implement a
wrapper
as usual, where we use any additional decorator argument (n
in this example). - Since
wrapper
is not run until execution time, we can then check what is the value offunc
:- if it is
None
, then it means that only additional arguments have been provided to the decorator, and therefore we return a partial decorator with the given arguments that will decorate our function. - Otherwise, the function is provided and we return the
wrapper
.
- if it is
This "trick" allows us to use the decorator with parens, providing custom arguments, or without parens, using defaults, i.e.
@repeat_n_times(n=3) # uses custom argument value
def say_hello(name: str) -> str:
return f"Hello {name}!"
@repeat_n_times # uses default argument value
def say_goodbye(name: str) -> str:
return f"Goodbye {name}!"
print(say_hello("Fra"))
# ["Hello Fra!", "Hello Fra!", "Hello Fra!"]
print(say_goodbye("Fra"))
# ['Goodbye Fra!', 'Goodbye Fra!']
Neat! This was possible to achieve using the control flow and partial
block at the end of the decorator.
Since in deczoo every decorator is implemented using this strategy, we wrote a sort of "meta-decorator", called check_parens that adds such block to every decorator!