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:
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:
repeat(num_times)
is a function that takes the decorator arguments- It returns the actual decorator function
decorator_repeat
decorator_repeat
takes the function to be decorated and returns a wrapper- When using
@repeat(3)
, Python first callsrepeat(3)
which returnsdecorator_repeat
- Then
decorator_repeat
is applied to thesay_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
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:
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:
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:
@italic
is applied toformat_text
first- Then
@bold
is applied to the result
It's equivalent to:
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:
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:
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:
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:
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:
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:
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:
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
- Create a decorator that logs function arguments and return values to a file.
- Implement a decorator that retries a function a specified number of times if it raises an exception.
- Create a decorator that enforces access control based on user roles.
- Build a class-based decorator that measures both memory usage and execution time.
- Implement a caching decorator with a maximum cache size and expiration time for entries.
Additional Resources
- PEP 318 - Decorators for Functions and Methods
- Python Decorators in the Real World
- Fluent Python by Luciano Ramalho (Ch. 7: Function Decorators and Closures)
- Python Cookbook, 3rd Edition by David Beazley and Brian K. Jones (Ch. 9: Metaprogramming)
Happy decorating!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)