When creating decorators in Python, one of the most common issues developers encounter is the loss of important function metadata like the function name, docstring, and other attributes. The functools.wraps
decorator solves this problem elegantly by preserving the original function's metadata in the decorated function. This comprehensive guide will show you why this matters and how to use functools.wraps
effectively in your decorators.
Table Of Contents
- The Problem: Lost Function Metadata
- The Solution: functools.wraps
- What functools.wraps Actually Does
- Essential Patterns with functools.wraps
- Advanced Decorator Patterns
- Debugging and Introspection Benefits
- Best Practices and Common Pitfalls
- Conclusion
The Problem: Lost Function Metadata
When you create a decorator without using functools.wraps
, the decorated function loses its original metadata:
import time
def timing_decorator(func):
"""A decorator that measures execution time."""
def wrapper(*args, **kwargs):
"""Wrapper function that adds timing functionality."""
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function executed in {end_time - start_time:.4f} seconds")
return result
return wrapper
@timing_decorator
def calculate_fibonacci(n):
"""Calculate the nth Fibonacci number recursively."""
if n <= 1:
return n
return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)
# Check the function metadata
print(f"Function name: {calculate_fibonacci.__name__}")
print(f"Function docstring: {calculate_fibonacci.__doc__}")
print(f"Function module: {calculate_fibonacci.__module__}")
# Output:
# Function name: wrapper
# Function docstring: Wrapper function that adds timing functionality.
# Function module: __main__
As you can see, the decorated function has lost its original name and docstring, making debugging and introspection difficult.
The Solution: functools.wraps
functools.wraps
is a decorator specifically designed to solve this problem:
import time
from functools import wraps
def timing_decorator(func):
"""A decorator that measures execution time."""
@wraps(func) # This preserves the original function's metadata
def wrapper(*args, **kwargs):
"""Wrapper function that adds timing functionality."""
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function executed in {end_time - start_time:.4f} seconds")
return result
return wrapper
@timing_decorator
def calculate_fibonacci(n):
"""Calculate the nth Fibonacci number recursively."""
if n <= 1:
return n
return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)
# Check the function metadata - now preserved!
print(f"Function name: {calculate_fibonacci.__name__}")
print(f"Function docstring: {calculate_fibonacci.__doc__}")
print(f"Function module: {calculate_fibonacci.__module__}")
# Output:
# Function name: calculate_fibonacci
# Function docstring: Calculate the nth Fibonacci number recursively.
# Function module: __main__
What functools.wraps Actually Does
functools.wraps
is itself a decorator that copies several important attributes from the original function to the wrapper function:
from functools import wraps
def demonstrate_wraps(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@demonstrate_wraps
def example_function():
"""This is an example function."""
pass
# These attributes are preserved by @wraps:
print("Preserved attributes:")
print(f"__name__: {example_function.__name__}") # example_function
print(f"__doc__: {example_function.__doc__}") # This is an example function.
print(f"__module__: {example_function.__module__}") # __main__
print(f"__qualname__: {example_function.__qualname__}") # example_function
print(f"__annotations__: {example_function.__annotations__}") # {}
# The wrapper function is still accessible
print(f"__wrapped__: {example_function.__wrapped__}") # <function example_function at 0x...>
Essential Patterns with functools.wraps
Basic Decorator Template
from functools import wraps
def my_decorator(func):
"""Template for creating decorators with preserved metadata."""
@wraps(func)
def wrapper(*args, **kwargs):
# Pre-execution logic
print(f"Calling {func.__name__}")
# Call the original function
result = func(*args, **kwargs)
# Post-execution logic
print(f"Finished {func.__name__}")
return result
return wrapper
@my_decorator
def greet(name):
"""Greet a person by name."""
return f"Hello, {name}!"
print(greet("Alice"))
print(f"Function name preserved: {greet.__name__}")
Logging Decorator
import logging
from functools import wraps
from datetime import datetime
def log_calls(level=logging.INFO, include_args=True, include_result=True):
"""Decorator that logs function calls with configurable options."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
logger = logging.getLogger(func.__module__)
# Prepare log message
func_name = func.__qualname__
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if include_args and (args or kwargs):
args_str = ', '.join(repr(arg) for arg in args)
kwargs_str = ', '.join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
log_msg = f"[{timestamp}] Calling {func_name}({all_args})"
else:
log_msg = f"[{timestamp}] Calling {func_name}()"
logger.log(level, log_msg)
try:
result = func(*args, **kwargs)
if include_result:
logger.log(level, f"[{timestamp}] {func_name} returned: {result!r}")
else:
logger.log(level, f"[{timestamp}] {func_name} completed successfully")
return result
except Exception as e:
logger.error(f"[{timestamp}] {func_name} raised {type(e).__name__}: {e}")
raise
return wrapper
return decorator
# Configure logging
logging.basicConfig(level=logging.INFO)
@log_calls(level=logging.DEBUG, include_result=False)
def divide(a, b):
"""Divide two numbers."""
return a / b
@log_calls()
def fetch_user_data(user_id, include_preferences=False):
"""Fetch user data from the database."""
# Simulate database operation
return {"id": user_id, "name": "John Doe", "preferences": {} if include_preferences else None}
# Test the decorated functions
result = divide(10, 2)
user_data = fetch_user_data(123, include_preferences=True)
Caching Decorator with Metadata Preservation
from functools import wraps
import time
def cache_with_ttl(ttl_seconds=300):
"""Decorator that caches function results with time-to-live."""
def decorator(func):
cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key
key = str(args) + str(sorted(kwargs.items()))
current_time = time.time()
# Check if result is in cache and not expired
if key in cache:
result, timestamp = cache[key]
if current_time - timestamp < ttl_seconds:
print(f"Cache hit for {func.__name__}")
return result
else:
print(f"Cache expired for {func.__name__}")
del cache[key]
# Call function and cache result
print(f"Computing {func.__name__}")
result = func(*args, **kwargs)
cache[key] = (result, current_time)
return result
# Add cache management methods
wrapper.cache_info = lambda: {
'size': len(cache),
'keys': list(cache.keys())
}
wrapper.cache_clear = lambda: cache.clear()
return wrapper
return decorator
@cache_with_ttl(ttl_seconds=5)
def expensive_computation(x, y):
"""Perform an expensive mathematical computation."""
time.sleep(1) # Simulate expensive operation
return x ** y + y ** x
# Test caching behavior
print(expensive_computation(2, 3)) # Computed
print(expensive_computation(2, 3)) # Cache hit
time.sleep(6)
print(expensive_computation(2, 3)) # Cache expired, computed again
# Function metadata is preserved
print(f"Function: {expensive_computation.__name__}")
print(f"Docstring: {expensive_computation.__doc__}")
print(f"Cache info: {expensive_computation.cache_info()}")
Advanced Decorator Patterns
Decorator with Arguments and Metadata Preservation
from functools import wraps
import time
def retry(max_attempts=3, delay=1, exceptions=(Exception,)):
"""Decorator that retries function execution on failure."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
result = func(*args, **kwargs)
if attempt > 0:
print(f"{func.__name__} succeeded on attempt {attempt + 1}")
return result
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
print(f"{func.__name__} failed (attempt {attempt + 1}/{max_attempts}): {e}")
print(f"Retrying in {delay} seconds...")
time.sleep(delay)
else:
print(f"{func.__name__} failed after {max_attempts} attempts")
raise last_exception
# Preserve decorator configuration in the wrapped function
wrapper.max_attempts = max_attempts
wrapper.delay = delay
wrapper.exceptions = exceptions
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def unreliable_network_call():
"""Simulate an unreliable network call."""
import random
if random.random() < 0.7: # 70% failure rate
raise ConnectionError("Network is unreliable")
return "Success!"
# Function metadata and retry configuration are preserved
print(f"Function: {unreliable_network_call.__name__}")
print(f"Max attempts: {unreliable_network_call.max_attempts}")
print(f"Delay: {unreliable_network_call.delay}")
try:
result = unreliable_network_call()
print(f"Result: {result}")
except Exception as e:
print(f"Final failure: {e}")
Class-Based Decorators with wraps
from functools import wraps
import time
import threading
class RateLimiter:
"""Class-based decorator for rate limiting function calls."""
def __init__(self, max_calls=5, time_window=60):
self.max_calls = max_calls
self.time_window = time_window
self.calls = []
self.lock = threading.Lock()
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
with self.lock:
current_time = time.time()
# Remove old calls outside the time window
self.calls = [call_time for call_time in self.calls
if current_time - call_time < self.time_window]
# Check rate limit
if len(self.calls) >= self.max_calls:
oldest_call = min(self.calls)
wait_time = self.time_window - (current_time - oldest_call)
raise Exception(f"Rate limit exceeded. Try again in {wait_time:.1f} seconds")
# Record this call
self.calls.append(current_time)
return func(*args, **kwargs)
# Preserve rate limiter configuration
wrapper.max_calls = self.max_calls
wrapper.time_window = self.time_window
wrapper.get_remaining_calls = lambda: self.max_calls - len(self.calls)
return wrapper
@RateLimiter(max_calls=3, time_window=10)
def api_call(endpoint):
"""Make an API call to the specified endpoint."""
print(f"Calling API endpoint: {endpoint}")
return f"Response from {endpoint}"
# Test rate limiting
for i in range(5):
try:
result = api_call(f"/users/{i}")
print(f"Success: {result}")
print(f"Remaining calls: {api_call.get_remaining_calls()}")
except Exception as e:
print(f"Error: {e}")
print(f"Function name preserved: {api_call.__name__}")
print(f"Rate limit config: {api_call.max_calls} calls per {api_call.time_window}s")
Debugging and Introspection Benefits
Debugging Decorated Functions
from functools import wraps
import traceback
def debug_decorator(func):
"""Decorator that helps with debugging by preserving stack traces."""
@wraps(func)
def wrapper(*args, **kwargs):
try:
print(f"DEBUG: Entering {func.__qualname__}")
print(f"DEBUG: Args: {args}")
print(f"DEBUG: Kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"DEBUG: {func.__qualname__} returned: {type(result).__name__}")
return result
except Exception as e:
print(f"DEBUG: Exception in {func.__qualname__}: {e}")
print("DEBUG: Full traceback:")
traceback.print_exc()
raise
return wrapper
@debug_decorator
def problematic_function(x, y):
"""A function that might cause problems."""
if x == 0:
raise ValueError("x cannot be zero")
return y / x
# Test debugging
try:
result = problematic_function(2, 10)
print(f"Result: {result}")
# This will trigger the debug information
problematic_function(0, 10)
except ValueError as e:
print(f"Caught expected error: {e}")
# Function metadata is preserved for better debugging
print(f"Function name: {problematic_function.__name__}")
print(f"Function file: {problematic_function.__code__.co_filename}")
print(f"Function line: {problematic_function.__code__.co_firstlineno}")
Creating Decorator Documentation
from functools import wraps
import inspect
def documented_decorator(description=""):
"""Decorator that automatically documents the decoration."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# Enhance docstring with decorator information
original_doc = func.__doc__ or ""
decoration_info = f"\n\nDecorated with: {description}"
wrapper.__doc__ = original_doc + decoration_info
# Add decorator metadata
wrapper._decorator_info = {
'decorator': 'documented_decorator',
'description': description,
'original_function': func,
'decorated_at': inspect.currentframe().f_back.f_lineno
}
return wrapper
return decorator
@documented_decorator("Adds automatic retry logic with exponential backoff")
def network_operation():
"""Perform a network operation that might fail."""
return "Network operation completed"
@documented_decorator("Validates input parameters before execution")
def calculate_area(length, width):
"""Calculate the area of a rectangle."""
return length * width
# Check enhanced documentation
print("Network operation docstring:")
print(network_operation.__doc__)
print("\nDecorator info:")
print(network_operation._decorator_info)
print("\nCalculate area docstring:")
print(calculate_area.__doc__)
Best Practices and Common Pitfalls
Always Use functools.wraps
from functools import wraps
# ❌ BAD: Without wraps
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# ✅ GOOD: With wraps
def good_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def function_a():
"""Original docstring A"""
pass
@good_decorator
def function_b():
"""Original docstring B"""
pass
print(f"Bad: {function_a.__name__}, {function_a.__doc__}") # wrapper, None
print(f"Good: {function_b.__name__}, {function_b.__doc__}") # function_b, Original docstring B
Preserving Type Hints
from functools import wraps
from typing import Callable, TypeVar, Any
F = TypeVar('F', bound=Callable[..., Any])
def preserve_signature(func: F) -> F:
"""Decorator that preserves type hints and signatures."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper # type: ignore
@preserve_signature
def typed_function(x: int, y: str = "default") -> str:
"""A function with type hints."""
return f"{x}: {y}"
# Type hints are preserved for static analysis tools
print(f"Annotations: {typed_function.__annotations__}")
Handling Multiple Decorators
from functools import wraps
def decorator_a(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Decorator A: before")
result = func(*args, **kwargs)
print("Decorator A: after")
return result
return wrapper
def decorator_b(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Decorator B: before")
result = func(*args, **kwargs)
print("Decorator B: after")
return result
return wrapper
@decorator_a
@decorator_b
def multiple_decorated():
"""Function with multiple decorators."""
print("Original function")
return "result"
# Even with multiple decorators, metadata is preserved
print(f"Function name: {multiple_decorated.__name__}")
print(f"Docstring: {multiple_decorated.__doc__}")
# Execute to see decorator order
multiple_decorated()
Conclusion
functools.wraps
is essential for creating professional, maintainable decorators in Python. It ensures that:
- Function metadata is preserved for debugging and introspection
- Documentation tools work correctly with decorated functions
- Stack traces remain clear and point to the original function
- Type hints and signatures are maintained for static analysis
- IDE support remains functional for decorated functions
Key benefits:
- Better debugging experience
- Preserved function identity
- Maintained documentation
- Improved developer experience
- Professional code quality
Best practices:
- Always use
@wraps(func)
in decorator implementations - Include it even in simple decorators
- Combine it with proper error handling
- Document your decorators thoroughly
- Consider type hints for better static analysis
By consistently using functools.wraps
, you'll create decorators that integrate seamlessly with Python's ecosystem while maintaining the clarity and debuggability of your code.
Add Comment
No comments yet. Be the first to comment!