Skip to main content

Decorators and Callable Wrappers

A decorator receives an object and returns the object that its name will refer to after decoration; it need not return a different object. A function decorator often returns a wrapper that calls the original function. Below, traced creates such a wrapper: each call prints the original function’s name, forwards the arguments, and returns its result. The type notation describes that forwarding: P represents the original parameters, R its return type, and Callable[P, R] a callable with those parameters and return type.

from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def traced(function: Callable[P, R]) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
name = getattr(function, "__qualname__", type(function).__qualname__)
print(f"calling {name}")
return function(*args, **kwargs)
return wrapper

functools.wraps preserves metadata and exposes __wrapped__, which matters to documentation, introspection, debugging, and other decorators. The type parameters preserve the wrapped callable's static signature.

Evaluation and composition​

Decorator expressions are evaluated when the containing definition executes, usually during module import. Keep registration and configuration side effects predictable.

Stacked decorators apply from the inside out:

@outer
@inner
def operation(): ...

# Equivalent to: operation = outer(inner(operation))

Order therefore changes behavior for caching, retries, authentication, transactions, and logging.

Configured decorators​

A decorator with arguments is a factory that returns the actual decorator:

class TransientError(Exception):
pass

def retry(*, attempts: int):
if attempts < 1:
raise ValueError("attempts must be positive")

def decorate(function):
@wraps(function)
def wrapper(*args, **kwargs):
for attempt in range(attempts):
try:
return function(*args, **kwargs)
except TransientError:
if attempt == attempts - 1:
raise
return wrapper
return decorate

A retry policy must identify recoverable failures and whether repeated execution is safe. A generic decorator cannot infer those domain rules.

Design boundaries​

  • Match the wrapped callable kind. A synchronous wrapper around an async function returns a coroutine without awaiting it; generators have similar lifecycle concerns.
  • Do not hide important control flow, I/O, or broad exception suppression behind decorative syntax.
  • lru_cache requires hashable arguments and is appropriate only when reusing a result is semantically safe. Mutable external state and time-dependent results violate that assumption.
  • property, classmethod, staticmethod, contextmanager, and singledispatch use decorator syntax but define different contracts. Learn each API rather than treating every decorator as a wrapper.

Calling a decorated function​

The typed example requires Python 3.10+ for ParamSpec. After defining traced, try:

@traced
def add(left: int, right: int = 1) -> int:
return left + right

assert add(2, right=3) == 5
assert add.__name__ == "add"
assert add.__wrapped__(2, 3) == 5

Only the first call prints calling add. *args and **kwargs forward positional and keyword arguments; returning the result preserves the caller's return contract. wraps does not enforce types or turn a synchronous wrapper into an asynchronous one.

In the retry example, TransientError is a demonstration exception: substitute the specific recoverable error from the real operation. Retrying a generator function only retries creation of the generator, not failures raised later during iteration. Likewise, an async operation's failure occurs when awaited. Backoff and jitter are useful for contended remote services, not requirements for every local retry; first decide whether repeating a partly completed operation is safe.

Source​

Explore connectionsOpen network