Python Decorators Demystified

Simplifying your Python code with decorators

Python Decorators Demystified
Naomi is trying to find the bug in the program. Image generated by Midjourney, prompt by author

My colleague Naomi worked on a complex Python project a few weeks ago. As the deadline loomed, she struggled with a stubborn bug that seemed to defy all attempts to squash it. Her frustration was palpable, and as a fellow programmer, I couldn’t help but empathize.

One afternoon, I decided to drop by her desk to see if I could assist. As we talked, it became clear that she was having trouble identifying the bug’s root cause due to the code’s complicated nature.

I couldn’t help but think this was the perfect opportunity to introduce her to one of my favorite Python tools — decorators.

I showed Naomi how to create a simple logging decorator and add it to one of the standard Python functions she used internally.

This would enable her to better understand the program’s execution flow without modifying its core logic.

Initially skeptical, she was soon on board with some explanation and a quick demonstration.

The results were astounding. With the help of the logging decorator, Naomi was able to diagnose the issue and squash the bug quickly.

Not only did it save her hours of debugging, but she also added several other decorators that left her with cleaner and more maintainable code.

In this Medium article, I’ll demystify Python decorators and show how they can simplify your code and life as a programmer.

I’ll share the tips and tricks that helped Naomi overcome her debugging nightmare and harness the power of decorators to improve her code.

So please grab a cup of coffee, sit back, and let’s dive into the wonderful world of Python decorators!

The Python examples from this article can be found in this GitHub repository.


What is a Python decorator?

Python decorators allow you to wrap a function or method with additional functionality, effectively “decorating” the original function with new capabilities.

This is achieved by employing a higher-order function that takes the target function as an argument and returns a new function with the extended behavior.

In simpler terms, a decorator is a function that receives another function as its input, enhances or alters the input function’s behavior, and then returns the modified function without explicitly changing the original function’s implementation.

By using decorators, you can keep your code modular, clean, and maintainable while reducing the need for repetition when implementing shared functionality across multiple functions.

Before we can explore the usage of decorators, it’s essential to understand Python functions as first-class objects that can be passed around and used like any other type of argument.


Functions as first-class objects in Python

When we say that functions are first-class objects, they can be treated like any other object, such as a string, integer, float, or list. This includes being used as arguments in the same way as different types of objects.

To illustrate this point, consider the following code.

def say_message_to_naomi(message_func): 
    message_func("Naomi") 
 
def say_hello(name): 
    print(f"Hello, {name}") 
 
def say_goodbye(name): 
    print(f"Goodbye, {name}") 
 
say_message_to_naomi(say_hello) 
say_message_to_naomi(say_goodbye)

The first function say_message_to_naomi, accepts a single function argument called message_func. It then calls the function given as an argument with the string "Naomi".

We then define two additional functions: say_hello and say_goodbye. Both functions take a name parameter and print a message to the console.

The two functions say_hello and say_goodbye, are then passed as arguments to the say_message_to_naomi function.

When say_message_to_naomi is called with say_hello as an argument, it calls say_hello with the string "Naomi" as its argument, resulting in the message "Hello, Naomi" being printed to the console.

Similarly, when say_message_to_naomi is called with say_goodbye as an argument, it calls say_goodbye with the string "Naomi" as its argument, resulting in the message "Goodbye, Naomi" being printed to the console.

The result of executing the Python program

Creating decorators

Now that we have seen how functions can be used as arguments, we can move on to creating decorators. We’ll begin by creating a basic decorator and then explore how Python’s syntactic sugar can simplify the process.

Creating a Simple Decorator

Let’s create a basic decorator that prints a message before and after the decorated function is called.

The following code demonstrates how this can be done.

def my_first_decorator(func): 
    def wrapper(): 
        print("Before the function call") 
        func() 
        print("After the function call") 
    return wrapper 
 
def hello_world(): 
    print("Hello, World!") 
 
hello_world = my_first_decorator(hello_world) 
hello_world()

The my_first_decorator function is defined with a single parameter func, representing the function to be decorated. Inside my_first_decorator, a new function wrapper prints a message before and after the call to func. Finally, wrapper is returned.

The hello_world function is defined, which prints the message "Hello, World!".

The hello_world function is then decorated by calling my_first_decorator with hello_world as an argument. This returns the wrapper function, which is assigned to the hello_world variable. Thus, hello_world now points to the wrapper function returned by my_first_decorator.

When hello_world() is called, it calls the wrapper function, which first prints "Before the function call", then calls the original hello_world function, which prints "Hello, World!" and finally prints "After the function call".

