DEV Community

Cover image for Boosting your code with Python Decorators
Lucas Andrade
Lucas Andrade

Posted on

Boosting your code with Python Decorators

There are various programming concepts that spread across different languages. One of them is the decorator, which exists in Python. In this article, we'll understand why to use it, how it works, and the different applications of decorators with Python.

What is a decorator?

A decorator is a feature that allows modifying or enhancing the behavior of functions, methods, or classes.

They are functions that wrap around other functions to extend or modify them, adding behaviors without modifying the original code of the function.

So imagine there are different functions going through the same step, but not necessarily a step that is part of the logic of the function itself. It's in this context that we'll use decorators.

Analogy of decorator

Still confused? Be relax, I'll start with some basic examples! But first, let's understand how the syntax of a decorator works.

How to declare a decorator?

A decorator in Python is created using the following syntax: you define a function that will act as the decorator and also receives a function as an argument. Inside it, we'll return another function that, in addition to executing the original function, will concentrate all the "extended" behavior. The syntax looks like this:

def decorator(func):
  def innerFunction():
    # additional behavior
    print('Hello world')
    func()
  return innerFunction
Enter fullscreen mode Exit fullscreen mode

How to call a decorator?

The syntax for calling a decorator is also simple. Basically, we use the symbol '@' followed by the name of the decorator function. This is placed above the function, method, or class you want to modify.

When the "decorated" function is called, it is replaced by the modified version returned by the decorator:

@decorator
def sum(a, b):
  return a + b

print(sum(2, 2)) # 4
                 # Hello world

Enter fullscreen mode Exit fullscreen mode

Importance of functools.wraps in decorators

In my examples below, I'll use the @wraps decorator from functools to declare the decorators. Decorators are usually declared like this to preserve the metadata of the original function. When you define a decorator without wraps, you may lose important information about the original function. This can affect, for example, the documentation of the function and access to specific attributes of it.

Let's use an example of a decorator without functools.wraps:

def decorator_without_wraps(func):
    def inner_function(*args, **kwargs):
        print('This is a decorator')
        return result
    return wrapper

@decorator_without_wraps
def hello_world():
    print("Hello")

hello_world() # This is a decorator
              # Hello
print(hello_world.__name__) # inner_function
Enter fullscreen mode Exit fullscreen mode

Note that if I wrap the function in a decorator without wraps, the decorator will assume all the metadata of the decorated function.

Using wraps, the entire signature of that function remains:

from functools import wraps

def decorator_with_wraps(func):
    @wraps
    def inner_function(*args, **kwargs):
        print('This is a decorator')
        return result
    return wrapper

@decorator_without_wraps
def hello_world():
    print("Hello")

print(hello_world()) # This is a decorator
                     # Hello world
print(hello_world.__name__) # hello_world
Enter fullscreen mode Exit fullscreen mode

Examples of decorator usage

@timer

import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f'{func.__name__} took {end - start:.6f} seconds to complete')
        return result
    return wrapper
Enter fullscreen mode Exit fullscreen mode

In this case, the timer function is defined as a decorator. Inside it, there is an inner function called wrapper. This function accepts any number of positional and named arguments.

Next, the start time of the execution of the original function using time.perf_counter() is measured.

The original function (func) is then called with the arguments passed to wrapper, and the result is stored. The end time of the execution is recorded using time.perf_counter() again.

A message is printed, indicating the name of the function and the time it took to execute, formatted with six decimal places. Finally, the result of the original function is returned:

import requests

@timer
def generate_lorem_ipsum():
    rootApi = 'http://asdfast.beobit.net/api/'
    response = requests.get(rootApi).json().get('text')
    return response

generate_lorem_ipsum() # generate_lorem_ipsum took 1.063780 seconds to complete
Enter fullscreen mode Exit fullscreen mode

Note: here and in the other decorators, I'm using *args and **kwargs in the decorator parameters. This is to allow functions that accept a variable number of arguments and pass these arguments forward smoothly. You can read more about it here.

@repeat

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
    return decorator_repeat
Enter fullscreen mode Exit fullscreen mode

Note that this decorator is wrapped in another wrapper, and this is because it receives a parameter. The outer function repeat accepts num_times as an argument and returns the inner function decorator_repeat.

Inside decorator_repeat, which is the decorator itself, the inner function wrapper_repeat is defined. This function, in turn, wraps the execution of the original function in a loop, repeating it num_times times.

The for loop iterates the number specified by num_times, calling the original function func on each iteration. The result of the last call is stored in value. Finally, wrapper_repeat returns this value, representing the result of the last execution of the original function after all repetitions:

