Lightning bolt with Python code snippet and "Python Decorators" in blocky caps

Python Decorators

A decorator in Python is a function that takes another function as an argument and extends or alters its behaviour. Don’t worry if that sounds a bit of a mouthful, all will become clear very soon!

Decorators are often used for logging, access control, and performance measurement, but there are all kinds of application.

The most common way to apply a decorator is by using the @decorator_name syntax, which is ‘syntactic sugar’ for passing a function to a decorator.

Basic Decorator Example

It’s often easiest to explain with an example, so here is a simple decorator that prints a message before and after calling a function:

def my_decorator(func):
    def wrapper():
        print("Before the function call")
        func()
        print("After the function call")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

Output:

Before the function call
Hello!
After the function call
  • @my_decorator: This applies the my_decorator function to say_hello(). It’s equivalent to say_hello = my_decorator(say_hello).
  • wrapper(): This is the inner function that wraps the original function (say_hello()). It adds the extra behavior (printing “Before” and “After”).

Anatomy of a Decorator

Decorators typically have three components:

  1. The outer function: This takes the target function as an argument.
  2. The wrapper function: This is the inner function that wraps the original function and adds new functionality.
  3. The return statement: The outer function returns the wrapper function, which replaces the original function.

Example:

def decorator(func):
    def wrapper():
        # Code to run before the function
        print("Before the function is called")

        func()  # Call the original function

        # Code to run after the function
        print("After the function is called")
    return wrapper

Functions as First-Class Objects in Python

To understand decorators, it’s essential to know that functions are first-class objects in Python. This means you can:

  • Assign functions to variables.
  • Pass functions as arguments to other functions.
  • Return functions from other functions.

Example:

def greet():
    return "Hello!"

# Assign a function to a variable
say_hello = greet

print(say_hello())  # Output: Hello!

This behavior enables decorators, as they rely on passing functions as arguments and returning modified versions.

Applying Multiple Decorators

You can apply multiple decorators to a function by stacking them. Decorators are applied from top to bottom.

Example:

def decorator_one(func):
    def wrapper():
        print("Decorator One")
        func()
    return wrapper

def decorator_two(func):
    def wrapper():
        print("Decorator Two")
        func()
    return wrapper

@decorator_one
@decorator_two
def say_hello():
    print("Hello!")

say_hello()

Output:

Decorator One
Decorator Two
Hello!

Here, decorator_two is applied first, followed by decorator_one.

Passing Arguments to Decorators

So far, we’ve seen decorators that wrap functions with no arguments. To create a decorator for a function that takes arguments, you need to modify the wrapper to accept *args and **kwargs. These allow the wrapper to handle any number of positional and keyword arguments.

Example: A Decorator with Arguments

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper

@my_decorator
def greet(name, age):
    print(f"Hello, {name}. You are {age} years old.")

greet("Alice", 30)

Output:

Before the function call
Hello, Alice. You are 30 years old.
After the function call
  • *args: Captures all positional arguments.
  • **kwargs: Captures all keyword arguments.
  • The wrapper() function now accepts any arguments passed to greet() and forwards them to the original function.

Returning Values from Decorated Functions

If the original function returns a value, the decorator should also return that value to maintain the original behavior.

Example: Returning Values

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result  # Ensure the return value is passed back
    return wrapper

@my_decorator
def add(a, b):
    return a + b

result = add(3, 4)
print(f"Result: {result}")

Output:

Before the function call
After the function call
Result: 7

Here, the decorator ensures that the return value from add() is passed back by the wrapper.

Real-World Use Cases for Decorators

Decorators are used in a variety of scenarios to simplify and reuse common logic. Let’s look at some common use cases:

1. Logging

Logging is a common use case for decorators. You can create a decorator that logs the execution of a function.

Example:

