An Introduction to Decorators in Python
Table of Contents
Introduction
Decorators can be a daunting topic when first encountered. While The Zen of Python states “There should be one– and preferably only one –obvious way to do it”, there are many, equally valid ways to implement the same decorator. These different methods can be categorized as either function-based, class-based, or a hybrid of both. In this post I will explain the design and behavior of Python decorators and provide examples of decorators that I frequently use in my own code.
What is a Decorator?
In Python, absolutely everything is an object, including functions. Since functions are objects, they can be passed as arguments to another function, they can be the return value of a function, and they can be assigned to a variable. If you understand these concepts then you have everything you need to understand decorators.
A decorator is any callable object that takes a function as an input parameter. I specifically said “callable object” rather than “function” since Python allows you to create other types of callable objects. This interesting language feature is what allows us to create class-based decorators, as we will see shortly.
A Simple Decorator
Decorators are wrappers that allow you to execute code before and after the “wrapped” (or “decorated”) function is executed. By manually constructing a decorator function this “wrapping” effect can easily be demonstrated. Consider the decorators.basic
module given below:
|
|
Line 2:
simple_decorator
accepts a function as a parameter, making it a decorator.Line 3:
function_wrapper
(defined withinsimple_decorator
) allows us to execute code before and after executing the wrapped function.Line 5: This print statement will be executed before the wrapped function.
Line 6: The wrapped function is executed within
function_wrapper
.Line 7: This print statement will be executed after the wrapped function
Line 9: This is probably the most confusing part.
function_wrapper
is the return value of the decorator function (simple_decorator
). At this point, the wrapped function (function_to_decorate
) HAS NOT been executed.Line 12: We will use this function to demonstrate how
simple_decorator
works.
Open an interactive Python shell and execute undecorated_function()
to see the behavior before applying any decorator. Next, we pass undecorated_function
as a parameter to simple_decorator
, and store the return value in decorated_function
:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.basic import *
>>> undecorated_function()
I FORBID you from modifying how this function behaves!
>>> decorated_function = simple_decorator(undecorated_function)
Finally, we execute decorated_function()
:
>>> decorated_function()
Preparing to execute: undecorated_function
I FORBID you from modifying how this function behaves!
Finished executing: undecorated_function
This is the result we expect after applying simple_decorator
. However, wouldn’t it be better if we could permanently alter the behavior of undecorated_function
? We can easily do this if we replace the reference to undecorated_function
with the function returned by simple_decorator
:
>>> undecorated_function = simple_decorator(undecorated_function)
>>> undecorated_function()
Preparing to execute: undecorated_function
I FORBID you from modifying how this function behaves!
Finished executing: undecorated_function
This is exactly what happens when you decorate a function using Python’s @decorator
syntax. To reinforce this point, we can modify our code to use the normal decorator syntax. Note that we are applying the decorator to undecorated_function
in Line 12:
|
|
Let’s verify that the behavior of the function has been modified:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.basic import *
>>> undecorated_function()
Preparing to execute: undecorated_function
I FORBID you from modifying how this function behaves!
Finished executing: undecorated_function
As you can see, @simple_decorator
is just syntactic sugar for undecorated_function = simple_decorator(undecorated_function)
.
This type of decorator is nice, but what if our original function has one or more input parameters? It wouldn’t be possible to provide values for these parameters with the current implementation of simple_decorator
. What can we do to fix this?
Passing Arguments to the Wrapped Function
Fixing this is very easy. Remember, when we execute the “wrapped” function, we are really executing the function returned by the decorator function (function_wrapper
in the previous example). Any values we provide to this function can be passed on to the wrapped function.
However, we need to accommodate all possible combinations of input parameters. We can do this by modifying function_wrapper
to accept *args, **kwargs
and passing them on to function_to_decorate
. This has been implemented in a new module, decorators.better
:
|
|
Let’s test this version with the two decorated functions, greeting_anonymous
and greeting_personal
:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.better import *
>>> greeting_anonymous()
Preparing to execute: greeting_anonymous
Hello anonymous website viewer!
Finished executing: greeting_anonymous
>>> greeting_personal()
Preparing to execute: greeting_personal
Hello Aaron!
Finished executing: greeting_personal
>>> greeting_personal(name="Jerry")
Preparing to execute: greeting_personal
Hello Jerry!
Finished executing: greeting_personal
This works exactly the way we need it to, the value we passed to greeting_personal
is used because it was passed from function_wrapper
to function_to_decorate
. Also, we did not break any existing functionality since the function that does not take any input parameters (greeting_anonymous
) is also decorated and behaves as expected.
Passing Arguments to the Decorator
The decorators we have created so far are certainly useful for many different scenarios, but they are also limited since there is no way to pass arguments to the decorator itself. How is this different than the last decorator we created? A decorator that accepts arguments would have the form shown below:
@foo(baz, fiz=buz)
def bar():
...
If you remember back to the beginning of this post, I mentioned that, in general, there are two different ways to implement Python decorators: function-based and class-based. The examples we have seen so far have all been function-based. Unless you need to create a decorator that accepts arguments, you should use these function-based designs since they are more readable and require less nesting/indentation than the equivalent class-based design.
However, when you need to pass arguments to a decorator, there isn’t an advantage to using either the function-based or class-based design. Let’s take a look at the function-based design first since it is a natural progression from the decorators we have already examined.
Function-based Design
The generic form of a function-based decorator that accepts arguments is given below:
|
|
In order to pass arguments to the decorator, we add another wrapper function, called decorator_factory
(Line 2). I am calling it a factory because the return value is the actual function decorator (decorator_with_args
is where function_to_decorate
is passed in as an argument).
In order to understand how this works you need to realize that Line 15 (where we are applying the decorator to a function) is actually a function call to decorator_factory("foo", "bar")
which returns decorator_with_args
. decorator_with_args
is the actual decorator but in order to pass the params “foo” and “bar” to the decorator, we had to call the factory method that creates the actual decorator which accepts the function special_greeting
as an argument and wraps it.
Contrast this with the other decorators we have created: when a function is decorated with @simple_decorator
or @a_better_decorator
these are not function calls (notice that there are no parentheses after the decorator name).
Let’s fire up the REPL and test this decorator. We will also verify that we are still able to use the decorated function as expected by passing arguments that modify its behavior:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.function_based import *
>>> special_greeting()
First argument provided to decorator: foo
Allow me to give a very special welcome to Dennis!
Second argument provided to decorator: bar
>>> special_greeting(name="Harold")
First argument provided to decorator: foo
Allow me to give a very special welcome to Harold!
Second argument provided to decorator: bar
As you can see, the arguments that were passed into the decorator ("foo"
and "bar"
) were used by the wrapper function, and the value passed into the decorated function ("Dennis"
/"Harold"
) is also used when the wrapper function is executed.
Functions, Classes and Callables
Are you familiar with the built-in callable
function? callable
accepts a single argument and returns a bool value: True
if the object provided appears to be callable and False
if it does not. If you think that functions are the only “callable” that exists you might consider this function rather useless or unnecessary. However, classes are also callable since this is how new instances are created (e.g., object = MyObject()
).
The call syntax, (...)
, can call functions or create class instances as we have just seen. But Python has a unique feature that objects other than functions can also be called. Adding the __call__
method to any class will make instances of that class callable. This allows us to create decorators that are implemented using classes.
Class-based Design
The same decorator can be implemented using a callable class instance:
|
|
The main difference between the function-based and class-based designs is how the arguments passed to the decorator are handled. In the function-based approach, the arguments are available to function_wrapper
as local variables. In the class-based design, the arguments are provided to the __init__
method and assigned to instance variables which can be accessed from function_wrapper
.
We can confirm that this decorator behaves in exactly the same way as the function-based version:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.class_based import *
>>> special_greeting()
First argument provided to decorator: foo
Allow me to give a very special welcome to Dennis!
Second argument provided to decorator: bar
>>> special_greeting(name="Hank")
First argument provided to decorator: foo
Allow me to give a very special welcome to Hank!
Second argument provided to decorator: bar
Which Design Is Better?
Is there any advantage to using either decorator design? In my opinion, the class-based design is flatter and easier to read, making it the more Pythonic choice. However, I acknowledge that the function-based design is more conventional since the idea of a callable object that is an instance of a class (rather than a function) is not what most people expect when they encounter the concept of decorators.
Other than that, there are no obvious benefits to choosing one design over the other. You should use the design that makes the most sense to you.
Always Use functools.wraps
I have intentionally left out something very important from these decorator examples. You should ALWAYS decorate function_wrapper
(or whatever name is used in your application) with the functools.wraps
decorator (located in the standard library’s functools
module). Why is this important? Consider the example below:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.class_based import *
>>> import inspect
>>> special_greeting.__name__
'function_wrapper'
>>> inspect.signature(special_greeting)
<Signature (*args, **kwargs)>
When we inspect the name and signature of the special_greeting
function, we instead receive the name and signature of the decorator that was applied to it. While confusing, it becomes even more of a headache if you need to debug this code. This is easily fixed with the functools.wraps
decorator (Lines 2,11):
|
|
Now, if we inspect special_greeting
we will see the correct name and signature:
(venv) decorators $ python
Python 3.7.6 (default, Jan 19 2020, 06:08:58)
[Clang 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from decorators.class_based import *
>>> import inspect
>>> special_greeting.__name__
'special_greeting'
>>> inspect.signature(special_greeting)
<Signature (name='Dennis')>
Hopefully you have a better understanding of how decorators are designed and how they behave in Python. The remainder of this post will contain examples of decorators that I frequently use in my Python projects along with pytest functions that demonstrate the intended usage.
Example: Function Timeout
If you have ever written code that interacts with an outside service, you have probably encountered a situation where your program becomes stuck waiting for a response with no way to abort the function call. One way to get un-stuck is with the @timeout
decorator:
|
|
It’s very easy to use. Simply decorate any function with @timeout(seconds=X)
and specify the number of seconds to wait before aborting the function call as shown below (Line 7). If the function completes before the specified number of seconds have elapsed, your program will continue executing. However if the function has not completed after the specified number of seconds have elapsed, a TimeoutError
will be raised, aborting the function call.
In the simple test scenario below, the sleep
function waits for two seconds when called. Since it is decorated with @timeout(seconds=1)
a TimeoutError
will be raised one second after it is called. The test_timeout
function verifies that the correct error is raised (Lines 13-14).
|
|
Example: Retry Function
The next example is similar to the @timeout
decorator since both are designed to handle functions that are unreliable. The @retry
decorator adds retry logic to the decorated function.
To use it, you specify a set of exceptions
that can trigger a failed attempt, the number of failed attempts that can occur before aborting the function call (max_attempts
), the number of seconds to delay after each failed attempt before trying again (delay
) and an optional handler method to be called whenever an exception is raised (for logging, etc).
The actual decorator definition begins on Line 26 below. Before that, the custom Exception RetryLimitExceededError
is defined (Lines 6-13). This is the exception raised after max_attempts
to call the function have failed. The handle_failed_attempt
function (Lines 16-23) is provided as an example of what could be provided to the @retry
decorator’s on_failure
parameter.
|
|
We can use the @timeout
decorator to demonstrate and test the @retry
decorator. Since we need to know the type of Exception
that we expect to occur when we call the decorated function, we specify exceptions=(TimeoutError,)
since this is the error raised by the @timeout
decorator.
With the code below, we will attempt to call retry_with_timeout
a maximum of two times. Since the only thing this function does is wait for two seconds and we have decorated it with @timeout(seconds=1)
, calling it will always raise a TimeoutError
. Therefore, after the second failed attempt, the @retry
decorator will raise a RetryLimitExceededError
.
Finally, the test_retry_with_timeout
function verifies that the RetryLimitExceededError
is in fact raised after calling the retry_with_timeout
function.
|
|
Example: Log Call Signature and Execution Time
The most common application of decorators might be logging. It’s easy to see why, having the ability to run code immediately before and after a function is called allows you to report information and capture metrics.
This decorator uses the class-based design, and allows the user to provide a custom logger. If none is provided, a logger will be created based on the module that contains the wrapped function.
Whenever the function is called, the @LogCall()
decorator adds an info
level entry the the log with the following data:
- Timestamp when the function was called
- function name and values of all arguments provided to the function (i.e. the call signature)
- Time elapsed while executing the function
|
|
The code below tests the @LogCall()
decorator with a custom logger and with the default logger. With the default logger, we expect the name of the logger to be the name of the module containing the decorated function, tests.test_log_call
(Line 30). When a custom logger is provided, we expect the name to match the value we specified when the logger was created, custom_log
(Lines 10, 38).
A nice feature of this decorator is that the function call signature contains the names and values of all keyword arguments, even if a default value was used or if the name was not given when the call occurred. For example, the call to the decorated function in Line 28 is save_values("Aaron", "Charlie", "Ollie")
, but the call signature that is logged contains the names of all three arguments, save_values(a=Aaron, b=Charlie, c=Ollie)
(Line 32).
Similarly, the call to the decorated function in Line 36 is rand_time(max=4, add_random=True)
, which only provides two arguments. The call signature that is logged includes the default value of the missing argument, rand_time(min=1, max=4, add_random=True)
(Line 40).
|
|
If we run pytest
for these decorator examples, all of the tests pass:
(venv) decorators $ pytest tests/test_*
================================================================= test session starts ==================================================================
platform darwin -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /Users/aaronluna/Desktop/vigorish/venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/aaronluna/Desktop/vigorish, inifile: pytest.ini
plugins: clarity-0.3.0a0, black-0.3.8, mypy-0.5.0, dotenv-0.4.0, flake8-1.0.4, cov-2.8.1
collected 10 items
tests/test_log_call.py::FLAKE8 PASSED [ 10%]
tests/test_log_call.py::BLACK PASSED [ 20%]
tests/test_log_call.py::test_default_logger PASSED [ 30%]
tests/test_log_call.py::test_custom_logger PASSED [ 40%]
tests/test_retry.py::FLAKE8 PASSED [ 50%]
tests/test_retry.py::BLACK PASSED [ 60%]
tests/test_retry.py::test_retry_with_timeout PASSED [ 70%]
tests/test_timeout.py::FLAKE8 PASSED [ 80%]
tests/test_timeout.py::BLACK PASSED [ 90%]
tests/test_timeout.py::test_timeout PASSED [100%]
================================================================== 10 passed in 6.63s ==================================================================
Summary
If you would like to download all or some of the code from this post, you can easily do so from the Github gist linked below:
I hope this introduction to decorators in Python was helpful and easy to understand. If you have any questions, criticism or feedback please leave a comment. Thanks!