@repeat(num_times=3)
def hello_world():
    print('Hello world')

hello_world() 
# Output:
# Hello world
# Hello world
# Hello world
Enter fullscreen mode Exit fullscreen mode

@login_required

from functools import wraps
from flask import abort, request
from services import validate_token

def login_required(func):
    @wraps(func)
    def inner_function(*args, **kwargs):
        if not validate_token(request.headers.Authorization):
            return abort(401)
        return func(*args, **kwargs)
    return inner_function
Enter fullscreen mode Exit fullscreen mode

This is an example for a Flask API, but it can be adapted for any Python API. The login_required decorator is designed to ensure that only authenticated users can access certain routes or functions in your application.

Inside the decorator, the inner function inner_function is defined. This function performs an authentication check before allowing the execution of the original function. If the Authorization header is somehow invalid, access is denied with an HTTP response code 401.

If the authentication check is successful, the original function (func) is called with the passed arguments. This decorator can be applied to specific routes in a Flask application, ensuring that only authenticated users have access, improving the security of the API. This approach is easily adaptable to other Python APIs, providing an effective means of protecting sensitive resources:

@app.route('/protected')
@login_required
def protected():
    return 'Protected route'
Enter fullscreen mode Exit fullscreen mode

@rate_limit

from functools import wraps
from redis import Redis
from datetime import datetime, timedelta

redis = Redis(host='127.0.0.1', port=6379)

def rate_limit(limit, per):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            key = f'{request.remote_addr}:{func.__name__}'
            count = redis.get(key)
            if count is not None and int(count) > limit:
                reset_time = datetime.now() + timedelta(seconds=per)
                headers = {'X-RateLimit-Reset': reset_time
                           .strftime('%Y-%m-%d %H:%M:%S')}
                return Response(status=429, headers=headers)
            redis.incr(key)
            redis.expire(key, per)
            return func(*args, **kwargs)
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

This decorator is much more complex, using Redis and also receiving parameters. It should be used in an API context and is also highly adaptable.

The rate_limit decorator is designed to apply rate limits to a function in an API, controlling the number of times it can be called within a certain interval. It uses Redis as an external storage to keep track of the number of allowed calls.

By receiving the limit (maximum call limit) and per (time period in seconds) parameters, the decorator creates an inner function called wrapper. Within this function, a unique key is generated using the remote client's address and the name of the original function. This allows tracking the number of calls made by a client for a specific function.

The counter associated with the key is retrieved from Redis, and if the number of calls exceeds the defined limit, a 429 (Rate Limit Exceeded) response is returned with a header indicating the reset time.

If the rate limit is not exceeded, the counter in Redis is incremented, and the key is set to expire after the per period, ensuring that the counter is reset after the defined interval.

Finally, the original function (func) is called with the passed arguments.

This decorator is highly adaptable and can be applied to different functions in an API, especially to enforce access limits to certain routes, preventing DDoS attacks:

@app.route('/limited')
@rate_limit(limit=10, per=60)
def limited_route():
    return 'You are within the rate limit!'
Enter fullscreen mode Exit fullscreen mode

Advantages of Decorator

As you may have noticed in these examples, the decorator has numerous advantages in Python. Among them, we have:

  • Modularity Improvement: Decorators can be useful for separating specific concerns and improving code modularity. By applying decorators appropriately, it's possible to isolate specific functionalities, making it easier to maintain and understand each component.
  • Code Reusability: the use of decorators can promote code reuse since certain functionalities can be encapsulated and applied to various functions. This can reduce code duplication and facilitate the application of consistent patterns in different parts of the system.
  • Ease of Adding Functionality: Decorators offer a flexible way to add functionality to existing functions without directly modifying the source code of the function. This can be useful for incorporating new features or behaviors without affecting existing code.

Risks of Decorator

Like everything in programming, the decorator also has its risks and dangers. These are:

  • Traceability Loss: Depending on the context, decorators can make code traceability difficult, making it challenging to understand the origin of modifications and identify the behavior of a decorated function.
  • Excessive Dependency: Intensive use of decorators can create dependencies between functions, increasing complexity and making maintenance difficult, especially when many decorators are applied in various parts of the code.
  • Confusion in Decorator Chains: by putting many decorators in a single function, reading the order of application and understanding the execution flow can cause potential confusion in the execution logic.

When using a decorator, it's important to keep it simple, readable, tested, and well-applied, avoiding excessive use, which can become a bad practice.


Well, thank you for reading! I hope I've helped you better organize your journey. Well, if you have any suggestions or useful decorators to mention and contribute to the community, don't forget to leave your comment!

Until next time!

Top comments (0)