Python Decorators and Context Managers: Advanced Patterns
Decorators are functions that wrap other functions to add behavior without modifying their code. Context managers manage resources (files, connections, locks) ensuring cleanup when leaving a with block.
1. Basic decorators
import functools
def log_calls(func):
@functools.wraps(func) # Preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 4)
# Calling add((3, 4), {})
# add returned: 7
Why functools.wraps? Without it, add.__name__ would be "wrapper", breaking logging, documentation and introspection.
2. Parameterized decorators (decorator factory)
import functools, time
def retry(max_attempts=3, delay=1.0):
"""Retries a function on exception."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unstable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("Timeout")
return "Success"
result = unstable_api_call()
3. Timing and caching decorators
import functools, time
def timeit(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - t0:.4f} s")
return result
return wrapper
@timeit
def compute(n):
return sum(range(n))
compute(1_000_000)
# Memoization with functools.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Fast due to cache
# cache (Python 3.9+): unlimited size
from functools import cache
@cache
def factorial(n):
return 1 if n == 0 else n * factorial(n - 1)
4. Class decorators
import functools
def singleton(cls):
"""Ensures only one instance of the class exists."""
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Config:
def __init__(self):
self.debug = False
self.port = 8000
c1 = Config()
c2 = Config()
print(c1 is c2) # True
# @property, @classmethod, @staticmethod
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5/9)
@staticmethod
def is_valid(c):
return c >= -273.15
t = Temperature(100)
print(t.fahrenheit) # 212.0
t2 = Temperature.from_fahrenheit(32)
print(t2.celsius) # 0.0
5. Decorator as a class
import functools
class Validate:
"""Decorator that validates arguments are positive."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
def __call__(self, *args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Negative argument: {arg}")
return self.func(*args, **kwargs)
@Validate
def square_root(n):
return n ** 0.5
print(square_root(9)) # 3.0
square_root(-1) # ValueError
6. Context managers with contextlib
import contextlib, time
@contextlib.contextmanager
def timer(label="block"):
t0 = time.perf_counter()
try:
yield
finally:
print(f"[{label}] {time.perf_counter() - t0:.4f} s")
with timer("my operation"):
time.sleep(0.1)
# Yield a value to the with block
@contextlib.contextmanager
def temp_file(suffix=".tmp"):
import tempfile
from pathlib import Path
f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
path = Path(f.name)
f.close()
try:
yield path
finally:
if path.exists():
path.unlink()
print(f"Removed: {path.name}")
with temp_file(".csv") as path:
path.write_text("a,b,c\n1,2,3\n")
print(path.read_text())
# Auto-deleted when block exits
7. Context manager as a class
import sqlite3
class DBConnection:
"""Context manager for SQLite with auto commit/rollback."""
def __init__(self, db_path):
self.db_path = db_path
self.conn = None
def __enter__(self):
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
return self.conn
def __exit__(self, exc_type, exc_val, tb):
if exc_type is None:
self.conn.commit()
print("Transaction committed")
else:
self.conn.rollback()
print(f"Rolled back: {exc_val}")
self.conn.close()
return False # Don't suppress exceptions
with DBConnection(":memory:") as conn:
conn.execute("CREATE TABLE data (id INTEGER, value TEXT)")
conn.execute("INSERT INTO data VALUES (1, 'hello')")
# Auto commit and close
8. contextlib.suppress and ExitStack
import contextlib, io
# suppress: silently ignore specific exceptions
with contextlib.suppress(FileNotFoundError):
import os
os.unlink("file_that_doesnt_exist.txt")
# redirect_stdout: capture print output
output = io.StringIO()
with contextlib.redirect_stdout(output):
print("This goes to the buffer")
print("And this too")
print(f"Captured: {output.getvalue()!r}")
# ExitStack: dynamic number of context managers
from contextlib import ExitStack
files = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
handles = [stack.enter_context(open(f, "w")) for f in files]
for i, fh in enumerate(handles):
fh.write(f"File {i}\n")
# All files closed automatically
9. Stacking decorators
import functools, time, logging
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def log_it(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info("Start: %s", func.__name__)
result = func(*args, **kwargs)
logger.info("End: %s", func.__name__)
return result
return wrapper
def timeit(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
logger.info("%s took %.4f s", func.__name__, time.perf_counter() - t0)
return result
return wrapper
# Decorators apply bottom-up
@log_it
@timeit
def process(n):
return sum(range(n))
process(1_000_000)
10. Best practices
- Always use
@functools.wrapsto preserve the original function's metadata. - Keep decorators focused: if one does too much, split it.
- Use
@contextlib.contextmanagerfor simple context managers; use__enter__/__exit__classes for complex logic or state. contextlib.suppressfor expected exceptions — more readable than a bare try/except.- Chain context managers on one line:
with open(a) as fa, open(b) as fb:. ExitStackwhen the number of context managers is not known at compile time.
Related conversions
Frequent conversions across the catalogue: