I pasted the same timing code into twenty functions
See the problem
Section titled “See the problem”Your web app feels slow, and you want to know which functions take the time. So you add three lines to each one: note the start time, do the work, record how long it took.
def load_report(day): start = time.perf_counter() result = ... # the real work TIMINGS.append(("load_report", time.perf_counter() - start)) return resultAfter twenty functions, the timing lines are bigger than the work. When you want to stop timing, you remove sixty lines by hand. There must be a way to add this once.
Define the task
Section titled “Define the task”- Input: any function, with any arguments.
- Output: a function that does exactly what the original did, returns what it returned, and also records
(name, seconds)inTIMINGS. - Rules: the original’s code doesn’t change. The new function keeps the original’s name and docstring, so logs,
help()and web frameworks still see the right name. - The hard part: a wrapper that is invisible to everyone except you.
Example: after load_report = timed(load_report), calling load_report("mon") returns the report and adds ("load_report", 0.0001…) to TIMINGS.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Inside timed(func), define wrapper(*args, **kwargs) that times func(*args, **kwargs) and returns its result.
Hint 2 of 3
@timed above def load_report is short for load_report = timed(load_report).
Hint 3 of 3
wrapper has its own name, "wrapper". functools.wraps(func) copies func’s name and docstring onto it.
Understand the idea
Section titled “Understand the idea”A decorator is a function that takes a function and returns another one. The @ line is a shortcut:
@timeddef load_report(day): ...
# is the same as:def load_report(day): ...load_report = timed(load_report)The returned function is usually a closure, called wrapper. It keeps func, the original, and calls it in the middle of its own work.
functools.wraps(func) copies __name__, __doc__ and more from func onto wrapper, and stores the original as wrapper.__wrapped__.
A decorator with arguments, like @retry(times=3), is one more layer. retry(3) runs first and returns the real decorator.
Here is a tiny decorator with the @ written out. hi = shout(hi) points hi at the wrapper, and the wrapper’s box keeps the old hi as func. The player stops to ask what happens next. Then type your own lines under it.
Built-inPython’s own names
- len
- max
Globalrunning now
no names yet
Step 1 of 12. Before the first line runs, the global box is empty. The built-in box holds names like print and len.
def shout(func): def wrapper( x): return func( x) + "!" return wrapper def hi( x): return "hi " + x hi = shout( hi) print( hi( "ana"))
Climb the ladder
Section titled “Climb the ladder”All three add O(1) work to each call. What changes is how many places hold the timing code, and what the wrapped function looks like.
Copy the timing lines
The idea: Add start, record and return lines to every function.
Each function records its time. You can see exactly what happens.
import time
TIMINGS: list[tuple[str, float]] = []
def load_report(day: str) -> str: start = time.perf_counter() result = f"report for {day}" TIMINGS.append(("load_report", time.perf_counter() - start)) return result
def send_email(to: str, body: str = "") -> bool: start = time.perf_counter() result = bool(to) TIMINGS.append(("send_email", time.perf_counter() - start)) return result- Time
O(1) extra- Space
O(1)- Why
- A clock read and one append per call.
Where it breaks: three lines in every function, each with the name typed by hand. A copy with the wrong name, or one you forget to remove, gives wrong timings.
A decorator
The idea: timed(func) returns a wrapper that times func(*args, **kwargs) and returns its result.
The waste in Okay: the same lines in twenty places.
@timed on each function is all you add. *args, **kwargs pass every argument through.
Highlighted lines are new in this rung.
import timefrom collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: start = time.perf_counter() result = func(*args, **kwargs) TIMINGS.append((func.__name__, time.perf_counter() - start)) return result
return wrapper
@timeddef load_report(day: str) -> str: """Build the report for one day.""" return f"report for {day}"- Time
O(1) extra- Space
O(1)- Why
- The same work, written once.
What changed from the previous rung
import timefrom collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def load_report(day: str) -> str: start = time.perf_counter() result = f"report for {day}" TIMINGS.append(("load_report", time.perf_counter() - start)) return resultdef timed(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: start = time.perf_counter() result = func(*args, **kwargs) TIMINGS.append((func.__name__, time.perf_counter() - start)) return result
return wrapper
def send_email(to: str, body: str = "") -> bool: start = time.perf_counter() result = bool(to) TIMINGS.append(("send_email", time.perf_counter() - start)) return result
@timeddef load_report(day: str) -> str: """Build the report for one day.""" return f"report for {day}"Where it breaks: load_report.__name__ is now "wrapper", and the docstring is gone. Flask names each page after its function, so two @timed pages both called wrapper clash. And if the function raises, no time is recorded.
functools.wraps, and finally
The idea: Decorate wrapper with @functools.wraps(func), and record the time in a finally block.
The waste in Better: the wrapper hides who it wraps.
@functools.wraps(func) copies the name and docstring, so help(load_report) and logs look right. try: return … finally: records the time whether func returns or raises.
Highlighted lines are new in this rung.
import functoolsimport timefrom collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable: @functools.wraps(func) # copy func's name, docstring and more onto wrapper def wrapper(*args: object, **kwargs: object) -> object: start = time.perf_counter() try: return func(*args, **kwargs) finally: # record the time even when func raises TIMINGS.append((func.__name__, time.perf_counter() - start))
return wrapper
@timeddef load_report(day: str) -> str: """Build the report for one day.""" return f"report for {day}"- Time
O(1) extra- Space
O(1)- Why
- The same work.
What changed from the previous rung
import functoolsimport timefrom collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable: @functools.wraps(func) # copy func's name, docstring and more onto wrapper def wrapper(*args: object, **kwargs: object) -> object: start = time.perf_counter() result = func(*args, **kwargs) TIMINGS.append((func.__name__, time.perf_counter() - start)) return result try: return func(*args, **kwargs) finally: # record the time even when func raises TIMINGS.append((func.__name__, time.perf_counter() - start))
return wrapper
@timeddef load_report(day: str) -> str: """Build the report for one day.""" return f"report for {day}"When Best isn’t best: for finding slow code, a profiler times everything without changing any code. The profiling lesson shows cProfile. Decorators suit things you keep, like metrics in production.
Write it yourself
Section titled “Write it yourself”Write timed. The tests decorate a small function with it, call it, and check the result, TIMINGS and the name.
A timing decorator
Ctrl + Enter runs- Not run yet:
timed(add)(2, b=3)→5 - Not run yet:
@timed add; add(1); add(2, 3); the names in TIMINGS→['add', 'add'] - Not run yet:
@timed add: [add.__name__, add.__doc__]→['add', 'Add two numbers.'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Lines per function | Keeps the name | Times a call that raises | Use it when |
|---|---|---|---|---|
| Okay: copies | 3 | Yes | No | One function, once |
| Better: a decorator | 1 | No | No | Quick experiments |
Best: wraps and finally | 1 | Yes | Yes | Anything you keep |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- The order of decorators: the one nearest
defwraps first.@athen@babove a function meansa(b(f)). @retrywithout brackets whenretrytakes arguments passes your function astimes. Write@retry(3), or make the arguments optional on purpose.- Decorating a method:
selfarrives in*args, so*args, **kwargswrappers work on methods too. - The decorator runs once, at
deftime: code intimedoutsidewrapperruns when the module loads, not on each call. - Built-in ones you’ll meet:
@property,@classmethod,@staticmethod,@functools.cache,@dataclass, and Flask’s@app.route.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Should the wrapper change the arguments or the result, or only watch?
- What should happen when the function raises?
A 60-second walkthrough
“A decorator is a function that takes a function and returns a new one. My
timeddefines awrapper(*args, **kwargs)that reads the clock and calls the original. It records the elapsed time in afinally, so failures are timed too, and returns the original’s result. I decorate the wrapper withfunctools.wraps(func)so it keeps the name, docstring and__wrapped__. The@timedline is justf = timed(f). For a decorator with arguments, like@retry(3), there’s one more level:retry(3)returns the decorator.”
Likely follow-ups
- Why
functools.wraps? Without it, every decorated function is calledwrapper, which breaks logs, debugging,help()and frameworks that use the name. - Can a class be a decorator? Yes: any callable works. A class with
__call__suits decorators that keep state. - Can you decorate a class? Yes:
@dataclasstakes a class and returns it with methods added.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. Shout the result (easy)
Section titled “1. Shout the result (easy)”Write a decorator shout that turns a function’s text result into capitals. It must work on functions with any arguments, and keep their name and docstring.
Hint 1 of 3
The wrapper must return something, or every call gives None.
Hint 2 of 3
Take *args and **kwargs, so any arguments pass through.
Hint 3 of 3
Put @functools.wraps(func) on the wrapper.
Shout the result
Ctrl + Enter runs- Not run yet:
shout(hi)("ana")→'HI ANA' - Not run yet:
shout(greet)("bo", greeting="hey")→'HEY BO' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Call and capitalise
The idea: wrapper(text) calls func(text).upper().
It does the work.
from collections.abc import Callable
def shout(func: Callable) -> Callable: def wrapper(text): func(text).upper()
return wrapper- Time
O(n)- Space
O(n)- Why
- upper() copies the n letters.
Where it breaks: it never returns, so every decorated function gives back None.
Return it
The idea: Return func(text).upper().
The waste in Okay: the work is thrown away.
Highlighted lines are new in this rung.
from collections.abc import Callable
def shout(func: Callable) -> Callable: def wrapper(text: str) -> str: return func(text).upper()
return wrapper- Time
O(n)- Space
O(n)- Why
- upper() copies the letters.
What changed from the previous rung
from collections.abc import Callable
def shout(func: Callable) -> Callable: def wrapper(text): func(text).upper() def wrapper(text: str) -> str: return func(text).upper()
return wrapperWhere it breaks: the wrapper takes exactly one argument, so greet("bo", greeting="hey") raises TypeError. And the name becomes wrapper.
*args, **kwargs and wraps
The idea: Pass every argument through and copy the name with functools.wraps.
The waste in Better: it fits only functions shaped like the first one.
Highlighted lines are new in this rung.
import functoolsfrom collections.abc import Callable
def shout(func: Callable[..., str]) -> Callable[..., str]: @functools.wraps(func) def wrapper(*args: object, **kwargs: object) -> str: return func(*args, **kwargs).upper()
return wrapper- Time
O(n)- Space
O(n)- Why
- upper() copies the letters.
What changed from the previous rung
import functoolsfrom collections.abc import Callable
def shout(func: Callable) -> Callable: def wrapper(text: str) -> str: return func(text).upper()def shout(func: Callable[..., str]) -> Callable[..., str]: @functools.wraps(func) def wrapper(*args: object, **kwargs: object) -> str: return func(*args, **kwargs).upper()
return wrapperWhen Best isn’t best: if only one caller wants capitals, call .upper() there. A decorator changes the function for everyone.
2. Register pages with @route (medium)
Section titled “2. Register pages with @route (medium)”Write a decorator with an argument, route(path), that adds the function to the ROUTES dict under path, like a web framework does. The function must still work when called by its own name. Registering a second function for the same path raises ValueError("/ is already handled by home").
Hint 1 of 3
route("/") runs first and returns the real decorator, register(func).
Hint 2 of 3
register must return func, or the name home ends up pointing at None.
Hint 3 of 3
Check path in ROUTES before adding it.
Register pages with @route
Ctrl + Enter runs- Not run yet:
@route("/about") about; ROUTES["/about"]()→'About us' - Not run yet:
@route("/about") about; about()→'About us' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Add each page by hand
The idea: Write ROUTES[“/”] = home after each function.
It works, and nothing is hidden.
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def home() -> str: return "Welcome"
def about() -> str: return "About us"
ROUTES["/"] = home # easy to forget when you add a pageROUTES["/about"] = about- Time
O(1) per page- Space
O(p)- Why
- One entry per page.
Where it breaks: the path lives far from the function, and a new page that you forget to add is a 404.
A decorator that registers
The idea: route(path) returns register(func), which adds func to ROUTES.
The waste in Okay: the path is written apart from its page.
Highlighted lines are new in this rung.
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable: def register(func: Callable) -> None: ROUTES[path] = func
return register
@route("/")def home() -> str: return "Welcome"- Time
O(1) per page- Space
O(p)- Why
- One entry per page.
What changed from the previous rung
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def home() -> str: return "Welcome"
def route(path: str) -> Callable: def register(func: Callable) -> None: ROUTES[path] = func
def about() -> str: return "About us" return register
ROUTES["/"] = home # easy to forget when you add a pageROUTES["/about"] = about@route("/")def home() -> str: return "Welcome"Where it breaks: register returns nothing, so @route("/") sets home to None. ROUTES["/"]() works, but home() raises TypeError: 'NoneType' object is not callable.
Return the function
The idea: register checks for a clash, adds func, and returns func unchanged.
The waste in Better: it registers the function and then throws it away.
A decorator doesn’t have to wrap anything. This one only records the function, which is how Flask’s @app.route works.
Highlighted lines are new in this rung.
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable[[Callable], Callable]: def register(func: Callable) -> Callable: if path in ROUTES: raise ValueError(f"{path} is already handled by {ROUTES[path].__name__}") ROUTES[path] = func return func # give the function back, unchanged
return register
@route("/")def home() -> str: return "Welcome"- Time
O(1) per page- Space
O(p)- Why
- One entry per page.
What changed from the previous rung
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable: def register(func: Callable) -> None:def route(path: str) -> Callable[[Callable], Callable]: def register(func: Callable) -> Callable: if path in ROUTES: raise ValueError(f"{path} is already handled by {ROUTES[path].__name__}") ROUTES[path] = func return func # give the function back, unchanged
return register
@route("/")def home() -> str: return "Welcome"When Best isn’t best: registration happens at import time, so a module nobody imports registers nothing. Big apps list their routes in one place instead.
3. Retry, with arguments (hard)
Section titled “3. Retry, with arguments (hard)”Write retry(times, on=(ConnectionError,)). A function decorated with @retry(3) is tried up to 3 times when it raises one of the errors in on. If the last try fails too, the error is raised. Any other error is raised at once, with no retry. Keep the function’s name.
Hint 1 of 3
Three layers: retry(times, on) returns decorate(func), which returns wrapper.
Hint 2 of 3
except on: catches only the errors in that tuple.
Hint 3 of 3
On the last attempt, raise the error again with a bare raise.
Retry, with arguments
Ctrl + Enter runs- Not run yet:
fetch fails twice, then works: @retry(3)→['ok', 3] - Not run yet:
fetch always fails: @retry(3)→ raisesConnectionError - Not run yet:
fetch has a bug (KeyError): no retry→ raisesKeyError - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Three tries, any error
The idea: A plain decorator that tries 3 times and swallows every error.
A short network problem is survived.
from collections.abc import Callable
def retry(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: for _ in range(3): try: return func(*args, **kwargs) except Exception: continue return None
return wrapper- Time
Up to 3 calls- Space
O(1)- Why
- At most three tries.
Where it breaks: the number of tries is fixed, @retry(3) doesn’t fit its shape, and when every try fails it returns None instead of the error.
retry(times)
The idea: A decorator factory: retry(times) returns the decorator; the last failure is raised.
The waste in Okay: it hides the final error, and can’t be told how many tries to make.
Highlighted lines are new in this rung.
from collections.abc import Callable
def retry(times: int) -> Callable[[Callable], Callable]: def decorate(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: for attempt in range(times): try: return func(*args, **kwargs) except Exception: if attempt == times - 1: raise return None
return wrapper
return decorate- Time
Up to times calls- Space
O(1)- Why
- At most times tries.
What changed from the previous rung
from collections.abc import Callable
def retry(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: for _ in range(3): try: return func(*args, **kwargs) except Exception: continue return Nonedef retry(times: int) -> Callable[[Callable], Callable]: def decorate(func: Callable) -> Callable: def wrapper(*args: object, **kwargs: object) -> object: for attempt in range(times): try: return func(*args, **kwargs) except Exception: if attempt == times - 1: raise return None
return wrapper return wrapper
return decorateWhere it breaks: it retries every error, so a KeyError from a bug is tried again as if the network had failed. And it has no on= argument at all.
Only the errors worth retrying
The idea: except on: retries only the listed errors; others go straight up; wraps keeps the name.
The waste in Better: it retries errors that will never go away.
Highlighted lines are new in this rung.
import functoolsfrom collections.abc import Callable
def retry( times: int, on: tuple[type[Exception], ...] = (ConnectionError,)) -> Callable[[Callable], Callable]: def decorate(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args: object, **kwargs: object) -> object: for attempt in range(1, times + 1): try: return func(*args, **kwargs) except on: # only the errors worth trying again if attempt == times: raise
return wrapper
return decorate- Time
Up to times calls- Space
O(1)- Why
- At most times tries.
What changed from the previous rung
import functoolsfrom collections.abc import Callable
def retry(times: int) -> Callable[[Callable], Callable]:def retry( times: int, on: tuple[type[Exception], ...] = (ConnectionError,)) -> Callable[[Callable], Callable]: def decorate(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args: object, **kwargs: object) -> object: for attempt in range(times): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except Exception: if attempt == times - 1: except on: # only the errors worth trying again if attempt == times: raise return None
return wrapper
return decorateWhen Best isn’t best: add a wait that grows between tries, with some randomness, so many clients don’t retry at the same moment. The exceptions lesson’s practice builds that; the tenacity package does it all.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”@decoabovedef fmeansf = deco(f).- The wrapper:
def wrapper(*args, **kwargs): … return func(*args, **kwargs). - Always
@functools.wraps(func)on the wrapper. - With arguments,
@deco(x)meansf = deco(x)(f): three layers. - Stacked decorators apply bottom up: the one nearest
defwraps first.
Go deeper
Section titled “Go deeper”- Function definitions, in the language reference, for what
@means. functools.wraps.- PEP 318, which added decorators.
- Before this: Closures. Next: Context managers.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started