1.3.8 · D2Python Intermediate

Visual walkthrough — Decorators — function decorators, @, wraps

1,739 words8 min readBack to topic

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).

Figure — Decorators — function decorators, @, wraps

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.

Figure — Decorators — function decorators, @, wraps

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.

Figure — Decorators — function decorators, @, wraps
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 back

Step 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.

Figure — Decorators — function decorators, @, wraps

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.

Figure — Decorators — function decorators, @, wraps
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 wrapper

Step 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.

Figure — Decorators — function decorators, @, wraps
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 wrapper

Step 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.

Figure — Decorators — function decorators, @, wraps
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 times

The 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.

Figure — Decorators — function decorators, @, wraps
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?
Because add now points at the inner wrapper, which was born with that name.
What does result = func(*args, **kwargs) capture?
The original function's return value, so the wrapper can hand it back.

Connections