Decorators and Metaprogramming

Updated

September 7, 2026

Decorators and Metaprogramming

After reading this chapter, you will master Python decorators from first principles, preserve function introspection with @functools.wraps, construct parameterized decorator factories, implement class decorators, and use @classmethod and @staticmethod appropriately.

Mental model

A decorator in Python is syntactic sugar for passing a function (or class) into a higher-order function, and rebinding the original name to the returned callable.

Syntactic Sugar:
  @log_execution
  def query_db(query_str):
      ...

Is Bit-for-Bit Equivalent To:
  def query_db(query_str):
      ...
  query_db = log_execution(query_db)

The Wrapper Wrapping Pipeline

Caller executes query_db("SELECT 1")
             │
             ▼
      [ wrapper(*args, **kwargs) ]
             │
             ├─ Pre-execution logic (start timer, log entry)
             ├─ Result = original_func(*args, **kwargs)
             ├─ Post-execution logic (stop timer, record metrics)
             │
             ▼
      Return Result to Caller

Minimal example

Save as decorators_fundamentals.py:

# decorators_fundamentals.py
import functools
import time
from collections.abc import Callable
from typing import Any

def measure_latency(func: Callable[..., Any]) -> Callable[..., Any]:
    """Decorator that measures and prints the execution latency of any function."""
    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        start_time = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed_ms = (time.perf_counter() - start_time) * 1000
            print(f"[{func.__name__}] Executed in {elapsed_ms:.2f} ms")
    return wrapper

@measure_latency
def compute_hash_digest(payload: str) -> int:
    """Compute an integer checksum from payload."""
    total = 0
    for char in payload:
        total = (total * 31 + ord(char)) & 0xFFFFFFFF
    return total

def main() -> None:
    digest = compute_hash_digest("production-event-payload-string")
    print(f"Computed digest: {digest}")
    print(f"Function name  : {compute_hash_digest.__name__}")
    print(f"Docstring      : {compute_hash_digest.__doc__}")

if __name__ == "__main__":
    main()

Run via uv run python decorators_fundamentals.py:

[compute_hash_digest] Executed in 0.01 ms
Computed digest: 1475752251
Function name  : compute_hash_digest
Docstring      : Compute an integer checksum from payload.

Worked examples

Case 1: Parameterized Decorator Factory (@retry)

When a decorator requires configuration arguments (@retry(max_attempts=3, backoff=0.05)), you must create a three-tier factory function:

# retry_decorator.py
import functools
import time
from collections.abc import Callable
from typing import Any

def retry(max_attempts: int = 3, backoff: float = 0.05) -> Callable[..., Any]:
    """Decorator factory that retries an operation on failure with exponential backoff."""
    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
        @functools.wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            attempts = 0
            while True:
                attempts += 1
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    if attempts >= max_attempts:
                        print(f"[{func.__name__}] Failed after {attempts} attempts. Raising error.")
                        raise
                    sleep_duration = backoff * (2 ** (attempts - 1))
                    print(f"[{func.__name__}] Attempt {attempts} failed: {exc}. Retrying in {sleep_duration:.2f}s...")
                    time.sleep(sleep_duration)
        return wrapper
    return decorator

# Flaky mock service
call_counter = 0

@retry(max_attempts=3, backoff=0.02)
def query_payment_gateway() -> str:
    global call_counter
    call_counter += 1
    if call_counter < 3:
        raise ConnectionResetError("Transient network blip")
    return "PAYMENT_CONFIRMED"

if __name__ == "__main__":
    result = query_payment_gateway()
    print(f"Final outcome: {result}")

Run:

uv run python retry_decorator.py

Output:

[query_payment_gateway] Attempt 1 failed: Transient network blip. Retrying in 0.02s...
[query_payment_gateway] Attempt 2 failed: Transient network blip. Retrying in 0.04s...
Final outcome: PAYMENT_CONFIRMED

Case 2: Method Decorators: @classmethod vs @staticmethod

  • @classmethod: Receives the class object (cls) as its first argument. Ideal for alternative factory constructors.
  • @staticmethod: Receives neither self nor cls. Acts as a plain function grouped inside the class namespace.
# class_and_static_methods.py
class IPAddress:
    def __init__(self, octets: tuple[int, int, int, int]) -> None:
        self.octets = octets

    @classmethod
    def from_string(cls, ip_str: str) -> "IPAddress":
        """Alternative factory constructor parsing a dotted-quad string."""
        parts = tuple(int(p) for p in ip_str.split("."))
        if len(parts) != 4 or any(not (0 <= o <= 255) for o in parts):
            raise ValueError(f"Invalid IPv4 string: {ip_str}")
        return cls(parts)  # type: ignore[arg-type]

    @staticmethod
    def is_private_range(first_octet: int) -> bool:
        """Utility check requiring neither instance nor class state."""
        return first_octet in (10, 172, 192)

    def __repr__(self) -> str:
        return f"IPAddress({'.'.join(str(o) for o in self.octets)})"

if __name__ == "__main__":
    ip = IPAddress.from_string("192.168.1.1")
    print("Constructed IP:", repr(ip))
    print(f"Is private?   : {IPAddress.is_private_range(ip.octets[0])}")

Run:

uv run python class_and_static_methods.py

Output:

Constructed IP: IPAddress(192.168.1.1)
Is private?   : True

Case 3: Class Decorators for Automated Registration

Decorators can wrap classes as well as functions, allowing automatic registration into a centralized registry:

# plugin_registry.py
from typing import Any

PLUGIN_REGISTRY: dict[str, type] = {}

def register_plugin(plugin_name: str) -> Any:
    def class_decorator(cls: type) -> type:
        PLUGIN_REGISTRY[plugin_name] = cls
        return cls
    return class_decorator

@register_plugin("json_exporter")
class JSONExporter:
    def export(self, data: Any) -> str:
        return f"Exported {data} as JSON"

@register_plugin("csv_exporter")
class CSVExporter:
    def export(self, data: Any) -> str:
        return f"Exported {data} as CSV"

if __name__ == "__main__":
    print("Registered plugins:", list(PLUGIN_REGISTRY.keys()))
    exporter_cls = PLUGIN_REGISTRY["json_exporter"]
    exporter_instance = exporter_cls()
    print(exporter_instance.export({"status": "ok"}))

Run:

uv run python plugin_registry.py

Output:

Registered plugins: ['json_exporter', 'csv_exporter']
Exported {'status': 'ok'} as JSON

Pitfalls

Pitfall 1: Omitting @functools.wraps

Without @functools.wraps(func), the wrapper function overwrites the decorated function’s __name__, __doc__, and __annotations__. This breaks debugging tools, loggers, and test runners that rely on function reflection.

Pitfall 2: Decorator Definition-Time vs Execution-Time Confusion

Code in the outer decorator body runs immediately at import time when Python compiles the module. Only code inside wrapper() runs when the decorated function is actually called.


Exercises

  1. Write a decorator @log_arguments that prints the positional and keyword arguments passed into the decorated function upon each invocation.
  2. Implement an @enforce_types decorator that checks if the runtime types of arguments match their type annotations.
  3. Write a @singleton class decorator that ensures only one instance of the decorated class can ever be created.
  4. Create a @rate_limited(max_per_second=5) decorator using time.monotonic() to throttle fast callers.

Further reading

  • PEP 318: Decorators for Functions and Methods.
  • Python Standard Library: functools.wraps.
  • Python Language Reference: Function definitions.