Day 12 Β· Gift-wrapping functions

Closures, Decorators & Functional Style

You will be able to
  • Treat functions as values: store them, pass them, return them from other functions
  • Explain what a closure captures and predict what an inner function will see
  • Write decorators (timing, retry) using *args/**kwargs and functools.wraps
  • Apply functools.lru_cache and measure the speedup on a recursive function
  • Use sorted with key= and judge when lambda helps versus hurts readability
Today's ~120 minutes
Spaced-rep warm-up: due cards (LEGB from Day 3 will resurface)10 min
Concept study: ELI5 + tech β€” desugar one @ by hand on paper20 min
Guided: closures, @timed build, @retry + lru_cache45 min
Practice: the @logged decorator20 min
Project: wrappers.py toolkit15 min
Quiz + flashcards10 min

Builds on: Day 3 β€” Functions, scope, *args/**kwargs Β· Day 10 β€” You already used decorators: @dataclass, @property Β· Day 11 β€” Laziness and wrapping behavior

The analogy

A plain gift is a watch. A wrapped gift is still a watch β€” but now opening it involves ribbon, paper, and a card that says who it's from. The watch inside is untouched; the *experience around it* changed. A decorator gift-wraps a function: same function inside, but now calling it also starts a stopwatch, or retries on failure, or writes a log line. The wrapping is reusable β€” one @timed wrapper can wrap any function in your codebase, because the wrapper doesn't care what's inside the box.

Two ideas make the wrapping possible. First: in Python, functions are things β€” values you can put in a variable, pass to another function, or hand back as a return value, exactly like numbers and lists. Second: a closure β€” when a function is created inside another function, it keeps a backpack containing the variables from its birthplace, even after the birthplace has returned. The wrapper function keeps the original function in its backpack; that's how the gift stays inside the wrapping. You've already been *using* decorators (@dataclass, @property yesterday) β€” today you find out they were never magic, just backpacks and wrapping paper.

Why this matters on the job

Cross-cutting behavior β€” timing, retries, caching, logging, auth β€” is the same everywhere: you need it on many functions without editing any of them. Decorators are Python's answer, and the ecosystem runs on them: FastAPI routes are decorators (Day 41), pytest fixtures (Day 19), and your Day 107 LLM client wrapper is essentially today's @retry with backoff attached β€” network calls to model APIs fail routinely, and retry-wrapping is the difference between a flaky demo and a robust one. Caching via lru_cache is the one-line performance fix interviewers love, and closures explain half the "why does this variable have that value?!" mysteries in real callback-heavy code.

Guided practice

guided 1

Functions in boxes, functions in backpacks

15 min
  1. Create week-02/closures_lab.py with the starter code. Section 1: functions as values β€” alias one, store two in a dict, dispatch by key. Notice shout vs shout(): the function itself versus the result of calling it. Mixing those up is a daily-life bug.
  2. Section 2: run make_counter twice and confirm the two counters are independent β€” separate backpacks, packed at creation time.
  3. Remove nonlocal, rerun, and read the UnboundLocalError. Connect it in a comment to Day 3's shadowing rule (assignment makes a local unless told otherwise).
  4. Section 3: make_multiplier β€” the classic closure factory. Build double and triple from ONE definition. What exactly is in each backpack? Print double.__closure__[0].cell_contents to literally look inside.
  5. Write one sentence: why does the backpack survive after make_multiplier has returned? (The inner function holds a reference; Day 4's object-lifetime rules apply to variables too.)
🐍 python β€” editable, runs in your browser
Ctrl/⌘+Enter runs · Tab indents · numpy/pandas/sklearn auto-load on import (torch and network calls need a local run)
guided 2

Wrap your first gifts β€” @timed, properly

15 min
  1. Create decorators_lab.py. Build @timed in three steps, feeling each problem before fixing it. Step 1: write the naive version WITHOUT *args/**kwargs (wrapper() taking nothing) and wrap a function that takes an argument. Watch it explode. Fix with the collectors.
  2. Step 2: forget the return (call fn but don't return its result). Wrap a function that returns a value, print the result: None. Fix it. These two bugs are 90% of all broken decorators ever written.
  3. Step 3: print slow_add.__name__ β€” it says "wrapper". Add @functools.wraps(fn) and check again. Now the finished decorator matches the starter code below.
  4. Desugar once by hand: define a function plain(), then wrap it without the @ sign: plain = timed(plain). Verify @ was pure sugar.
  5. Wrap three different functions β€” one fast, one slow (time.sleep(0.3)), one taking kwargs β€” with the SAME @timed. One wrapper fits all: that's what *args/**kwargs bought.
🐍 python β€” editable, runs in your browser
Ctrl/⌘+Enter runs · Tab indents · numpy/pandas/sklearn auto-load on import (torch and network calls need a local run)
guided 3

@retry and @lru_cache β€” wrappers that earn money

15 min
  1. Still in decorators_lab.py: build @retry from the starter code. The flaky() function fails randomly ~60% of the time β€” exactly how networks behave. Run it several times; watch retries save the call.
  2. Note the decorator-with-arguments shape: retry(times=3) is a function returning a decorator returning a wrapper β€” three layers. Trace which layer runs at decoration time vs call time (add prints if unsure). This shape is everywhere in libraries; recognize it rather than memorizing it.
  3. lru_cache: implement naive_fib(n), time naive_fib(32) with your own @timed. Then add @functools.lru_cache(maxsize=None) above it and time again. Record both numbers β€” you should see roughly a thousand-fold difference. One line. (Day 27 explains the exponential tree you just pruned; Day 34 builds memoization by hand.)
  4. sorted-with-key reps: given tasks = [("rope", 2), ("map", 1), ("tent", 3)], sort by the number with key=lambda t: t[1], then descending. Then sort your Day 9 Flashcards by due_in.
  5. Judgment line in a comment: one place lambda was perfect here, and one kind of place it wouldn't be (multi-line logic β€” give it a def and a name).
🐍 python β€” editable, runs in your browser
Ctrl/⌘+Enter runs · Tab indents · numpy/pandas/sklearn auto-load on import (torch and network calls need a local run)

On your own

The @logged decorator

20 min

Build the third member of your wrapper toolkit, unguided.

Goal: @logged appends one line per call to calls.log (Day 5's append mode): timestamp, function name, arguments, and the returned value β€” or the exception type if it raised (re-raise it after logging; never swallow). Then stack it: put @timed AND @logged on one function and check both effects fire.

Constraints: use functools.wraps; the log line must include the real function name even when stacked under @timed; exceptions must propagate after logging.

Hints (only if stuck): repr(args) and repr(kwargs) make loggable strings. For stacking order: decorators apply bottom-up β€” @timed above @logged wraps the logged version. Try both orders and read the log to see the difference (this exact stacking question is a favorite interview aside).

Ship before you stop

wrappers.py β€” your instrumentation toolkit

Assemble today's work into a module you will import for the rest of the program: wrappers.py containing @timed, @retry(times, delay), and @logged, each with docstrings and functools.wraps, plus a demo under a main guard exercising all three (including a stacked example and a flaky function saved by retry). Then put it to work: wrap your streamstats parsing run (Day 11) with @timed, and wrap the tracker's save function with @logged. Commit. On Day 107 you will meet this module's grown-up sibling β€” an LLM API client wrapped with retry/backoff/timing β€” and recognize every moving part.

Rubric β€” check what you completed (0/6)

Common mistakes & misconceptions

  • Writing shout when you mean shout() or vice versa. Without parens you have the function object; with them, its result. Passing shout() to sorted's key= calls it immediately and passes the result β€” usually a crash.
  • A wrapper that forgets to return fn(...)'s result. Every function you wrap silently starts returning None. Always capture and return.
  • Wrapper signature that isn't (*args, **kwargs). Your decorator only fits functions with one exact shape and breaks on everything else.
  • Skipping functools.wraps. Tracebacks, help(), and logs all report "wrapper" for every decorated function β€” debugging misery on a delay timer.
  • Assigning to a closed-over variable without nonlocal. You get UnboundLocalError or a shadow local β€” Day 3's rule operating at the enclosing level.
  • lru_cache on functions with list/dict arguments (unhashable β€” TypeError) or on functions with side effects (the effect fires once, then never again). Cache pure computations only.
Knowledge check

Q1. @timed above def slow(): is exactly equivalent to…

Q2. make_counter() returns bump, which uses count. After make_counter returns, count is…

Q3. A wrapped function returns None even though its body returns a value. Most likely cause?

Go deeper β€” curated resources

docsfunctools β€” official docs (wraps, lru_cache) β†—20 mindocsSorting HOWTO β€” key functions done right β†—15 mindocsFunctional Programming HOWTO β€” the wider ideas β†—20 min
If you have a third hour
  • Decorators with state: class-based decorators β€” A class with __call__ can decorate too, holding state in self (call counts, rate limits). Ties Day 9 to today β€” try rewriting @timed as a class in ten lines.
Done means
  • Both closure factories built; backpack inspected via __closure__
  • @timed built through all three deliberate-bug steps
  • fib timing recorded with and without lru_cache
  • @logged works stacked, with exceptions logged and re-raised
  • wrappers.py committed and imported by two real scripts; quiz β‰₯ 2/3
How this connects

← Back: Closures are Day 3's LEGB rule bearing fruit β€” the E finally matters. *args/**kwargs from Day 3 became load-bearing. And @dataclass/@property from Day 10 just lost their mystery: functions transforming functions/classes, nothing more.

Forward β†’: pytest fixtures (Day 19) and FastAPI routes (Day 41) are decorators; Day 27 revisits lru_cache when recursion trees get pruned; Day 34 builds memoization by hand. Day 107's production LLM client is @retry with exponential backoff and a budget β€” today's wrapper, promoted to revenue-critical.

Unlocks: D27 Recursion & Divide/Conquer Β· D34 Dynamic Programming Intro Β· D40 Concurrency & Async Python