Advanced Function Decorators
Decorators are a powerful Python feature that wrap functions or classes to add behavior without modifying the original code.
functools.wraps — Preserving Metadata
Applying a decorator loses the original function's metadata such as __name__ and __doc__. Use functools.wraps to preserve them.
import functools
# Decorator without wraps — metadata lost
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Decorator with wraps — metadata preserved
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def greet(name: str) -> str:
"""Returns a greeting."""
return f"Hello, {name}!"
bad_greet = bad_decorator(greet)
good_greet = good_decorator(greet)
print(bad_greet.__name__) # wrapper (original name lost)
print(good_greet.__name__) # greet (original name preserved)
print(bad_greet.__doc__) # None (docstring lost)
print(good_greet.__doc__) # Returns a greeting.
import inspect
print(inspect.signature(good_greet)) # (name: str) -> str
Parameterized Decorators
To pass arguments to the decorator itself, use a triple-nested function structure.
import functools
import time
def repeat(times: int):
"""Decorator that runs a function n times"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for i in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
def retry(max_attempts: int = 3, delay: float = 0.5, exceptions: tuple = (Exception,)):
"""Decorator that retries on failure"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts:
print(f"[Retry {attempt}/{max_attempts}] {func.__name__}: {e}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello() # prints "Hello!" 3 times
@retry(max_attempts=3, delay=0.1, exceptions=(ValueError,))
def risky_parse(text: str) -> int:
import random
if random.random() < 0.5:
raise ValueError("Parse failed!")
return int(text)
try:
result = risky_parse("42")
print(f"Parse result: {result}")
except ValueError:
print("Max retries exceeded")
Decorator Factory Pattern
import functools
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(level: str = "INFO", include_args: bool = True, include_result: bool = True):
"""Decorator that logs function calls"""
log_fn = getattr(logger, level.lower())
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if include_args:
log_fn(f"Call: {func.__name__}({args}, {kwargs})")
else:
log_fn(f"Call: {func.__name__}()")
start = time.perf_counter()
try:
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
if include_result:
log_fn(f"Done: {func.__name__} → {result!r} ({elapsed:.4f}s)")
else:
log_fn(f"Done: {func.__name__} ({elapsed:.4f}s)")
return result
except Exception as e:
elapsed = time.perf_counter() - start
logger.error(f"Error: {func.__name__} → {type(e).__name__}: {e} ({elapsed:.4f}s)")
raise
return wrapper
return decorator
def rate_limit(calls_per_second: float):
"""Decorator that limits call frequency"""
min_interval = 1.0 / calls_per_second
last_call_time = [0.0]
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
elapsed = now - last_call_time[0]
if elapsed < min_interval:
sleep_time = min_interval - elapsed
time.sleep(sleep_time)
last_call_time[0] = time.monotonic()
return func(*args, **kwargs)
return wrapper
return decorator
@log_calls(level="INFO", include_args=True)
def add(a: int, b: int) -> int:
return a + b
add(3, 5)
add(10, 20)
Decorator Stacking (Combining Multiple Decorators)
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"[Timer] {func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return wrapper
def validate_positive(*param_names: str):
"""Validates that specified parameters are positive"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name in param_names:
if name in bound.arguments:
value = bound.arguments[name]
if isinstance(value, (int, float)) and value <= 0:
raise ValueError(f"{name} must be positive: {value}")
return func(*args, **kwargs)
return wrapper
return decorator
def memoize(func):
cache: dict = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
# Decorator order: bottom to top (memoize → validate_positive → timer)
@timer
@validate_positive("n")
@memoize
def fibonacci(n: int) -> int:
"""Compute the nth Fibonacci number"""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55
print(fibonacci(10)) # 55 (from cache, timer still runs)
try:
fibonacci(-1) # ValueError: n must be positive
except ValueError as e:
print(f"Validation error: {e}")
print(fibonacci.__name__) # fibonacci (preserved by wraps)
Implementing Decorators as Classes
import functools
import time
class Timer:
"""Class-based decorator — implements __call__"""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
self.total_time = 0.0
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed = time.perf_counter() - start
self.call_count += 1
self.total_time += elapsed
print(f"[{self.func.__name__}] Call #{self.call_count}: {elapsed:.4f}s")
return result
@property
def average_time(self) -> float:
return self.total_time / self.call_count if self.call_count else 0
def stats(self) -> str:
return (f"{self.func.__name__}: "
f"{self.call_count} calls, "
f"avg {self.average_time:.4f}s, "
f"total {self.total_time:.4f}s")
@Timer
def slow_function(n: int) -> int:
time.sleep(0.01)
return n ** 2
for i in range(3):
slow_function(i)
print(slow_function.stats())
print(f"Call count: {slow_function.call_count}")
# Parameterized class-based decorator
class Retry:
def __init__(self, max_attempts: int = 3, exceptions: tuple = (Exception,)):
self.max_attempts = max_attempts
self.exceptions = exceptions
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, self.max_attempts + 1):
try:
return func(*args, **kwargs)
except self.exceptions as e:
if attempt == self.max_attempts:
raise
print(f"Retry {attempt}/{self.max_attempts}: {e}")
return wrapper
@Retry(max_attempts=3, exceptions=(ConnectionError,))
def connect_to_server(host: str) -> str:
import random
if random.random() < 0.7:
raise ConnectionError(f"Failed to connect to {host}")
return f"Connected to {host}"
try:
print(connect_to_server("api.example.com"))
except ConnectionError as e:
print(f"Final failure: {e}")
Real-World Example: API Endpoint Decorators
import functools
import time
import hashlib
from typing import Callable, Any
def require_auth(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
token = kwargs.get("token") or (args[0] if args else None)
if not token or not token.startswith("Bearer "):
raise PermissionError("Authentication required.")
return func(*args, **kwargs)
return wrapper
def cache_with_ttl(ttl_seconds: int = 60):
def decorator(func: Callable) -> Callable:
_cache: dict[str, tuple[Any, float]] = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = hashlib.md5(
f"{args}{sorted(kwargs.items())}".encode()
).hexdigest()
now = time.monotonic()
if key in _cache:
value, expire_at = _cache[key]
if now < expire_at:
return value
del _cache[key]
result = func(*args, **kwargs)
_cache[key] = (result, now + ttl_seconds)
return result
wrapper.cache_clear = lambda: _cache.clear()
wrapper.cache_size = lambda: len(_cache)
return wrapper
return decorator
def json_response(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
data = func(*args, **kwargs)
return {"success": True, "data": data, "error": None}
except ValueError as e:
return {"success": False, "data": None, "error": str(e)}
except PermissionError as e:
return {"success": False, "data": None, "error": f"Permission error: {e}"}
return wrapper
@json_response
@cache_with_ttl(ttl_seconds=30)
@require_auth
def get_user_data(token: str, user_id: int) -> dict:
if user_id <= 0:
raise ValueError(f"Invalid user ID: {user_id}")
return {"id": user_id, "name": f"user_{user_id}", "email": f"user{user_id}@example.com"}
print(get_user_data("Bearer valid-token", 42))
print(get_user_data("invalid-token", 42))
print(get_user_data("Bearer valid-token", -1))
Pro Tips
1. Access the original function via __wrapped__
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def original():
"""Original function"""
return "original"
print(original.__wrapped__) # <function original at ...>
print(original.__wrapped__()) # original
print(original()) # original (passes through decorator)
2. Conditional decorator application
import functools
import os
def maybe_decorate(condition: bool, decorator):
"""Apply decorator if condition is True, otherwise return function as-is"""
def apply(func):
if condition:
return decorator(func)
return func
return apply
def debug_mode_only(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[DEBUG] {func.__name__} called")
return func(*args, **kwargs)
return wrapper
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
@maybe_decorate(DEBUG, debug_mode_only)
def process_data(data: list) -> list:
return [x * 2 for x in data]
result = process_data([1, 2, 3])
print(result) # [2, 4, 6]
Summary
| Pattern | Use Case | Key Point |
|---|---|---|
| Basic decorator | Wrapping functions | functools.wraps is essential |
| Parameterized decorator | Configurable behavior | Triple-nested function structure |
| Class-based decorator | When state needs to be maintained | Implement __call__ |
| Decorator stack | Combining multiple features | Watch application order (bottom → top) |
Always use functools.wraps. Omitting it makes debugging and introspection difficult.