C.W.K.
Stream
Lesson 03 of 05 · published

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
import functools

def logged(level="INFO", include_args=True):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            if include_args:
                arglist = f"args={args}, kwargs={kwargs}"
            else:
                arglist = ""
            print(f"[{level}] {fn.__name__} {arglist}")
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@logged(level="DEBUG", include_args=True)
def compute(x, y):
    return x + y

@logged(level="WARN", include_args=False)
def run():
    return "done"

compute(3, 4)
run()
Closure captures the config — different settings work independently·python
import functools

def tagged(prefix):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            return f"[{prefix}] {fn(*args, **kwargs)}"
        return wrapper
    return decorator

@tagged("INFO")
def info_msg(s):
    return s

@tagged("ERROR")
def err_msg(s):
    return s

print(info_msg("hello"))   # '[INFO] hello'
print(err_msg("oops"))     # '[ERROR] oops'

# Each decoration captured its own `prefix` via closure

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.