Decorators, Generators, Iterators & Context Managers
Metaprogramming enables you to inspect, modify, and extend code dynamically. Learn how decorators inject logging, caching, and authentication into functions, why @functools.wraps is mandatory, how the Iterator Protocol powers loops, and how generators enable infinite data streams with yield.
1. How Decorators Work Under the Hood
A decorator is a higher-order function that takes a function as input, extends its behavior, and returns a new wrapper function. Writing @my_decorator above a function is syntactic sugar for:func = my_decorator(func).
import time
import functools
# Production execution timer decorator
def timing_decorator(func):
# @functools.wraps preserves original function's __name__, docstrings, and signature!
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.perf_counter()
result = func(*args, **kwargs)
duration = time.perf_counter() - start_time
print(f"[PERF] {func.__name__} executed in {duration:.4f} seconds.")
return result
return wrapper
@timing_decorator
def fetch_api_records(batch_size: int):
"""Simulates fetching external data."""
time.sleep(0.15)
return [f"record_{i}" for i in range(batch_size)]
records = fetch_api_records(500)
print("Function Name Preserved:", fetch_api_records.__name__) # 'fetch_api_records' (not 'wrapper')2. Parameterized Decorators (Decorators with Arguments)
To pass configuration arguments into a decorator (e.g. @retry(max_attempts=3)), you add an outer factory function that returns the actual decorator:
def retry(max_attempts: int = 3, delay_sec: float = 0.5):
"""Decorator factory that retries failing functions."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
while attempts < max_attempts:
try:
return func(*args, **kwargs)
except Exception as err:
attempts += 1
print(f"Attempt {attempts} failed: {err}. Retrying in {delay_sec}s...")
time.sleep(delay_sec)
raise RuntimeError(f"{func.__name__} failed after {max_attempts} attempts.")
return wrapper
return decorator
@retry(max_attempts=2, delay_sec=0.1)
def unstable_network_call():
raise ConnectionResetError("Socket timeout")
# Calling it will retry twice before raising RuntimeError!3. Generators & State Suspension with "yield"
When a regular function encounters return, its stack frame is destroyed and local variables are freed. When a generator encounters yield, execution is suspended, the state frame is frozen in RAM, and control returns to the caller until next() is invoked:
# Infinite Fibonacci Generator (Consumes constant memory!)
def fibonacci_stream():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci_stream()
first_six = [next(fib) for _ in range(6)]
print("First 6 Fibonacci numbers:", first_six) # [0, 1, 1, 2, 3, 5]
# Delegating to sub-generators with 'yield from'
def combined_pipeline():
yield from ["stage_1_init", "stage_1_clean"]
yield from ["stage_2_embed", "stage_2_save"]
for step in combined_pipeline():
print("Pipeline Step:", step)4. Custom Context Managers with @contextlib.contextmanager
Instead of writing a boilerplate class with __enter__ and __exit__, the standard library provides @contextmanager to convert a single generator into a with block:
from contextlib import contextmanager
@contextmanager
def temporary_database_transaction(db_connection):
print("BEGIN TRANSACTION;")
try:
yield db_connection # Code inside the 'with' block runs here!
print("COMMIT TRANSACTION;")
except Exception as err:
print(f"ROLLBACK TRANSACTION due to error: {err}")
raise
# Usage:
# with temporary_database_transaction(conn) as db:
# db.insert_records(data)Explore Related Tools
Hand-picked utilities and calculators related to this tool.
