Decorators and Metaprogramming
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.pyOutput:
[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 neitherselfnorcls. 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.pyOutput:
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.pyOutput:
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
- Write a decorator
@log_argumentsthat prints the positional and keyword arguments passed into the decorated function upon each invocation. - Implement an
@enforce_typesdecorator that checks if the runtime types of arguments match their type annotations. - Write a
@singletonclass decorator that ensures only one instance of the decorated class can ever be created. - Create a
@rate_limited(max_per_second=5)decorator usingtime.monotonic()to throttle fast callers.
Further reading
- PEP 318: Decorators for Functions and Methods.
- Python Standard Library:
functools.wraps. - Python Language Reference: Function definitions.