Decorator Factories
Deep dive · part of Python Decorators
A decorator factory is a function that takes configuration and returns a decorator. It allows decorators to accept arguments at the call site: @retry(times=3) rather than @retry. The shape is three nested functions.
A decorator factory is a function that accepts configuration and returns a decorator: @retry(times=3) requires three nested functions (factory, decorator, wrapper). This pattern powers parameterized cross-cutting concerns—retries, logging levels, access control roles—without one-off copy-paste wrappers.
Mastering the factory shape clarifies how frameworks like pytest.mark.parametrize and click.option work: outer call captures config, middle call receives the target, inner call runs at invocation time with functools.wraps preserving metadata.
Call shape: factory(*factory_args) -> decorator -> decorator(target) -> wrapped callable.
Use functools.wraps on the innermost wrapper so __name__, __doc__, and signatures stay intact.
Factories can return decorators for both functions and classes depending on what the inner decorator accepts.
Default arguments on the factory (times=3) make decorators optional: @retry equals @retry().
Closure variables hold per-decoration configuration; avoid mutating shared lists unless intentional.
Type checkers need ParamSpec and TypeVar for accurate typing of decorator factories (3.10+).
When the factory is also meant to be used bare (@retry without parens), return a decorator that detects whether the first argument is callable—a common dual-mode trick, but explicit @retry() is clearer for readers.
Stack order matters: @a @b def f applies b first, then a. Factories that alter signatures should document interaction with other decorators.
For async functions, the wrapper must be async def and await the underlying coroutine; sync wrappers around async defs break await semantics silently at call time.
Omitting functools.wraps, breaking introspection and pytest fixture name matching.
Forgetting parentheses on factories (@retry times=3) which passes invalid arguments to the factory.
Capturing loop variables incorrectly in closures inside factories (classic late-binding bug).
Applying sync retry wrappers to async functions without await.
Keep factory parameter names explicit (times, delay, exc) and validate ranges in the factory layer.
Unit-test the wrapper with injected failures, not only the happy path.
Re-raise the last exception after retries with context chaining (raise ... from e).
Publish minimal examples in docstrings showing both @deco and @deco() usage.
Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.
The program below demonstrates retry on exception. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# Example: Retry on exception
# Run in the REPL or save as a .py file and execute with python.
import time, functools, random
def retry(times=3, delay=0.1, exc=Exception):
def deco(fn):
@functools.wraps(fn)
def wrap(*a, **kw):
for i in range(times):
try:
return fn(*a, **kw)
except exc as e:
print(f"attempt {i+1} failed: {e}")
time.sleep(delay)
raise RuntimeError("all retries failed")
return wrap
return deco
@retry(times=5, exc=ValueError)
def flaky():
if random.random() < 0.7:
raise ValueError("transient")
return "ok"
print(flaky())
This sample walks through validate arg types in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.
# Example: Validate arg types
# Run in the REPL or save as a .py file and execute with python.
import functools
def typed(**spec):
def deco(fn):
@functools.wraps(fn)
def wrap(*a, **kw):
bound = dict(zip(fn.__code__.co_varnames, a)) | kw
for name, t in spec.items():
if name in bound and not isinstance(bound[name], t):
raise TypeError(f"{name} must be {t.__name__}")
return fn(*a, **kw)
return wrap
return deco
@typed(n=int, msg=str)
def greet(n, msg="hi"):
return msg * n
print(greet(3, msg="ha"))
# greet("oops") -> TypeError
Here is a hands-on illustration of retry factory. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.
# Three-level nesting: factory -> decorator -> wrapper
import time, functools # deps
def retry(times=3, delay=0.1): # factory with config
def deco(fn): # real decorator
@functools.wraps(fn) # preserve metadata
def wrap(*a, **kw): # wrapper
for i in range(times): # attempts
try: # call
return fn(*a, **kw) # success path
except Exception as e: # failure
print(f"try {i+1}: {e}") # log
time.sleep(delay) # backoff
raise RuntimeError("failed") # exhausted
return wrap
return deco
@retry(times=2) # configure
def flaky(): # demo
raise ValueError("nope") # always fails
The program below demonstrates typed factory. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# Factory can enforce argument types at runtime
import functools # wraps
def typed(**spec): # name->type map
def deco(fn): # decorator
@functools.wraps(fn)
def wrap(*a, **kw):
bound = dict(zip(fn.__code__.co_varnames, a)) | kw # bind args
for name, t in spec.items(): # each rule
if name in bound and not isinstance(bound[name], t): # mismatch
raise TypeError(f"{name} must be {t.__name__}") # error
return fn(*a, **kw) # call through
return wrap
return deco
@typed(n=int) # require int n
def double(n): # function
return n * 2 # body
print(double(4)) # 8
Related deep dives on Python Decorators: