Decorators & Context Managers
Decorators & Context Managers: Python's Abstraction Powerhouses
Decorators transform functions and classes without modifying their source code. Context managers handle setup/teardown automatically. Both are used constantly in real Python codebases — Flask routes, Django views, pytest fixtures, and timing utilities all rely on them.
What Is a Decorator?
A decorator is a function that takes a function, wraps it with additional behavior, and returns the wrapped function.
# The concept without decorator syntax
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before the function")
result = func(*args, **kwargs)
print("After the function")
return result
return wrapper
def greet(name):
print(f"Hello, {name}!")
# Apply decorator manually
greet = my_decorator(greet)
greet("Alice")
# Before the function
# Hello, Alice!
# After the function
# The @ syntax does exactly the same thing
@my_decorator
def greet(name):
print(f"Hello, {name}!")
A Practical Example: Timing Functions
import time
import functools
def timer(func):
@functools.wraps(func) # Preserves the original function's metadata
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(0.5)
return "done"
@timer
def fast_function(n):
return sum(range(n))
slow_function() # slow_function took 0.5001s
print(fast_function(1000000)) # fast_function took 0.0234s
Decorators with Arguments
When a decorator needs parameters, you add another layer:
def repeat(times):
"""Repeat a function n times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
# Retry decorator — extremely useful for network calls
def retry(max_attempts=3, exceptions=(Exception,), delay=1.0):
"""Retry a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts:
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=3, exceptions=(ConnectionError,), delay=2.0)
def fetch_data(url):
# Might fail on flaky network
import requests
return requests.get(url, timeout=5).json()
Real-World Decorator Patterns
# 1. Caching (memoization)
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(100)) # Instant after first call
# Manual cache decorator
def cache(func):
memo = {}
@functools.wraps(func)
def wrapper(*args):
if args not in memo:
memo[args] = func(*args)
return memo[args]
wrapper.cache = memo # Expose the cache
return wrapper
# 2. Logging decorator
import logging
def log_calls(logger=None, level=logging.INFO):
if logger is None:
logger = logging.getLogger(__name__)
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.log(level, f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logger.log(level, f"{func.__name__} returned {result!r}")
return result
except Exception as e:
logger.error(f"{func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
return decorator
@log_calls()
def process_order(order_id, quantity):
return {"order_id": order_id, "status": "processed", "qty": quantity}
# 3. Authentication / Authorization
def require_auth(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
if not getattr(request, 'user', None) or not request.user.is_authenticated:
raise PermissionError("Authentication required")
return func(request, *args, **kwargs)
return wrapper
def require_permission(permission):
def decorator(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
if not request.user.has_permission(permission):
raise PermissionError(f"Permission '{permission}' required")
return func(request, *args, **kwargs)
return wrapper
return decorator
# 4. Rate limiting
def rate_limit(calls_per_second):
import threading
lock = threading.Lock()
call_times = []
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
with lock:
now = time.time()
call_times[:] = [t for t in call_times if now - t < 1.0]
if len(call_times) >= calls_per_second:
raise RuntimeError(f"Rate limit: max {calls_per_second} calls/second")
call_times.append(now)
return func(*args, **kwargs)
return wrapper
return decorator
Class Decorators
Decorators work on classes too:
def singleton(cls):
"""Ensure only one instance of a 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 DatabasePool:
def __init__(self):
self.connections = []
print("Creating database pool")
db1 = DatabasePool()
db2 = DatabasePool()
print(db1 is db2) # True — same instance
# Adding methods to a class
def add_method(cls):
def new_method(self):
return f"Added to {cls.__name__}"
cls.added_method = new_method
return cls
@add_method
class MyClass:
pass
obj = MyClass()
print(obj.added_method())
Context Managers: The with Statement
# Using contextlib.contextmanager — the easiest way
from contextlib import contextmanager, suppress
@contextmanager
def managed_resource(name):
print(f"Setting up {name}")
resource = {"name": name, "active": True}
try:
yield resource # Execution pauses here; 'as' variable gets resource
except Exception as e:
print(f"Exception in {name}: {e}")
raise
finally:
resource["active"] = False
print(f"Cleaning up {name}")
with managed_resource("database") as db:
print(f"Using resource: {db['name']}")
# Even if exception here, cleanup runs
# suppress: ignore specific exceptions
with suppress(FileNotFoundError):
import os
os.remove("nonexistent.txt") # No error raised
# ExitStack: manage multiple resources dynamically
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 handle in handles:
handle.write("data\n")
# All files closed automatically
Stacking Decorators
@timer
@retry(max_attempts=3)
@log_calls()
def fetch_user_data(user_id):
# Execution order: log → retry → timer → function
return {"id": user_id, "name": "Alice"}
# Equivalent to:
# fetch_user_data = timer(retry(3)(log_calls()(fetch_user_data)))
# Decorators apply from bottom to top
functools.wraps: Always Use It
Without functools.wraps, decorated functions lose their identity:
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper # wrapper.__name__ = 'wrapper', not the original!
def good_decorator(func):
@functools.wraps(func) # Copies name, docstring, annotations
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def my_function():
"""My docstring."""
pass
@good_decorator
def my_function2():
"""My docstring."""
pass
print(my_function.__name__) # 'wrapper' — broken!
print(my_function2.__name__) # 'my_function2' — correct
print(my_function2.__doc__) # 'My docstring.' — preserved
Decorators and context managers are fundamental to writing Pythonic code that's clean, reusable, and production-ready.
Next lesson: File I/O & Working with CSV, JSON — reading and writing data from disk.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises