In the previous lesson the signatures of warehouse.py got their annotations, and while writing them a new need appeared: Ana wants to know how long close_till() takes with a year's worth of sales, and she wants every call to sell() audited in papyrus.log. You could open each function and add the stopwatch and the logging inside... but that mixes business logic with plumbing, and you'd have to repeat it in every function. Decorators solve this: they wrap a function with extra behavior without touching its code. You've been using them since module 5 (@property, @dataclass) without knowing how they work inside; today we take them apart piece by piece.
Contents
- Functions as first-class objects
- Closures: functions that remember
- A decorator from scratch, without syntactic sugar
- The
@syntax *argsand**kwargs: wrapping any signaturefunctools.wraps: keeping the identity- Decorators for Papyrus:
@timedand@log_call - Decorators with arguments:
@retry(times=3) - The decorators you already knew, in a new light
- Don't over-decorate
Functions as first-class objects
Everything that follows rests on a fact you already brushed against in M3 with sorted(key=...) and lambdas: in Python, a function is an object like any other. It can be assigned, passed as an argument and returned:
def greet(name: str) -> str:
return f"Welcome to Papyrus, {name}"
serve = greet # assigning: NO parentheses — the function, not its result
print(serve("Omar")) # Welcome to Papyrus, Omar
def apply(function, value):
return function(value) # passed as an argument (like key= in sorted)
print(apply(greet, "Julia"))The crucial distinction is greet (the function object) versus greet() (the result of calling it). The whole decorator mechanism revolves around that difference.
Functions can also be defined and returned inside other functions:
def make_greeting(shop: str):
def greeting(name: str) -> str: # function defined inside
return f"Welcome to {shop}, {name}"
return greeting # the function object is returned
greet_papyrus = make_greeting("Papyrus")
print(greet_papyrus("Julia")) # Welcome to Papyrus, JuliaClosures: functions that remember
Notice something surprising in the previous example: when make_greeting("Papyrus") finishes, its local variable shop should disappear... and yet greet_papyrus keeps using it. An inner function that captures variables from the function that created it is called a closure. Step by step:
- You call
make_greeting("Papyrus")→shop = "Papyrus"is created. greetingis defined and mentionsshop— Python notes that it needs it.make_greetingreturnsgreetingand finishes, butshopis not destroyed: it travels tucked away inside the returned function.- Every later call to
greet_papyrus(...)findsshopintact.
Closures are the memory of decorators: that's how the wrapper function "remembers" which function it wraps.
A decorator from scratch, no sugar
A decorator is simply a function that takes a function and returns another one that wraps it. Without @, by hand:
def announce(function):
def wrapper():
print(f"→ Calling {function.__name__}...")
result = function() # the original function, remembered by the closure
print(f"← {function.__name__} finished.")
return result
return wrapper
def open_shop():
print("Papyrus opens its doors.")
open_shop = announce(open_shop) # decorating = REASSIGNING!
open_shop()The key line is open_shop = announce(open_shop): the name open_shop stops pointing to the original function and starts pointing to the wrapper, which keeps the original in its closure. Nobody touched the body of open_shop; it just got a layer wrapped around it.
The @ syntax
Because that reassignment pattern is so common, Python gives it syntactic sugar:
@announce right above the def means exactly open_shop = announce(open_shop) executed after defining the function. There's no more magic than that: if you understand the reassignment, you understand the @.
*args and **kwargs: wrapping any signature
The wrapper() above takes no arguments, so it can't wrap sell(catalog, title, units). The solution is the M3 duo *args/**kwargs, which finds its star role here — collecting whatever comes in and passing it along untouched:
def announce(function):
def wrapper(*args, **kwargs):
print(f"→ {function.__name__}")
return function(*args, **kwargs) # forwards the arguments intact
return wrapperWith this, the decorator is universal: it works for functions with zero, three or twenty parameters, positional or keyword. It's the standard signature of every wrapper.
functools.wraps: keeping the identity
There's a subtle side effect. After decorating, sell.__name__ is no longer "sell" but "wrapper", and its docstring — the contract we took such care of in 08-01 — vanishes. For the logger.exception() calls from M7, which record function names, this is a real problem. functools.wraps copies the original's identity onto the wrapper:
import functools
def announce(function):
@functools.wraps(function) # ← a decorator... inside your decorator
def wrapper(*args, **kwargs):
return function(*args, **kwargs)
return wrapperWithout @functools.wraps |
With @functools.wraps |
|---|---|
sell.__name__ → "wrapper" |
sell.__name__ → "sell" |
help(sell) → wrapper's docstring (or nothing) |
help(sell) → original docstring |
| Confusing tracebacks and logs | Correct tracebacks and logs |
Rule with no exceptions: every decorator carries @functools.wraps.
Decorators for Papyrus
@timed: measuring close_till
import functools
import logging
import time
logger = logging.getLogger(__name__) # the per-module pattern from 07-05
def timed(function):
"""Logs how long each call takes."""
@functools.wraps(function)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return function(*args, **kwargs)
finally: # measured even if the function raises (07-02)
duration = time.perf_counter() - start
logger.info("%s took %.3f s", function.__name__, duration)
return wrapper
@timed
def close_till(sales_path):
...After running the close, this shows up in data/papyrus.log with the M7 format:
The try/finally guarantees the measurement even if close_till raises: the same cleanup discipline from 07-02.
@log_call: auditing sales
Ana wants to be able to reconstruct what was sold and what failed. Instead of putting logging inside sell(), we wrap it:
def log_call(function):
"""Logs every call, its result or its exception."""
@functools.wraps(function)
def wrapper(*args, **kwargs):
logger.info("call: %s args=%r kwargs=%r", function.__name__, args, kwargs)
try:
result = function(*args, **kwargs)
logger.info("ok: %s → %r", function.__name__, result)
return result
except Exception:
logger.exception("failure in %s", function.__name__) # with traceback, as in 07-05
raise # re-raise: the decorator doesn't decide
return wrapper
@log_call
def sell(catalog, title, units):
...A sale of Hamlet x2 leaves ok: sell → 20.7; an attempt to sell 9 copies of The Odyssey with 4 in stock leaves the full InsufficientStockError traceback. And pay attention to the bare raise: the decorator observes and re-raises. Catching and silencing here would violate the three-rings rule of 07-04 — the decision about the exception belongs to the counter or to the boundary, not to the wrapper.
Decorators with arguments: @retry(times=3)
What if saving the catalog fails sporadically (network drive, antivirus) and you want to retry? We'd like to write @retry(times=3). But that means retry(times=3) is called first and must return a decorator: it's a decorator factory. Three levels:
def retry(times: int = 3, pause: float = 0.5):
"""Factory: returns a decorator that retries up to `times` times."""
def decorator(function): # level 2: the actual decorator
@functools.wraps(function)
def wrapper(*args, **kwargs): # level 3: the wrapper
for attempt in range(1, times + 1):
try:
return function(*args, **kwargs)
except OSError: # only transient errors, not bugs
logger.warning("attempt %d/%d of %s failed",
attempt, times, function.__name__)
if attempt == times:
raise # attempts exhausted, propagate
time.sleep(pause)
return wrapper
return decorator
@retry(times=3)
def save_catalog(catalog, path):
...Read the chain: @retry(times=3) → retry(times=3) runs → returns decorator → decorator(save_catalog) → returns wrapper → save_catalog = wrapper. Each level is a closure: wrapper remembers function, times and pause all at once. Note the choice of except OSError: you retry what's transient; a TypeError is a bug and should blow up on the first try (07-04).
The decorators you already knew, in a new light
| Decorator | Where you saw it | What it does, now that you know how |
|---|---|---|
@property |
M5 (encapsulation) | Wraps the method so it's accessed like an attribute |
@dataclass |
05-06 | Decorates the class: reads its annotations (08-01) and generates __init__, __repr__, __eq__ |
@staticmethod / @classmethod |
M5 | Change how self/cls is (or isn't) passed to the method |
@functools.wraps |
This lesson | A decorator that fixes decorators |
None of them was magic: they're all "function (or class) in, wrapped or transformed version out".
Don't over-decorate
A warning before you decorate the whole shop: every decorator adds a layer to dig through when debugging. If sell carries @timed, @log_call and @retry, an error traceback shows three wrappers before reaching the real code, and the stacking order starts to matter (they apply bottom-up). Practical criterion: decorate when the behavior is cross-cutting and repeated (logging, timing, retries); don't decorate for single-function business logic — that goes inside the function, where it can be seen.
Common Mistakes and Tips
- Decorating with one set of parentheses too many or too few:
@timed()fails iftimedis a plain decorator, and@retry(without parentheses) fails if it's a factory. Rule: factory → always with parentheses, even when using the defaults (@retry()). - Forgetting
return function(...)in the wrapper: the decorated function silently starts returningNone. If the decoratedsellstops returning the amount, check the wrapper before the function. - Forgetting
@functools.wraps: the 07-05 logs start sayingwrapperinstead ofselland debugging becomes a maze. - Swallowing exceptions in the decorator: an
except Exception: passin a wrapper is the "silence" that 07-04 banned, now in disguise. Observe, log withlogger.exception, and re-raise. - Tip: write the decorator without
@first, with the explicit reassignment, until the flow feels obvious. The@comes on its own. - Tip: wrappers benefit from the type hints of 08-01 —
def wrapper(*args, **kwargs) -> ...can be annotated precisely withtyping.Callable; keep it as optional reading, you don't need it yet.
Exercises
- Write
@members_only, a decorator for functions whose first argument is a member code ("LUIS-001","MARTA-002","PAU-003"). If the code isn't in the set of valid members, it must raiseInvalidMemberError(code)without calling the function; if it's valid, it calls it normally. Don't forgetfunctools.wraps. - Without using the
@syntax, decorateclose_tillwith the lesson'stimedvia explicit reassignment, and explain in a comment whyclose_till.__name__is still"close_till". - Turn
@timedinto a factory@timed(threshold=1.0)that only writes to the log if the function took more thanthresholdseconds (to avoid fillingpapyrus.logwith fast closes).
Solutions
-
import functools from errors import InvalidMemberError VALID_MEMBERS: set[str] = {"LUIS-001", "MARTA-002", "PAU-003"} def members_only(function): @functools.wraps(function) def wrapper(code, *args, **kwargs): if code not in VALID_MEMBERS: raise InvalidMemberError(code) # the 07-03 guard clause, now reusable return function(code, *args, **kwargs) return wrapperThe wrapper names
codeexplicitly (it needs it to validate) and forwards the rest with*args, **kwargs. -
close_till = timed(close_till) # __name__ is still "close_till" because the wrapper carries # @functools.wraps(function), which copies __name__ and the docstring # of the original function onto the wrapper before returning it. -
def timed(threshold: float = 0.0): def decorator(function): @functools.wraps(function) def wrapper(*args, **kwargs): start = time.perf_counter() try: return function(*args, **kwargs) finally: duration = time.perf_counter() - start if duration > threshold: logger.info("%s took %.3f s", function.__name__, duration) return wrapper return decorator @timed(threshold=1.0) def close_till(sales_path): ...thresholdtravels in the closure through all three levels. Common mistake here: leaving@timedwithout parentheses — it's a factory now and must always be called.
Conclusion
There's nothing magical about a decorator: functions that are objects, closures that remember, and a reassignment that the @ abbreviates. With @timed, @log_call and @retry(times=3), Papyrus adds timing, auditing and resilience without cluttering sell() or close_till() — and along the way, @property and @dataclass stopped being incantations. Timing the close is precisely what raises the next question: close_till() reads the entire sales.csv to add it up, and the file grows every day. Do you really need to load a year of sales into memory to sum today's? No: rows can be produced and consumed on demand, one at a time. That's the territory of generators and yield, the next lesson.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
