Contents
Chapter 14

Decorators

Logging, timing, retrying, and validating arguments are cross-cutting concerns: they show up in unrelated functions, not just one. Writing them into each function’s own body spreads the same few lines everywhere they apply, and a change to that logic means editing every copy. A decorator factors that behavior out once and reapplies it wherever needed, without changing the function’s own code.

A decorator is a callable that you apply to a function or a class. The decorator receives the thing it decorates, does something with it, then returns a result, which Python binds to the original name. Most decorators apply to functions, so this chapter starts there.

To apply a decorator, put @ followed by the decorator name on the line above the definition. For simplicity, this first example uses an untyped Callable:

# simple_decoration.py
from collections.abc import Callable

def hijack(func: Callable) -> Callable:
    def doesnt_matter() -> None:
        print("Replacement behavior")

    return doesnt_matter

@hijack
def cheese() -> None:
    print("Wensleydale")

cheese()
#: Replacement behavior

Later, Maintaining the Wrapped Interface shows decorators with types.

The @hijack above cheese() means:

cheese = hijack(cheese)

hijack returns doesnt_matter, which Python assigns to the name cheese, so cheese now refers to doesnt_matter. Calling cheese() runs doesnt_matter, which never calls func and prints its own message instead. The original body of cheese() never runs.

Since Python binds the returned function to the name cheese, the local name (doesnt_matter()) can be anything. Convention calls it wrapper().

A typical decorator returns a wrapper that does some work, calls the original function, then does some more work:

# typical_decorator.py
from collections.abc import Callable

def add_behavior(func: Callable) -> Callable:
    def wrapper() -> None:
        print("Some work")
        func()
        print("Some more work")

    return wrapper

@add_behavior
def cheese() -> None:
    print("Wensleydale")

cheese()
#: Some work
#: Wensleydale
#: Some more work

A decorator that forgets its return wrapper returns None instead, so Python binds cheese to None. The failure surfaces at the next call to cheese(), not at the decoration that caused it.

The decorator runs when Python executes the def, not when you call the decorated function:

# decoration_time.py
from collections.abc import Callable

def announce(func: Callable) -> Callable:
    print("Decorating")
    def wrapper() -> None:
        print("Calling")
        func()
    return wrapper

@announce
def cheese() -> None:
    print("Wensleydale")

print("Definitions done")
cheese()
#: Decorating
#: Definitions done
#: Calling
#: Wensleydale

Decorating prints before Definitions done, so announce runs while Python is still executing the def above cheese. Only the body of wrapper() waits for the call.

wrapper() is a closure. Defined inside its decorator, it refers to func, a variable from the enclosing scope, not one of its own parameters. Python keeps func alive for as long as wrapper() exists, even after the decorator has returned. That lets cheese(), called long after decoration finished, still reach the original cheese function through func. Closures covers the general mechanism.

Decoration is a simple kind of metaprogramming. The same idea appears in design patterns as the Decorator pattern: wrap an object to add responsibilities to it, while keeping the wrapped object’s interface so the wrapping stays invisible to the code that uses it.

Maintaining the Wrapped Interface

The wrappers so far declare no parameters, so add_behavior only works on functions that take none. With add_behavior on a def add(a, b), the call add(2, 3) raises a TypeError: the wrapper takes zero positional arguments, and the call passes two. A wrapper that must handle any function collects the call with *args, **kwargs and forwards it unchanged, the pattern from Unpacking Arguments. This decorator traces calls, and its wrapper() takes that shape:

# tracer.py
from collections.abc import Callable
from functools import wraps