The result of the simple decorator Python program

Thus, using a decorator, we added functionality to the hello_world function without modifying the original.

Now let's see how Python has a special syntax for decorators.

Adding Python syntactic sugar

Python provides a special syntax for assigning decorators to functions, using the @ symbol followed by the decorator function name.

This allows us to easily attach a decorator to a function without modifying the original function's code.

The following code demonstrates how the @ symbol can attach the my_first_decorator to the hello_world function.

def my_first_decorator(func): 
    def wrapper(): 
        print("Before the function call") 
        func() 
        print("After the function call") 
    return wrapper 
 
@my_first_decorator 
def hello_world(): 
    print("Hello, World!") 
 
hello_world()

Parameterized decorators

Decorators can be parameterized. This involves an additional level of nesting to handle the arguments.

Parameterized decorators are essentially functions that return decorator functions, which in turn accept the function or class being decorated.

This extra layer allows you to pass arguments to the outermost function, enabling customization of the decorator’s behavior. Here’s an example to illustrate this concept.

def my_parameterized_decorator(arg1, arg2): 
    def decorator_function(func): 
        def wrapper(*args, **kwargs): 
            print(f"Decorator arguments: {arg1}, {arg2}") 
            return func(*args, **kwargs) 
        return wrapper 
    return decorator_function 
 
@my_parameterized_decorator("Hello", "World") 
def my_function(): 
    print("Inside the function") 
 
my_function()

As a side note, it’s worth mentioning that the decorator behavior relies on the closure mechanism for handling arguments, which allows decorators to maintain their state and encapsulate logic effectively.

A template for Python decorators

We can now define a template with boilerplate Python code for creating Python decorators. To illustrate this, consider the following template.

import functools 
 
def my_decorator(func): 
    @functools.wraps(func) 
    def wrapper(*args, **kwargs): 
        # Code to be executed before the decorated function is called 
        result = func(*args, **kwargs) 
        # Code to be executed after the decorated function is called 
        return result 
    return wrapper

While this decorator may look similar to the one we previously created, there are a few key differences that set it apart.

*args and **kwargs

First, we added *args and **kwargs as arguments to the wrapper function. In the context of decorators, *args and **kwargs are often used to allow the decorated function to accept any number of arguments, regardless of their type or number.

By passing *args and **kwargs to the wrapper function inside the decorator, we can ensure that the decorated function can accept any arguments passed to it without specifying them all in advance.

@functools.wraps(func)

A problem that can arise when creating decorators is that the resulting decorated function may retain only some of the metadata from the original function, such as the function name, docstring, parameter list, etc. This can make working with the decorated function more challenging.

The functools.wraps decorator copies the metadata from the original to the new function.

By using functools.wraps in a decorator, we ensure that the resulting decorated function retains all of the metadata from the original function. This makes working with the decorated function easier and helps avoid confusion when debugging or maintaining the code.

return result

The last change we made in the decorator is to capture the return value of the function we’re wrapping and store it in a variable called result.

This ensures that if the decorated function returns a value, we return it from the wrapper function. Finally, we return the result variable at the end of the wrapper function.


Real-world examples of Python decorators

Decorators are used in several Python packages. For example, Flask uses the @app.route decorator. This decorator is used in Flask web applications to map a URL route to a view function that handles requests for that route.

@app.route("/") 
def index(): 
    return "Hello, world!" 
 
@app.route("/about") 
def about(): 
    return "This is the about page."

Django uses the @login_required decorator. This decorator is used in Django web applications to restrict access to certain views to authenticated users only.

@login_required 
def my_view(request): 
    # View code goes here

Pytest is a popular testing framework for Python. It uses decorators extensively to mark test functions and modify their behavior. For example, the @pytest.fixture decorator defines fixtures that provide test data and other resources to test functions.

Practical examples including Naomi’s

This section will explore several use cases for decorators and provide examples of implementing them using the decorator template we introduced earlier.

Specifically, we’ll demonstrate how decorators can be used for logging, timing, retry on failure, and caching. We’ll conclude by sharing a real-world example of how Naomi used a decorator to find a bug in her project.

A decorator for timing and logging function execution

One everyday use case for Python decorators is logging information about a function call, such as input arguments, output value, and execution time.

This can help with debugging or performance analysis. See below a possible implementation of such a decorator.

import functools 
import logging 
import time 
 
