Skip to main content

Python Decorators Advanced

Introduction

In the previous tutorial, we covered the basics of Python decorators - functions that modify other functions. Now, it's time to dive deeper into the world of decorators and explore their advanced capabilities.

Advanced decorators allow you to create more powerful and flexible code transformations. They enable you to implement sophisticated patterns like function registration, memoization, access control, and much more with elegant syntax.

In this tutorial, you'll learn how to:

  • Create decorators with arguments
  • Use class-based decorators
  • Stack multiple decorators
  • Preserve decorator metadata
  • Implement real-world decorator patterns

Let's take your decorator skills to the next level!

Prerequisites

Before proceeding, make sure you understand:

  • Basic Python decorators
  • Functions as first-class objects
  • Closures and function nesting
  • Basic Python object-oriented programming

Decorators with Arguments

Standard decorators are great, but sometimes we need to configure their behavior. This is where decorators with arguments come into play.

Creating Configurable Decorators

To create a decorator that accepts arguments, we need to add another layer of nesting:

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

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

# Test our decorator
say_hello("Alice")

Output:

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

How It Works

Let's break down what's happening:

  1. repeat(num_times) is a function that takes the decorator arguments
  2. It returns the actual decorator function decorator_repeat
  3. decorator_repeat takes the function to be decorated and returns a wrapper
  4. When using @repeat(3), Python first calls repeat(3) which returns decorator_repeat
  5. Then decorator_repeat is applied to the say_hello function

This triple-nested structure is common for decorators with arguments.

Class-Based Decorators

Decorators don't have to be functions - they can also be classes. This approach can be more readable and maintainable for complex decorators.

Basic Class Decorator

python
class Timer:
def __init__(self, func):
self.func = func
self.calls = 0

def __call__(self, *args, **kwargs):
self.calls += 1
print(f"Call #{self.calls} of {self.func.__name__}")
start_time = time.time()
result = self.func(*args, **kwargs)
end_time = time.time()
print(f"Time taken: {end_time - start_time:.5f} seconds")
return result

@Timer
def slow_function(n):
"""A deliberately slow function"""
time.sleep(n)
return n * n

# Test our class-based decorator
result = slow_function(0.5)
print(f"Result: {result}")
result = slow_function(1)
print(f"Result: {result}")

Output:

Call #1 of slow_function
Time taken: 0.50104 seconds
Result: 0.25
Call #2 of slow_function
Time taken: 1.00209 seconds
Result: 1

Class Decorator with Arguments

Class-based decorators can also accept arguments:

python
class Repeat:
def __init__(self, num_times):
self.num_times = num_times

def __call__(self, func):
def wrapper(*args, **kwargs):
result = None
for _ in range(self.num_times):
result = func(*args, **kwargs)
return result
return wrapper

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

# Test our class-based decorator with arguments
greet("Bob")

Output:

Hello, Bob!
Hello, Bob!
Hello, Bob!

Stacking Decorators

Decorators can be stacked - you can apply multiple decorators to a single function:

python
def bold(func):
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper

def italic(func):
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper

@bold
@italic
def format_text(text):
return text

print(format_text("Hello, World!"))

Output:

<b><i>Hello, World!</i></b>

Execution Order

When stacking decorators, they are applied from bottom to top:

  1. @italic is applied to format_text first
  2. Then @bold is applied to the result

It's equivalent to:

python
format_text = bold(italic(format_text))

Preserving Function Metadata

One issue with decorators is that they replace the original function, which means the original function's metadata (name, docstring, etc.) is lost:

python
def simple_decorator(func):
def wrapper(*args, **kwargs):
"""Wrapper function"""
return func(*args, **kwargs)
return wrapper

@simple_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b

print(add.__name__)
print(add.__doc__)

Output:

wrapper
Wrapper function

Using functools.wraps

The functools.wraps decorator helps preserve the original function's metadata:

python
import functools

def better_decorator(func):
@functools.wraps(func) # Preserves metadata
def wrapper(*args, **kwargs):
"""Wrapper function"""
return func(*args, **kwargs)
return wrapper

@better_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b

print(add.__name__)
print(add.__doc__)

Output:

add
Add two numbers and return the result.

Always use functools.wraps in your decorators to maintain proper introspection!

Real-World Decorator Patterns

Let's explore some commonly used advanced decorator patterns.

Memoization (Caching)

Memoization is a technique to cache function results to avoid redundant calculations:

python
def memoize(func):
cache = {}

@functools.wraps(func)
def wrapper(*args):
# We use args as the key since it's hashable
if args in cache:
print(f"Cache hit for {args}")
return cache[args]

print(f"Cache miss for {args}, calculating...")
result = func(*args)
cache[args] = result
return result

return wrapper

@memoize
def fibonacci(n):
"""Calculate the Fibonacci number recursively."""
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)

# Test our memoization decorator
print(fibonacci(5))
print(fibonacci(5)) # This should use cached result

