Decorator Factories — Decorators That Take Arguments
~22 min · decorator-factory, parameterized, @deco(args), closure
Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
The three-level structure
A regular decorator has two levels: outer function takes the wrapped function, inner wrapper does the work. A parameterized decorator (one you write as @retry(times=3)) has three levels: an outer function takes the arguments, returns a regular decorator, which returns the wrapper. The first level is the "decorator factory" — it manufactures decorators based on the configuration.
Why three levels — what each does
Level 1 — the factory function — takes the configuration arguments (retry(times=3)). It returns level 2. Level 2 — the actual decorator — takes the function being decorated. It returns level 3. Level 3 — the wrapper — takes *args, **kwargs and does the work. The reason for three levels is that @retry(times=3) means "call retry(times=3), then apply the result as a decorator."
Closures save the configuration
The wrapper at level 3 needs to know times=3 from level 1. It accesses it through the enclosing scope — that's a closure. The factory creates a fresh decorator each time it's called, so each call site can have different configuration that's correctly captured.
War Story: The most common bug with decorator factories — calling the decorator without parens. @retry tries to use the function being decorated as the times argument. The error message is confusing. The fix is always: parens, even if you don't pass any arguments — @retry().
The optional-args trick — and when to skip it
Some decorators try to support both @deco and @deco(args). It involves checking whether the first argument is a function (no-args case) or something else (factory case). It's clever but it's also one of the bigger sources of confusion in Python decorator code. Most well-designed decorators in the wild require parens unconditionally — @retry() with default settings is the price of clarity.
Code
Decorator factory — the three levels·python
import functools
import time
def retry(times): # LEVEL 1: takes config
def decorator(fn): # LEVEL 2: takes function
@functools.wraps(fn)
def wrapper(*args, **kwargs): # LEVEL 3: does the work
last_err = None
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_err = e
print(f"attempt {attempt+1} failed: {e}")
raise last_err
return wrapper
return decorator
@retry(times=3)
def flaky():
import random
if random.random() < 0.7:
raise RuntimeError("flake")
return "ok"
# Call it; it'll retry up to 3 times on flake
Common bug — forgetting the parens·python
import functools
def cache(maxsize=None): # factory
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
return decorator
# Right way
@cache() # parens, even if no args
def good():
pass
# Wrong way — common bug
try:
@cache # missing parens — passes 'good' as maxsize
def bad():
pass
bad()
except TypeError as e:
print("error:", e)
Configurable logging — the real-world shape·python
Write a parameterized decorator cap(max_calls) that limits how many times the wrapped function can run. After max_calls invocations, calling it raises RuntimeError("call limit reached"). Demonstrate by decorating one function with @cap(max_calls=3) and another with @cap(max_calls=1), calling each enough times to trigger the limit. Use functools.wraps. Use closure scope to track the call count per-decoration.
Progress
Progress is local-only — sign in to sync across devices.