Decorators & Context Managers
Decorators and context managers are two of Python’s most powerful features for writing clean, reusable code. They enable elegant solutions for cross-cutting concerns like logging, timing, resource management, and code enhancement.
Understanding Decorators
Decorators are a way to modify or enhance functions and classes without permanently modifying their code. They follow the principle of separation of concerns and enable clean, readable implementations of common patterns.
Basic Function Decorators
1def timing_decorator(func):
2 """A decorator that measures function execution time."""
3 import time
4
5 def wrapper(*args, **kwargs):
6 start_time = time.time()
7 result = func(*args, **kwargs)
8 end_time = time.time()
9 print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
10 return result
11
12 return wrapper
13
14@timing_decorator
15def slow_function():
16 """A function that takes some time to execute."""
17 import time
18 time.sleep(1)
19 return "Task completed"
20
21# Usage
22result = slow_function()
23# Output: slow_function took 1.0041 seconds
24print(result) # Output: Task completed
Key Concepts:
- The decorator function takes another function as an argument
- It returns a new function (wrapper) that enhances the original
- The
@
syntax is syntactic sugar forslow_function = timing_decorator(slow_function)
1from functools import wraps
2
3def logging_decorator(func):
4 """A decorator that logs function calls and preserves metadata."""
5
6 @wraps(func) # Preserves original function's metadata
7 def wrapper(*args, **kwargs):
8 print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
9 try:
10 result = func(*args, **kwargs)
11 print(f"{func.__name__} returned: {result}")
12 return result
13 except Exception as e:
14 print(f"{func.__name__} raised: {e}")
15 raise
16
17 return wrapper
18
19@logging_decorator
20def divide(a, b):
21 """Divide two numbers."""
22 return a / b
23
24# Usage
25print(f"Function name: {divide.__name__}") # Output: divide (not wrapper)
26print(f"Function doc: {divide.__doc__}") # Output: Divide two numbers.
27
28result = divide(10, 2)
29# Output: Calling divide with args=(10, 2), kwargs={}
30# Output: divide returned: 5.0
31
32try:
33 divide(10, 0)
34except ZeroDivisionError:
35 print("Caught division by zero")
36# Output: Calling divide with args=(10, 0), kwargs={}
37# Output: divide raised: division by zero
Why Use @wraps:
- Preserves the original function’s
__name__
,__doc__
, and other attributes - Essential for debugging and introspection
- Makes decorated functions behave more like the original
Decorators with Parameters
1def retry(max_attempts=3, delay=1):
2 """Decorator that retries a function on failure."""
3 def decorator(func):
4 @wraps(func)
5 def wrapper(*args, **kwargs):
6 for attempt in range(max_attempts):
7 try:
8 return func(*args, **kwargs)
9 except Exception as e:
10 if attempt == max_attempts - 1:
11 print(f"Failed after {max_attempts} attempts")
12 raise
13 print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
14 time.sleep(delay)
15 return wrapper
16 return decorator
17
18@retry(max_attempts=3, delay=0.5)
19def unreliable_network_call():
20 """Simulates an unreliable network operation."""
21 import random
22 if random.random() < 0.7: # 70% chance of failure
23 raise ConnectionError("Network timeout")
24 return "Success!"
25
26# Usage
27try:
28 result = unreliable_network_call()
29 print(result)
30except ConnectionError:
31 print("All attempts failed")
Pattern Breakdown:
retry()
is a decorator factory that returns a decorator- The actual decorator is created with the specified parameters
- This enables customizable behavior while maintaining clean syntax
1def memoize(maxsize=128):
2 """A simple memoization decorator with size limit."""
3 def decorator(func):
4 cache = {}
5 cache_order = []
6
7 @wraps(func)
8 def wrapper(*args, **kwargs):
9 # Create a hashable key
10 key = str(args) + str(sorted(kwargs.items()))
11
12 if key in cache:
13 print(f"Cache hit for {func.__name__}{args}")
14 return cache[key]
15
16 # Compute result
17 result = func(*args, **kwargs)
18 print(f"Cache miss for {func.__name__}{args}, computing...")
19
20 # Manage cache size
21 if len(cache) >= maxsize:
22 oldest_key = cache_order.pop(0)
23 del cache[oldest_key]
24
25 cache[key] = result
26 cache_order.append(key)
27 return result
28
29 # Add cache inspection methods
30 wrapper.cache_info = lambda: {
31 'size': len(cache),
32 'maxsize': maxsize,
33 'keys': list(cache.keys())
34 }
35 wrapper.cache_clear = lambda: cache.clear() or cache_order.clear()
36
37 return wrapper
38 return decorator
39
40@memoize(maxsize=5)
41def fibonacci(n):
42 """Calculate Fibonacci number (inefficient recursive version)."""
43 if n < 2:
44 return n
45 return fibonacci(n - 1) + fibonacci(n - 2)
46
47# Usage
48print(fibonacci(10)) # Computes and caches intermediate results
49print(fibonacci(10)) # Cache hit!
50print(fibonacci.cache_info()) # Inspect cache state
Class-Based Decorators
1import time
2from collections import defaultdict
3
4class RateLimiter:
5 """A class-based decorator for rate limiting function calls."""
6
7 def __init__(self, max_calls=5, time_window=60):
8 self.max_calls = max_calls
9 self.time_window = time_window
10 self.calls = defaultdict(list) # Track calls per function
11
12 def __call__(self, func):
13 @wraps(func)
14 def wrapper(*args, **kwargs):
15 now = time.time()
16 func_calls = self.calls[func.__name__]
17
18 # Remove old calls outside the time window
19 func_calls[:] = [call_time for call_time in func_calls
20 if now - call_time < self.time_window]
21
22 # Check rate limit
23 if len(func_calls) >= self.max_calls:
24 raise Exception(f"Rate limit exceeded for {func.__name__}. "
25 f"Max {self.max_calls} calls per {self.time_window}s")
26
27 # Record this call and execute function
28 func_calls.append(now)
29 return func(*args, **kwargs)
30
31 return wrapper
32
33@RateLimiter(max_calls=3, time_window=10)
34def api_call(endpoint):
35 """Simulate an API call."""
36 print(f"Making API call to {endpoint}")
37 return f"Response from {endpoint}"
38
39# Usage
40for i in range(5):
41 try:
42 result = api_call(f"/endpoint_{i}")
43 print(result)
44 time.sleep(1)
45 except Exception as e:
46 print(f"Error: {e}")
47 break
Advantages of Class-Based Decorators:
- Can maintain state between calls
- More complex logic and configuration
- Can implement multiple methods for different behaviors
1class ValidatedProperty:
2 """A descriptor that validates property values."""
3
4 def __init__(self, validator=None, doc=None):
5 self.validator = validator
6 self.doc = doc
7 self.name = None # Set by __set_name__
8
9 def __set_name__(self, owner, name):
10 self.name = name
11 self.private_name = f'_{name}'
12
13 def __get__(self, instance, owner):
14 if instance is None:
15 return self
16 return getattr(instance, self.private_name, None)
17
18 def __set__(self, instance, value):
19 if self.validator:
20 value = self.validator(value)
21 setattr(instance, self.private_name, value)
22
23def positive_number(value):
24 """Validator for positive numbers."""
25 if not isinstance(value, (int, float)):
26 raise TypeError("Must be a number")
27 if value <= 0:
28 raise ValueError("Must be positive")
29 return value
30
31def non_empty_string(value):
32 """Validator for non-empty strings."""
33 if not isinstance(value, str):
34 raise TypeError("Must be a string")
35 if not value.strip():
36 raise ValueError("Must not be empty")
37 return value.strip()
38
39class Product:
40 """A product with validated properties."""
41
42 price = ValidatedProperty(positive_number, "Product price")
43 name = ValidatedProperty(non_empty_string, "Product name")
44
45 def __init__(self, name, price):
46 self.name = name
47 self.price = price
48
49 def __repr__(self):
50 return f"Product(name='{self.name}', price={self.price})"
51
52# Usage
53try:
54 product = Product("Widget", 19.99)
55 print(product) # Product(name='Widget', price=19.99)
56
57 product.price = 29.99 # Valid update
58 print(f"Updated price: {product.price}")
59
60 product.price = -5 # Raises ValueError
61except ValueError as e:
62 print(f"Validation error: {e}")
Understanding Context Managers
Context managers ensure proper resource management using the with
statement. They guarantee cleanup code runs even if exceptions occur.
Built-in Context Managers
1# File handling with automatic cleanup
2def read_config_file(filename):
3 """Read configuration from a file safely."""
4 config = {}
5
6 try:
7 with open(filename, 'r') as file:
8 for line_num, line in enumerate(file, 1):
9 line = line.strip()
10 if line and not line.startswith('#'):
11 try:
12 key, value = line.split('=', 1)
13 config[key.strip()] = value.strip()
14 except ValueError:
15 print(f"Invalid line {line_num}: {line}")
16 except FileNotFoundError:
17 print(f"Config file {filename} not found, using defaults")
18 except PermissionError:
19 print(f"Permission denied reading {filename}")
20
21 return config
22
23# Usage
24config = read_config_file('app.conf')
25print(config)
26# File is automatically closed even if an exception occurs
Benefits:
- File is automatically closed when leaving the
with
block - Works even if exceptions occur inside the block
- Cleaner, more readable code than manual try/finally
1def copy_file_with_backup(source, destination, backup_suffix='.bak'):
2 """Copy a file while creating a backup of the destination."""
3 import shutil
4 import os
5
6 backup_path = destination + backup_suffix
7
8 try:
9 # Handle multiple files with nested context managers
10 with open(source, 'rb') as src, \
11 open(destination, 'rb') as dst_read:
12
13 # Create backup first
14 with open(backup_path, 'wb') as backup:
15 shutil.copyfileobj(dst_read, backup)
16
17 print(f"Backup created: {backup_path}")
18
19 # Now perform the actual copy
20 with open(source, 'rb') as src, \
21 open(destination, 'wb') as dst:
22 shutil.copyfileobj(src, dst)
23
24 print(f"File copied: {source} -> {destination}")
25
26 except FileNotFoundError as e:
27 print(f"File not found: {e}")
28 except PermissionError as e:
29 print(f"Permission error: {e}")
30 except Exception as e:
31 print(f"Unexpected error: {e}")
32 # Clean up backup if copy failed
33 if os.path.exists(backup_path):
34 os.remove(backup_path)
35 print("Backup removed due to copy failure")
36
37# Usage
38copy_file_with_backup('source.txt', 'destination.txt')
Custom Context Managers
1import time
2import threading
3
4class TimingContext:
5 """Context manager for measuring execution time."""
6
7 def __init__(self, description="Operation", verbose=True):
8 self.description = description
9 self.verbose = verbose
10 self.start_time = None
11 self.end_time = None
12
13 def __enter__(self):
14 self.start_time = time.time()
15 if self.verbose:
16 print(f"Starting {self.description}...")
17 return self
18
19 def __exit__(self, exc_type, exc_val, exc_tb):
20 self.end_time = time.time()
21 duration = self.end_time - self.start_time
22
23 if exc_type is not None:
24 print(f"{self.description} failed after {duration:.4f} seconds")
25 return False # Don't suppress the exception
26
27 if self.verbose:
28 print(f"{self.description} completed in {duration:.4f} seconds")
29
30 return False
31
32 @property
33 def duration(self):
34 """Get the duration of the operation."""
35 if self.start_time and self.end_time:
36 return self.end_time - self.start_time
37 return None
38
39class DatabaseConnection:
40 """Mock database connection with transaction support."""
41
42 def __init__(self, connection_string):
43 self.connection_string = connection_string
44 self.connected = False
45 self.transaction_active = False
46
47 def connect(self):
48 print(f"Connecting to {self.connection_string}")
49 time.sleep(0.1) # Simulate connection time
50 self.connected = True
51
52 def disconnect(self):
53 if self.connected:
54 print("Disconnecting from database")
55 self.connected = False
56
57 def begin_transaction(self):
58 if not self.connected:
59 raise Exception("Not connected to database")
60 print("Beginning transaction")
61 self.transaction_active = True
62
63 def commit(self):
64 if self.transaction_active:
65 print("Committing transaction")
66 self.transaction_active = False
67
68 def rollback(self):
69 if self.transaction_active:
70 print("Rolling back transaction")
71 self.transaction_active = False
72
73 def execute(self, query):
74 if not self.connected:
75 raise Exception("Not connected to database")
76 print(f"Executing: {query}")
77 if "ERROR" in query:
78 raise Exception("SQL Error")
79
80class DatabaseTransaction:
81 """Context manager for database transactions."""
82
83 def __init__(self, connection):
84 self.connection = connection
85
86 def __enter__(self):
87 self.connection.begin_transaction()
88 return self.connection
89
90 def __exit__(self, exc_type, exc_val, exc_tb):
91 if exc_type is not None:
92 self.connection.rollback()
93 print(f"Transaction rolled back due to: {exc_val}")
94 return False # Don't suppress the exception
95 else:
96 self.connection.commit()
97 return False
98
99# Usage
100db = DatabaseConnection("postgresql://localhost/mydb")
101db.connect()
102
103try:
104 with TimingContext("Database operations"):
105 with DatabaseTransaction(db) as conn:
106 conn.execute("INSERT INTO users (name) VALUES ('Alice')")
107 conn.execute("INSERT INTO users (name) VALUES ('Bob')")
108 # Transaction automatically commits
109
110 print("All operations successful")
111
112except Exception as e:
113 print(f"Operation failed: {e}")
114finally:
115 db.disconnect()
1from contextlib import contextmanager
2import threading
3import os
4import tempfile
5
6@contextmanager
7def temporary_directory():
8 """Create a temporary directory that's cleaned up automatically."""
9 temp_dir = tempfile.mkdtemp()
10 try:
11 print(f"Created temporary directory: {temp_dir}")
12 yield temp_dir
13 finally:
14 import shutil
15 shutil.rmtree(temp_dir)
16 print(f"Cleaned up temporary directory: {temp_dir}")
17
18@contextmanager
19def thread_lock(lock, timeout=None):
20 """Context manager for thread locks with timeout."""
21 acquired = lock.acquire(timeout=timeout)
22 if not acquired:
23 raise TimeoutError(f"Could not acquire lock within {timeout} seconds")
24
25 try:
26 yield
27 finally:
28 lock.release()
29
30@contextmanager
31def suppress_stdout():
32 """Temporarily suppress stdout output."""
33 import sys
34 from io import StringIO
35
36 old_stdout = sys.stdout
37 sys.stdout = StringIO()
38 try:
39 yield
40 finally:
41 sys.stdout = old_stdout
42
43@contextmanager
44def environment_variable(name, value):
45 """Temporarily set an environment variable."""
46 old_value = os.environ.get(name)
47 os.environ[name] = value
48 try:
49 yield
50 finally:
51 if old_value is None:
52 os.environ.pop(name, None)
53 else:
54 os.environ[name] = old_value
55
56# Usage examples
57def demonstrate_context_managers():
58 """Demonstrate various custom context managers."""
59
60 # Temporary directory
61 with temporary_directory() as temp_dir:
62 test_file = os.path.join(temp_dir, "test.txt")
63 with open(test_file, 'w') as f:
64 f.write("This is a test file")
65 print(f"Created file: {test_file}")
66 print(f"File exists: {os.path.exists(test_file)}")
67 # Directory and file are automatically cleaned up
68
69 # Thread synchronization
70 lock = threading.Lock()
71
72 def worker(worker_id):
73 try:
74 with thread_lock(lock, timeout=1.0):
75 print(f"Worker {worker_id} acquired lock")
76 time.sleep(0.5)
77 print(f"Worker {worker_id} releasing lock")
78 except TimeoutError:
79 print(f"Worker {worker_id} timed out waiting for lock")
80
81 # Start multiple threads
82 threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
83 for t in threads:
84 t.start()
85 for t in threads:
86 t.join()
87
88 # Suppress output
89 print("This will be printed")
90 with suppress_stdout():
91 print("This will not be printed")
92 print("This will be printed again")
93
94 # Environment variable
95 print(f"HOME before: {os.environ.get('TEST_VAR', 'Not set')}")
96 with environment_variable('TEST_VAR', 'temporary_value'):
97 print(f"HOME during: {os.environ.get('TEST_VAR')}")
98 print(f"HOME after: {os.environ.get('TEST_VAR', 'Not set')}")
99
100demonstrate_context_managers()
Advantages of @contextmanager:
- Simpler syntax than class-based approach
- Clear separation of setup and cleanup code
- Automatic exception handling
Advanced Patterns
1from contextlib import contextmanager
2import logging
3import functools
4
5# Set up logging
6logging.basicConfig(level=logging.INFO)
7logger = logging.getLogger(__name__)
8
9@contextmanager
10def performance_monitor(operation_name):
11 """Context manager that monitors performance and logs results."""
12 import psutil
13 import time
14
15 process = psutil.Process()
16 start_time = time.time()
17 start_memory = process.memory_info().rss / 1024 / 1024 # MB
18 start_cpu = process.cpu_percent()
19
20 logger.info(f"Starting {operation_name}")
21
22 try:
23 yield
24 finally:
25 end_time = time.time()
26 end_memory = process.memory_info().rss / 1024 / 1024 # MB
27 end_cpu = process.cpu_percent()
28
29 duration = end_time - start_time
30 memory_diff = end_memory - start_memory
31
32 logger.info(
33 f"Completed {operation_name}: "
34 f"Duration={duration:.2f}s, "
35 f"Memory={memory_diff:+.2f}MB, "
36 f"CPU={end_cpu:.1f}%"
37 )
38
39def monitor_performance(operation_name=None):
40 """Decorator that monitors function performance."""
41 def decorator(func):
42 op_name = operation_name or f"{func.__module__}.{func.__name__}"
43
44 @functools.wraps(func)
45 def wrapper(*args, **kwargs):
46 with performance_monitor(op_name):
47 return func(*args, **kwargs)
48 return wrapper
49 return decorator
50
51@monitor_performance("Data Processing")
52def process_large_dataset(size=1000000):
53 """Simulate processing a large dataset."""
54 import random
55
56 # Generate data
57 data = [random.random() for _ in range(size)]
58
59 # Process data
60 result = {
61 'sum': sum(data),
62 'average': sum(data) / len(data),
63 'max': max(data),
64 'min': min(data)
65 }
66
67 return result
68
69# Usage
70result = process_large_dataset(500000)
71print(f"Processing result: {result}")
1import asyncio
2import aiohttp
3import time
4from contextlib import asynccontextmanager
5
6class AsyncTimingContext:
7 """Async context manager for measuring execution time."""
8
9 def __init__(self, description="Async operation"):
10 self.description = description
11 self.start_time = None
12
13 async def __aenter__(self):
14 self.start_time = time.time()
15 print(f"Starting {self.description}...")
16 return self
17
18 async def __aexit__(self, exc_type, exc_val, exc_tb):
19 duration = time.time() - self.start_time
20 if exc_type is not None:
21 print(f"{self.description} failed after {duration:.2f}s")
22 else:
23 print(f"{self.description} completed in {duration:.2f}s")
24 return False
25
26@asynccontextmanager
27async def http_session_manager():
28 """Async context manager for HTTP sessions."""
29 print("Creating HTTP session")
30 session = aiohttp.ClientSession()
31 try:
32 yield session
33 finally:
34 await session.close()
35 print("HTTP session closed")
36
37async def fetch_urls(urls):
38 """Fetch multiple URLs concurrently."""
39 async with AsyncTimingContext("Fetching URLs"):
40 async with http_session_manager() as session:
41 tasks = []
42 for url in urls:
43 async def fetch_one(url):
44 try:
45 async with session.get(url) as response:
46 return {
47 'url': url,
48 'status': response.status,
49 'size': len(await response.text())
50 }
51 except Exception as e:
52 return {'url': url, 'error': str(e)}
53
54 tasks.append(fetch_one(url))
55
56 results = await asyncio.gather(*tasks)
57 return results
58
59# Usage (would need to be run in an async context)
60async def main():
61 urls = [
62 'https://httpbin.org/delay/1',
63 'https://httpbin.org/json',
64 'https://httpbin.org/html'
65 ]
66
67 results = await fetch_urls(urls)
68 for result in results:
69 if 'error' in result:
70 print(f"Error fetching {result['url']}: {result['error']}")
71 else:
72 print(f"Fetched {result['url']}: {result['status']} ({result['size']} bytes)")
73
74# To run: asyncio.run(main())
Best Practices and Common Patterns
When to Use Decorators
Logging and Debugging
Perfect for cross-cutting concerns that affect multiple functions:
1@log_calls
2@measure_time
3@cache_result
4def expensive_computation(n):
5 return sum(i**2 for i in range(n))
Validation and Security
Ideal for enforcing constraints and security policies:
1@require_authentication
2@validate_input(schema=user_schema)
3@rate_limit(calls_per_minute=60)
4def create_user(user_data):
5 return User.create(user_data)
API and Web Development
Common in web frameworks for routing and middleware:
1@app.route('/api/users')
2@require_json
3@handle_errors
4def get_users():
5 return jsonify(User.get_all())
When to Use Context Managers
Resource Management
Essential for anything that needs cleanup:
- File operations
- Database connections
- Network sockets
- Threading locks
Temporary State Changes
Perfect for temporary modifications that need restoration:
- Environment variables
- Current working directory
- Global configuration
- Exception suppression
Transaction-Like Operations
Great for operations that need atomicity:
- Database transactions
- Backup and restore operations
- Temporary file creation
- State rollback mechanisms
Performance Considerations
Decorator Overhead
- Function decorators add a small call overhead
- Use
functools.wraps
to preserve metadata - Consider class-based decorators for complex state management
Context Manager Efficiency
- Context managers have minimal overhead
- Async context managers are efficient for I/O-bound operations
- Nested context managers are handled efficiently by Python
Common Pitfalls
Decorator Pitfalls:
- Forgetting
@wraps
leads to lost function metadata - Mutable default arguments in decorator factories
- Decorators with side effects can be confusing
Context Manager Pitfalls:
- Not handling exceptions properly in
__exit__
- Forgetting to return
False
from__exit__
to propagate exceptions - Resource leaks when exceptions occur during
__enter__
Summary
Decorators and context managers are powerful Python features that enable:
- Clean separation of concerns
- Reusable code patterns
- Automatic resource management
- Elegant handling of cross-cutting concerns
Master these patterns to write more maintainable, readable, and robust Python code. They’re essential tools for any Python developer working on real-world applications.
Related Topics
- Python Fundamentals - Core concepts and syntax
- Generators & Iterators - Lazy evaluation patterns
- File I/O Operations - Working with files and resources
Next Steps
Ready to explore more advanced Python patterns? Check out:
- List/Dict/Set Comprehensions (Coming Soon)
- Object-Oriented Programming (Coming Soon)
- Concurrent Programming (Coming Soon)