Advanced Concepts
Master error handling, file operations, and object-oriented programming
Decorators and Context Managers in Python
Master Python's advanced features with free flashcards and interactive coding practice. This lesson covers decorators, context managers, and the with statementโessential concepts for writing elegant, maintainable Python code that handles resource management and function modification cleanly.
Welcome to Advanced Python Patterns ๐ป
Welcome to one of Python's most powerful and elegant features! If you've ever wondered how frameworks like Flask use @app.route() or why database connections use with statements, you're about to discover the magic behind these patterns. Decorators and context managers are what separate intermediate Python developers from advanced onesโthey're the tools that let you write clean, reusable, and Pythonic code.
These concepts might seem intimidating at first, but they're built on simple principles you already know: functions are objects, and Python loves clean syntax. By the end of this lesson, you'll be creating your own decorators to modify function behavior and context managers to handle resources safely.
What Are Decorators? ๐จ
A decorator is a function that takes another function as an argument, adds some functionality to it, and returns a new functionโall without modifying the original function's code. Think of it like gift wrapping: the present (original function) stays the same, but you add a beautiful wrapper (additional functionality) around it.
Why use decorators?
- Separation of concerns: Keep core logic separate from cross-cutting concerns like logging, timing, or authentication
- Code reusability: Write the decorator once, apply it to many functions
- Clean syntax: The
@decoratorsyntax is more readable than manual function wrapping
The Anatomy of a Decorator
Decorators work because functions are first-class objects in Pythonโthey can be passed around, assigned to variables, and returned from other functions.
Here's the basic structure:
def my_decorator(func):
def wrapper(*args, **kwargs):
# Code to run BEFORE the original function
result = func(*args, **kwargs) # Call the original function
# Code to run AFTER the original function
return result
return wrapper
The wrapper pattern:
- The decorator receives a function as input
- It defines an inner function (wrapper) that will replace the original
- The wrapper calls the original function and can add behavior before/after
- The decorator returns the wrapper
๐ก Tip: The *args and **kwargs make your decorator work with any function signatureโit captures all positional and keyword arguments.
Using Decorators with @ Syntax
The @decorator_name syntax is just syntactic sugar for function wrapping:
@my_decorator
def say_hello():
print("Hello!")
# This is equivalent to:
def say_hello():
print("Hello!")
say_hello = my_decorator(say_hello)
The @ syntax is cleaner and makes it immediately obvious that the function is decorated.
Common Decorator Patterns ๐ง
1. Timing Decorator
Measure how long a function takes to execute:
import time
def timing_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timing_decorator
def slow_function():
time.sleep(1)
return "Done!"
2. Logging Decorator
Automatically log function calls:
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_decorator
def add(a, b):
return a + b
3. Decorators with Arguments
Sometimes you want to configure your decorator. This requires one more level of nesting:
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
How it works:
@repeat(times=3)callsrepeat(3), which returns the actual decorator- That decorator is then applied to
greet - The pattern is:
@decorator_factory(args)โ returns decorator โ decorates function
๐ง Mnemonic: "RDW" - Repeat (outer), Decorator (middle), Wrapper (inner)โthree levels for parameterized decorators!
Preserving Function Metadata with functools.wraps
When you wrap a function, it loses its original name, docstring, and other metadata. The @functools.wraps decorator fixes this:
import functools
def my_decorator(func):
@functools.wraps(func) # Preserves func's metadata
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
โ ๏ธ Common Mistake: Forgetting @functools.wraps makes debugging harder because stack traces show "wrapper" instead of the actual function name.
What Are Context Managers? ๐ช
A context manager is an object that manages resources by defining what happens when you enter and exit a context. The most common use is the with statement, which ensures resources are properly cleaned up even if errors occur.
The problem they solve:
# Without context manager - easy to forget to close!
file = open('data.txt', 'r')
data = file.read()
file.close() # What if an error occurs before this?
# With context manager - automatic cleanup!
with open('data.txt', 'r') as file:
data = file.read()
# File is automatically closed, even if an exception occurs
The Context Manager Protocol
Context managers implement two special methods:
__enter__(): Called when entering thewithblock (setup)__exit__(): Called when exiting thewithblock (cleanup)
class MyContextManager:
def __enter__(self):
print("Entering context")
return self # This becomes the 'as' variable
def __exit__(self, exc_type, exc_value, traceback):
print("Exiting context")
return False # False = don't suppress exceptions
with MyContextManager() as cm:
print("Inside context")
The __exit__ parameters:
exc_type: The exception class (or None)exc_value: The exception instance (or None)traceback: The traceback object (or None)- Return
Trueto suppress the exception,Falseto let it propagate
Creating Context Managers with contextlib ๐ ๏ธ
Writing a class with __enter__ and __exit__ is verbose. Python's contextlib module provides a simpler way using generators:
from contextlib import contextmanager
@contextmanager
def my_context():
print("Setting up")
yield "resource" # This value goes to 'as' variable
print("Tearing down")
with my_context() as resource:
print(f"Using {resource}")
How it works:
- Code before
yieldruns during__enter__ - The yielded value becomes the
asvariable - Code after
yieldruns during__exit__ - Exceptions in the
withblock are raised at theyieldpoint
๐ก Tip: Use try...finally in your context manager to ensure cleanup happens even if exceptions occur:
@contextmanager
def safe_context():
resource = acquire_resource()
try:
yield resource
finally:
release_resource(resource) # Always runs
Real-World Examples ๐
Example 1: Database Connection Manager
import sqlite3
from contextlib import contextmanager
@contextmanager
def database_connection(db_name):
conn = sqlite3.connect(db_name)
try:
yield conn
conn.commit() # Commit if no errors
except Exception:
conn.rollback() # Rollback on error
raise
finally:
conn.close() # Always close
# Usage
with database_connection('app.db') as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
users = cursor.fetchall()
Why this is better: You never forget to close connections, commit transactions, or rollback on errors!
Example 2: Timing Context Manager
import time
from contextlib import contextmanager
@contextmanager
def timer(name):
start = time.time()
yield
end = time.time()
print(f"{name} took {end - start:.4f} seconds")
# Usage
with timer("Data processing"):
# Expensive operation here
time.sleep(1)
process_data()
Example 3: Temporary Directory Manager
import os
import tempfile
import shutil
from contextlib import contextmanager
@contextmanager
def temp_directory():
temp_dir = tempfile.mkdtemp()
try:
yield temp_dir
finally:
shutil.rmtree(temp_dir) # Clean up
with temp_directory() as tmpdir:
# Work with temporary files
filepath = os.path.join(tmpdir, 'data.txt')
with open(filepath, 'w') as f:
f.write('Temporary data')
# Directory and all contents automatically deleted
๐ค Did you know? The with statement can handle multiple context managers at once:
with open('input.txt', 'r') as infile, open('output.txt', 'w') as outfile:
outfile.write(infile.read())
Both files are guaranteed to close properly!
Combining Decorators and Context Managers ๐ญ
You can create powerful patterns by combining these concepts:
import functools
from contextlib import contextmanager
@contextmanager
def error_handler():
try:
yield
except Exception as e:
print(f"Error caught: {e}")
# Log error, send alert, etc.
def with_error_handling(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
with error_handler():
return func(*args, **kwargs)
return wrapper
@with_error_handling
def risky_operation():
return 10 / 0 # Error handled gracefully
Advanced Patterns ๐
Class-Based Decorators
Decorators don't have to be functionsโthey can be classes with a __call__ method:
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
Advantage: Classes can maintain state between calls (like the count variable).
Reusable Context Manager Classes
class FileManager:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
self.file = None
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_value, traceback):
if self.file:
self.file.close()
with FileManager('data.txt', 'w') as f:
f.write('Hello, World!')
Common Mistakes to Avoid โ ๏ธ
1. Forgetting to Return the Result
โ Wrong:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before")
func(*args, **kwargs) # Result is lost!
print("After")
return wrapper
โ Right:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before")
result = func(*args, **kwargs)
print("After")
return result # Return the result!
return wrapper
**2. Not Using *args and kwargs
โ Wrong:
def my_decorator(func):
def wrapper(): # Only works with no-argument functions!
return func()
return wrapper
โ Right:
def my_decorator(func):
def wrapper(*args, **kwargs): # Works with any function!
return func(*args, **kwargs)
return wrapper
3. Forgetting @functools.wraps
โ Wrong:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def important_function():
"""This docstring will be lost!"""
pass
print(important_function.__name__) # Prints "wrapper"
โ Right:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def important_function():
"""This docstring is preserved!"""
pass
print(important_function.__name__) # Prints "important_function"
4. Not Handling Exceptions in Context Managers
โ Wrong:
@contextmanager
def my_context():
resource = acquire_resource()
yield resource
release_resource(resource) # Won't run if exception occurs!
โ Right:
@contextmanager
def my_context():
resource = acquire_resource()
try:
yield resource
finally:
release_resource(resource) # Always runs!
5. Returning True from exit Without Understanding
def __exit__(self, exc_type, exc_value, traceback):
return True # Suppresses ALL exceptions - dangerous!
Only return True if you specifically want to suppress exceptions. Usually, return False or None.
Try This! ๐ง
Exercise 1: Create a decorator that caches function results:
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
Exercise 2: Create a context manager that changes the current directory temporarily:
import os
from contextlib import contextmanager
@contextmanager
def change_directory(path):
old_dir = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_dir)
Key Takeaways ๐ฏ
โ Decorators modify or enhance functions without changing their code
โ
Use @functools.wraps to preserve function metadata
โ Decorators with arguments need three levels: factory โ decorator โ wrapper
โ
Context managers ensure proper resource management using __enter__ and __exit__
โ
The @contextmanager decorator simplifies context manager creation
โ
Always use try...finally in context managers to guarantee cleanup
โ
The with statement works with any object that implements the context manager protocol
โ Decorators and context managers make code more readable, reusable, and maintainable
๐ Quick Reference Card
๐ Python Decorators & Context Managers Cheat Sheet
| Concept | Syntax | Use Case |
|---|---|---|
| Simple Decorator | @decorator |
Modify function behavior |
| Decorator with Args | @decorator(arg) |
Configurable decorators |
| Preserve Metadata | @functools.wraps(func) |
Keep function name/docstring |
| Context Manager | with obj as var: |
Resource management |
| @contextmanager | @contextmanager |
Simple context managers |
| Multiple Contexts | with a() as x, b() as y: |
Multiple resources |
Common Patterns:
- ๐ Timing: Measure execution time
- ๐ Logging: Track function calls
- ๐ Authentication: Check permissions
- ๐พ Caching: Store results (memoization)
- ๐๏ธ Database: Manage connections/transactions
- ๐ Files: Ensure proper closing
- ๐ Retry: Automatic error recovery
๐ Further Study
- Python Official Documentation on Decorators: https://docs.python.org/3/glossary.html#term-decorator
- PEP 343 - The "with" Statement: https://peps.python.org/pep-0343/
- Real Python: Primer on Python Decorators: https://realpython.com/primer-on-python-decorators/