Session 31 of the Python Fundamentals series, covered in depth. A decorator is a function that wraps another function to add behavior around it, and the session builds up to that from the ground: functions are ordinary objects you can pass around, a function can return another function to form a closure, and the @decorator line is just sugar for f = deco(f). You write a logging and timing wrapper that forwards *args and **kwargs, use functools.wraps to keep the original __name__ and docstring, and stack two decorators. The traps are forgetting to return the wrapper, which makes the decorated function None, losing __name__ by omitting functools.wraps, and a wrapper that does not forward its arguments. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 100 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 31
Wrap a function to add behavior around it
Objectives
A decorator adds behavior around a function without editing the function's body. By the end you can:
@deco as shorthand for f = deco(f).*args and **kwargs and returns the result.functools.wraps to keep the original __name__ and docstring.Warm-up
Discussion prompt
Before we open Session 31 - Decorators: without looking back, what was the main idea of Session 30 - Iterators & Generators, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 30 of the Python Fundamentals series, in depth. What actually happens when you write a for loop: iter() builds an iterator, next() pulls one value at a time, and a StopIteration signal ends the loop.
Section
Part 1
Concept
A def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.
function object — The thing a def creates. You can store it in a variable, put it in a list, pass it to another function, or return it - all without calling it.
Counterexample
Discussion prompt
A def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Ranking
Put in order
Put the moves of Store a function in another name into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. No parentheses, so f now points at the same function object as greet.
Worked example
def greet():
return "hello"
f = greet
print(f())
print(greet.__name__)Line 4 copies the function, not its result
Why: No parentheses, so f now points at the same function object as greet.
Line 5 calls it through the new name
Why: f() runs the same body greet() would, so it returns hello.
Read the output
Why: Verified by execution: hello, then greet (the __name__ attribute holds the original name).
| expression | value |
|---|---|
| f | the greet function object |
| f() | hello |
| greet.__name__ | greet |
Comparison
Comparison matrix
From Store a function in another name: refill the value column from what you know. The rest of the table is as it appeared.
| expression | value |
|---|---|
| f | the greet function object |
| f() | hello |
| greet.__name__ | greet |
Intuition
Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.
Either label opens the box. Calling is the parentheses - f() and greet() open the same box the same way.
Analogy
Discussion prompt
Explain The name is a label on a box by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.
Concept
Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.
Explain it
Discussion prompt
Explain Functions fit anywhere a value fits to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.
Step zero
Discussion prompt
A list of functions — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: The list holds two function objects
Answer:
Worked example
def shout(s):
return s.upper()
def whisper(s):
return s.lower()
funcs = [shout, whisper]
for f in funcs:
print(f("Hello"))The list holds two function objects
Why: shout and whisper are stored without calling them - no parentheses in the list.
The loop calls each in turn
Why: f is each function; f("Hello") runs it.
Read the output
Why: Verified by execution: HELLO, then hello.
| f | f("Hello") |
|---|---|
| shout | HELLO |
| whisper | hello |
Trade off
Comparison matrix
From A list of functions: every row here is a choice with a cost. Fill the f("Hello") column, then say which row you would actually pick and what you give up for it.
| f | f("Hello") |
|---|---|
| shout | HELLO |
| whisper | hello |
Concept
Because a function is just a value, another function can accept one as a parameter and call it. A function that takes or returns a function is called higher-order.
Pattern
Predict first
The table runs: x | 5 · func(x) | 6
In Pass a function in, given the rows so far: what is the next one — the row where step is func(func(x))?
Correct: func(func(x)) | 7
| step | value |
|---|---|
| x | 5 |
| func(x) | 6 |
| func(func(x)) | 7 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. apply_twice receives the function object as func, then calls it itself.
Worked example
def apply_twice(func, x):
return func(func(x))
def add_one(n):
return n + 1
print(apply_twice(add_one, 5))add_one is passed without parentheses
Why: apply_twice receives the function object as func, then calls it itself.
func(func(x)) applies it twice
Why: Inner call: add_one(5) is 6. Outer call: add_one(6) is 7.
Read the output
Why: Verified by execution: 7.
| step | value |
|---|---|
| x | 5 |
| func(x) | 6 |
| func(func(x)) | 7 |
Error analysis
Annotate
Walk the callouts on Pass a function in. Each one is a place this is easy to get subtly wrong.
Section
Part 2
Concept
Inside a def, you can write another def, then return that inner function. The caller gets back a brand-new function to call later.
The outer function is a factory; the inner function is the product.
Fill the middle
Fill in the blanks
From outer returns inner — one line has had its right-hand side removed. Put it back.
def outer():
def inner():
return "inner result"
return inner
fn = outer()
print(fn)
print(fn())
Why: fn is what everything below it consumes, so the wrong expression here fails later and somewhere else. return inner hands back the function object itself - it is not called yet.
Worked example
def outer():
def inner():
return "inner result"
return inner
fn = outer()
print(fn)
print(fn())outer() runs and returns the inner function
Why: return inner hands back the function object itself - it is not called yet.
fn now holds inner; fn() calls it
Why: print(fn) shows a function object; print(fn()) runs it and shows its return value.
Read the output
Why: Verified by execution: line 7 prints a function object, line 8 prints inner result.
| expression | value |
|---|---|
| outer() | the inner function object |
| fn | <function outer.<locals>.inner at 0x...> |
| fn() | inner result |
Blank canvas
Draw it
Draw what outer returns inner just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
The inner function can use variables from the outer function, even after outer has finished. That captured-variable bundle is a closure.
closure — An inner function together with the outer-function variables it still remembers. This is what lets a decorator's wrapper keep a reference to the original function.
Step zero
Discussion prompt
A closure captures n — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: make_multiplier(3) bakes n = 3 into multiply
Answer:
Worked example
def make_multiplier(n):
def multiply(x):
return x * n
return multiply
triple = make_multiplier(3)
print(triple(5))
print(triple(10))make_multiplier(3) bakes n = 3 into multiply
Why: The returned function still remembers n = 3 after make_multiplier finished.
triple is that remembering function
Why: Each call multiplies its argument by the captured n.
Read the output
Why: Verified by execution: 15, then 30.
| call | n (captured) | x | returns |
|---|---|---|---|
| triple(5) | 3 | 5 | 15 |
| triple(10) | 3 | 10 | 30 |
Intuition
When the inner function leaves the factory, it carries a little backpack of the outer variables it used. Here the backpack holds n = 3.
A decorator works exactly this way: the wrapper carries a backpack holding the original function, so it can call it whenever it likes.
Section
Part 3
Concept
Combine the two ideas: write a factory that takes a function, builds a wrapper that runs extra code around it, and returns the wrapper.
decorator — A function that takes a function and returns a new function (usually a wrapper) adding behavior before and/or after the original call.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of function object, closure, decorator as Session 31 - Decorators uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Fill the middle
Fill in the blanks
From Wrap by hand: announce — one line has had its right-hand side removed. Put it back.
def announce(func):
def wrapper():
print("before")
func()
print("after")
return wrapper
def say_hi():
print("hi")
say_hi = announce(say_hi)
say_hi()
Why: say_hi is what everything below it consumes, so the wrong expression here fails later and somewhere else. The wrapper's backpack holds the original say_hi as func.
Worked example
def announce(func):
def wrapper():
print("before")
func()
print("after")
return wrapper
def say_hi():
print("hi")
say_hi = announce(say_hi)
say_hi()announce(say_hi) builds a wrapper around say_hi
Why: The wrapper's backpack holds the original say_hi as func.
We reassign the name say_hi to the wrapper
Why: Now the name say_hi points at wrapper, not the original.
Calling say_hi() runs the wrapper
Why: Verified by execution: it prints before, then hi (the original), then after.
| line run | output |
|---|---|
| print("before") | before |
| func() | hi |
| print("after") | after |
Concept
say_hi = announce(say_hi) is the whole trick. The name you already use now runs the wrapper, and callers do not have to change anything.
The original function is not lost - the wrapper still holds it in its backpack and calls it as func().
Sorting
Sort into buckets
These are the pieces of Session 31 - Decorators, out of order. Put each one back under the part of the lesson it belongs to.
Section
Part 4
Concept
Writing @announce on the line above a def is exactly the same as defining the function and then writing say_hi = announce(say_hi).
The @ line is pure convenience - it makes the wrapping visible right at the definition.
Ranking
Put in order
Put the moves of The same thing with @ into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. At definition time Python runs say_hi = announce(say_hi) for you.
Worked example
def announce(func):
def wrapper():
print("before")
func()
print("after")
return wrapper
@announce
def say_hi():
print("hi")
say_hi()@announce sits above the def
Why: At definition time Python runs say_hi = announce(say_hi) for you.
say_hi is now the wrapper
Why: Nothing else changed - the call site say_hi() is identical.
Read the output
Why: Verified by execution: before, hi, after - identical to the hand-wired version.
| you write | python does |
|---|---|
| @announce above def say_hi | say_hi = announce(say_hi) |
| say_hi() | runs wrapper() |
Concept
The @ line runs the moment Python reads the def, not each time you call. After that, every call goes straight to the wrapper.
Intuition
The decorator wraps your function like a gift. From the outside you still hand people the same box by the same name - but now it comes with wrapping paper that does something extra.
Callers never see the wrapping. They call say_hi() as before; the paper (the wrapper) quietly runs its before/after code.
Section
Part 5
Concept
A real wrapper must work for functions that take any arguments. Give the wrapper *args, **kwargs and pass them straight through to the original.
*args catches any positional arguments; **kwargs catches any keyword arguments. Forwarding them makes one wrapper fit every function.
Hypothesis
Predict first
A logging decorator is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.
Correct: wrapper accepts and forwards the arguments
Why: add(3, 4) reaches wrapper as args = (3, 4), which it passes on to func.
A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.
Worked example
def logged(func):
def wrapper(*args, **kwargs):
print("calling", func.__name__)
result = func(*args, **kwargs)
print("got", result)
return result
return wrapper
@logged
def add(a, b):
return a + b
print(add(3, 4))wrapper accepts and forwards the arguments
Why: add(3, 4) reaches wrapper as args = (3, 4), which it passes on to func.
It logs before and after, and returns the result
Why: The wrapper stores func's return in result and hands it back so add(3, 4) is still 7.
Read the output
Why: Verified by execution: calling add, got 7, then 7 (the print of the returned value).
| line | output |
|---|---|
| print("calling", func.__name__) | calling add |
| print("got", result) | got 7 |
| print(add(3, 4)) | 7 |
Concept
The wrapper replaces the function, so whatever the wrapper returns is what the caller gets. If the wrapper never returns func(...)'s result, the caller gets None.
Step zero
Discussion prompt
A call counter — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: wrapper.calls starts at 0 and climbs each call
Answer:
Worked example
def counted(func):
def wrapper(*args, **kwargs):
wrapper.calls += 1
print("call number", wrapper.calls)
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
@counted
def greet(name):
return "hi " + name
greet("Ana")
greet("Bo")
print(greet("Cy"))wrapper.calls starts at 0 and climbs each call
Why: An attribute on the wrapper survives between calls, like a closure's memory.
The third call also prints the returned greeting
Why: Only the last call is wrapped in print, so only its return value shows.
Read the output
Why: Verified by execution: call number 1, call number 2, call number 3, then hi Cy.
| call | wrapper.calls | printed |
|---|---|---|
| greet("Ana") | 1 | call number 1 |
| greet("Bo") | 2 | call number 2 |
| print(greet("Cy")) | 3 | call number 3 then hi Cy |
Cost model
Annotate
In A call counter, before reading the notes: mark where the time actually goes. Which line dominates?
Fill the middle
Fill in the blanks
From Decorate the return value — one line has had its right-hand side removed. Put it back.
def loud(func):
def wrapper(*args, kwargs):
result = func(*args, kwargs)
return str(result) + "!!!"
return wrapper
@loud
def greet(name):
return "hi " + name
print(greet("Ana"))
Why: result is what everything below it consumes, so the wrong expression here fails later and somewhere else. It calls func, then appends !!!
Worked example
def loud(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return str(result) + "!!!"
return wrapper
@loud
def greet(name):
return "hi " + name
print(greet("Ana"))The wrapper changes the result after the call
Why: It calls func, then appends !!! before returning.
Read the output
Why: Verified by execution: hi Ana!!! - the wrapper transformed the return value.
| step | value |
|---|---|
| func("Ana") | hi Ana |
| str(result) + "!!!" | hi Ana!!! |
| greet("Ana") | hi Ana!!! |
Comparison
Comparison matrix
From Decorate the return value: refill the value column from what you know. The rest of the table is as it appeared.
| step | value |
|---|---|
| func("Ana") | hi Ana |
| str(result) + "!!!" | hi Ana!!! |
| greet("Ana") | hi Ana!!! |
Intuition
Think of the wrapper as a checkpoint on the road to the real function. Every call must pass through it, so it can log, time, count, or transform on the way in and on the way out.
The *args, **kwargs are the road: they let anything drive through unchanged, so the checkpoint fits any function.
Worked example
def twice(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
return func(*args, **kwargs)
return wrapper
@twice
def shout(word):
print(word + "!")
return word
print(shout("go"))The wrapper calls func two times
Why: First call runs for its side effect; the second call's result is returned.
shout prints each time and returns once
Why: Two prints of go!, and the returned word is printed by the outer print.
Read the output
Why: Verified by execution: go!, go!, then go.
| step | output |
|---|---|
| first func call | go! |
| second func call | go! |
| print(shout("go")) | go |
Section
Part 6
Concept
After decorating, the name points at wrapper. So add.__name__ reads wrapper, and the original docstring is gone. Debuggers and docs get confused.
Worked example
def logged(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"Add two numbers."
return a + b
print(add.__name__)
print(add.__doc__)add is really the wrapper now
Why: The name add points at wrapper, so its __name__ is wrapper.
The docstring is the wrapper's (none)
Why: wrapper has no docstring, so __doc__ is None - the original 'Add two numbers.' is hidden.
Read the output
Why: Verified by execution: wrapper, then None.
| expression | value |
|---|---|
| add.__name__ | wrapper |
| add.__doc__ | None |
Concept
Decorate the inner wrapper with @functools.wraps(func). It copies the original __name__, __doc__, and more onto the wrapper.
Make this a habit: every wrapper you write gets @functools.wraps(func).
Step zero
Discussion prompt
With functools.wraps — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: @functools.wraps(func) sits on the wrapper
Answer:
Worked example
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"Add two numbers."
return a + b
print(add.__name__)
print(add.__doc__)@functools.wraps(func) sits on the wrapper
Why: It copies func's identity onto wrapper as the wrapper is built.
Now the name and docstring survive
Why: add.__name__ is add and the docstring is back.
Read the output
Why: Verified by execution: add, then Add two numbers.
| expression | without wraps | with wraps |
|---|---|---|
| add.__name__ | wrapper | add |
| add.__doc__ | None | Add two numbers. |
Anomaly
Predict first
A student writes this, and it looks reasonable:
No wraps - the wrapper's own identity leaks out.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.
Add @functools.wraps(func) to the wrapper.
Why: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.
Trap
No wraps - the wrapper's own identity leaks out.
def logged(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
print(add.__name__)add.__name__ reports wrapper
Why: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.
| expression | prints |
|---|---|
| add.__name__ | wrapper |
Add @functools.wraps(func) to the wrapper.
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
print(add.__name__)add.__name__ reports add
Why: wraps copied the original identity onto the wrapper. Real output: add. Make it reflexive on every decorator you write.
| expression | prints |
|---|---|
| add.__name__ | add |
Section
Part 7
Concept
Stack decorators by writing several @ lines above one def. They apply bottom-up: the one nearest the def wraps first, then the one above wraps that.
Ranking
Put in order
Put the moves of bold around italic into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. hello becomes italic(hello), turning hi into <i>hi</i>.
Worked example
def bold(func):
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@bold
@italic
def hello():
return "hi"
print(hello())@italic (nearest the def) wraps first
Why: hello becomes italic(hello), turning hi into <i>hi</i>.
@bold wraps the result
Why: Then bold wraps that, adding <b>...</b> on the outside.
Read the output
Why: Verified by execution: <b><i>hi</i></b>.
| stage | value |
|---|---|
| hello() raw | hi |
| after @italic | <i>hi</i> |
| after @bold | <b><i>hi</i></b> |
Concept
Swap the two @ lines and the layers swap. The bottom decorator is always the innermost wrapping, closest to the original result.
Explain it
Discussion prompt
Explain Order changes the nesting to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Swap the two @ lines and the layers swap. The bottom decorator is always the innermost wrapping, closest to the original result.
Pattern
Predict first
The table runs: hello() raw | hi · after @bold | <b>hi</b>
In italic around bold, given the rows so far: what is the next one — the row where stage is after @italic?
Correct: after @italic | <i><b>hi</b></i>
| stage | value |
|---|---|
| hello() raw | hi |
| after @bold | <b>hi</b> |
| after @italic | <i><b>hi</b></i> |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. hi becomes <b>hi</b>, then italic wraps that.
Worked example
def bold(func):
def wrapper():
return "<b>" + func() + "</b>"
return wrapper
def italic(func):
def wrapper():
return "<i>" + func() + "</i>"
return wrapper
@italic
@bold
def hello():
return "hi"
print(hello())Now @bold is nearest the def, so it wraps first
Why: hi becomes <b>hi</b>, then italic wraps that.
Read the output
Why: Verified by execution: <i><b>hi</b></i> - the tags are nested in the opposite order.
| stage | value |
|---|---|
| hello() raw | hi |
| after @bold | <b>hi</b> |
| after @italic | <i><b>hi</b></i> |
Blank canvas
Draw it
Draw what italic around bold just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
When you stack decorators, give each wrapper its own @functools.wraps(func). Otherwise the outer wrapper's name overwrites the identity, and tracebacks point at wrapper again.
Analogy
Discussion prompt
Explain Stacking still needs functools.wraps by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
When you stack decorators, give each wrapper its own @functools.wraps(func). Otherwise the outer wrapper's name overwrites the identity, and tracebacks point at wrapper again.
Concept
Use a decorator when the same extra behavior wraps many functions: logging, timing, caching, access checks. One decorator, applied with one line each.
If only one function needs the behavior, just write it inline - a decorator earns its keep through reuse.
Counterexample
Discussion prompt
Use a decorator when the same extra behavior wraps many functions: logging, timing, caching, access checks. One decorator, applied with one line each.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
If only one function needs the behavior, just write it inline - a decorator earns its keep through reuse.
Section
Part 8
Anomaly
Predict first
A student writes this, and it looks reasonable:
The decorator builds a wrapper but never returns it.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.
Return the wrapper from the decorator.
Why: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.
Trap
The decorator builds a wrapper but never returns it.
def announce(func):
def wrapper():
print("before")
func()
print("after")
@announce
def say_hi():
print("hi")
say_hi()announce returns None, so say_hi becomes None
Why: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.
| step | result |
|---|---|
| announce(say_hi) | None |
| say_hi | None |
| say_hi() | TypeError: 'NoneType' object is not callable |
Return the wrapper from the decorator.
def announce(func):
def wrapper():
print("before")
func()
print("after")
return wrapper
@announce
def say_hi():
print("hi")
say_hi()return wrapper makes say_hi the wrapper
Why: Now say_hi() runs the wrapper. Real output: before, hi, after. A decorator must always return the replacement function.
| step | result |
|---|---|
| announce(say_hi) | the wrapper |
| say_hi() | before / hi / after |
Error analysis
Annotate
Walk the callouts on Trap: forgetting to return the wrapper. Each one is a place this is easy to get subtly wrong.
Anomaly
Predict first
A student writes this, and it looks reasonable:
The wrapper takes no arguments, so a call with arguments fails.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.
Give the wrapper *args, **kwargs and forward them.
Why: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.
Trap
The wrapper takes no arguments, so a call with arguments fails.
def logged(func):
def wrapper():
return func()
return wrapper
@logged
def add(a, b):
return a + b
print(add(3, 4))add(3, 4) really calls wrapper(3, 4)
Why: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.
| call | result |
|---|---|
| add(3, 4) | wrapper(3, 4) |
| wrapper(3, 4) | TypeError: logged.<locals>.wrapper() takes 0 positional arguments but 2 were given |
Give the wrapper *args, **kwargs and forward them.
def logged(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
print(add(3, 4))*args, **kwargs accept and pass on any arguments
Why: wrapper(3, 4) now works and forwards to add. Real output: 7. Always forward with *args, **kwargs.
| call | result |
|---|---|
| add(3, 4) | wrapper(3, 4) |
| func(3, 4) | 7 |
Trade off
Comparison matrix
From Trap: wrapper does not forward arguments: every row here is a choice with a cost. Fill the result column, then say which row you would actually pick and what you give up for it.
| call | result |
|---|---|
| add(3, 4) | wrapper(3, 4) |
| func(3, 4) | 7 |
Anomaly
Predict first
A student writes this, and it looks reasonable:
The wrapper calls func but never returns its value.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: The wrapper does not return it, so it hands back None; total becomes None.
Return what func returns.
Why: The wrapper does not return it, so it hands back None; total becomes None.
Trap
The wrapper calls func but never returns its value.
def logged(func):
def wrapper(*args, **kwargs):
print("calling")
func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
total = add(3, 4)
print(total)func's 7 is computed then thrown away
Why: The wrapper does not return it, so it hands back None; total becomes None.
| step | value |
|---|---|
| func(3, 4) | 7 (discarded) |
| total | None |
Return what func returns.
def logged(func):
def wrapper(*args, **kwargs):
print("calling")
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
total = add(3, 4)
print(total)return func(...) passes the result through
Why: total is now 7. Real output: calling, then 7. A wrapper should almost always return func's result.
| step | value |
|---|---|
| func(3, 4) | 7 |
| total | 7 |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.; Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.; Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.Section
Part 9
Pattern
1. def deco(func): - the decorator takes the function
Why: func is the original you are wrapping; it lives in the wrapper's backpack (closure).
2. Inside, def wrapper(*args, **kwargs): with @functools.wraps(func)
Why: args/*kwargs fit any function; wraps keeps the original name and docstring.
3. In the wrapper: extra code, result = func(*args, **kwargs), more code, return result
Why: Run behavior before and after, and pass the real result back to the caller.
4. return wrapper from the decorator
Why: Forget this and the decorated name becomes None. The decorator must return the replacement.
Pattern
@deco above def f means f = deco(f)
Why: The name f now points at whatever deco returned - normally a wrapper.
Stacked @a @b above def f means f = a(b(f))
Why: Bottom-up: the decorator nearest the def wraps first, the top one wraps last.
The wrapping runs once, at def time
Why: After that, every f(...) call goes through the wrapper - no re-wrapping per call.
Real world
Discussion prompt
Outside this lesson: where does Session 31 - Decorators actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Reading the @ line is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 31 of the Python Fundamentals series, in depth. A decorator is a function that wraps another function to add behavior around it.
Check
What does line 5 print?
def greet():
return "hello"
f = greet
print(f)| expression | value |
|---|---|
| f | ? |
Check your understanding
What does print(f) show?
Answer: A
Why: f = greet copies the function object without calling it (no parentheses), so printing f shows the function itself, not its return value. Verified by execution.
Elimination
Eliminate the wrong options
@deco above def f() is shorthand for which line?
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: A decorator receives the function it sits above and its return value is rebound to the same name: f = deco(f). Verified by execution.
Check
Which plain line means the same as @deco?
@deco
def f():
...
# equivalent to ?| sugar | plain form |
|---|---|
| @deco over def f | ? |
Check your understanding
@deco above def f() is shorthand for which line?
Answer: A
Why: A decorator receives the function it sits above and its return value is rebound to the same name: f = deco(f). Verified by execution.
Check
The decorator has no return statement.
def deco(func):
def wrapper():
return func()
# no return here
@deco
def task():
return 1
print(task())| deco returns | task is |
|---|---|
| None | ? |
Check your understanding
What happens when task() runs?
Answer: A
Why: deco never returns wrapper, so deco(task) is None and the name task becomes None. Calling None raises TypeError: 'NoneType' object is not callable. Verified by execution.
Check
The wrapper takes no parameters.
def deco(func):
def wrapper():
return func()
return wrapper
@deco
def add(a, b):
return a + b
print(add(2, 5))| call | result |
|---|---|
| add(2, 5) | ? |
Check your understanding
What does add(2, 5) do?
Answer: A
Why: add now refers to wrapper, which is defined with no parameters, so passing 2 and 5 raises TypeError before add's body runs. The fix is def wrapper(*args, **kwargs). Verified by execution.
Check
Trace the two layers bottom-up.
def bold(f):
def w():
return "<b>" + f() + "</b>"
return w
def italic(f):
def w():
return "<i>" + f() + "</i>"
return w
@bold
@italic
def hello():
return "hi"
print(hello())| stage | value |
|---|---|
| raw | hi |
| after inner | ? |
| after outer | ? |
Check your understanding
What does hello() return?
Answer: A
Why: Decorators apply bottom-up: @italic (nearest the def) wraps first giving <i>hi</i>, then @bold wraps that giving <b><i>hi</i></b>. Verified by execution.
Comparison
Comparison matrix
From Check: stacking order: refill the value column from what you know. The rest of the table is as it appeared.
| stage | value |
|---|---|
| raw | hi |
| after inner | ? |
| after outer | ? |
Check
What is preserved here?
import functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def multiply(a, b):
return a * b
print(multiply.__name__)| expression | value |
|---|---|
| multiply.__name__ | ? |
Check your understanding
What does multiply.__name__ print?
Answer: A
Why: @functools.wraps(func) copies the original function's __name__ onto the wrapper, so multiply keeps its own name instead of showing wrapper. Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Functions Are Objects · A Function Returning a Function · The Wrapper Idea · The @ Syntax · Wrappers That Take Arguments · Keeping the Name. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
A decorator is a function that takes a function and returns a new one - usually a wrapper that runs code around the original. @deco is just f = deco(f).
| You write | It means |
|---|---|
| f = greet | copy the function object (no call) |
| return inner | hand back a function to call later |
| @deco over def f | f = deco(f) |
| def wrapper(*args, **kwargs) | accept and forward any arguments |
| @functools.wraps(func) | keep the original name and docstring |
| @a @b over def f | f = a(b(f)), bottom-up |
Always return the wrapper, forward *args/**kwargs, return func's result, and add functools.wraps. Next session we turn the wrapper pattern into decorators that take their own arguments.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.