Exercises — Decorators — function decorators, @, wraps
Before we start, one picture of the machinery so every later reference has a shape to point at.

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
d1— yes. Takes a callable, returns a callable (the same one). Legal, though it adds nothing.d2— no as a useful decorator: it returns42, anint, which is not callable.@d2 def fmakesfthe integer42, sof()raisesTypeError.d3— structurally yes (takes one arg, returns a callable), but it ignores the function and always returnslambda: x. It "decorates" but destroys the original — a valid callable-in, callable-out, still legal syntactically.d4— yes, 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 wrapperOutput:
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")wrapstext→ 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.

Recall Solution L3.2
Two calls happen at decoration time:
repeat(3)runs → Layer 1 capturesn = 3, returnsdecorator.decorator(hi)runs → Layer 2 capturesfunc = hi, returnswrapper.- Layer 3 (
wrapper) captures nothing new; it usesnandfuncat call time. Sohi = repeat(3)(hi). When you later callhi(), the loopfor _ in range(n)runs the original 3 times. Why three layers: each()you write needs its own function to receive its arguments —nfrom@repeat(3),funcfrom the@, and*argsfrom the eventual call.
Recall Solution L3.3
Without wraps:
foo.__name__→'w'foo.__doc__→NoneThe name now points at the innerw, whose own docstring is empty. With@wraps(f)onw(from functools — wraps, lru_cache, partial):foo.__name__→'foo'foo.__doc__→'the docstring'Why:wrapscopies__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 decoratorTrace: 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 wrapperKeys 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)(nowraps) makes a wrapper whose__name__is'w'.A(...)uses@wraps(f)wherefis 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
- Parent: Decorators
- Closures and free variables
- First-class functions
- Higher-order functions
- functools — wraps, lru_cache, partial
- args and kwargs unpacking
- Class decorators and property