def trace[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        positional = [repr(a) for a in args]
        named = [f"{k}={v!r}" for k, v in kwargs.items()]
        arglist = ", ".join(positional + named)
        print(f"-> {func.__name__}({arglist})")  # type: ignore
        result = func(*args, **kwargs)
        print(f"<- {func.__name__} = {result!r}")  # type: ignore
        return result
    return wrapper

@trace
def add(a: int, b: int) -> int:
    return a + b

if __name__ == "__main__":
    add(2, b=3)
#: -> add(2, b=3)
#: <- add = 5

functools.wraps copies the original function’s metadata onto the wrapper: its name, docstring, and other attributes. Without it, the decorated add reports its name as wrapper and loses its docstring, misleading debuggers, help(), and documentation tools. wraps is optional, and reasons to omit it are rare. wraps also sets __wrapped__ on the wrapper, pointing at the original, so add.__wrapped__(2, 3) calls the function without the tracing, and inspect.signature() follows that chain automatically.

wraps keeps the runtime interface. The type parameters (introduced in Static Types) keep the static one. trace[**P, R] declares two of them. R is the wrapped function’s return type. **P is a parameter specification (ParamSpec). It captures the whole parameter list of the wrapped function as a single unit, names and types included. func: Callable[P, R] reads as “a function whose parameters are P and whose result is R,” and returning Callable[P, R] declares that the wrapper has that same signature.

Inside the wrapper, *args: P.args and **kwargs: P.kwargs are the two halves of that captured list. P.args is the positional part and P.kwargs the keyword part. You may only use them together, as the *args and **kwargs of a function typed with P. They bind the wrapper’s arguments to the parameters captured by **P, so the type checker accepts add(2, 3) but rejects add("x") or add(2, 3, 4), even though the body of wrapper() forwards anything. Without **P you fall back to *args: Any, **kwargs: Any, and the wrapper swallows any arguments, discarding the signature the decorator should preserve.

The # type: ignore comments mark where ty cannot follow: a Callable need not have a __name__ attribute, though every function does. Pyright and mypy both accept the attribute here.

trace assumes func runs to completion inside the call that invokes it, which is true of an ordinary function and false of an async def function. Decorating a coroutine function raises no exception. func(*args, **kwargs) returns a coroutine object immediately, without running the coroutine’s body, so result holds that coroutine object rather than the value the coroutine will eventually produce. The trace line then prints <- add = <coroutine object add at 0x...>. A wrapper over a coroutine function must itself be async def and await func(*args, **kwargs), the shape covered in async def, await, and the Event Loop.

The tests verify two things: the wrapper reports the original function’s name, and it still returns the original result:

# test_tracer.py
from tracer import trace

def test_trace_preserves_name() -> None:
    @trace
    def add(a: int, b: int) -> int:
        return a + b

    assert add.__name__ == "add"

def test_trace_returns_original_result() -> None:
    @trace
    def add(a: int, b: int) -> int:
        return a + b

    assert add(2, 3) == 5

Decorators That Take Arguments

To pass arguments to a decorator, add another layer. A decorator with arguments is a function that returns a decorator:

# repeat.py
from collections.abc import Callable
from functools import wraps

def repeat[**P, R](
        times: int
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    if times < 1:
        raise ValueError(f"times must be >= 1, got {times}")
    def decorate(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorate

@repeat(times=3)
def greet(name: str) -> str:
    print(f"Hello, {name}")
    return name

if __name__ == "__main__":
    greet("Bob")
#: Hello, Bob
#: Hello, Bob
#: Hello, Bob

Take the return type apart: Callable[[Callable[P, R]], Callable[P, R]]. Callable[[A, B], X] reads as “a callable that takes A and B and returns X” (see the summary in Static Types). The first bracket group is a list holding the parameter types, so [Callable[P, R]] is a parameter list of length one, not a list of callables. That single parameter type is Callable[P, R], the wrapped function’s type. So the whole annotation reads as “a callable that takes a Callable[P, R] and returns a Callable[P, R].” That describes decorate, which takes func and returns wrapper, both typed Callable[P, R].

@repeat(times=3) first evaluates repeat(times=3). That returns decorate, the real decorator, and Python then calls decorate(greet). Only now does decorate’s own body run, including its @wraps(func) line. @wraps(func) is the same two-step pattern one level down: call wraps(func) to get a decorator, then apply it to wrapper. The inner decoration runs inside the outer one, so @repeat(times=3) and @wraps(func) are two separate applications of the same two-step pattern, not one recursive call. The nesting stops at two levels, matching the two nested defs in the source.

Forgetting the parentheses is the common mistake here. @repeat without them calls repeat(greet), passing the function where repeat expects times. The times < 1 check turns that into a TypeError at decoration, since a function does not support < 1, though the message says nothing about parentheses. A repeat without that comparison would fail silently. Python would bind greet to decorate. Calling greet("Bob") would then pass "Bob" where decorate expects a function and hand back a wrapper, and the only symptom would be missing output. The annotations catch the mistake either way, at the decoration rather than at the call: ty reports that repeat expected an int for times and got a function. A second diagnostic follows at greet("Bob"), but that one is harder to read back to the missing ().

repeat() rejects times below one rather than quietly rounding it up to one. The check runs at decoration, so the failure appears at the @ line rather than at some later call. Because that check already guarantees times >= 1, wrapper()’s loop always runs at least once, so result always holds a value of type R to return. test_repeat.py parametrizes over times and covers the rejection:

# test_repeat.py
import pytest
from repeat import repeat

@pytest.mark.parametrize("times, expected", [
    (3, 3),
    (1, 1),
])
def test_repeat_call_count(times: int,
                           expected: int) -> None:
    calls: list[str] = []

    @repeat(times=times)
    def record() -> None:
        calls.append("call")

    record()
    assert len(calls) == expected

@pytest.mark.parametrize("times", [0, -1])
def test_repeat_rejects_times_below_one(times: int) -> None:
    with pytest.raises(ValueError):
        repeat(times=times)

Decorators With Optional Parentheses

trace never takes arguments; repeat always does. A decorator can support both conventions at once, @name and @name(...), letting a caller add arguments only when the defaults don’t fit. pytest.fixture and click.command both work this way.

The two forms differ in what Python passes first. @label calls label(one): a function arrives as the only argument. @label(prefix="TAG") calls label(prefix="TAG") first, with no function, then applies the result to two. Checking whether that first argument is callable tells the two calls apart:

# optional_parens.py
from collections.abc import Callable
from functools import wraps
from typing import Any, overload

@overload
def label[**P, R](
        func: Callable[P, R],
) -> Callable[P, R]: ...
@overload
def label[**P, R](
        func: None = None, *, prefix: str = "LOG"
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...

def label[**P, R](
        func: Callable[P, R] | None = None,
        *, prefix: str = "LOG",
) -> Any:
    def decorate(
            f: Callable[P, R]
    ) -> Callable[P, R]:
        @wraps(f)
        def wrapper(
                *args: P.args, **kwargs: P.kwargs
        ) -> R:
            print(f"[{prefix}] {f.__name__}")  # type: ignore
            return f(*args, **kwargs)
        return wrapper
    return decorate(func) if callable(func) else decorate

@label
def one() -> None: ...

@label(prefix="TAG")
def two() -> None: ...

if __name__ == "__main__":
    one()
    two()
#: [LOG] one
#: [TAG] two

func defaults to None, and the body branches on callable(func). Called bare, func is one itself, callable(func) is True, so label decorates it immediately by calling decorate(func). Called with arguments, func stays None, callable(func) is False, so label returns decorate for Python to apply to two. The two @overload declarations tell the type checker the same story the runtime branch tells: given a function, label returns a function of the same signature; given only keyword arguments, it returns a decorator. The implementation must satisfy both overloads, so it declares the widest return type, Any, and the overloads narrow that back down at every call site.

This idiom assumes that only the decorated function can arrive in that first position. Where a decorator’s own argument could itself be callable, checking func is None instead of callable(func) removes the ambiguity.

# test_optional_parens.py
from optional_parens import label

def test_bare_decoration() -> None:
    @label
    def greet() -> str:
        return "hi"

    assert greet() == "hi"
    assert greet.__name__ == "greet"

def test_decoration_with_arguments() -> None:
    @label(prefix="TAG")
    def greet() -> str:
        return "hi"

    assert greet() == "hi"
    assert greet.__name__ == "greet"

Stacking Decorators

A decorator can wrap the wrapper another decorator produced. Decorators stack, nesting from the bottom up:

# stacking.py
from repeat import repeat
from tracer import trace

@trace
@repeat(times=2)
def greet(name: str) -> str:
    print(f"Hello, {name}")
    return name

if __name__ == "__main__":
    greet("Bob")
#: -> greet('Bob')
#: Hello, Bob
#: Hello, Bob
#: <- greet = 'Bob'

The two @ lines mean greet = trace(repeat(times=2)(greet)). @repeat(times=2) wraps greet() first, then @trace wraps that result, so a single greet("Bob") traces one call whose body runs twice. Each decorator wraps the result of the one below it. Stacking works because each wrapper preserves the interface of what it wraps: every layer looks like the original function, so the layers compose to any depth.

test_stacking.py confirms both claims: the name survives two layers of wrapping, and the inner decorator still repeats the body once per outer call:

# test_stacking.py
from repeat import repeat
from tracer import trace

def test_stacked_decorators_preserve_name() -> None:
    @trace
    @repeat(times=2)
    def greet(name: str) -> str:
        return name

    assert greet.__name__ == "greet"

def test_stacked_decorators_repeat_the_call() -> None:
    calls: list[str] = []

    @trace
    @repeat(times=2)
    def record() -> None:
        calls.append("call")

    record()
    assert calls == ["call", "call"]

Decorators as Classes

A decorator is any callable that accepts one argument. A class with __call__() is a callable, so a decorator can be a class instead of a function. The class form separates the two phases cleanly: the constructor runs once, at decoration, and __call__() runs on every call to the decorated function.

These classes take lowercase names, against the usual PascalCase rule, because a decorator reads like a function at the call site. property, staticmethod, and functools.partial are all lowercase classes for that reason.

A Stateless Class Decorator

The class version of trace:

# trace_class.py
from collections.abc import Callable
from functools import update_wrapper

class trace[**P, R]:
    __name__: str  # Set by update_wrapper(), not __init__

    def __init__(self, func: Callable[P, R]) -> None:
        self.func = func
        # Copy __name__, __doc__, etc
        update_wrapper(self, func)

    def __call__(self, *args: P.args,
                 **kwargs: P.kwargs) -> R:
        positional = [repr(a) for a in args]
        named = [f"{k}={v!r}" for k, v in kwargs.items()]
        arglist = ", ".join(positional + named)
        print(f"-> {self.func.__name__}({arglist})")  # type: ignore
        result = self.func(*args, **kwargs)
        print(f"<- {self.func.__name__} = {result!r}")  # type: ignore
        return result

@trace
def add(a: int, b: int) -> int:
    return a + b

if __name__ == "__main__":
    add(2, b=3)
#: -> add(2, b=3)
#: <- add = 5

@trace runs add = trace(add), so the constructor receives the function and stores it. The name add now refers to a trace instance, and calling add(2, 3) invokes __call__().

functools.update_wrapper(self, func) copies func’s metadata onto an existing object. wraps is the decorator form of that same call: @wraps(func) above def wrapper runs update_wrapper(wrapper, func). The class form has no inner function to decorate, only self, so it calls update_wrapper() directly.

Like the function form, the class is generic in **P and R, so __call__() keeps the wrapped signature and add(2, 3) still type-checks as an int.

# test_trace_class.py
from trace_class import trace

def test_trace_preserves_name() -> None:
    @trace
    def add(a: int, b: int) -> int:
        return a + b

    assert add.__name__ == "add"

def test_trace_returns_original_result() -> None:
    @trace
    def add(a: int, b: int) -> int:
        return a + b

    assert add(2, 3) == 5

A Class Decorator with State

Because the instance can hold attributes, it can carry state between calls. This decorator counts calls and keeps the count on the instance:

# count_calls.py
from collections.abc import Callable
from functools import update_wrapper

class count_calls[**P, R]:
    def __init__(self, func: Callable[P, R]) -> None:
        self.func = func
        self.count = 0
        update_wrapper(self, func)

    def __call__(self, *args: P.args,
                 **kwargs: P.kwargs) -> R:
        self.count += 1
        print(f"call {self.count} of {self.func.__name__}")  # type: ignore
        return self.func(*args, **kwargs)

@count_calls
def hello() -> None:
    print("hello")

if __name__ == "__main__":
    hello()
    hello()
    # The state lives on the decorator instance
    print(hello.count)
#: call 1 of hello
#: hello
#: call 2 of hello
#: hello
#: 2

Each @count_calls creates its own instance, so the count on one decorated function never leaks into another:

# test_count_calls.py
from count_calls import count_calls

def test_counts_are_independent_per_function() -> None:
    @count_calls
    def greet() -> None:
        pass

    @count_calls
    def farewell() -> None:
        pass

    greet()
    greet()
    farewell()

    assert greet.count == 2
    assert farewell.count == 1

A Class Decorator with Arguments

The class form pays off when the decorator takes arguments. Without arguments, the constructor receives the function. With arguments, the constructor receives the arguments, and __call__() receives the function and returns the wrapper:

# repeat_class.py
from collections.abc import Callable
from functools import wraps

class repeat:
    def __init__(self, times: int) -> None:
        if times < 1:
            raise ValueError(
                f"times must be >= 1, got {times}")
        self.times = times  # The decoration arguments

    def __call__[**P, R](
        self, func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            result = func(*args, **kwargs)
            for _ in range(self.times - 1):
                result = func(*args, **kwargs)
            return result
        return wrapper

@repeat(times=3)
def greet(name: str) -> None:
    print(f"Hello, {name}")

if __name__ == "__main__":
    greet("Bob")
#: Hello, Bob
#: Hello, Bob
#: Hello, Bob

repeat here validates times the same way repeat.py does, in the constructor rather than in the outer function. With decorator arguments, the class form is typically easier to reason about than the function form. test_repeat_class.py checks the same cases:

# test_repeat_class.py
import pytest
from repeat_class import repeat

@pytest.mark.parametrize("times, expected", [
    (3, 3),
    (1, 1),
])
def test_repeat_call_count(times: int,
                           expected: int) -> None:
    calls: list[str] = []

    @repeat(times=times)
    def record() -> None:
        calls.append("call")

    record()
    assert len(calls) == expected

@pytest.mark.parametrize("times", [0, -1])
def test_repeat_rejects_times_below_one(times: int) -> None:
    with pytest.raises(ValueError):
        repeat(times=times)

A Limitation: Methods Need a Descriptor

The class form has one limitation: a method decorated this way becomes an instance rather than a function, and the call then fails. trace and count_calls above decorated bare functions on purpose:

# method_decoration.py
from collections.abc import Callable
from exceptions import expect

class logged:
    def __init__(self, func: Callable) -> None:
        self.func = func

    def __call__(self, *args: object,
                 **kwargs: object) -> object:
        return self.func(*args, **kwargs)

class Ex:
    @logged
    def method(self, x: int) -> int:
        return x

ex = Ex()
expect(TypeError, ex.method, 5)
#: [TypeError] Ex.method() missing 1 required positional
#: argument: 'x'

Ex.method is a logged instance, stored as a class attribute. Python binds a class attribute to the instance only when that attribute is a descriptor, an object with a __get__() method, and every ordinary function is one. A logged instance has no __get__(), so ex.method hands back the instance unbound, and ex.method(5) really calls logged.__call__(logged_instance, 5). self.func runs with 5 as its only argument, so 5 fills method’s own self parameter and leaves nothing for x. The Ex instance never arrives. The TypeError blames a missing x, with no hint that the real cause is a missing __get__(). Metaprogramming shows the descriptor protocol, which a class-based decorator must implement to work on methods.

A function is already a descriptor, so wrapper() in the function form binds to an instance like any other method:

# method_function_form.py
from tracer import trace

class Ex:
    @trace
    def method(self, x: int) -> int:
        return x

    def __repr__(self) -> str:
        return "Ex()"

ex = Ex()
print(ex.method(5))
#: -> method(Ex(), 5)
#: <- method = 5
#: 5

self arrives as the first traced argument, and the __repr__() the class defines prints it as Ex(), so the same decoration that failed as a class works here with no descriptor of your own. For the same reason, repeat_class.repeat escapes the limitation: its __call__() returns wrapper, an ordinary function, so a method decorated with @repeat(times=3) is still a function. A fully typed class-based decorator, like trace_class.trace, gets the type checker involved: ty reports a missing argument and a type mismatch on a call like ex.method(5), catching the same problem.

Function Form or Class Form?

Compare the two class-form cases. @trace with no arguments calls trace(add). The function goes straight to the constructor. @repeat(times=3) calls repeat(times=3) first, producing an instance, then applies that instance to greet. The arguments go to the constructor, and the function arrives later, at __call__(). Gaining arguments moves the function’s arrival from __init__() to __call__(). The function form hides this shift inside an extra nested def. The class form makes it visible.

Outside of methods, the form you choose is mostly a matter of taste. Both forms preserve the wrapped function’s exact signature for the type checker, using the same **P and R type parameters, and both stack, the way stacking.py stacks two function-form decorators. The function form is more compact. The class form reads better when the decorator carries state or grows complicated, because the phases are separate methods instead of nested closures.

The class form with arguments scales up to small frameworks. A build tool can offer a @rule(target, *deps) decorator whose constructor records the target and its dependencies, and whose __call__() registers the decorated function in a class-level table with that metadata. A driver walks the table later and runs the rules in order, so the decorator becomes the registration mechanism for the whole system.

The descriptor limitation is the one hard reason to choose the function form. A decorator meant for methods either returns a function, as the function form and repeat_class.repeat both do, or implements __get__().

A context manager can also decorate a function, bracketing every call with its setup and cleanup code. Context Managers shows contextlib.ContextDecorator.

Decorating Classes

Everything so far decorated a function. A class statement takes a decorator the same way, and the decorator receives the class object. Decorating a class differs from Decorators as Classes, where the class is the decorator. Here the decorator is an ordinary function, and the class is the thing decorated. This one registers every class it decorates in registry:

# register.py

registry: dict[str, type] = {}

def register[T](cls: type[T]) -> type[T]:
    registry[cls.__name__] = cls
    return cls

@register
class Espresso:
    ...

@register
class Latte:
    ...

if __name__ == "__main__":
    print(sorted(registry))
#: ['Espresso', 'Latte']

register() returns cls unchanged, so this decoration adds no wrapper. register() exists for the side effect of recording the class. The type parameter T does for a class decorator what **P and R do for a function decorator. If register’s annotation were (cls: type) -> type, it would hand back a bare type, and ty and Pyright would see Espresso() as an Any. A class decorator can also return a replacement class, just as a function decorator returns a replacement function.

A registry filled this way is as complete as the imports that ran: a class in a module nobody imported never registers. Keying on cls.__name__ also means two same-named classes from different modules overwrite each other. Factory returns to both.

Metaprogramming shows __init_subclass__(), which builds a registry like this without a decorator.

# test_register.py
from register import Espresso, Latte, register, registry

def test_register_returns_same_class() -> None:
    assert register(Espresso) is Espresso

def test_registry_looks_up_by_name() -> None:
    assert registry["Espresso"] is Espresso
    assert registry["Latte"] is Latte

What @ Does Not Require

@ constrains the statement below it and nothing else. A decorator line must sit directly above a def or a class. @decorator above a bare assignment, or above a type alias, is a syntax error rather than a decorator applied to something unusual. Past that, @ places no requirement on the callable it hands over. The callable you decorate can come from somewhere other than a def:

# lambda_decoration.py
from collections.abc import Callable

def report(
        func: Callable[[int], int]) -> Callable[[int], int]:
    def wrapper(n: int) -> int:
        print(f"Calling {func.__name__} with {n}")  # type: ignore
        return func(n)
    return wrapper

double = report(lambda n: n * 2)

@report
def triple(n: int) -> int:
    return n * 3

if __name__ == "__main__":
    print(double(21))
    print(triple(21))
#: Calling <lambda> with 21
#: 42
#: Calling triple with 21
#: 63

report requires a callable; where func came from does not matter. Calling it directly, instead of through @, decorates the lambda in place. @ is convenient sugar for the common case of decorating a fresh def, not a requirement. The same call decorates a functools.partial, a bound method, or an instance of a class with __call__(), since all a decorator receives is a callable. Calling the result is another matter: report’s wrapper reads func.__name__, which a partial and a callable instance lack, so those two raise an AttributeError at the call, while the function and the bound method run.

The return side is equally unconstrained. This chapter opened by saying a decorator “returns a result, which Python binds to the original name.” That result need not be callable:

# run_once.py
from collections.abc import Callable

def run_once[T](func: Callable[[], T]) -> T:
    return func()

@run_once
def greeting() -> str:
    return "Hello, world"

if __name__ == "__main__":
    print(greeting)
    print(type(greeting).__name__)
#: Hello, world
#: str

run_once calls greeting immediately, at decoration time, and hands back whatever greeting() returned. After decoration the name greeting refers to that str, so greeting() raises a TypeError: a str is not callable. This idiom pays off for a value that needs one-time setup logic but stays constant afterward. For anything simpler, a module-level constant computed the ordinary way reads better.

Classes collapse the same way. Singleton replaces a class with a callable object that stands in for it: the first call constructs one instance, and every later call returns that same instance. The name that follows class then refers to an object, not a type.

The Decorator Pattern

The @ syntax decorates a function or class once, at definition, so every call or every instance gets the wrapping. Sometimes you want to choose later: add responsibilities to individual objects at runtime, and let each caller decide which responsibilities to add. That is the object-oriented Decorator pattern.

Consider a pizza shop. A class for every pizza-and-topping combination explodes: Margherita, Margherita with olives, Margherita with olives and feta, and so on. Each new topping doubles the menu.

Instead, model the toppings as decorators. A plain pizza knows its own cost and description. A topping dynamically wraps a pizza, adds to the cost, and adds to the description. Because a topping is a pizza, you can wrap a topping in another topping.

Margherita and Hawaiian satisfy Pizza directly; Topping wraps a Pizza and also satisfies it, so Garlic, Olives, and Feta can wrap any pizza, including each other
# pizza_decorator.py
from typing import ClassVar, Protocol

class Pizza(Protocol):
    @property
    def cost(self) -> float: ...
    @property
    def description(self) -> str: ...

class Margherita:
    cost = 8.00
    description = "Margherita"

class Hawaiian:
    cost = 9.50
    description = "Hawaiian"

class Topping:
    add_cost: ClassVar[float] = 0.0

    def __init__(self, pizza: Pizza) -> None:
        self.pizza = pizza
        self.name = type(self).__name__

    @property
    def cost(self) -> float:
        return self.pizza.cost + self.add_cost

    @property
    def description(self) -> str:
        return f"{self.pizza.description} + {self.name}"

class Garlic(Topping):
    add_cost = 0.50

class Olives(Topping):
    add_cost = 0.75

class Feta(Topping):
    add_cost = 1.25

if __name__ == "__main__":
    order = Feta(Olives(Margherita()))
    print(f"{order.description}: ${order.cost:.2f}")

    haw = Garlic(Feta(Hawaiian()))
    print(f"{haw.description}: ${haw.cost:.2f}")
#: Margherita + Olives + Feta: $10.00
#: Hawaiian + Feta + Garlic: $11.25

Feta(Olives(Margherita())) is the object version of stacked @ decorators. Each topping wraps the pizza inside it and forwards through the same two-property interface, cost and description. The Pizza Protocol describes that interface. Both the plain pizzas and the toppings satisfy it structurally, with no shared base class required. This is structural typing. A read-only @property in a Protocol requires that reading the name produce that type, and says nothing about how. Margherita supplies cost as a class attribute and Topping computes it in a property. Both read as a float, so both match.

Topping.__init__() sets self.name = type(self).__name__, reading each subclass’s own name at construction time instead of repeating it as a string. Garlic, Olives, and Feta never mention their own names. The class name is the topping name.

Adding a new topping means adding one class with one line, add_cost. Changing the price of a topping means changing one number, in one place. Compare that to a class per combination, where a price change touches every class that includes that topping.

A Pizza with a toppings: list[Topping] field, summing each topping’s add_cost and joining its name, solves the same combinatorial problem, with no wrapping and no Protocol. Here, where a topping only contributes a number and a name, that list is the simpler design. The Decorator pattern earns its structure when a topping needs behavior, not just data: one that changes how cost rounds, adds a description only under some condition, or must itself be handed elsewhere as a Pizza. The list stores toppings; the decorator chain is one, each layer still satisfying the same interface the plain pizzas do.

Factory has its own Pizza, a frozen data class that a PizzaBuilder assembles, to illustrate the unrelated Builder pattern. The two examples share a topic, not a type.

A Decorator keeps the wrapped object’s interface and adds behavior. Proxy, Adapter, and Façade wrap the same way and differ in intent. Telling the Wrappers Apart sorts the four.

# test_pizza_decorator.py
import pytest
from pizza_decorator import Feta, Garlic, Margherita, Olives

def test_stacked_toppings() -> None:
    order = Feta(Olives(Margherita()))
    assert order.cost == pytest.approx(10.00)
    assert order.description == (
        "Margherita + Olives + Feta")

def test_single_topping() -> None:
    order = Garlic(Margherita())
    assert order.cost == pytest.approx(8.50)
    assert order.description == "Margherita + Garlic"

Decorators You Already Know

Several decorators elsewhere in this book use this mechanism. @property, @cached_property, @staticmethod, and @classmethod (see Properties and Static and Class Methods) each wrap a function the same way trace does, but return a descriptor instead of a plain wrapper. That lets them change how attribute access behaves, and makes them work on methods, where a __call__-based class like logged fails. @dataclass (see Data Classes as Types) is a class decorator like register, except it mutates the class instead of leaving it unchanged, adding a generated __init__(), __repr__(), and __eq__() to the same object it received. @functools.cache and @functools.lru_cache (see Performance) wrap a function in the same closure-plus-func shape as add_behavior, storing results in a memo dictionary instead of printing around the call. Understanding any of these needs no new syntax. They are ordinary decorators. The one piece of machinery left for later is the descriptor protocol those first four return; Metaprogramming takes it up.

Every decorator costs two things wraps does not remove, since wraps copies metadata, not the call itself. A traceback through a decorated function shows wrapper, one more frame than the caller and the original body alone would show. Each call also pays for an extra Python-level function call, the wrapper’s own, before the real body runs. Neither matters for a function called occasionally; both add up for one called in a tight loop, and stacking decorators multiplies both by the number of layers.

Exercises

  1. Write a class decorator announce that prints the name of each class it decorates and returns it unchanged, then apply it to two small classes. Compare what it can do to what register does.
  2. Write a timing decorator that prints how long the wrapped function took, using time.perf_counter(). Apply it together with @trace and predict the order of the output.
  3. Implement the object-oriented Decorator pattern for a coffee shop: plain drinks (Espresso, Cappuccino) and extra decorators (Whipped cream, Decaf, Extra shot). Build an espresso decorated with an extra shot and whipped cream, then print its cost and description.
  4. Write trace as a class-based decorator that also keeps a class-level counter shared across every decorated function, and report the total number of traced calls in the program. Note where the shared state lives compared to the per-instance count in count_calls.
  5. Write a memo decorator that works both with and without parentheses, so @memo and @memo(maxsize=10) both decorate a function. Cache each result in a dictionary keyed by the arguments, and drop the oldest entry once the cache holds more than maxsize of them. Distinguish the two forms by checking whether the first argument arrived at all.
  6. Write a retry(times) decorator in the function form that calls the wrapped function again when it raises an exception, up to times attempts, and re-raises the last exception when they all fail. Check that __name__ survives.