Decorators and Closures Checkpoint
25 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Fix the mistakes
The slips that cost marks in this module. Spot each one, then check the fix.
In a real service the same checks are needed on many functions: logging, validation and caching. This checkpoint builds that toolbox and then tests the whole module.
Keep checks out of the business code
A function such as order_total should only calculate. Logging and validation belong in decorators, so every function gets them the same way and the logic stays short.
from functools import wraps
def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print("calling", func.__name__) return func(*args, **kwargs) return wrapper
@log_callsdef order_total(price, qty): return price * qtyAdd or remove @log_calls without touching order_total.
Combine a factory, a cache and stacking
Settings go in a factory such as @validate(min_qty=1). Expensive pure lookups go under @lru_cache. When decorators stack, the nearest one runs closest to the function.
@log_calls@lru_cache(maxsize=32)def tax_rate(country): return {"IN": 0.18, "US": 0.07}[country]Example 1 · Toolbox on one function
from functools import wraps
def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print("calling", func.__name__, args) return func(*args, **kwargs) return wrapper
def validate(min_qty): def decorator(func): @wraps(func) def wrapper(price, qty): if qty < min_qty: raise ValueError(f"quantity must be at least {min_qty}") return func(price, qty) return wrapper return decorator
@log_calls@validate(min_qty=1)def order_total(price, qty): return price * qty
print(order_total(250, 3))try: order_total(250, 0)except ValueError as err: print(err)calling order_total (250, 3) 750 calling order_total (250, 0) quantity must be at least 1
What this code does
log_callsis outermost, so it logs even the call that fails validation.- The factory
validate(min_qty=1)keeps its setting in a closure.
Example 2 · Cache behind a logger
from functools import lru_cache, wraps
reads = 0
def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print("calling", func.__name__, args) return func(*args, **kwargs) return wrapper
@log_calls@lru_cache(maxsize=32)def tax_rate(country): global reads reads += 1 return {"IN": 0.18, "US": 0.07}[country]
print(tax_rate("IN"))print(tax_rate("IN"))print("table reads:", reads)calling tax_rate ('IN',)
0.18
calling tax_rate ('IN',)
0.18
table reads: 1What this code does
- Both calls are logged, but only the first one reaches the table.
- If the order were swapped, the second call would not be logged.
Here the cache sits inside the logger: every call is logged, but only the first call per country reads the table.
Choose the right tool
Use a closure for one small piece of state, a decorator to add the same behaviour to many functions, and a class when the state needs several methods or readable attributes.
def make_counter(): count = 0 def inc(): nonlocal count count += 1 return count return incExample 3 · Closures in a loop
handlers = []for level in ("low", "high"): handlers.append(lambda msg, level=level: f"{level}: {msg}")
broken = [lambda msg: f"{level}: {msg}" for level in ("low", "high")]
print(handlers[0]("disk"), "|", handlers[1]("disk"))print(broken[0]("disk"), "|", broken[1]("disk"))low: disk | high: disk high: disk | high: disk
What this code does
- The default argument keeps each level in its own function.
- The broken version reads the last value of
levelfor both.
Check for traps too: late binding in loops, missing return result, and forgetting @wraps.
Finish up
Mark complete & continue
Next: The with Statement and Resource Cleanup