Decorators with Arguments

advanced
Published

June 17, 2024

Understanding the Basics: Decorators without Arguments

Before diving into arguments, let’s briefly review the fundamental concept of a decorator. A decorator is essentially a function that takes another function as input and returns a modified version of that function.

def my_decorator(func):
  def wrapper():
    print("Something is happening before the function is called.")
    func()
    print("Something is happening after the function is called.")
  return wrapper

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

say_hello()

This code defines a decorator my_decorator that prints messages before and after the execution of the decorated function say_hello. The @my_decorator syntax is syntactic sugar for say_hello = my_decorator(say_hello).

Adding Arguments to Your Decorators

The key to creating decorators with arguments lies in adding another layer of function nesting. The outer function accepts the arguments, while the inner function (the actual decorator) receives the original function.

def repeat(num_times):
  def decorator_repeat(func):
    def wrapper(*args, **kwargs):
      for _ in range(num_times):
        result = func(*args, **kwargs)
      return result
    return wrapper
  return decorator_repeat

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

greet("World")

In this example, repeat is a decorator factory. It takes num_times as an argument and returns the actual decorator decorator_repeat. decorator_repeat then wraps the function greet, executing it multiple times. The *args and **kwargs allow the decorator to handle functions with any number of positional or keyword arguments.

More Complex Examples: Decorators with Arguments and Variable Behavior

Let’s explore a more sophisticated scenario: a decorator that times the execution of a function.

import time

def timing(func):
  def wrapper(*args, **kwargs):
    start = time.time()
    result = func(*args, **kwargs)
    end = time.time()
    print(f"Execution time: {end - start:.4f} seconds")
    return result
  return wrapper

@timing
def slow_function(n):
  time.sleep(n)
  return n*2

slow_function(2)

This timing decorator measures and prints the execution time of the decorated function. Notice how it seamlessly handles functions with varying argument numbers and types.

Using functools.wraps for Improved Debugging

When debugging decorated functions, it’s beneficial to preserve the original function’s metadata (name, docstring, etc.). The functools.wraps decorator helps achieve this.

import functools
import time

def timing(func):
    @functools.wraps(func) #Preserves function metadata
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"Execution time: {end - start:.4f} seconds")
        return result
    return wrapper


@timing
def slow_function(n):
    time.sleep(n)
    return n*2

slow_function(2)

By incorporating functools.wraps, you improve the readability and debuggability of your code significantly. Without it, the decorated function’s metadata would be replaced by that of the wrapper function.

Practical Applications of Decorators with Arguments

Decorators with arguments are invaluable for a wide range of tasks, including:

  • Authentication and Authorization: Controlling access to functions based on user roles or permissions.
  • Logging and Monitoring: Tracking function calls, execution times, and error handling.
  • Caching: Storing and reusing function results to improve performance.
  • Input Validation: Ensuring that function arguments meet specific criteria before execution.

By mastering decorators with arguments, you enhance your Python skills and write more concise, maintainable, and reusable code.