Lecture 16 — Decorators and Context Managers#
🎯 Learning Objectives
- ✅ Understand what decorators are and how they modify function behavior
- ✅ Write your own decorators using closures and
functools.wraps - ✅ Use built-in decorators:
@property,@staticmethod,@classmethod - ✅ Understand context managers and the
withstatement - ✅ Build custom context managers using classes and
contextlib
📑 Table of Contents
- ● 🎀 Lecture 16 — Decorators & Context Managers
- 1. ● 1. Functions Are Objects
- 2. ● 2. What Is a Decorator?
- 3. ● 3. Preserving Function Identity with functools.wraps
- 4. ● 4. Practical Decorator Patterns
- 5. ● 5. The @property Decorator
- 6. ● 6. Context Managers and the with Statement
- 7. ● 7. The contextlib Shortcut
- 8. ● 8. Stacking Decorators & Real-World Patterns
- 9. ● 9. Practice Exercises
- 10. ▸ 🎯 Key Takeaways
@staticmethod and @classmethod. Now we'll unpack what the @ symbol actually does and how to build your own decorators.1. Functions Are Objects
Before we can understand decorators, we need to appreciate a powerful fact about Python: functions are objects, just like integers or strings. You can store them in variables, pass them to other functions, and return them from other functions.
# Functions can be assigned to variablesdef greet(name): return f"Hello, {name}!"say_hello = greet # No parentheses — we're storing the function itselfprint(say_hello("Alice"))print(type(say_hello))
Notice that we didn’t call greet() — we assigned the function itself (without parentheses) to a new variable. Both greet and say_hello now point to the same function object in memory. This is what “first-class” means: functions are values, just like numbers or strings.Now, if functions are values, we should be able to pass them as arguments to other functions:
# Functions can be passed as argumentsdef apply_twice(func, value): """Apply a function twice to a value.""" return func(func(value))def add_exclamation(text): return text + "!"print(apply_twice(add_exclamation, "Hello"))
This ability to pass functions around is powerful — it’s the foundation of Python’s map(), filter(), and sorted(key=...) that you’ve seen in earlier lectures. But what if we want to create new functions on the fly? That’s where closures come in:
# Functions can return other functions (closures)def make_multiplier(n): """Return a function that multiplies by n.""" def multiplier(x): return x * n return multiplierdouble = make_multiplier(2)triple = make_multiplier(3)print(double(5)) # 10print(triple(5)) # 15print(double(triple(4))) # 24
multiplier function above "closes over" the variable n. Closures are the foundation of decorators.np.vectorize(), SciPy's minimize() with callback functions, and Matplotlib's event handlers all rely on functions that remember their enclosing state. Understanding closures will help you write more effective numerical code.2. What Is a Decorator?
A decorator is a function that takes another function, extends or modifies its behavior, and returns the modified version — all without changing the original function's source code. Think of it like wrapping a gift: the gift (your function) stays the same, but the wrapping (the decorator) adds something extra.
import time# A decorator that measures execution timedef timer(func): """Decorator that prints how long a function takes to run.""" def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) elapsed = time.time() - start print(f"{func.__name__} took {elapsed:.4f} seconds") return result return wrapper# Manual decorationdef slow_sum(n): return sum(range(n))slow_sum = timer(slow_sum) # Wrap the functionslow_sum(1_000_000)
Let’s unpack what just happened step by step:1. We defined timer(func) — a function that accepts a function and returns a new function (wrapper)2. wrapper adds timing logic around the original function call3. We replaced slow_sum with the wrapped version: slow_sum = timer(slow_sum)Now every call to slow_sum() automatically gets timed, and we never touched the original function’s code. This pattern — func = decorator(func) — is so common that Python provides the @ syntax as shorthand:
@timerdef slow_sum(n): """Sum numbers from 0 to n-1.""" return sum(range(n))# This is IDENTICAL to writing: slow_sum = timer(slow_sum)result = slow_sum(1_000_000)print(f"Result: {result}")
The @decorator syntax is purely syntactic sugar — it does exactly the same thing as the manual approach. But it’s cleaner, more readable, and makes it immediately obvious that the function is being modified. You’ll see @ decorators everywhere in professional Python code.
3. Preserving Function Identity with functools.wraps
There's a subtle problem with our decorator: the wrapped function loses its original name and docstring. The functools.wraps decorator fixes this by copying metadata from the original function to the wrapper.
from functools import wrapsdef timer(func): @wraps(func) # Preserves func.__name__, func.__doc__, etc. def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) elapsed = time.time() - start print(f"{func.__name__} took {elapsed:.4f}s") return result return wrapper@timerdef slow_sum(n): """Sum numbers from 0 to n-1.""" return sum(range(n))print(slow_sum.__name__) # slow_sum (not "wrapper")print(slow_sum.__doc__) # Sum numbers from 0 to n-1.
@functools.wraps(func) in your decorators. Without it, debugging becomes harder because tracebacks show wrapper instead of the real function name.With @wraps in our toolbox, we now have the complete decorator recipe:from functools import wrapsdef my_decorator(func): @wraps(func) def wrapper(*args, **kwargs): # ... do something before ... result = func(*args, **kwargs) # ... do something after ... return result return wrapperThis three-part structure — outer function, @wraps, inner wrapper — is the template you’ll use for virtually every decorator you write. Let’s put it to work with some real-world patterns:
4. Practical Decorator Patterns
Decorators are used everywhere in Python — from web frameworks (Flask's @app.route) to testing (@pytest.mark.parametrize) to caching (@lru_cache). Let's build some useful ones.
from functools import wraps# 1. A logging decoratordef log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__}({args}, {kwargs})") result = func(*args, **kwargs) print(f" → returned {result}") return result return wrapper@log_callsdef add(a, b): return a + badd(3, 5)add(10, b=20)
The @log_calls decorator is invaluable during debugging — you can temporarily add it to any function to see exactly what’s being called with what arguments, without modifying the function’s code.Next, let’s tackle a more advanced pattern: decorator factories. What if your decorator needs to accept its own parameters (like “retry up to 3 times”)?
# 2. A retry decorator (useful for network calls)import randomdef retry(max_attempts=3): """Decorator factory — takes parameters and returns a decorator.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as e: print(f" Attempt {attempt} failed: {e}") if attempt == max_attempts: raise return wrapper return decorator@retry(max_attempts=3)def unreliable_api(): """Simulates a flaky API call.""" if random.random() < 0.7: raise ConnectionError("Server unreachable") return {"status": "ok"}try: result = unreliable_api() print(f"Success: {result}")except ConnectionError: print("All attempts failed.")
Notice the three nested layers here: retry(max_attempts=3) returns decorator, which wraps func into wrapper. The call @retry(max_attempts=3) first evaluates retry(max_attempts=3) to get the actual decorator, then applies it to the function. This “decorator factory” pattern is how @lru_cache(maxsize=128) and @pytest.mark.parametrize(...) work under the hood.
# 3. Built-in: functools.lru_cache for memoizationfrom functools import lru_cache@lru_cache(maxsize=128)def fibonacci(n): """Compute the nth Fibonacci number (with caching).""" if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)# Without caching this would be extremely slow for large nprint([fibonacci(i) for i in range(20)])print(f"Cache info: {fibonacci.cache_info()}")
@lru_cache is a game-changer for scientific computing. If you have a function that's called repeatedly with the same inputs (e.g., computing basis functions, evaluating polynomials at grid points, or recursive dynamic programming), caching can turn O(2n) algorithms into O(n) with a single line. The Fibonacci example above would take minutes for fibonacci(40) without caching — with it, the result is instant.5. The @property Decorator
In Lecture 13 we used getter methods like get_balance(). Python's @property decorator provides a much cleaner approach — it lets you access a method as if it were a simple attribute.
class Temperature: """Temperature with automatic Celsius ↔ Fahrenheit conversion.""" def __init__(self, celsius=0): self._celsius = celsius @property def celsius(self): """Get the temperature in Celsius.""" return self._celsius @celsius.setter def celsius(self, value): if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = value @property def fahrenheit(self): """Get the temperature in Fahrenheit (read-only).""" return self._celsius * 9/5 + 32temp = Temperature(25)print(f"{temp.celsius}°C = {temp.fahrenheit}°F")temp.celsius = 100 # Uses the setterprint(f"{temp.celsius}°C = {temp.fahrenheit}°F")try: temp.celsius = -300 # Below absolute zero!except ValueError as e: print(f"Error: {e}")
@property is the Pythonic way to control attribute access. Use it instead of Java-style get_x() / set_x() methods. Your class users write obj.x — they never need to know that validation is happening behind the scenes.The beauty of @property is that you can start with a simple attribute (self.celsius = value) and later add validation or computed behavior without changing the public interface. Code that uses temp.celsius keeps working unchanged. This is why Python doesn’t need Java’s convention of writing getters and setters for every attribute from the start — you can add them later when needed.
6. Context Managers and the with Statement
You've been using context managers since Lecture 3: with open('file.txt') as f:. A context manager is any object that defines setup and teardown actions — it guarantees that cleanup happens, even if an error occurs. Think of it like a try/finally block wrapped in a clean interface.
# You already know this pattern:# with open("data.txt", "w") as f:# f.write("hello")# File is automatically closed, even if an error occurs# What's actually happening behind the scenes?# 1. f = open("data.txt").__enter__() ← setup# 2. ... your code runs ...# 3. f.__exit__() ← cleanup (always runs)# Let's see it explicitly:f = open("testfile.txt", "w")print(f"File closed? {f.closed}")f.write("test data")f.close()print(f"File closed? {f.closed}")
The problem with manual open()/close() is obvious: if an error occurs between open() and close(), the file stays open — leaking resources. You could wrap it in try/finally, but that’s verbose. Context managers solve this elegantly: the __exit__ method is guaranteed to run, whether the block succeeds, raises an exception, or even uses return or break.Let’s build our own context manager to see the protocol in action:
# Building a context manager as a classclass Timer: """Context manager that measures execution time of a block.""" def __enter__(self): self.start = time.time() print("⏱️ Timer started...") return self # The value bound to 'as' variable def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed = time.time() - self.start print(f"⏱️ Elapsed: {self.elapsed:.4f} seconds") return False # Don't suppress exceptions# Usagewith Timer() as t: total = sum(range(1_000_000)) print(f"Sum: {total}")print(f"Stored elapsed time: {t.elapsed:.4f}s")
Key points about the context manager protocol:- __enter__ runs when entering the with block. Its return value is bound to the as variable.- __exit__ runs when leaving the with block — always, even if an exception occurred.- __exit__ receives exception info (exc_type, exc_val, exc_tb). Return True to suppress the exception, False to propagate it.Context managers aren’t just for files — any resource that needs setup and cleanup is a candidate:
# Another example: managing a temporary directoryimport tempfile, osclass TempDirectory: """Context manager that creates and cleans up a temp directory.""" def __enter__(self): self.path = tempfile.mkdtemp() print(f"Created temp dir: {self.path}") return self.path def __exit__(self, exc_type, exc_val, exc_tb): import shutil shutil.rmtree(self.path) print(f"Cleaned up temp dir: {self.path}") return Falsewith TempDirectory() as tmp: filepath = os.path.join(tmp, "test.txt") with open(filepath, "w") as f: f.write("temporary data") print(f"File exists: {os.path.exists(filepath)}")# After the with block, the directory is gone# print(f"Dir exists: {os.path.exists(tmp)}") # Would be False
h5py.File() for HDF5 data, torch.no_grad() in PyTorch for disabling gradient tracking during inference, np.errstate() for controlling NumPy's floating-point error behavior, and database connections in SQLAlchemy. The pattern is always the same: acquire resource → use → release.7. The contextlib Shortcut
Writing a whole class with __enter__ and __exit__ for simple cases is verbose. Python's contextlib.contextmanager decorator lets you write context managers as generator functions — everything before yield is setup, everything after is teardown.
from contextlib import contextmanager@contextmanagerdef timer(): """Measure execution time of a code block.""" start = time.time() print("⏱️ Timer started...") yield # Control passes to the 'with' block here elapsed = time.time() - start print(f"⏱️ Elapsed: {elapsed:.4f} seconds")with timer(): total = sum(range(2_000_000)) print(f"Sum: {total}")
Notice the structure: everything before yield is the setup (__enter__), and everything after yield is the teardown (__exit__). The yield statement is where your with block’s code runs. If you need to pass a value to the as variable, use yield value.Here’s another practical example — a context manager that suppresses specific exceptions:
@contextmanagerdef suppress_errors(*exception_types): """Silently ignore specified exceptions.""" try: yield except exception_types as e: print(f"(Suppressed: {type(e).__name__}: {e})")with suppress_errors(ZeroDivisionError, ValueError): result = 1 / 0 print("This line won't run")print("But execution continues here!")# Python provides this built-in as contextlib.suppress:from contextlib import suppresswith suppress(FileNotFoundError): os.remove("nonexistent_file.txt")print("No crash!")
8. Stacking Decorators & Real-World Patterns
You can apply multiple decorators to a single function. They are applied bottom-up (closest to the function first) but execute top-down when the function is called.
from functools import wrapsdef bold(func): @wraps(func) def wrapper(*args, **kwargs): return f"<b>{func(*args, **kwargs)}</b>" return wrapperdef italic(func): @wraps(func) def wrapper(*args, **kwargs): return f"<i>{func(*args, **kwargs)}</i>" return wrapper@bold # Applied second (outer layer)@italic # Applied first (inner layer)def greet(name): return f"Hello, {name}!"# Equivalent to: greet = bold(italic(greet))print(greet("Alice")) # <b><i>Hello, Alice!</i></b>
Reading stacked decorators: Read them from bottom to top to understand the wrapping order, but from top to bottom to understand the execution order. In the example above:1. italic wraps greet first (bottom decorator, inner layer)2. bold wraps the italic-wrapped function (top decorator, outer layer)3. When called: bold runs first → calls italic → calls original greet
9. Practice Exercises
Exercise 1 (Beginner): Write a @count_calls decorator that tracks how many times a function has been called. Store the count as an attribute of the wrapper function (wrapper.call_count).Hint: Initialize wrapper.call_count = 0 after defining wrapper but before returning it. Increment it inside wrapper on each call.Exercise 2 (Intermediate): Write a @validate_positive decorator that raises ValueError if any positional argument is negative. It should work with any function that takes numeric arguments.Hint: Loop through args inside the wrapper and check if arg < 0. Use func.__name__ in the error message.Exercise 3 (Intermediate): Create a DatabaseConnection context manager class that:- Prints “Connecting to database…” on enter- Prints “Connection closed.” on exit- If an exception occurs, prints “Rolling back transaction…” before closingHint: Check if exc_type is not None in __exit__ to detect exceptions.Exercise 4 (Advanced): Rewrite your DatabaseConnection from Exercise 3 using @contextmanager. Use a try/finally block around the yield to ensure cleanup always runs.
# Exercise 1: count_calls decorator
Click to show solution
import functools
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.call_count += 1
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@count_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
greet("Bob")
greet("Charlie")
print(f"greet was called {greet.call_count} times") # 3
# Exercise 2: validate_positive decorator
Click to show solution
import functools
def validate_positive(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(
f"{func.__name__}() received negative argument: {arg}"
)
return func(*args, **kwargs)
return wrapper
@validate_positive
def compute_area(width, height):
return width * height
print(compute_area(5, 10)) # 50
try:
compute_area(-3, 10)
except ValueError as e:
print(f"Error: {e}")
# Exercise 3: DatabaseConnection context manager (class)
Click to show solution
class DatabaseConnection:
def __enter__(self):
print("Connecting to database...")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print(f"Rolling back transaction... ({exc_val})")
print("Connection closed.")
return False # Do not suppress exceptions
# Normal usage
with DatabaseConnection() as db:
print("Running queries...")
print()
# Usage with an exception
try:
with DatabaseConnection() as db:
print("Running queries...")
raise RuntimeError("Query failed!")
except RuntimeError:
print("Handled the error outside the context manager.")
# Exercise 4: DatabaseConnection using @contextmanager
Click to show solution
from contextlib import contextmanager
@contextmanager
def database_connection():
print("Connecting to database...")
try:
yield "db_connection"
except Exception as e:
print(f"Rolling back transaction... ({e})")
raise
finally:
print("Connection closed.")
# Normal usage
with database_connection() as db:
print(f"Running queries on {db}...")
print()
# Usage with an exception
try:
with database_connection() as db:
print(f"Running queries on {db}...")
raise RuntimeError("Query failed!")
except RuntimeError:
print("Handled the error outside the context manager.")
🎯 Key Takeaways
- In Python, functions are first-class objects — they can be passed around and returned.
- A decorator wraps a function to add behavior:
@decoratorsyntax. - Use
@functools.wrapsto preserve the original function's name and docstring. - The
withstatement and context managers handle resource setup/teardown automatically. @propertylets you define computed attributes that look like regular attributes.
—
—