Output:

Cache miss for (5,), calculating...
Cache miss for (4,), calculating...
Cache miss for (3,), calculating...
Cache miss for (2,), calculating...
Cache miss for (1,), calculating...
Cache miss for (0,), calculating...
Cache hit for (1,)
Cache hit for (2,)
Cache hit for (3,)
Cache hit for (4,)
5
Cache hit for (5,)
5

Timing and Profiling

Decorators are excellent for performance monitoring:

python
import time
import functools

def timing_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function {func.__name__} took {end_time - start_time:.6f} seconds to run")
return result
return wrapper

@timing_decorator
def slow_operation():
"""A deliberately slow operation."""
total = 0
for i in range(10000000):
total += i
return total

# Test our timing decorator
result = slow_operation()
print(f"Result: {result}")

Output:

Function slow_operation took 0.423561 seconds to run
Result: 49999995000000

Parameter Validation

Decorators can validate function inputs:

python
import functools

def validate_types(**expected_types):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Get function parameter names
func_code = func.__code__
param_names = func_code.co_varnames[:func_code.co_argcount]

# Combine positional and keyword arguments
all_args = dict(zip(param_names, args))
all_args.update(kwargs)

# Check types
for arg_name, expected_type in expected_types.items():
if arg_name in all_args:
actual_value = all_args[arg_name]
if not isinstance(actual_value, expected_type):
raise TypeError(f"Argument '{arg_name}' should be {expected_type.__name__}, "
f"got {type(actual_value).__name__}")

return func(*args, **kwargs)
return wrapper
return decorator

@validate_types(a=int, b=int)
def add_numbers(a, b):
return a + b

# Test our parameter validation decorator
print(add_numbers(5, 10))

try:
print(add_numbers("5", 10))
except TypeError as e:
print(f"Error: {e}")

Output:

15
Error: Argument 'a' should be int, got str

Rate Limiting

This advanced decorator limits how often a function can be called:

python
import functools
import time

def rate_limit(max_calls, period):
"""Limit the call rate of the decorated function.

Args:
max_calls: Maximum number of calls allowed within the period
period: Time period in seconds
"""
call_history = []

def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
current_time = time.time()

# Remove calls older than the period
while call_history and current_time - call_history[0] > period:
call_history.pop(0)

# Check if we've reached the maximum calls
if len(call_history) >= max_calls:
raise Exception(f"Rate limit exceeded. Maximum {max_calls} calls allowed per {period} seconds.")

# Add current call to history
call_history.append(current_time)

return func(*args, **kwargs)
return wrapper
return decorator

@rate_limit(max_calls=2, period=5)
def api_request(endpoint):
print(f"Requesting data from {endpoint}...")
# Simulate API call
return {"data": "some data"}

# Test our rate limiting decorator
print(api_request("/users"))
print(api_request("/posts"))

try:
print(api_request("/comments"))
except Exception as e:
print(f"Error: {e}")

Output:

Requesting data from /users...
{'data': 'some data'}
Requesting data from /posts...
{'data': 'some data'}
Error: Rate limit exceeded. Maximum 2 calls allowed per 5 seconds.

Decorators and Context Managers

Decorators can also incorporate context managers for resource handling:

python
import functools
import contextlib

def with_logging_context(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
with open("log.txt", "a") as log_file:
log_file.write(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}\n")
try:
result = func(*args, **kwargs)
log_file.write(f"{func.__name__} returned: {result}\n")
return result
except Exception as e:
log_file.write(f"{func.__name__} raised exception: {e}\n")
raise
return wrapper

@with_logging_context
def divide(a, b):
return a / b

# Example usage (creates log.txt in your working directory)
print(divide(10, 2))
try:
divide(10, 0)
except ZeroDivisionError:
print("Caught division by zero error")

Output:

5.0
Caught division by zero error

Contents of log.txt:

Calling divide with args: (10, 2), kwargs: {}
divide returned: 5.0
Calling divide with args: (10, 0), kwargs: {}
divide raised exception: division by zero

Summary

We've explored several advanced decorator techniques:

  • Creating decorators that accept arguments
  • Building class-based decorators
  • Stacking multiple decorators
  • Preserving metadata with functools.wraps
  • Implementing practical decorator patterns:
    • Memoization
    • Performance timing
    • Parameter validation
    • Rate limiting
    • Resource management with context managers

Decorators are a powerful metaprogramming tool in Python that allows you to extend and modify the behavior of functions without changing their code. With these advanced techniques, you can implement elegant solutions to complex problems.

Exercises

  1. Create a decorator that logs function arguments and return values to a file.
  2. Implement a decorator that retries a function a specified number of times if it raises an exception.
  3. Create a decorator that enforces access control based on user roles.
  4. Build a class-based decorator that measures both memory usage and execution time.
  5. Implement a caching decorator with a maximum cache size and expiration time for entries.

Additional Resources

Happy decorating!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)