Lecture 16 — Decorators and Context Managers

Lecture 16 — Decorators and Context Managers#

Lecture 16 of 18 — Progress: 89%

PyPro-SCiDaS

An Initiation to Programming using Python (Init2Py)

🎀 Lecture 16 — Decorators & Context Managers

Python Proficiency for Scientific Computing and Data Science

🧑‍🏫 Instructor: Yaé Gaba 📘 Course: Init2Py 📅 Date: October 2025 🎓 Semester: Semester 1, 2025–2026 ⏱️ Estimated: 50 min Advanced
🏛️ AI Research and Innovation Nexus for Africa (AIRINA Labs), AI.Technipreneurs, Bénin
& African Center for Advanced Studies (ACAS), Cameroon
✉️ yaeulrich.gaba@gmail.com   |   🔗 LinkedIn | 🌐 Website

🎯 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 with statement
  • ✅ Build custom context managers using classes and contextlib

🌉 Building on what you know: In Lecture 7 you learned that functions are first-class objects — they can be passed as arguments and returned from other functions. In Lecture 13 you saw @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
💡 Note: A closure is a function that remembers values from its enclosing scope even after that scope has finished executing. The multiplier function above "closes over" the variable n. Closures are the foundation of decorators.
🔬 In scientific computing: Closures are widely used in scientific libraries. NumPy's 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.
💡 Note: Best practice: Always use @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()}")
🔬 In scientific computing: @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}")
💡 Note: @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
🔬 In scientific computing: Context managers are everywhere in scientific Python: 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: @decorator syntax.
  • Use @functools.wraps to preserve the original function's name and docstring.
  • The with statement and context managers handle resource setup/teardown automatically.
  • @property lets you define computed attributes that look like regular attributes.

🏁 End of Lecture 16 — Decorators & Context Managers

PyPro-SCiDaS • Python Proficiency for Scientific Computing and Data Science

Course progress: 94% complete (17 of 18 lectures)

© 2025 Yaé Gaba — CC BY-NC 4.0