DevRef / Python / Language Features
Python Decorators: Advanced Patterns
Decorators let you augment or replace functions and classes without modifying their source. Used well, they separate cross-cutting concerns cleanly. Used carelessly, they produce invisible behaviour that makes code hard to reason about.
What a decorator actually is
A decorator is syntactic sugar for passing a callable to a higher-order function. These two snippets are identical:
@log_call
def greet(name):
print(f"Hello, {name}")
# equivalent to:
def greet(name):
print(f"Hello, {name}")
greet = log_call(greet)
The decorator receives the function object and returns something — typically a wrapper function, but it can return the same function, a class, or anything else. The @ syntax is just a convenient way to express the wrapping at definition time.
Preserving function metadata
Naive wrappers shadow the wrapped function's __name__, __doc__, and __module__. This breaks introspection, documentation generators, and test output. functools.wraps fixes this by copying the original's metadata onto the wrapper.
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def greet(name):
print(f"Hello, {name}")
print(greet.__name__) # "greet", not "wrapper"
Parameterised decorators
When a decorator needs configuration, you add another layer: a factory function that takes the parameters and returns the actual decorator.
def retry(max_attempts=3, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == max_attempts - 1:
raise
return wrapper
return decorator
@retry(max_attempts=5, exceptions=(IOError, TimeoutError))
def fetch_data(url):
...
Class-based decorators
A class with a __call__ method can be used as a decorator. This is useful when the decorator needs to maintain state across calls.
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("hello")
say_hello()
say_hello()
print(say_hello.num_calls) # 2
Stacking decorators
Decorators apply bottom-up. The decorator closest to the function definition wraps the function first; outer decorators wrap the result. Order matters if decorators inspect the function's metadata or behaviour.
@outer
@inner
def func():
...
# equivalent to:
func = outer(inner(func))