1.3.8 · D4Python Intermediate

Exercises — Decorators — function decorators, @, wraps

2,538 words12 min readBack to topic

Before we start, one picture of the machinery so every later reference has a shape to point at.

Figure — Decorators — function decorators, @, wraps

Read the picture left to right: the name f originally points at the original function (cyan box). The decorator deco builds a wrapper (amber box) that holds a reference to the original inside it, and then the name f is rebound to that wrapper. That rebinding is the entire meaning of @deco.


Level 1 — Recognition

You only need to read code and state what it becomes. No execution tricks yet.

Recall Solution L1.1

The rule from the parent note: @deco above def f means f = deco(f). So:

def greet(): return "hi"
greet = shout(greet)

What we did: applied the desugaring rule literally. Why: @ is only sugar for reassigning the name to the decorator's return value.

Recall Solution L1.2
  • d1yes. Takes a callable, returns a callable (the same one). Legal, though it adds nothing.
  • d2no as a useful decorator: it returns 42, an int, which is not callable. @d2 def f makes f the integer 42, so f() raises TypeError.
  • d3structurally yes (takes one arg, returns a callable), but it ignores the function and always returns lambda: x. It "decorates" but destroys the original — a valid callable-in, callable-out, still legal syntactically.
  • d4yes, the canonical shape: Take, Wrap, Return. Why the distinction: "decorator" is defined by shape (callable → callable), not by usefulness.
Recall Solution L1.3

wrapper. Why: greet now points at the inner function wrapper; no functools.wraps was used, so the original name label was not copied. This is exactly the __name__-lie problem from the parent note. Prints:

wrapper

Level 2 — Application

Now you write decorators and compute outputs.

Recall Solution L2.1

add(3,4) desugars to double(add)(3,4). Inner call f(3,4) = 7, doubled → 14.

14

Why *a, **k: so the wrapper forwards (3,4) unchanged to the original — see args and kwargs unpacking.

Recall Solution L2.2
from functools import wraps
def announce(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"running {func.__name__}")
        result = func(*args, **kwargs)   # capture the value
        return result                    # return it so caller still gets 10
    return wrapper

Output:

running area
10

Why result = ...; return result: if we drop the return, area(2,5) becomes None. Why @wraps: keeps area.__name__ == "area".

Recall Solution L2.3
call #1
call #2
call #3

Why it remembers: count is a free variable captured by the inner function's closure; nonlocal lets the wrapper rebind it (without nonlocal, count += 1 would raise UnboundLocalError).


Level 3 — Analysis

Trace multi-layer behaviour and explain WHY, not just WHAT.

Recall Solution L3.1

Stacking desugars bottom-up: text = tag("b")(tag("i")(text)).

  • Innermost tag("i") wraps text → produces <i>hi</i>.
  • Then tag("b") wraps that → produces <b> + <i>hi</i> + </b>.
<b><i>hi</i></b>

Why bottom-up: @tag("b") sits above the whole result of @tag("i") def text, so tag("i") is applied first and tag("b") last — the outer tag ends up outermost.

Figure — Decorators — function decorators, @, wraps
Recall Solution L3.2

Two calls happen at decoration time:

  1. repeat(3) runs → Layer 1 captures n = 3, returns decorator.
  2. decorator(hi) runs → Layer 2 captures func = hi, returns wrapper.
  3. Layer 3 (wrapper) captures nothing new; it uses n and func at call time. So hi = repeat(3)(hi). When you later call hi(), the loop for _ in range(n) runs the original 3 times. Why three layers: each () you write needs its own function to receive its arguments — n from @repeat(3), func from the @, and *args from the eventual call.
Recall Solution L3.3

Without wraps:

  • foo.__name__'w'
  • foo.__doc__None The name now points at the inner w, whose own docstring is empty. With @wraps(f) on w (from functools — wraps, lru_cache, partial):
  • foo.__name__'foo'
  • foo.__doc__'the docstring' Why: wraps copies __name__, __doc__, __module__, __qualname__, and sets __wrapped__ back to the original.

Level 4 — Synthesis

Combine ideas into a working tool you design yourself.

Recall Solution L4.1
from functools import wraps
def retry(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)   # success exits immediately
                except Exception as e:
                    last = e
            raise last                              # all attempts failed
        return wrapper
    return decorator

Trace: attempt 1 → raises (len=1), attempt 2 → raises (len=2), attempt 3 → len==3, returns "ok". So:

ok 3

Why the factory shape: @retry(3) first calls retry(3), so retry must return a decorator — three layers, as in Higher-order functions.

Recall Solution L4.2
from functools import wraps
def memo(func):
    cache = {}                     # free variable, lives in the closure
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)   # compute only once per key
        return cache[args]
    return wrapper

Keys computed: (2,) once, (3,) once; the repeats hit the cache. So the body runs 2 times → len(calls) == 2.

2

Why a closure dict: cache persists across calls because the wrapper holds it — the real functools.lru_cache is this idea, industrial-strength.


Level 5 — Mastery

Edge cases, degenerate inputs, and subtle interactions.

Recall Solution L5.1

range(0) is empty, so the for loop body never runs — func is never called. last stays None, and raise last becomes raise None, which Python turns into TypeError: exceptions must derive from BaseException. Fix / lesson: guard degenerate parameters: times = max(1, times), or handle last is None explicitly. Zero-iteration loops are a classic degenerate case you must cover.

Recall Solution L5.2

Bottom-up: job = A(B(job)).

  • B(job) (no wraps) makes a wrapper whose __name__ is 'w'.
  • A(...) uses @wraps(f) where f is B's wrapper, so it copies that wrapper's already-broken __name__ == 'w'. Result:
w

Lesson: wraps only copies from its immediate target. One un-wrapsed layer anywhere in the stack corrupts the name for everyone above it. Fix: put @wraps on every layer.

Recall Solution L5.3

No trouble — because w(*a, **k) forwards everything, self simply arrives as the first positional in a. So f(self, "hi") runs normally.

called
HI

Why *args saves us: we never named self in the wrapper, so it needs no special handling; it is just another forwarded argument. This is why *args, **kwargs (see args and kwargs unpacking) makes a decorator universal across plain functions and methods. For decorators that need to know they're on a class, see Class decorators and property.


Recall Final self-check

Answer without looking: What does @d above def f do to the name f? ::: Rebinds it: f = d(f). Read @a over @b over def f — which applies first? ::: b (nearest the def); result is a(b(f)). Why does retry(0) blow up? ::: The range(0) loop never runs, so raise last becomes raise None → TypeError. Which layer of @retry(3) captures n? ::: The outermost factory layer retry(n). What corrupts __name__ in a stack? ::: Any single layer missing @wraps; upper layers copy the broken name.

Connections