This is the "run every case" companion to the parent Decorators note . Before we begin, one promise: every symbol here was earned in the parent. If you have not yet met first-class functions (functions you can pass around like numbers) or closures (an inner function remembering a variable from outside), pause and read First-class functions and Closures and free variables first.
We test a decorator by asking: when I put @deco above a def, does the name now point to a new function that still does the old job, plus the new one?
Think of a decorator as a machine with many dials: does the wrapped function take arguments? does it return a value? is the decorator itself parametrised? are decorators stacked? Each combination is a cell we must survive.
Cell
Case class
The tricky part
A
No-arg function, no return
Simplest wrapper — does @ even fire?
B
Function with positional + keyword args
*args, **kwargs forwarding
C
Function returns a value
Must return func(...) or lose it
D
Metadata preserved
@wraps vs the __name__ lie
E
Parametrised decorator @repeat(3)
Three nested layers, factory called first
F
Stacked decorators @a @b
Order = a(b(f)), bottom-up
G
Degenerate input: @repeat(0)
Loop runs zero times → what returns?
H
Real-world word problem
Caching an expensive call
I
Exam twist: state between calls
Closure counter, mutable via nonlocal
The nine examples below hit every cell. Each one asks you to forecast first — cover the answer and guess.
Worked example Example 1 — Cell A: does
@ even fire? (no args, no return)
def announce (func):
def wrapper ():
print ( "start" )
func()
print ( "end" )
return wrapper
@announce
def greet ():
print ( "hi" )
greet()
Forecast: what three lines print, in what order?
Read the sugar. @announce above def greet means greet = announce(greet). Why this step? Because @ is pure notation for that rebinding — nothing magic happens at call time.
Follow the rebind. announce(greet) returns wrapper, so the name greet now points at wrapper. Why? A decorator hands back a replacement ; the old body is only reachable through the closure variable func.
Call it. greet() runs wrapper(): prints start, then func() (the original greet) prints hi, then prints end. Why does func still work? The inner wrapper closed over func, remembering the original even though the outer name changed.
Answer: start / hi / end.
Verify: three prints, wrapper's own two plus the original's one — count matches.
Worked example Example 2 — Cell B + C: arguments AND a return value
def double (func):
def wrapper ( * args, ** kwargs):
return func( * args, ** kwargs) * 2
return wrapper
@double
def add (a, b):
return a + b
print (add( 4 , 5 ))
Forecast: guess the printed number before reading on.
Desugar. add = double(add), so add now points at wrapper. Why this step? Same rule as Cell A — always start here so you know which function you are calling.
Forward the args. Calling add(4, 5) enters wrapper(*args, **kwargs) with args = (4, 5). Then func(*args, **kwargs) unpacks that back into add(4, 5). Why *args? So the wrapper works for any signature — see args and kwargs unpacking . Without it, wrapper() would reject the two arguments.
Use the return. The original add(4,5) gives 9; the wrapper computes 9 * 2 and returns it. Why must we return? If we wrote func(*args, **kwargs) * 2 without return, the wrapper hands back None (Cell C's classic trap).
Answer: 18.
Verify: ( 4 + 5 ) × 2 = 18 . Return value is preserved and transformed, not swallowed.
Worked example Example 3 — Cell C failure mode: the missing
return
def broken (func):
def wrapper ( * args, ** kwargs):
func( * args, ** kwargs) # <-- no return!
return wrapper
@broken
def square (x):
return x * x
print (square( 6 ))
Forecast: the inner func clearly runs and computes 36 — so what prints?
Trace the call. square = broken(square), so square(6) runs wrapper(6). Inside, func(6) does compute 36. Why this step? To show the computation genuinely happens — the bug is subtle precisely because the work is done.
Find the leak. The line func(*args, **kwargs) runs but its result is thrown away — there is no return. wrapper ends, and a function with no return yields None. Why does this feel right yet be wrong? Runtime shows no error; the value silently vanishes.
Answer: None.
Verify: a function that falls off its end returns None; 36 was computed then discarded. Fix = prepend return.
Worked example Example 4 — Cell D: the
__name__ lie and @wraps
from functools import wraps
def plain (func):
def wrapper ( * args, ** kwargs):
return func( * args, ** kwargs)
return wrapper
def tidy (func):
@wraps (func)
def wrapper ( * args, ** kwargs):
return func( * args, ** kwargs)
return wrapper
@plain
def alpha (): pass
@tidy
def beta (): pass
print (alpha. __name__ , beta. __name__ )
Forecast: two names print. What are they?
Ask what __name__ reads. After alpha = plain(alpha), the name alpha points at the inner function literally called wrapper. So alpha.__name__ reads "wrapper" — the identity got overwritten. Why? Introspection reads attributes off the object the name points to, and that object is wrapper.
Apply @wraps. In tidy, @wraps(func) copies func's __name__, __doc__, __module__ onto wrapper. Why this tool and not manual copying? functools.wraps copies the whole standard metadata set in one line and also sets __wrapped__ so tools can find the original — see functools — wraps, lru_cache, partial . So beta.__name__ stays "beta".
Answer: wrapper beta.
Verify: without @wraps the name is "wrapper"; with it, the original "beta" survives.
Worked example Example 5 — Cell E: parametrised decorator
@repeat(n)
from functools import wraps
def repeat (n):
def decorator (func):
@wraps (func)
def wrapper ( * args, ** kwargs):
result = None
for _ in range (n):
result = func( * args, ** kwargs)
return result
return wrapper
return decorator
calls = []
@repeat ( 3 )
def ping ():
calls.append( 1 )
return len (calls)
print (ping())
Forecast: how many times does ping's body run, and what does the call return?
Peel the factory. @repeat(3) first calls repeat(3), which returns decorator. So the real sugar is ping = repeat(3)(ping) = decorator(ping). Why three layers? Layer 1 (repeat) captures n, layer 2 (decorator) captures func, layer 3 (wrapper) does the work — see the figure.
Run the loop. range(3) runs the body three times, appending 1 three times → calls == [1,1,1]. Why keep only the last result? Each iteration overwrites result, so we return the value of the final call.
Read the last return. On the third call, len(calls) is 3, which becomes result and is returned.
Answer: body runs 3 times; ping() returns 3.
Verify: len(calls) == 3 and the returned value equals 3.
Worked example Example 6 — Cell G: degenerate input
@repeat(0)
# same repeat as Example 5
@repeat ( 0 )
def hello ():
print ( "hello" )
return "done"
print (hello())
Forecast: does "hello" print? What does the call return?
Look at the loop bound. range(0) is empty, so the for body never runs — func is never called. Why does this matter? A boundary input (n = 0) exposes whether your wrapper assumes at least one call.
Trace result. We initialised result = None before the loop. Since the loop never assigns it, wrapper returns that initial None. Why the guard? Without result = None, an empty loop would raise NameError: result — a real degenerate-case bug.
Answer: nothing prints; hello() returns None.
Verify: empty range → zero body executions → the pre-loop None is returned.
Worked example Example 7 — Cell F: stacked decorators
@a @b
def a (func):
def wrapper ( * args, ** kwargs):
return func( * args, ** kwargs) + "A"
return wrapper
def b (func):
def wrapper ( * args, ** kwargs):
return func( * args, ** kwargs) + "B"
return wrapper
@a
@b
def base ():
return "X"
print (base())
Forecast: guess the final string.
Desugar bottom-up. Two stacked decorators mean base = a(b(base)). Why bottom-up? @b (nearest the def) is applied first, then @a wraps its result — see the layering figure.
Inner layer runs first at call time. Calling base() enters a's wrapper, which calls b's wrapper, which calls the original base() returning "X". Why? The outermost wrapper (a) is entered first but must call inward to reach the real work.
Unwind, appending. b's wrapper turns "X" into "X" + "B" = "XB". Then a's wrapper turns that into "XB" + "A" = "XBA". Why this order? On the way back out , b finishes before a, so B is appended before A.
Answer: "XBA".
Verify: ("X" + "B") + "A" == "XBA".
Worked example Example 8 — Cell H: real-world caching (word problem)
Problem. A weather service function slow_lookup(city) takes a full second per call because it hits the network. Users often ask for the same city repeatedly. Write a decorator that returns the stored answer instantly on repeats, counting how many real lookups happen.
from functools import wraps
def cache (func):
store = {}
misses = { "count" : 0 }
@wraps (func)
def wrapper (city):
if city not in store:
misses[ "count" ] += 1
store[city] = func(city) # the expensive call
return store[city]
wrapper.misses = misses
return wrapper
@cache
def slow_lookup (city):
return city.upper() # stand-in for the slow work
slow_lookup( "delhi" )
slow_lookup( "delhi" )
slow_lookup( "paris" )
print (slow_lookup.misses[ "count" ])
Forecast: three calls happen — how many real lookups (misses)?
Store lives in the closure. store and misses are defined once in cache, so every call to wrapper shares the same dictionaries. Why a dict, not a plain variable? We need a lookup keyed by input; and mutating a dict avoids the nonlocal dance for the store.
First "delhi": not in store → miss count becomes 1, run func, save "DELHI". Second "delhi": already in store → returned from cache, no miss. "paris": new → miss count becomes 2. Why check in store first? That single test is what turns a repeat from expensive to instant. This is exactly what functools.lru_cache automates.
Answer: 2 real lookups for 3 calls.
Verify: distinct cities {delhi, paris} ⇒ 2 misses; the second delhi is a cache hit.
Worked example Example 9 — Cell I (exam twist): a counter with
nonlocal
from functools import wraps
def count_calls (func):
n = 0
@wraps (func)
def wrapper ( * args, ** kwargs):
nonlocal n
n += 1
return (n, func( * args, ** kwargs))
return wrapper
@count_calls
def shout (word):
return word + "!"
print (shout( "go" ))
print (shout( "run" ))
Forecast: two tuples print. What are they exactly?
Why nonlocal? n lives in the enclosing count_calls, not in wrapper. Assigning n += 1 without nonlocal would make Python treat n as a brand-new local, then complain it is read before assignment (UnboundLocalError). nonlocal n says "reuse the outer n." See Closures and free variables .
First call. n goes 0 → 1; func("go") returns "go!"; wrapper returns (1, "go!"). Why does state persist? The closure keeps one n alive between calls — the decorator carries memory.
Second call. Same closure, so n goes 1 → 2; returns (2, "run!").
Answer: (1, 'go!') then (2, 'run!').
Verify: counter increments 1, 2; each string gets its "!".
Recall Which cell did each example cover?
Cell A (no-arg fires) ::: Example 1
Cell B + C (args & return) ::: Example 2
Cell C failure (missing return → None) ::: Example 3
Cell D (@wraps vs __name__) ::: Example 4
Cell E (parametrised @repeat(n)) ::: Example 5
Cell G (degenerate @repeat(0)) ::: Example 6
Cell F (stacked order a(b(f))) ::: Example 7
Cell H (real-world cache) ::: Example 8
Cell I (stateful counter, nonlocal) ::: Example 9
Mnemonic Two facts survive every cell
"Desugar first, then trace." Always rewrite @deco as f = deco(f) before predicting output. And "empty loop → initial value" : any range(0) returns whatever you set before the loop.