Decorators

Wrap a function without touching it: timing, caching, retrying and logging, written once and applied anywhere.

19of 24
≈ 13Min read
5Parts

A decorator is a function that returns a function

args, *kwargs in the wrapper is not decoration — it is what lets one decorator work on functions with any signature. Without it, your decorator only fits the function you were looking at when you wrote it.

py
def shout(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs).upper()
    return wrapper

@shout
def greet(name):
    return f"hi, {name}"

greet("ada")          # 'HI, ADA'
the @ is only shorthand
greet = shout(greet)      # this is literally what @shout does

Always use functools.wraps

Without wraps, every decorated function in your program reports itself as wrapper: in tracebacks, in logs, in help(), and to anything that inspects signatures. It is one line, it is never wrong, and forgetting it is the most common decorator bug.

py
from functools import wraps

def shout(fn):
    @wraps(fn)                 # copies __name__, __doc__, __module__, __wrapped__
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs).upper()
    return wrapper

greet.__name__     # 'greet'  — without @wraps it would be 'wrapper'

A decorator that takes arguments

Three levels, because @retry(times=5) calls retry first and decorates with whatever comes back. That is the whole reason for the extra layer, and knowing it is why the shape stops looking arbitrary.

py
def retry(times=3):                     # the factory…
    def decorator(fn):                  # …returns the decorator…
        @wraps(fn)
        def wrapper(*args, **kwargs):   # …which returns the wrapper
            last = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as err:
                    last = err
            raise last
        return wrapper
    return decorator

@retry(times=5)
def fetch(url): ...

Order, and state

Decorators apply from the bottom up. @cache under @log logs every call; @log under @cache logs only the misses. Which you want depends on what you are debugging, and getting it backwards is a puzzling afternoon.

A decorator can keep state — a call count, a cache, a set of seen arguments — in its closure, and expose it by attaching an attribute to the wrapper: wrapper.calls = 0.

py
@log          # applied second — the outer wrapper
@cache        # applied first  — the inner one
def work(n): ...

# work = log(cache(work))      — bottom-up, like the mathematics

Where you have already met them

That last pattern — a decorator whose real job is to register the function somewhere — is how most web frameworks, CLI libraries and plugin systems work.

DecoratorDoes
@propertyMethod, accessed like an attribute
@staticmethod / @classmethodChanges what the first argument is
@functools.cacheMemoises on the arguments
@dataclassWrites __init__, __repr__, __eq__
@app.route("/")Registers the function in a table — a decorator with a side effect

Practice