Session 7 · Decorator pattern#

The big idea#

You want to add behaviour to an object or function (logging, timing, retrying, caching) without changing its code and without creating a subclass for every combination. The Decorator pattern wraps the original in another object with the same interface that does something extra before or after.

Everyday example: a gift. The gift is the same inside. You wrap it in paper, then add a ribbon, then a card. Each layer adds something, and you can pick which layers to use.

(The Python @decorator syntax is a close cousin. We cover both.)

In plain words

Wrap something to give it an extra feature (logging, timing, retry) without changing it. The wrapped thing still looks and works the same from outside, and you can stack wrappers.

Reach for it when:

  • you want the same extra behaviour on many functions or objects

  • the extra work isn’t part of the function’s real job

  • subclasses for every combination of features would explode

Without Decorator#

0.no_decorator.py: extras pile up inside the function.

import time

def get_price(item):
    start = time.time()
    print(f"calling get_price({item})")
    price = {"apple": 120, "mango": 200}[item]
    print(f"took {time.time() - start:.4f}s")
    return price

Timing and logging are mixed with the real work, and you copy them into every function.

With Python function decorators#

1.func_decorator.py:

import time
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper

@logged
@timed
def get_price(item):
    return {"apple": 120, "mango": 200}[item]

get_price("apple")
calling get_price('apple',)
get_price took 0.0000s

get_price contains only the real logic. Stack decorators like layers. @wraps keeps the original name and docstring.

The classic object version#

2.classic.py: wrap objects that share an interface. This works for classes too.

from abc import ABC, abstractmethod

class Coffee(ABC):
    @abstractmethod
    def cost(self) -> int: ...
    @abstractmethod
    def description(self) -> str: ...

class Espresso(Coffee):
    def cost(self): return 100
    def description(self): return "Espresso"

class AddOn(Coffee):                       # the decorator base
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

class Milk(AddOn):
    def cost(self): return self._coffee.cost() + 20
    def description(self): return self._coffee.description() + " + Milk"

class Sugar(AddOn):
    def cost(self): return self._coffee.cost() + 5
    def description(self): return self._coffee.description() + " + Sugar"

order = Sugar(Milk(Espresso()))
print(order.description(), order.cost())    # Espresso + Milk + Sugar 125

Without decorators, you would need EspressoWithMilk, EspressoWithMilkAndSugar, … a class for every combination.

Decorator: wrap, keep the interface, add a little Sugar wraps Milk, which wraps Espresso. Each layer passes the call inward and adds its own part on the way out. Sugar +5 Milk +20 Espresso cost() = 100 order.cost() 100 + 20 + 5 = 125
Sugar wraps Milk, which wraps Espresso. Each layer passes the call inward and adds its own part on the way out.

Decorator + AI: retry and cache around an LLM call#

LLM calls fail and cost money. Add that protection without touching the call itself (3.llm_decorators.py):

import time
from functools import wraps, lru_cache

def retry(times=3, delay=1):
    def deco(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == times:
                        raise
                    print(f"attempt {attempt} failed: {e}. retrying...")
                    time.sleep(delay)
        return wrapper
    return deco

@lru_cache(maxsize=256)
@retry(times=3, delay=2)
def ask_llm(prompt: str) -> str:
    return llm.invoke(prompt).content

The same question is answered from the cache for free, and a flaky network is retried.

Common mistakes#

  • Forgetting @wraps. Your function’s __name__ becomes wrapper, which breaks debugging.

  • Wrong order. @lru_cache above @retry caches the final answer. Swap them and you cache the retrying wrapper, which is rarely what you want. Read stacked decorators from bottom to top.

  • Caching functions with side effects (sending emails). A cached call does nothing the second time.

  • Decorators that change the return type. The wrapper must behave like the original.

Hands-on exercises#

Exercise 1 · @timed for async. Write a timing decorator that works on async def.

Solution
import time, asyncio
from functools import wraps

def timed_async(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = await func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
        return result
    return wrapper

@timed_async
async def fetch():
    await asyncio.sleep(0.5)
    return "done"

asyncio.run(fetch())

Exercise 2 · @require_admin. A decorator that raises PermissionError unless user["role"] == "admin".

Solution
from functools import wraps

def require_admin(func):
    @wraps(func)
    def wrapper(user, *args, **kwargs):
        if user.get("role") != "admin":
            raise PermissionError("admins only")
        return func(user, *args, **kwargs)
    return wrapper

@require_admin
def delete_all(user):
    print("deleted everything")

delete_all({"role": "admin"})

Exercise 3 · Pizza toppings. Rebuild the coffee example for pizza with Cheese and Olives.

Solution
class Pizza:
    def cost(self): return 200
    def description(self): return "Margherita"

class Topping:
    def __init__(self, pizza): self._p = pizza

class Cheese(Topping):
    def cost(self): return self._p.cost() + 40
    def description(self): return self._p.description() + " + Cheese"

class Olives(Topping):
    def cost(self): return self._p.cost() + 25
    def description(self): return self._p.description() + " + Olives"

p = Olives(Cheese(Pizza()))
print(p.description(), p.cost())    # Margherita + Cheese + Olives 265

Full source#

1.func_decorator.py
"""Python function decorators: add timing and logging without touching the function."""
import time
from functools import wraps


def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper


def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


@logged
@timed
def get_price(item):
    """Look up the price of a fruit."""
    return {"apple": 120, "mango": 200}[item]


print(get_price("apple"))
print(get_price.__name__, "-", get_price.__doc__)    # @wraps keeps the real name and docstring
3.llm_decorators.py
"""Decorator + AI: retry and cache around an LLM call, without touching the call itself."""
import random
import time
from functools import lru_cache, wraps


def retry(times=3, delay=1):
    def deco(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == times:
                        raise
                    print(f"attempt {attempt} failed: {e}. retrying...")
                    time.sleep(delay)
        return wrapper
    return deco


class FlakyLLM:
    """Offline stand-in that fails half the time, like a busy API."""
    def invoke(self, prompt):
        if random.random() < 0.5:
            raise ConnectionError("503 service unavailable")
        return type("Msg", (), {"content": f"(answer to: {prompt})"})()


llm = FlakyLLM()


@lru_cache(maxsize=256)
@retry(times=5, delay=0.1)
def ask_llm(prompt: str) -> str:
    return llm.invoke(prompt).content


random.seed(3)
print(ask_llm("What is a decorator?"))
print(ask_llm("What is a decorator?"))      # from the cache: no call, no retries
print(ask_llm.cache_info())

Downloads: 0.no_decorator.py · 1.func_decorator.py · 2.classic.py · 3.llm_decorators.py