def log_execution(func):
    def wrapper(*args, **kwargs):
        print(f"Executing {func.__name__} with arguments {args} and {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_execution
def multiply(a, b):
    return a * b

multiply(2, 3)

Output:

Executing multiply with arguments (2, 3) and {}
multiply returned 6

2. Timing Execution

You can measure the time it takes for a function to execute using a decorator.

Example:

import time

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

@time_it
def slow_function():
    time.sleep(2)
    return "Finished"

slow_function()

Output:

slow_function took 2.0004 seconds to execute

3. Access Control (Authorization)

Decorators can be used to restrict access to certain functions, such as checking user permissions before allowing a function to run.

Example:

def requires_admin(func):
    def wrapper(user):
        if user != "admin":
            print("Access denied. Admins only.")
            return
        return func(user)
    return wrapper

@requires_admin
def delete_database(user):
    print("Database deleted.")

delete_database("guest")  # Access denied
delete_database("admin")  # Database deleted

Decorators with Arguments

Sometimes you need a decorator that accepts its own arguments. This requires adding an extra layer of functions.

Example: Decorator with Arguments

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

Output:

Hello, Alice!
Hello, Alice!
Hello, Alice!

Here, the repeat() decorator takes an argument (times) that controls how many times the greet() function is called.

Preserving Function Metadata with functools.wraps

When a function is wrapped by a decorator, its metadata (such as the function name and docstring) is replaced by the wrapper’s metadata. To preserve the original function’s metadata, you can use the functools.wraps decorator inside your wrapper function.

Example:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """Greet the person by name."""
    print(f"Hello, {name}!")

print(greet.__name__)  # Output: greet
print(greet.__doc__)   # Output: Greet the person by name.

By using @functools.wraps, the original function’s name and docstring are preserved.

Key Concepts Recap

In this lesson, we covered:

  • The basics of Python decorators and how they work.
  • How to create decorators to modify the behavior of functions.
  • Real-world use cases for decorators, such as logging, timing, and access control.
  • How to use *args and **kwargs to handle arguments in decorators.
  • How to use functools.wraps to preserve function metadata.

Decorators are a powerful tool that can enhance code reusability and maintainability. With this knowledge, you can start creating your own decorators to streamline repetitive logic in your Python projects.

Exercises

  1. Write a decorator that logs the arguments and return value of any function it is applied to.
  2. Create a decorator that times how long a function takes to execute and prints the result.
  3. Write a decorator that checks if a user is authorized to run a function by comparing a “role” argument to a predefined list of allowed roles.
  4. Create a decorator that retries calling a function a specified number of times if it raises an exception.

FAQ

Q1: What exactly is a decorator in Python?

A1: A decorator in Python is a function that takes another function (or method) as an argument, adds some additional functionality to it, and returns a new function or the original function with the added behavior. The syntax @decorator_name is used to apply the decorator to a function. Decorators are commonly used for logging, timing, validation, and access control.

Q2: Can a decorator modify the arguments passed to a function?

A2: Yes, a decorator can modify the arguments before passing them to the original function. This is done inside the wrapper function by manipulating *args and **kwargs.

Example:

def modify_args(func):
    def wrapper(*args, **kwargs):
        # Modify the arguments
        args = (arg.upper() if isinstance(arg, str) else arg for arg in args)
        return func(*args, **kwargs)
    return wrapper

@modify_args
def greet(name):
    print(f"Hello, {name}!")

greet("alice")  # Output: Hello, ALICE!

Q3: Can a decorator be applied to a function with a return value?

A3: Yes, decorators can be used with functions that return values. In fact, it’s important to ensure the wrapper function in the decorator properly returns the value from the decorated function so that the function behaves as expected.

Example:

def add_logging(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result  # Ensure the result is returned
    return wrapper

@add_logging
def add(a, b):
    return a + b

add(3, 4)  # Output: add returned 7

Q4: Can I apply multiple decorators to a single function?

A4: Yes, you can apply multiple decorators to a function. The decorators are applied from top to bottom, meaning the bottom-most decorator is applied first, and then the one above it, and so on.

Example:

def decorator_one(func):
    def wrapper(*args, **kwargs):
        print("Decorator One")
        return func(*args, **kwargs)
    return wrapper

def decorator_two(func):
    def wrapper(*args, **kwargs):
        print("Decorator Two")
        return func(*args, **kwargs)
    return wrapper

@decorator_one
@decorator_two
def say_hello():
    print("Hello!")

say_hello()

Output:

Decorator Two
Decorator One
Hello!

Q5: What is functools.wraps, and why should I use it?

A5: The functools.wraps decorator is used inside a wrapper function to preserve the original function’s metadata (like its name and docstring). Without functools.wraps, the name and docstring of the original function will be replaced by the wrapper function’s name and docstring, which can cause confusion when debugging or using reflection.

Example:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def my_function():
    """This is my function."""
    print("Function executed")

print(my_function.__name__)  # Output: my_function
print(my_function.__doc__)   # Output: This is my function.

Q6: How do I create a decorator that takes its own arguments?

A6: To create a decorator that takes its own arguments, you need to add an extra layer of functions. The outermost function accepts the decorator’s arguments, the next layer is the actual decorator, and the innermost function is the wrapper.

Example:

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

This example calls the greet function 3 times because of the repeat(3) decorator.

Q7: Can decorators be applied to class methods or static methods?

A7: Yes, decorators can be applied to class methods and static methods. They work the same way as for regular functions, but you need to ensure that the correct method type (e.g., self for instance methods) is passed.

Example for a class method:

def log_method_call(func):
    def wrapper(self, *args, **kwargs):
        print(f"Calling method {func.__name__}")
        return func(self, *args, **kwargs)
    return wrapper

class MyClass:
    @log_method_call
    def say_hello(self, name):
        print(f"Hello, {name}!")

obj = MyClass()
obj.say_hello("Alice")

Q8: How do I decorate a function that takes an unknown number of arguments?

A8: To handle a variable number of arguments, use *args and **kwargs in your decorator’s wrapper function. This allows you to pass any number of positional and keyword arguments to the decorated function.

Example:

def log_arguments(func):
    def wrapper(*args, **kwargs):
        print(f"Arguments: {args}, {kwargs}")
        return func(*args, **kwargs)
    return wrapper

@log_arguments
def add(a, b, c=0):
    return a + b + c

add(1, 2)           # Output: Arguments: (1, 2), {}
add(1, 2, c=3)      # Output: Arguments: (1, 2), {'c': 3}

Q9: Can I use a decorator to modify the return value of a function?

A9: Yes, a decorator can modify or alter the return value of a function before passing it back to the caller. This is done within the wrapper function.

Example:

def double_return(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result * 2  # Modify the return value
    return wrapper

@double_return
def get_number():
    return 5

print(get_number())  # Output: 10

Q10: Can I remove a decorator after it’s applied to a function?

A10: Once a decorator is applied to a function, it wraps the original function, so you can’t directly “remove” a decorator. However, if you need to use the original function without the decorator, you can access it by keeping a reference to the undecorated function before applying the decorator.

Example:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Decorator applied")
        return func(*args, **kwargs)
    return wrapper

def my_function():
    print("Original function")

# Keep a reference to the original function
original_function = my_function

# Apply the decorator
my_function = my_decorator(my_function)

my_function()          # Output: Decorator applied, Original function
original_function()    # Output: Original function (without decorator)

In this example, you keep a reference to the undecorated my_function and use it later if needed.

Similar Posts