Michael Rose
by Abdelsalam Elomda

Today I want to talk to you about Python decorators. I remember when I first encountered decorators back in 2015 while preparing for the Cairo University Programming Contest. I was fascinated by how they could simplify and enhance my code. So, without further ado, let's dive into decorators and see how they can level up our Python skills!

What are Decorators?

Decorators in Python are a powerful feature that allows you to "decorate" or "wrap" a function or method, modifying its behavior without changing its core functionality. They are an example of the decorator pattern in software design and are particularly useful for adding functionality that is common across multiple functions, such as logging, memoization, or access control.

To understand decorators better, let's start with a simple example:

def greet(name):
    return f"Hello, {name}!"

def excited_greet(name):
    return f"Hello, {name}!!!"

print(greet("Abdelsalam"))
print(excited_greet("Abdelsalam"))

Now, suppose we want to add a timestamp to the greeting messages. We could modify both functions, but that would mean duplicating code, and we don't want that! Instead, let's create a decorator that adds the timestamp for us:

from datetime import datetime

def timestamp_decorator(func):
    def wrapper(*args, **kwargs):
        timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        result = func(*args, **kwargs)
        return f"{timestamp} - {result}"
    return wrapper

@timestamp_decorator
def greet(name):
    return f"Hello, {name}!"

@timestamp_decorator
def excited_greet(name):
    return f"Hello, {name}!!!"

print(greet("Abdelsalam"))
print(excited_greet("Abdelsalam"))

By using the @timestamp_decorator before our greet and excited_greet functions, we "wrap" them with the decorator, which adds the timestamp to the output. The *args and **kwargs in the wrapper function ensure that it can handle any number of positional and keyword arguments.

Creating Your Own Decorator

Now that you've seen a simple example let's create a custom decorator step by step. We'll make a decorator that measures the execution time of a function:

1. Define the decorator function, which takes another function as its argument:

def timer_decorator(func):

2. Inside the decorator, create a wrapper function that will be called instead of the original function:

def timer_decorator(func):
def wrapper(*args, **kwargs):

Inside the wrapper function, add the code to measure the execution time, and call the original function:

from time import time

def timer_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time()
        result = func(*args, **kwargs)
        end_time = time()
        duration = end_time - start_time
        print(f"{func.__name__} took {duration:.2f} seconds to execute.")
        return result
    return wrapper

4. Apply the decorator to any function you want to measure:

import time

@timer_decorator
def slow_function():
    time.sleep(2)
    print("I'm a slow function!")

slow_function()


Output
I'm a slow function!
slow_function took 2.00 seconds to execute.

Now we have a reusable `timer_decorator` that can measure the execution time of any function, without having to modify the original function's code.

Decorators with Arguments

Sometimes you might need to create a decorator that takes arguments. For instance, let's say we want to create a `retry_decorator` that retries a function if it fails, with a specified number of retries. We can achieve this by adding another layer of nesting:

import random

def retry_decorator(max_retries):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries + 1):
                try:
                    result = func(*args, **kwargs)
                    print(f"Success on attempt {attempt + 1}")
                    return result
                except Exception as e:
                    if attempt == max_retries:
                        print(f"Failed after {max_retries} attempts")
                        raise e
                    print(f"Attempt {attempt + 1} failed. Retrying...")
        return wrapper
    return decorator

@retry_decorator(3)
def unreliable_function():
    if random.random() < 0.7:
        raise Exception("Failed")
    return "Success"

print(unreliable_function())

In this example, the retry_decorator takes an argument max_retries and then returns the actual decorator function. This allows us to pass arguments to our decorators and make them more flexible.

Conclusion

Python decorators are a powerful tool that can help you write cleaner, more modular code. In this tutorial, we covered the basics of creating and using decorators, along with some practical examples. I hope you find this knowledge as useful as I did when I first encountered decorators, and I encourage you to explore further and create your own decorators to enhance your Python projects. Happy coding!