def log_function_call(func): 
    @functools.wraps(func) 
    def wrapper(*args, **kwargs): 
        logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") 
        start_time = time.perf_counter() 
        result = func(*args, **kwargs) 
        end_time = time.perf_counter() 
        elapsed_time = end_time - start_time 
        logging.info(f"Returned {result} from {func.__name__} in {elapsed_time:.6f} seconds") 
        return result 
    return wrapper

A decorator for retrying on failure

Retry on failure is a technique for retrying a function multiple times if it fails.

This can make your implementation more robust. See below a possible implementation of such a decorator.

import time 
 
def retry_on_failure(max_attempts, delay): 
    def decorator(func): 
        @functools.wraps(func) 
        def wrapper(*args, **kwargs): 
            for i in range(max_attempts): 
                try: 
                    result = func(*args, **kwargs) 
                    return result 
                except Exception as e: 
                    if i == max_attempts - 1: 
                        raise e 
                    time.sleep(delay) 
        return wrapper 
    return decorator 
 
@retry_on_failure(max_attempts=3, delay=1) 
def send_email(to, subject, body): 
    # Function code goes here

A decorator for caching the previous function result

Memoization is a technique for optimizing expensive recursive functions by caching the results of last calls.

Here’s an example of a memoization decorator:

def memoize(func): 
    cache = {} 
    @functools.wraps(func) 
    def wrapper(*args): 
        if args in cache: 
            return cache[args] 
        result = func(*args) 
        cache[args] = result 
        return result 
    return wrapper 
 
@memoize 
def fibonacci(n): 
    if n in (0, 1): 
        return n 
    return fibonacci(n - 1) + fibonacci(n - 2)

Naomi’s example use of a decorator

In the introduction, we described a bug my colleague Naomi was trying to find in her code.

She eventually succeeded by using Python decorators, which allowed her to modify the behavior of existing standard Python functions used internally by her code.

In her case, the bug was incorrect usage of the Python len function. We found this by adding the logging and timing decorator, as shown before, to the standard Python len function.

import functools 
import logging 
import time 
 
def log_function_call(func): 
    @functools.wraps(func) 
    def wrapper(*args, **kwargs): 
        logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") 
        start_time = time.perf_counter() 
        result = func(*args, **kwargs) 
        end_time = time.perf_counter() 
        elapsed_time = end_time - start_time 
        logging.info(f"Returned {result} from {func.__name__} in {elapsed_time:.6f} seconds") 
        return result 
    return wrapper 
 
# Decorate the built-in len function 
len = log_function_call(len) 
 
print(len("Naomi"))

Using decorators for function registration and extensibility

Decorators can also serve purposes other than modifying or wrapping the decorated objects. A possible use case is registering objects, such as functions, within an extensible process.

For instance, consider a framework designed to apply treatments to graphic files based on their format (e.g., PNG, BMP, JPEG). To create an extensible system, treatment functions can be registered in a dictionary with the associated file extension as the key.

A decorator can facilitate this registration process in a developer-friendly and readable manner. By decorating each treatment function with a decorator that takes the file format as an argument, the decorated function can be added to the dictionary with the specified key.

This approach enables the easy addition of new functions to handle various image formats while maintaining clear and organized code.

See the example below for an example.

imgproc_registry = {} 
 
def imgproc(format): 
    def decorator_function(func): 
        imgproc_registry[format] = func 
        return func 
    return decorator_function 
 
@imgproc(format="PNG") 
def process_png(file): 
    # Processing code for PNG files 
 
@imgproc(format="JPEG") 
def process_jpeg(file): 
    # Processing code for JPEG files

Conclusion

Python decorators are a powerful feature that allows you to modify the behavior of functions and classes at runtime.

Using decorators, you can add functionality to your code without modifying the underlying functions or classes, making your code more modular and easier to maintain.

We explored the basics of Python decorators and provided a decorator template that you can use to create your decorators.

We also demonstrated several real-world examples of decorators, including logging, timing, and caching.

The article includes a real-world example of how my colleague, Naomi, used a decorator to find and fix a bug in her code.

By adding a decorator to modify the behavior of an internal standard Python function, she was able to gain insight into its behavior and debug the issue.

Throughout this article, we have primarily discussed and provided examples of function decorators. However, it is important to note that decorators are not limited to functions alone. They can also be used to modify or similarly extend the behavior of classes.

While the syntax and implementation details might differ slightly when working with class decorators, the underlying concept remains the same.

By mastering the art of Python decorators, you can take your Python programming skills to the next level and build more powerful and flexible applications. So go forth and decorate your code!

The Python examples from this article can be found in this GitHub repository.

Special thanks to Eric PASCUAL for offering valuable feedback and contributing the insightful example of using decorators for an extensibility framework.