Visual walkthrough — Decorators — function decorators, @, wraps
This page is the visual companion to the parent Decorators note. Read it slowly; each figure carries one idea.
Step 0 — The picture of a "function as a box"
Before any decorator, we need one honest picture of what a Python function is.
WHAT. A function has two separate parts: a name (a label, like add) and a body (the machine that does the work). In Python the name is just a sticky note pointing at the machine.
WHY this matters. A decorator does not touch the machine. It peels the sticky note off and moves it onto a different machine. If you don't see the name and the machine as separate things, decorators look like magic. They are not.
PICTURE. Look at the sticky note add pointing at the orange box (the real code).

Step 1 — The one thing we actually want
WHAT. We want to add behaviour around a function — say, measure how long it takes — without editing its body.
WHY not just edit the body? Because you'd paste the same timing lines into 50 functions (this violates DRY, explained in the parent). One change to the log format = 50 edits. We want the extra behaviour in one place.
PICTURE. The green ring below is the "extra behaviour". We want to slip the orange machine inside it, so every call passes through the ring on the way in and out.

The ring runs start = clock() before the machine and print(elapsed) after. This ring is what we are about to build.
Step 2 — A function that eats a function
WHAT. We write a function timer whose argument is another function.
WHY is that allowed? Because in Python functions are first-class objects — you can pass one as an argument exactly like a number. This is the whole foundation.
PICTURE. func (orange) goes in the left of timer; a brand-new machine wrapper (blue) comes out the right.

def timer(func): # func = the orange box handed in
def wrapper(): # wrapper = the new blue box we invent
return func() # inside, still call the original
return wrapper # hand the blue box backStep 3 — The @ sticker is pure shorthand
WHAT. Writing @timer on the line above def add does exactly one thing: it runs add = timer(add).
WHY have a symbol for it? Readability. The @ sits at the top of the definition so you see "this function is wrapped" before you read its body — instead of hunting for a reassignment 40 lines later.
PICTURE. The sticky note add peels off the orange machine and lands on the blue wrapper machine. The orange machine is now only reachable through the blue one.

Step 4 — Forwarding any arguments
WHAT. The real add takes (a, b). But our wrapper() takes nothing! Calling add(2,3) would now crash, because the name add points at wrapper, which accepts zero arguments.
WHY *args, **kwargs? These two collect whatever arguments arrive and pass them straight through, so one wrapper works for every function signature. (See args and kwargs unpacking.)
PICTURE. Arguments (2,3) flow into the blue wrapper, get relayed into the orange func, and the answer 5 flows back out through the wrapper.

def timer(func):
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs) # forward + capture the answer
print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
return result # hand the answer back
return wrapperStep 5 — The name tag got swapped (and how to fix it)
WHAT. After add = timer(add), checking add.__name__ prints "wrapper", and add.__doc__ is gone. The name tag now describes the inner machine, not the original.
WHY does this happen? Because add literally is wrapper now (Step 3). wrapper was born with its own name, "wrapper", and it never knew about add.
FIX — functools.wraps. We apply @wraps(func) to the wrapper. It copies func's __name__, __doc__, __module__ onto wrapper, so tools, help(), and debuggers still see the real identity.
PICTURE. Left: without wraps, the outside label reads "wrapper" — a lie. Right: wraps glues the real "add" label back on.

from functools import wraps # from functools — wraps, lru_cache, partial
def timer(func):
@wraps(func) # copy func's name tag onto wrapper
def wrapper(*args, **kwargs):
...
return wrapperStep 6 — Degenerate & edge cases (the ones people trip on)
Every case, one picture.
Case A — A function that returns nothing.
def hi(): print("hi") returns None. Our return result faithfully returns None. No bug — forwarding None is correct; the mistake is only when you forget to return at all.
Case B — Stacking two decorators.
@a
@b
def f(): ...This desugars bottom-up: f = a(b(f)). The decorator nearest the def (here b) is applied first, then a wraps that result.
Case C — A parametrised decorator @repeat(3).
Here @repeat(3) first calls repeat(3). That call must return a decorator, which is then applied. So we need three nested layers:
PICTURE. Left: two decorators stacking as nested rings a(b(f)). Right: the factory repeat(3) producing a decorator, which then wraps f.

def repeat(n): # layer 1: catches n
def decorator(func): # layer 2: catches func
@wraps(func)
def wrapper(*args, **kwargs): # layer 3: the work
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def hi(): print("hi") # prints "hi" three timesThe one-picture summary
Everything at once: an argument enters the blue wrapper, the wrapper's ring runs before-code, relays the call into the orange original, catches its result, runs after-code, and returns the result — all while @wraps keeps the real name tag on the outside.

Recall Feynman: retell the whole walkthrough in plain words
A function is a machine with a name-sticker on it. A decorator is a factory that takes your machine, builds a new machine (wrapper) with a ring of extra code around it, and hands the new one back. The @ symbol just peels the sticker off the old machine and slaps it on the new one — so when you "call add" you're really calling the wrapper, which runs its extra code, quietly relays your arguments (*args, **kwargs) into the real machine, catches the answer, and returns it. Because the sticker moved, the wrapper wore the wrong name — so @wraps copies the real name-sticker back on. If you want a knob (like "repeat 3 times") you add one more factory on top that captures the number and then hands back the decorator. Stack two decorators and they nest like rings from the bottom up: a(b(f)).
Quick self-check
Desugar @repeat(3) on hi
hi = repeat(3)(hi) — factory called first, its return value decorates hi.In @a over @b over def f, what runs first?
b (nearest the def); result is f = a(b(f)).Why does add.__name__ say "wrapper" without @wraps?
add now points at the inner wrapper, which was born with that name.What does result = func(*args, **kwargs) capture?
Connections
- First-class functions
- Closures and free variables
- Higher-order functions
- args and kwargs unpacking
- functools — wraps, lru_cache, partial
- Class decorators and property