Iterators, generators, closures and decorators · about 50 min

Decorators

Why this matters

You often want to add the same behavior around many functions: log each call, check permissions, time it, retry on failure, cache results. Copying that code into every function is repetitive and mixes concerns. A decorator wraps a function in another function that adds the behavior, and the @decorator line above a def applies it in one readable step. You have probably seen @property, @dataclass and @classmethod already. Web frameworks use decorators for routing (@app.route("/")), test frameworks for marking tests, and the standard library for caching (@functools.cache). Decorators are nothing more than the closures of the last lesson, used with a bit of syntax.

A decorator is a function that takes a function

def shout(func):
    def wrapper(text):
        return func(text).upper() + "!"
    return wrapper

def greet(name):
    return f"hello, {name}"

greet = shout(greet)
print(greet("ada"))
HELLO, ADA!

shout receives the original function, defines a wrapper closure that calls it and changes the result, and returns the wrapper. Rebinding greet to the wrapper means every caller now gets the new behavior.

The @ syntax

Writing greet = shout(greet) after the function is easy to miss. The @ syntax does exactly the same thing, right where you define the function:

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

is equivalent to:

def greet(name):
    return f"hello, {name}"
greet = shout(greet)

The decorator runs once, at definition time (when the def statement executes, typically on import). The wrapper runs on every call. With several decorators, the one closest to def is applied first:

@a
@b
def f(): ...
# f = a(b(f))

Writing general decorators

A useful decorator should work with any function, whatever its parameters. Use *args and **kwargs to accept and forward everything:

def trace(func):
    def wrapper(*args, **kwargs):
        print(f"-> {func.__name__}{args} {kwargs}")
        result = func(*args, **kwargs)
        print(f"<- {func.__name__} returned {result!r}")
        return result
    return wrapper

@trace
def add(a, b=0):
    return a + b

add(2, b=3)
-> add(2,) {'b': 3}
<- add returned 5

Always return the result of func(...); forgetting it makes every decorated function return None.

functools.wraps: keep the identity

The wrapper replaced the original, so its metadata is now the wrapper's:

print(add.__name__, add.__doc__)
wrapper None

That breaks help(), tracebacks, debugging and tools that look up functions by name. functools.wraps copies __name__, __doc__, __module__, __qualname__ and the annotations from the original to the wrapper, and sets wrapper.__wrapped__ to the original:

import functools

def trace(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Notice that functools.wraps(func) is itself a decorator with an argument, which leads to the next topic. Use @functools.wraps in every decorator you write.

Decorators with arguments

@retry(3) passes an argument to the decorator. Read it literally: Python first evaluates retry(3), and whatever that returns is used as the decorator. So retry is a decorator factory: a function returning a decorator, which returns a wrapper. Three nested levels:

import functools

def repeat(times):                    # factory: receives the arguments
    def decorator(func):              # decorator: receives the function
        @functools.wraps(func)
        def wrapper(*args, **kwargs): # wrapper: receives the call arguments
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def ping():
    print("ping")

ping()
ping
ping
ping

@repeat(3) desugars to ping = repeat(3)(ping). Each level is a closure over the one outside it: wrapper sees func and times.

Practical decorators

Timing. A decorator can measure how long calls take, using time.perf_counter() (a high-resolution clock for measuring intervals):

import functools, time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.3f}s")
    return wrapper

The try/finally ensures the time is reported even if the function raises.

Caching. Results of a pure function can be stored and reused. The standard library does it for you:

import functools

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(100))
print(fib.cache_info())
354224848179261915075
CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)

Without the cache, this recursion makes an exponential number of calls; with it, each fib(k) is computed once. It works because the recursive calls look up the global name fib, which now refers to the caching wrapper. functools.lru_cache(maxsize=128) keeps only the most recent entries to bound memory. Arguments must be hashable, since they become dict keys.

Validation and registration. Decorators can check arguments before calling, or simply record the function somewhere and return it unchanged:

handlers = {}

def on(event):
    def decorator(func):
        handlers[event] = func
        return func                     # not wrapped at all
    return decorator

@on("start")
def starting():
    return "starting up"

print(handlers["start"]())
starting up

This registration pattern is how web frameworks map URLs to functions.

Classes can be decorated too: @dataclass takes a class and returns it with generated methods added. And any callable can be a decorator, including a class with __call__.

Common mistakes

Recap

Going further: read PEP 318 (the decorator syntax rationale) and the functools docs (wraps, cache, lru_cache, singledispatch).

Exercises

Exercise 1 · quiz

What @ means

Which code is exactly equivalent to this?

@log
@retry(3)
def fetch(url):
    ...
Answer
  • fetch = log(retry(3)(fetch))

Decorators apply bottom-up: the one nearest the def wraps the function first. @retry(3) first calls retry(3), which must return a decorator; that decorator is applied to fetch, and log wraps the result. So calling fetch(...) enters log's wrapper first, then retry's, then the original function.

Exercise 2 · write code

A call-counting decorator

In solution.py, write a decorator count_calls that:

@count_calls
def add(a, b=0):
    "Add two numbers."
    return a + b

add(1, 2); add(5, b=1)
add.calls       # 2
add.__name__    # 'add'

File: solution.py

Starting point

import functools


def count_calls(func):
    def wrapper(a, b):
        return func(a, b)
    return wrapper
Hint 1

Accept and forward anything: def wrapper(*args, **kwargs): return func(*args, **kwargs).

Hint 2

Functions are objects, so you can set wrapper.calls = 0 after defining it and do wrapper.calls += 1 inside.

Hint 3

Put @functools.wraps(func) on the line above def wrapper.

Reference solution
import functools


def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

Exercise 3 · write code

A decorator with arguments: retry

In solution.py, write a decorator factory retry(times, exceptions=(Exception,)).

@retry(3) makes the decorated function try up to times attempts in total. If an attempt raises an exception that is an instance of one of exceptions, it tries again; if all attempts fail, it re-raises the last exception. Other exception types propagate immediately without retrying. On success it returns the result. Use functools.wraps.

times must be at least 1; retry(0) raises ValueError at decoration-factory time (when retry(0) is called).

File: solution.py

Starting point

import functools


def retry(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
Hint 1

Three levels: retry(times, ...) returns decorator(func), which returns wrapper(*args, **kwargs).

Hint 2

Inside wrapper, loop times times; return func(...) in a try, and in except exceptions as e: remember e.

Hint 3

After the loop, raise last. except accepts a tuple of exception classes.

Reference solution
import functools


def retry(times, exceptions=(Exception,)):
    if times < 1:
        raise ValueError("times must be at least 1")

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last = e
            raise last
        return wrapper

    return decorator

Exercise 4 · write code

Memoize by hand

In solution.py, write a decorator memoize that caches results in a dict keyed by the positional-argument tuple, so a function called again with the same arguments returns the stored result without running. Expose the dict as an attribute cache on the wrapper. (Only positional, hashable arguments need to be supported.) Use functools.wraps.

Then decorate a naive recursive fib(n) (with fib(0) == 0, fib(1) == 1) with @memoize in the same file. Without the cache, fib(80) would take longer than the age of the universe; with it, it is instant, because the recursive calls go through the decorated name.

File: solution.py

Starting point

import functools


def memoize(func):
    return func


@memoize
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)
Hint 1

Create cache = {} in memoize; the wrapper closes over it.

Hint 2

In the wrapper: if args not in cache: cache[args] = func(*args), then return cache[args].

Hint 3

Set wrapper.cache = cache before returning the wrapper.

Reference solution
import functools


def memoize(func):
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]

    wrapper.cache = cache
    return wrapper


@memoize
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)