Decorators¶
1. What are decorators?¶
1.1. What are decorators in Python?¶
Decorators were introduced in Python a long time ago as a mechanism to simplify the way functions and methods are defined when they have to be modified after their original definition.
One of the original motivations for this was because functions such as classmethod
and
staticmethod
were used to transform the original definition of the method, but they
required an extra line, modifying the original definition of the function.
More generally speaking, every time we had to apply a transformation to a function, we had to call it with the modifier function, and then reassign it to the same name the function was originally defined with.
For instance, if we have a function called original, and then we have a function that changes the behavior of original on top of it, called modifier, we have to write something like the following:
def original(...):
...
original = modifier(original)
Notice how we change the function and reassign it to the same name. This is confusing, error-prone (imagine that someone forgets to reassign the function, or does reassign that but not in the line immediately after the function definition, but much farther away), and cumbersome. For this reason, some syntax support was added to the language.
The previous example could be rewritten like so:
@modifier
def original(...):
...
This means that decorators are just syntax sugar for calling whatever is after the decorator as a first parameter of the decorator itself, and the result would be whatever the decorator returns.
In line with the Python terminology, and our example, modifier is what we call the decorator, and original is the decorated function, often also called a wrapped object.
While the functionality was originally thought for methods and functions, the actual syntax allows any kind of object to be decorated, so we are going to explore decorators applied to functions, methods, generators, and classes.
One final note is that, while the name of a decorator is correct (after all, the decorator is in fact, making changes, extending, or working on top of the wrapped function), it is not to be confused with the decorator design pattern.
1.2. Decorate functions¶
Functions are probably the simplest representation of a Python object that can be decorated. We can use decorators on functions to apply all sorts of logic to them—we can validate parameters, check preconditions, change the behavior entirely, modify its signature, cache results (create a memorized version of the original function), and more.
As an example, we will create a basic decorator that implements a retry mechanism, controlling a particular domain-level exception and retrying a certain number of times:
class ControlledException(Exception):
"""A generic exception on the program's domain."""
def retry(operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
RETRIES_LIMIT = 3
for _ in range(RETRIES_LIMIT):
try:
return operation(*args, **kwargs)
except ControlledException as e:
logger.info("retrying %s", operation.__qualname__)
last_raised = e
raise last_raised
return wrapped
The use of @wraps
can be ignored for now. The use of _
in the for loop, means that
the number is assigned to a variable we are not interested in at the moment, because it’s not
used inside the for loop (it’s a common idiom in Python to name _
values that are ignored).
The retry
decorator doesn’t take any parameters, so it can be easily applied to any
function, as follows:
@retry
def run_operation(task):
"""Run a particular task, simulating some failures on its execution."""
return task.run()
As explained at the beginning, the definition of @retry
on top of run_operation
is just
syntactic sugar that Python provides to actually execute run_operation = retry(run_operation)
.
In this limited example, we can see how decorators can be used to create a generic retry operation that, under certain conditions (in this case, represented as exceptions that could be related to timeouts, for example), will allow calling the decorated code multiple times.
1.2. Decorate classes¶
Classes can also be decorated with the same as can be applied to syntax functions. The only difference is that when writing the code for this decorator, we have to take into consideration that we are receiving a class, not a function.
Some practitioners might argue that decorating a class is something rather convoluted and that such a scenario might jeopardize readability because we would be declaring some attributes and methods in the class, but behind the scenes, the decorator might be applying changes that would render a completely different class.
This assessment is true, but only if this technique is heavily abused. Objectively, this is no different from decorating functions; after all, classes are just another type of object in the Python ecosystem, as functions are. For now, we’ll explore the benefits of decorators that apply particularly to classes:
All the benefits of reusing code and the DRY principle. A valid case of a class decorator would be to enforce that multiple classes conform to a certain interface or criteria (by making this checks only once in the decorator that is going to be applied to those many classes).
We could create smaller or simpler classes that will be enhanced later on by decorators
The transformation logic we need to apply to a certain class will be much easier to maintain if we use a decorator, as opposed to more complicated (and often rightfully discouraged) approaches such as metaclasses
Among all possible applications of decorators, we will explore a simple example to give an idea of the sorts of things they can be useful for. Keep in mind that this is not the only application type for class decorators, but also that the code we show you could have many other multiple solutions as well, all with their pros and cons, but we chose decorators with the purpose of illustrating their usefulness.
Recalling our event systems for the monitoring platform, we now need to transform the data for each event and send it to an external system. However, each type of event might have its own particularities when selecting how to send its data.
In particular, the event
for a login might contain sensitive information such as credentials
that we want to hide. Other fields such as timestamp
might also require some
transformations since we want to show them in a particular format. A first attempt at
complying with these requirements would be as simple as having a class that maps to each
particular event
and knows how to serialize it:
class LoginEventSerializer:
def __init__(self, event):
self.event = event
def serialize(self) -> dict:
return {
"username": self.event.username,
"password": "**redacted**",
"ip": self.event.ip,
"timestamp": self.event.timestamp.strftime("%Y-%m-%d %H:%M")
}
class LoginEvent:
SERIALIZER = LoginEventSerializer
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
def serialize(self) -> dict:
return self.SERIALIZER(self).serialize()
Here, we declare a class that is going to map directly with the login event, containing the logic for it: hide the password field, and format the timestamp as required.
While this works and might look like a good option to start with, as time passes and we want to extend our system, we will find some issues:
Too many classes: As the number of events grows, the number of serialization classes will grow in the same order of magnitude, because they are mapped one to one.
The solution is not flexible enough: If we need to reuse parts of the components (for example, we need to hide the password in another type of event that also has it), we will have to extract this into a function, but also call it repeatedly from multiple classes, meaning that we are not reusing that much code after all.
Boilerplate: The
serialize()
method will have to be present in all event classes, calling the same code. Although we can extract this into another class (creating a mixin), it does not seem like a good use of inheritance.
An alternative solution is to be able to dynamically construct an object that, given a set of filters (transformation functions) and an event instance, is able to serialize it by applying the filters to its fields. We then only need to define the functions to transform each type of field, and the serializer is created by composing many of these functions.
Once we have this object, we can decorate the class in order to add the serialize()
method, which will just call these Serialization objects with itself:
def hide_field(field) -> str:
return "**redacted**"
def format_time(field_timestamp: datetime) -> str:
return field_timestamp.strftime("%Y-%m-%d %H:%M")
def show_original(event_field):
return event_field
class EventSerializer:
def __init__(self, serialization_fields: dict) -> None:
self.serialization_fields = serialization_fields
def serialize(self, event) -> dict:
return {
field: transformation(getattr(event, field))
for field, transformation in
self.serialization_fields.items()
}
class Serialization:
def __init__(self, **transformations):
self.serializer = EventSerializer(transformations)
def __call__(self, event_class):
def serialize_method(event_instance):
return self.serializer.serialize(event_instance)
event_class.serialize = serialize_method
return event_class
@Serialization(
username=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time
)
class LoginEvent:
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
Notice how the decorator makes it easier for the user to know how each field is going to be treated without having to look into the code of another class. Just by reading the arguments passed to the class decorator, we know that the username and IP address will be left unmodified, the password will be hidden, and the timestamp will be formatted.
Now, the code of the class does not need the serialize()
method defined, nor does it
need to extend from a mixin that implements it, since the decorator will add it. In fact, this
is probably the only part that justifies the creation of the class decorator, because otherwise,
the Serialization
object could have been a class attribute of LoginEvent
, but the fact
that it is altering the class by adding a new method to it makes it impossible.
Moreover, we could have another class decorator that, just by defining the attributes of the
class, implements the logic of the init method, but this is beyond the scope of this
example. This is what libraries such as attrs
do, and a similar functionality is
proposed in for the Standard library.
By using this class decorator, the previous example could be
rewritten in a more compact way, without the boilerplate code of the init
, as shown here:
from dataclasses import dataclass
from datetime import datetime
@Serialization(
username=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime
Note that @dataclass
is a decorator that is used to add generated special methods to classes.
It examines the class to find fields. A field is defined as class variable that has a type annotation.
Nothing in dataclass()
examines the type specified in the variable annotation.
1.3. Other types of decorator¶
Now that we know what the @
syntax for decorators actually means, we can conclude that
it isn’t just functions, methods, or classes that can be decorated; actually, anything that can
be defined, such as generators, coroutines, and even objects that have already been
decorated, can be decorated, meaning that decorators can be stacked.
The previous example showed how decorators can be chained. We first defined the class,
and then applied @dataclass
to it, which converted it into a data class, acting as a
container for those attributes. After that, the @Serialization
will apply the logic to that
class, resulting in a new class with the new serialize()
method added to it.
Another good use of decorators is for generators that are supposed to be used as
coroutines. The main idea is that, before sending any data to a newly created generator,
the latter has to be advanced up to their next yield
statement by calling next()
on it. This
is a manual process that every user will have to remember and hence is error-prone. We
could easily create a decorator that takes a generator as a parameter, calls next()
to it, and
then returns the generator.
1.4. Passing arguments to decorators¶
At this point, we already regard decorators as a powerful tool in Python. However, they could be even more powerful if we could just pass parameters to them so that their logic is abstracted even more.
There are several ways of implementing decorators that can take arguments, but we will go over the most common ones. The first one is to create decorators as nested functions with a new level of indirection, making everything in the decorator fall one level deeper. The second approach is to use a class for the decorator.
In general, the second approach favors readability more, because it is easier to think in terms of an object than three or more nested functions working with closures. However, for completeness, we will explore both, and the reader can decide what is best for the problem at hand.
1.4.1. Decorators with nested functions¶
Roughly speaking, the general idea of a decorator is to create a function that returns a function (often called a higher-order function). The internal function defined in the body of the decorator is going to be the one actually being called.
Now, if we wish to pass parameters to it, we then need another level of indirection. The first one will take the parameters, and inside that function, we will define a new function, which will be the decorator, which in turn will define yet another new function, namely the one to be returned as a result of the decoration process. This means that we will have at least three levels of nested functions.
Don’t worry if this didn’t seem clear so far. After reviewing the examples that are about to come, everything will become clear.
One of the first examples we saw of decorators implemented the retry functionality over some functions. This is a good idea, except it has a problem; our implementation did not allow us to specify the numbers of retries, and instead, this was a fixed number inside the decorator.
Now, we want to be able to indicate how many retries each instance is going to have, and
perhaps we could even add a default value to this parameter. In order to do this, we need
another level of nested functions—first for the parameters, and then for the decorator itself.
This is because we are now going to have something in the form of the following:
@retry(arg1, arg2,... )
. And that has to return a decorator because the @
syntax will apply the result
of that computation to the object to be decorated. Semantically, it would translate to something
like the following: <original_function> = retry(arg1, arg2, ....)(<original_function>)
Besides the number of desired retries, we can also indicate the types of exception we wish to control. The new version of the code supporting the new requirements might look like this:
RETRIES_LIMIT = 3
def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
allowed_exceptions = allowed_exceptions or (ControlledException,)
def retry(operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
for _ in range(retries_limit):
try:
return operation(*args, **kwargs)
except allowed_exceptions as e:
logger.info("retrying %s due to %s", operation, e)
last_raised = e
raise last_raised
return wrapped
return retry
Here are some examples of how this decorator can be applied to functions, showing the different options it accepts:
@with_retry()
def run_operation(task):
return task.run()
@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task):
return task.run()
@with_retry(allowed_exceptions=(AttributeError,))
def run_with_custom_exceptions(task):
return task.run()
@with_retry(
retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError)
)
def run_with_custom_parameters(task):
return task.run()
1.4.2. Decorator objects¶
The previous example requires three levels of nested functions. The first it is going to be a function that receives the parameters of the decorator we want to use. Inside this function, the rest of the functions are closures that use these parameters along with the logic of the decorator.
A cleaner implementation of this would be to use a class to define the decorator. In this
case, we can pass the parameters in the __init__
method, and then implement the logic of
the decorator on the magic method named __call__
.
The code for the decorator will look like it does in the following example:
class WithRetry:
def __init__(self, retries_limit=RETRIES_LIMIT,
allowed_exceptions=None):
self.retries_limit = retries_limit
self.allowed_exceptions = allowed_exceptions or (ControlledException,)
def __call__(self, operation):
@wraps(operation)
def wrapped(*args, **kwargs):
last_raised = None
for _ in range(self.retries_limit):
try:
return operation(*args, **kwargs)
except self.allowed_exceptions as e:
logger.info("retrying %s due to %s", operation, e)
last_raised = e
raise last_raised
return wrapped
And this decorator can be applied pretty much like the previous one, like so:
@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task):
return task.run()
It is important to note how the Python syntax takes effect here. First, we create the object, so
before the @
operation is applied, the object is created with its parameters passed to it. This
will create a new object and initialize it with these parameters, as defined in the init
method. After this, the @
operation is invoked, so this object will wrap the function named
run_with_custom_retries_limit
, meaning that it will be passed to the call magic
method.
Inside this call
magic method, we defined the logic of the decorator as we normally
do: we wrap the original function, returning a new one with the logic we want instead.
1.5. Good uses for decorators¶
In this section, we will take a look at some common patterns that make good use of decorators. These are common situations for when decorators are a good choice.
From all the countless applications decorators can be used for, we will enumerate a few, the most common or relevant:
Transforming parameters: Changing the signature of a function to expose a nicer API, while encapsulating details on how the parameters are treated and transformed underneath.
Tracing code: Logging the execution of a function with its parameters.
Validate parameters.
Implement retry operations.
Simplify classes by moving some (repetitive) logic into decorators.
1.5.1. Transforming parameters¶
We have mentioned before that decorators can be used to validate parameters (and even enforce some preconditions or postconditions under the idea of DbC), so from this you probably have got the idea that it is somehow common to use decorators when dealing with or manipulating parameters.
In particular, there are some cases on which we find ourselves repeatedly creating similar objects, or applying similar transformations that we would wish to abstract away. Most of the time, we can achieve this by simply using a decorator.
1.5.2. Tracing code¶
When talking about tracing in this section, we will refer to something more general that has to do with dealing with the execution of a function that we wish to monitor. This could refer to scenarios in which we want to:
Actually trace the execution of a function (for example, by logging the lines it executes)
Monitor some metrics over a function (such as CPU usage or memory footprint)
Measure the running time of a function
Log when a function was called, and the parameters that were passed to it
2. Effective decorators: avoid common mistakes¶
While decorators are a great feature of Python, they are not exempt from issues if used incorrectly. In this section, we will see some common issues to avoid in order to create effective decorators.
2.1. Preserving data about the original wrapped object¶
One of the most common problems when applying a decorator to a function is that some of the properties or attributes of the original function are not maintained, leading to undesired, and hard-to-track, side-effects.
To illustrate this we show a decorator that is in charge of logging when the function is about to run:
def trace_decorator(function):
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped
Now, let’s imagine we have a function with this decorator applied to it. We might initially think that nothing of that function is modified with respect to its original definition:
@trace_decorator
def process_account(account_id):
"""Process an account by Id."""
logger.info("processing account %s", account_id)
...
But maybe there are changes.
The decorator is not supposed to alter anything from the original function, but, as it turns out since it contains a flaw it’s actually modifying its name and docstring, among other properties.
Let’s try to get help for this function:
>>> help(process_account)
Help on function wrapped in module decorator_wraps_1:
wrapped(*args, **kwargs)
And let’s check how it’s called: .. code-block:: python
>>> process_account.__qualname__
'trace_decorator.<locals>.wrapped'
We can see that, since the decorator is actually changing the original function for a new one
(called wrapped
), what we actually see are the properties of this function instead of those
from the original function.
If we apply a decorator like this one to multiple functions, all with different names, they will all end up being called wrapped, which is a major concern (for example, if we want to log or trace the function, this will make debugging even harder).
Another problem is that, in case we placed docstrings with tests on these functions, they
will be overridden by those of the decorator. As a result, the docstrings with the test we
want will not run when we call our code with the doctest
module.
The fix is simple, though. We just have to apply the wraps decorator in the internal
function (wrapped
), telling it that it is actually wrapping function :
def trace_decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("running %s", function.__qualname__)
return function(*args, **kwargs)
return wrapped
Now, if we check the properties, we will obtain what we expected in the first place. Check help for the function, like so:
>>> Help on function process_account in module decorator_wraps_2:
process_account(account_id)
Process an account by Id.
And verify that its qualified name is correct, like so:
>>> process_account.__qualname__
'process_account'
Most importantly, we recovered the unit tests we might have had on the docstrings! By
using the wraps decorator, we can also access the original, unmodified function under the
__wrapped__
attribute. Although it should not be used in production, it might come in
handy in some unit tests when we want to check the unmodified version of the function.
In general, for simple decorators, the way we would use functools.wraps
would
typically follow the general formula or structure:
def decorator(original_function):
@wraps(original_function)
def decorated_function(*args, **kwargs):
# modifications done by the decorator ...
return original_function(*args, **kwargs)
return decorated_function
Note
Always use functools.wraps
applied over the wrapped function, when creating a decorator, as shown in the preceding formula.
2.2. Dealing with side-effects in decorators¶
In this section, we will learn that it is advisable to avoid side-effects in the body of the decorator. There are cases where this might be acceptable, but the bottom line is that, if in case of doubt, decide against it, for the reasons that are explained ahead. Everything that the decorator needs to do aside from the function that it’s decorating should be placed in the innermost function definition, or there will be problems when it comes to importing.
Nonetheless, sometimes these side-effects are required (or even desired) to run at import time, and the obverse applies.
We will see examples of both, and where each one applies. If in doubt, err on the side of
caution, and delay all side-effects until the very latest, right after the wrapped
function is
going to be called.
Next, we will see when it’s not a good idea to place extra logic outside the wrapped
function.
2.2.1. Incorrect handling of side-effects in a decorator¶
Let’s imagine the case of a decorator that was created with the goal of logging when a function started running and then logging its running time:
def traced_function_wrong(function):
logger.info("started execution of %s", function)
start_time = time.time()
@functools.wraps(function)
def wrapped(*args, **kwargs):
result = function(*args, **kwargs)
logger.info(
"function %s took %.2fs",
function,
time.time() - start_time
)
return result
return wrapped
Now we will apply the decorator to a regular function, thinking that it will work just fine:
@traced_function_wrong
def process_with_delay(callback, delay=0):
time.sleep(delay)
return callback()
This decorator has a subtle, yet critical bug in it. First, let’s import the function, call it several times, and see what happens:
>>> from decorator_side_effects_1 import process_with_delay
INFO:started execution of <function process_with_delay at 0x...>
Just by importing the function, we will notice that something’s amiss. The logging line should not be there, because the function was not invoked.
Now, what happens if we run the function, and see how long it takes to run? Actually, we would expect that calling the same function multiple times will give similar results:
>>> main()
...
INFO:function <function process_with_delay at 0x> took 8.67s
>>> main()
...
INFO:function <function process_with_delay at 0x> took 13.39s
>>> main()
...
INFO:function <function process_with_delay at 0x> took 17.01s
Every time we run the same function, it takes longer! At this point, you have probably already noticed the (now obvious) error.
Remember the syntax for decorators. @traced_function_wrong
actually means the
following: process_with_delay = traced_function_wrong(process_with_delay)
. And this will run when the
module is imported. Therefore, the time that is set in the
function will be the one at the time the module was imported. Successive calls will compute
the time difference from the running time until that original starting time. It will also log at
the wrong moment, and not when the function is actually called.
Luckily, the fix is also very simple: we just have to move the code inside the wrapped function in order to delay its execution:
def traced_function(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
logger.info("started execution of %s", function.__qualname__)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"function %s took %.2fs",
function.__qualname__,
time.time() - start_time
)
return result
return wrapped
With this new version, the previous problems are resolved.
If the actions of the decorator had been different, the results could have been much more disastrous. For instance, if it requires that you log events and send them to an external service, it will certainly fail unless the configuration has been run right before this has been imported, which we cannot guarantee. Even if we could, it would be bad practice. The same applies if the decorator has any other sort of side-effect, such as reading from a file, parsing a configuration, and many more.
2.2.2. Requiring decorators with side-effects¶
Sometimes, side-effects on decorators are necessary, and we should not delay their execution until the very last possible time, because that’s part of the mechanism which is required for them to work.
One common scenario for when we don’t want to delay the side-effect of decorators is when we need to register objects to a public registry that will be available in the module.
For instance, going back to our previous event system example, we now want to only make some events available in the module, but not all of them. In the hierarchy of events, we might want to have some intermediate classes that are not actual events we want to process on the system, but some of their derivative classes instead.
Instead of flagging each class based on whether it’s going to be processed or not, we could explicitly register each class through a decorator.
In this case, we have a class for all events that relate to the activities of a user. However, this
is just an intermediate table for the types of event we actually want, namely
UserLoginEvent
and UserLogoutEvent
:
EVENTS_REGISTRY = {}
def register_event(event_cls):
"""Place the class for the event into the registry to make it
accessible in
the module.
"""
EVENTS_REGISTRY[event_cls.__name__] = event_cls
return event_cls
class Event:
"""A base event object"""
class UserEvent:
TYPE = "user"
@register_event
class UserLoginEvent(UserEvent):
"""Represents the event of a user when it has just accessed the
system."""
@register_event
class UserLogoutEvent(UserEvent):
"""Event triggered right after a user abandoned the system."""
When we look at the preceding code, it seems that EVENTS_REGISTRY
is empty, but after
importing something from this module, it will get populated with all of the classes that are
under the register_event decorator:
>>> from decorator_side_effects_2 import EVENTS_REGISTRY
>>> EVENTS_REGISTRY
{'UserLoginEvent': decorator_side_effects_2.UserLoginEvent,
'UserLogoutEvent': decorator_side_effects_2.UserLogoutEvent}
This might seem like it’s hard to read, or even misleading, because EVENTS_REGISTRY
will
have its final value at runtime, right after the module was imported, and we cannot easily
predict its value by just looking at the code.
While that is true, in some cases this pattern is justified. In fact, many web frameworks or well-known libraries use this to work and expose objects or make them available.
It is also true that in this case, the decorator is not changing the wrapped object, nor altering the way it works in any way. However, the important note here is that, if we were to do some modifications and define an internal function that modifies the wrapped object, we would still probably want the code that registers the resulting object outside it.
Notice the use of the word outside. It does not necessarily mean before, it’s just not part of the same closure; but it’s in the outer scope, so it’s not delayed until runtime.
2.3. Creating decorators that will always work¶
There are several different scenarios to which decorators might apply. It can also be the case that we need to use the same decorator for objects that fall into these different multiple scenarios, for instance, if we want to reuse our decorator and apply it to a function, a class, a method, or a static method.
If we create the decorator, just thinking about supporting only the first type of object we want to decorate, we might notice that the same decorator does not work equally well on a different type of object. The typical example is where we create a decorator to be used on a function, and then we want to apply it to a method of a class, only to realize that it does not work. A similar scenario might occur if we designed our decorator for a method, and then we want it to also apply for static methods or class methods.
When designing decorators, we typically think about reusing code, so we will want to use that decorator for functions and methods as well.
Defining our decorators with the signature *args
, and **kwargs
, will make them work in
all cases, because it’s the most generic kind of signature that we can have. However,
sometimes we might want not to use this, and instead define the decorator wrapping
function according to the signature of the original function, mainly because of two reasons:
It will be more readable since it resembles the original function.
It actually needs to do something with the arguments, so receiving
*args
and**kwargs
wouldn’t be convenient.
Consider the case on which we have many functions in our code base that require a particular object to be created from a parameter. For instance, we pass a string, and initialize a driver object with it, repeatedly. Then we think we can remove the duplication by using a decorator that will take care of converting this parameter accordingly.
In the next example, we pretend that DBDriver
is an object that knows how to connect and
run operations on a database, but it needs a connection string. The methods we have in our
code, are designed to receive a string with the information of the database and require to
create an instance of DBDriver
always. The idea of the decorator is that it’s going to take
place of this conversion automatically: the function will continue to receive a string, but
the decorator will create a DBDriver
and pass it to the function, so internally we can
assume that we receive the object we need directly.
An example of using this in a function is shown in the next listing:
import logging
from functools import wraps
logger = logging.getLogger(__name__)
class DBDriver:
def __init__(self, dbstring):
self.dbstring = dbstring
def execute(self, query):
return f"query {query} at {self.dbstring}"
def inject_db_driver(function):
"""This decorator converts the parameter by creating a ``DBDriver``
instance from the database dsn string.
"""
@wraps(function)
def wrapped(dbstring):
return function(DBDriver(dbstring))
return wrapped
@inject_db_driver
def run_query(driver):
return driver.execute("test_function")
It’s easy to verify that if we pass a string to the function, we get the result done by an
instance of DBDriver
, so the decorator works as expected:
>>> run_query("test_OK")
'query test_function at test_OK'
But now, we want to reuse this same decorator in a class method, where we find the same problem:
class DataHandler:
@inject_db_driver
def run_query(self, driver):
return driver.execute(self.__class__.__name__)
We try to use this decorator, only to realize that it doesn’t work:
>>> DataHandler().run_query("test_fails")
Traceback (most recent call last):
...
TypeError: wrapped() takes 1 positional argument but 2 were given
What is the problem? The method in the class is defined with an extra argument: self
. Methods are just a
particular kind of function that receives self (the object they’re defined upon) as the first parameter.
Therefore, in this case, the decorator (designed to work with only one parameter, named
dbstring
), will interpret that self is said parameter, and call the method passing the
string in the place of self, and nothing in the place for the second parameter, namely the
string we are passing.
To fix this issue, we need to create a decorator that will work equally for methods and functions, and we do so by defining this as a decorator object, that also implements the protocol descriptor.
The solution is to implement the decorator as a class object and make this object a
description, by implementing the __get__
method.
from functools import wraps
from types import MethodType
class inject_db_driver:
"""Convert a string to a DBDriver instance and pass this to the
wrapped function."""
def __init__(self, function):
self.function = function
wraps(self.function)(self)
def __call__(self, dbstring):
return self.function(DBDriver(dbstring))
def __get__(self, instance, owner):
if instance is None:
return self
return self.__class__(MethodType(self.function, instance))
For now, we can say that what this decorator does is actually rebinding the callable it’s decorating to a method, meaning that it will bind the function to the object, and then recreate the decorator with this new callable.
For functions, it still works, because it won’t call the __get__
method at all.
3. The DRY principle with decorators¶
We have seen how decorators allow us to abstract away certain logic into a separate component. The main advantage of this is that we can then apply the decorator multiple times into different objects in order to reuse code. This follows the Don’t Repeat Yourself (DRY) principle since we define certain knowledge once and only once.
The retry mechanism implemented in the previous sections is a good example of a decorator that can be applied multiple times to reuse code. Instead of making each particular function include its retry logic, we create a decorator and apply it several times. This makes sense once we have made sure that the decorator can work with methods and functions equally.
The class decorator that defined how events are to be represented also complies with the DRY principle in the sense that it defines one specific place for the logic for serializing an event, without needing to duplicate code scattered among different classes. Since we expect to reuse this decorator and apply it to many classes, its development (and complexity) pay off.
This last remark is important to bear in mind when trying to use decorators in order to reuse code: we have to be absolutely sure that we will actually be saving code.
Any decorator (especially if it is not carefully designed) adds another level of indirection to the code, and hence more complexity. Readers of the code might want to follow the path of the decorator to fully understand the logic of the function (although these considerations are addressed in the following section), so keep in mind that this complexity has to pay off. If there is not going to be too much reuse, then do not go for a decorator and opt for a simpler option (maybe just a separate function or another small class is enough).
But how do we know what too much reuse is? Is there a rule to determine when to refactor existing code into a decorator? There is nothing specific to decorators in Python, but we could apply a general rule of thumb in software engineering that states that a component should be tried out at least three times before considering creating a generic abstraction in the sort of a reusable component.
The bottom line is that reusing code through decorators is acceptable, but only when you take into account the following considerations:
Do not create the decorator in the first place from scratch. Wait until the pattern emerges and the abstraction for the decorator becomes clear, and then refactor.
Consider that the decorator has to be applied several times (at least three times) before implementing it.
Keep the code in the decorators to a minimum.
4. Decorators and separation of concerns¶
The last point on the previous list is so important that it deserves a section of its own. We have already explored the idea of reusing code and noticed that a key element of reusing code is having components that are cohesive. This means that they should have the minimum level of responsibility: do one thing, one thing only, and do it well. The smaller our components, the more reusable, and the more they can be applied in a different context without carrying extra behavior that will cause coupling and dependencies, which will make the software rigid.
To show you what this means, let’s reprise one of the decorators that we used in a previous example. We created a decorator that traced the execution of certain functions with code similar to the following:
def traced_function(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
logger.info("started execution of %s", function.__qualname__)
start_time = time.time()
result = function(*args, **kwargs)
logger.info(
"function %s took %.2fs",
function.__qualname__,
time.time() - start_time
)
return result
return wrapped
Now, this decorator, while it works, has a problem: it is doing more than one thing. It logs that a particular function was just invoked, and also logs how much time it took to run. Every time we use this decorator, we are carrying these two responsibilities, even if we only wanted one of them.
This should be broken down into smaller decorators, each one with a more specific and limited responsibility:
def log_execution(function):
@wraps(function)
def wrapped(*args, **kwargs):
logger.info("started execution of %s", function.__qualname__)
return function(*kwargs, **kwargs)
return wrapped
def measure_time(function):
@wraps(function)
def wrapped(*args, **kwargs):
start_time = time.time()
result = function(*args, **kwargs)
logger.info("function %s took %.2f", function.__qualname__,
time.time() - start_time)
return result
return wrapped
Notice that the same functionality that we had previously can be achieved by simply combining both of them:
@measure_time
@log_execution
def operation():
....
Notice how the order in which the decorators are applied is also important.
Note
Do not place more than one responsibility in a decorator. The SRP applies to decorators as well.
5. Analyzing good decorators¶
As a closing note for this chapter, let’s review some examples of good decorators and how they are used both in Python itself, as well as in popular libraries. The idea is to get guidelines on how good decorators are created.
Before jumping into examples, let’s first identify traits that good decorators should have:
Encapsulation, or separation of concerns: A good decorator should effectively separate different responsibilities between what it does and what it is decorating. It cannot be a leaky abstraction, meaning that a client of the decorator should only invoke it in black box mode, without knowing how it is actually implementing its logic.
Orthogonality: What the decorator does should be independent, and as decoupled as possible from the object it is decorating.
Reusability: It is desirable that the decorator can be applied to multiple types, and not that it just appears on one instance of one function, because that means that it could just have been a function instead. It has to be generic enough.
A nice example of decorators can be found in the Celery project, where a task is defined by applying the decorator of the task from the application to a function:
@app.task
def mytask():
....
One of the reasons why this is a good decorator is because it is very good at
something: encapsulation. The user of the library only needs to define the function body
and the decorator will convert that into a task automatically. The @app.task
decorator
surely wraps a lot of logic and code, but none of that is relevant to the body of
mytask()
. It is complete encapsulation and separation of concerns—nobody will have to
take a look at what that decorator does, so it is a correct abstraction that does not leak any
details.
Another common use of decorators is in web frameworks (Pyramid, Flask, and Sanic, just to name a few), on which the handlers for views are registered to the URLs through decorators:
@route("/", method=["GET"])
def view_handler(request):
...
These sorts of decorator have the same considerations as before; they also provide total
encapsulation because a user of the web framework rarely (if ever) needs to know what
the @route
decorator is doing. In this case, we know that the decorator is doing
something more, such as registering these functions to a mapper to the URL, and also that it
is changing the signature of the original function to provide us with a nicer interface that
receives a request object with all the information already set.
The previous two examples are enough to make us notice something else about this use of decorators. They conform to an API. These libraries of frameworks are exposing their functionality to users through decorators, and it turns out that decorators are an excellent way of defining a clean programming interface.
This is probably the best way we should think about to decorators. Much like in the example of the class decorator that tells us how the attributes of the event are going to be handled, a good decorator should provide a clean interface so that users of the code know what to expect from the decorator, without needing to know how it works, or any of its details for that matter.