Decorator patterns
Every shape of decorator that wraps a function — the basic form, decorators with arguments, functools.wraps, class decorators, and ParamSpec.
The @contextmanager from Chapter 11 iterables, generators, yield from, the @dataclass from Chapter 8 dataclass, the @functools.cache in the standard library, and FastAPI’s @app.get(...) are all decorators. This chapter covers every way to build that pattern yourself.
The ParamSpec-based signature preservation we cover at the end of the chapter is revisited in Chapter 20 typing advanced — Variance, ParamSpec, Self, overload. Extra tools appear there from the library author’s perspective, not the decorator user’s.
Decorator = a function that wraps a function #
def log_calls(fn):
def wrapper(*args, **kwargs):
print(f"call: {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
print(f"return: {result}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
return a + b
add(2, 3)
# call: add((2, 3), {})
# return: 5Defining the function with @log_calls on top is just sugar for this:
def add(a: int, b: int) -> int:
return a + b
add = log_calls(add)A decorator takes a function and returns a new function. The result is that the original name now refers to the new function.
First pitfall — metadata loss #
The add above is no longer add.
@log_calls
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(add.__name__) # 'wrapper' ← !
print(add.__doc__) # NoneThe tool that fixes this is functools.wraps.
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
print(add.__name__) # 'add'
print(add.__doc__) # 'Add two integers.'A single line of @wraps(fn) copies the name, docstring, annotations, and signature from the original. If you’re writing a user-facing decorator, you should almost always attach it.
Decorators that take arguments #
To pass arguments to the decorator itself like @retry(times=3), you add one more layer.
@retry
def request(): ...
# Behavior: request = retry(request)@retry(times=3)
def request(): ...
# Behavior: request = retry(times=3)(request)
# ^^^^^^^^^^^^^^^ this has to return a decoratorThat means retry(...) has to be a function that returns a decorator. You end up with three nested levels.
from functools import wraps
def retry(times: int): # 1) receives arguments
def decorator(fn): # 2) receives the function
@wraps(fn)
def wrapper(*args, **kwargs): # 3) receives the call
last_error = None
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_error = e
raise last_error
return wrapper
return decorator
@retry(times=3)
def fetch():
...How to read it:
- Outermost
retry(times)— decorator arguments - Middle
decorator(fn)— the function being decorated - Innermost
wrapper(*args, **kwargs)— the actual call
The three levels look awkward, but they’re the standard form for every decorator with arguments.
The functools.partial trick
#
When you want a decorator that works both as @retry() (no args) and as @retry(3) (with args).
from functools import wraps, partial
def retry(fn=None, *, times: int = 3):
if fn is None:
return partial(retry, times=times)
@wraps(fn)
def wrapper(*args, **kwargs):
for _ in range(times):
try:
return fn(*args, **kwargs)
except Exception:
continue
raise
return wrapper
@retry
def a(): ...
@retry(times=5)
def b(): ...Complex, so not commonly used. Usually picking one of the two and standardizing on it is cleaner.
Commonly used standard decorators #
@functools.cache / @lru_cache — memoization
#
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(100)) # 354224848179261915075 — instantCalled again with the same arguments, it returns the cached result without recomputing. Only use on pure functions (not on functions with side effects).
@lru_cache(maxsize=128) is the variant with a cache-size limit. Use it when you need to prevent unbounded memory growth.
@property — call like an attribute
#
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self) -> float:
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12.0 ← attribute access, not a method callThe generalization of @property is the descriptor protocol in Chapter 16 descriptors and __set_name__.
@staticmethod / @classmethod
#
class Math:
@staticmethod
def add(a: int, b: int) -> int:
return a + b
@classmethod
def from_pair(cls, pair: tuple[int, int]):
return cls(*pair)staticmethod — takes neither self nor cls. classmethod — takes the class itself as the first arg (often used for alternative constructors).
Class decorators #
Decorators can also be applied to classes. Just like for functions, it’s a function that takes a class and returns a class.
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
print(User(1, "Curtis"))
# User(id=1, name='Curtis')@dataclass is exactly this pattern — it takes a class and returns a class with __init__ / __repr__ / __eq__ added.
Decorator classes — making the object on call #
Instead of a function, you can also use a class with __call__ as a decorator. Fits when you need state.
class CallCounter:
def __init__(self, fn):
self.fn = fn
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
@CallCounter
def greet():
print("hi")
greet()
greet()
greet()
print(greet.count) # 3greet is now a CallCounter instance. Attribute access like .count works naturally. Downside: function signature / metadata preservation is tricky, and static analyzers eye it warily. If you don’t need state, a function decorator is better.
Decorators on methods — self flows through
#
Because the wrapper inside the decorator takes *args, **kwargs, the method’s self simply comes in as args[0].
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call: {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
class Service:
@log_calls
def fetch(self, url: str):
return f"data from {url}"
s = Service()
s.fetch("https://api.example.com")
# call: fetchNo special handling needed. It just works.
Type hints for decorators — ParamSpec
#
Typing the decorator makes static analysis exact. But decorators wrap arbitrary functions, so preserving the type as-is is tricky. The tool that solves this is ParamSpec.
from typing import Callable
from collections.abc import Awaitable
def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"call: {fn.__name__}")
return fn(*args, **kwargs)
return wrapperWhat [**P, R] (3.12+) means:
P— the function’s parameter signature as-isR— the return type
Written this way, the decorated function’s signature is preserved. Caller-side autocomplete and verification stay accurate. The old way looked like:
from typing import TypeVar, ParamSpec, Callable
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapperNew code writes [**P, R] next to the method. Chapter 20 typing advanced revisits other uses of ParamSpec (decorators that accept only some of the arguments, etc.).
Composition — stacking multiple decorators #
@log_calls
@retry(times=3)
@cache
def fetch(url): ...The code above is applied bottom-up.
fetch = log_calls(retry(times=3)(cache(fetch)))Order matters, so watch it. In the example above:
cachefirst — on a cache hit, retry doesn’t even runretrynext — retries when the cache misses and the real call happenslog_callsoutermost — logs every call, retries included
Common patterns — in one place #
Timing #
import time
from functools import wraps
def timing[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__}: {elapsed:.3f}s")
return result
return wrapperPermission check #
def require_admin(fn):
@wraps(fn)
def wrapper(user, *args, **kwargs):
if not user.is_admin:
raise PermissionError("admin only")
return fn(user, *args, **kwargs)
return wrapper
@require_admin
def delete_user(actor, target_id): ...Web framework permission handling usually has this shape. In Part 4 FastAPI, the Depends system from Chapter 23 routing, Pydantic models, dependency injection sits in the same spot.
Result caching (with TTL) #
from functools import wraps
import time
def ttl_cache(seconds: float):
def decorator(fn):
cache_data: dict = {}
@wraps(fn)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.monotonic()
if key in cache_data:
value, ts = cache_data[key]
if now - ts < seconds:
return value
value = fn(*args, **kwargs)
cache_data[key] = (value, now)
return value
return wrapper
return decorator
@ttl_cache(seconds=60)
def get_config(): ...Exercises #
- Write a
@timingdecorator with the[**P, R]signature. Print the wrapped function’s execution time in the formatprint(f"{fn.__name__}: {elapsed:.3f}s"). After applying@wraps(fn), verify thatadd.__name__is still"add". - Write a decorator with arguments called as
@retry(times=3, delay=0.1). On failure, waitdelayseconds and retry; if all attempts fail, re-raise the last exception. Build a function that intentionally fails with 1/3 probability and confirm the statistics line up. - Write a class decorator
@add_reprthat automatically adds a__repr__printing every field of__dict__. Compare how@dataclassdoes the same job in old Chapter 8 dataclass.
In one line: A decorator is a function that takes a function and returns a function.
@dis sugar forf = d(f). Almost always attach@functools.wraps(fn)to user decorators. Decorators with arguments need three nested levels. You can also apply them to classes (@dataclass) and write class-form decorators (__call__). Preserve signatures with[**P, R](3.12+). Multiple decorators apply bottom-up.
Next chapter #
In Chapter 13 pattern matching in depth we cover every pattern of the match-case we glimpsed in Chapter 3 control flow — class patterns, __match_args__, capture variables, and the depth of guards.