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

  1. Functions as first-class objects
  2. Closures: functions that remember
  3. A decorator from scratch, without syntactic sugar
  4. The @ syntax
  5. *args and **kwargs: wrapping any signature
  6. functools.wraps: keeping the identity
  7. Decorators for Papyrus: @timed and @log_call
  8. Decorators with arguments: @retry(times=3)
  9. The decorators you already knew, in a new light
  10. 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, Julia

Closures: 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:

  1. You call make_greeting("Papyrus")shop = "Papyrus" is created.
  2. greeting is defined and mentions shop — Python notes that it needs it.
  3. make_greeting returns greeting and finishes, but shop is not destroyed: it travels tucked away inside the returned function.
  4. Every later call to greet_papyrus(...) finds shop intact.

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()
→ Calling open_shop...
Papyrus opens its doors.
← open_shop finished.

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
def open_shop():
    print("Papyrus opens its doors.")

@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 wrapper

With 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 wrapper
Without @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:

2026-07-13 20:05:12,481 INFO     warehouse: close_till took 0.042 s

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 decoratordecorator(save_catalog) → returns wrappersave_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 if timed is 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 returning None. If the decorated sell stops returning the amount, check the wrapper before the function.
  • Forgetting @functools.wraps: the 07-05 logs start saying wrapper instead of sell and debugging becomes a maze.
  • Swallowing exceptions in the decorator: an except Exception: pass in a wrapper is the "silence" that 07-04 banned, now in disguise. Observe, log with logger.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 with typing.Callable; keep it as optional reading, you don't need it yet.

Exercises

  1. 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 raise InvalidMemberError(code) without calling the function; if it's valid, it calls it normally. Don't forget functools.wraps.
  2. Without using the @ syntax, decorate close_till with the lesson's timed via explicit reassignment, and explain in a comment why close_till.__name__ is still "close_till".
  3. Turn @timed into a factory @timed(threshold=1.0) that only writes to the log if the function took more than threshold seconds (to avoid filling papyrus.log with fast closes).

Solutions

  1. 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 wrapper
    

    The wrapper names code explicitly (it needs it to validate) and forwards the rest with *args, **kwargs.

  2. 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.
    
  3. 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): ...
    

    threshold travels in the closure through all three levels. Common mistake here: leaving @timed without 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved