Advanced Python
Context Managers & Type Hints
Module 3, Chapter 2 introduced with open(...) as file: as "the safe way to handle files," promising the file always closes. Now, with dunder methods (Module 4)
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 2 Time to complete: ~25 minutes
Table of Contents
- Recap: What
withActually Does - Building a Context Manager with
__enter__/__exit__ - Handling Exceptions in
__exit__ - The
@contextmanagerDecorator — an Easier Way - Practical Context Manager Examples
- Type Hints — the Basics
- Type Hints for Collections & Optional Values
- Checking Types with
mypy - Summary & Next Steps
1. Recap: What with Actually Does
Module 3, Chapter 2 introduced with open(...) as file: as "the safe way to handle files," promising the file always closes. Now, with dunder methods (Module 4) and decorators (Chapter 2) under your belt, here's the actual mechanism.
with EXPRESSION as VARIABLE:
BODY
is equivalent to:
manager = EXPRESSION
VARIABLE = manager.__enter__()
try:
BODY
finally:
manager.__exit__(...) # ALWAYS runs, success or exception
Any object implementing __enter__ and __exit__ is a context manager — with is just syntax that calls these two methods for you, guaranteeing __exit__ runs no matter what.
2. Building a Context Manager with __enter__/__exit__
class Timer:
"""Context manager that times how long the `with` block took to run."""
def __enter__(self):
import time
self.start = time.perf_counter()
return self # whatever __enter__ returns becomes the `as` variable
def __exit__(self, exc_type, exc_value, traceback):
import time
elapsed = time.perf_counter() - self.start
print(f"Elapsed: {elapsed:.4f} seconds")
return False # False means: don't suppress any exception (Section 3)
with Timer() as t:
total = sum(range(1_000_000))
print(total)
# Elapsed: 0.0123 seconds
# 499999500000Execution Order
─────────────────────────────────────────
with Timer() as t:
│
▼
Timer().__enter__() runs → returns self, bound to `t`
│
▼
BODY runs (the sum(...) line)
│
▼
Timer().__exit__(...) runs → ALWAYS, even if BODY raised an exception
─────────────────────────────────────────
3. Handling Exceptions in __exit__
__exit__ receives details about any exception that occurred inside the with block — this lets a context manager clean up and optionally decide whether to suppress the error.
class SuppressErrors:
"""Context manager that swallows any exception raised inside the block."""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is not None:
print(f"Suppressed an error: {exc_type.__name__}: {exc_value}")
return True # True means: SUPPRESS the exception, don't propagate it
with SuppressErrors():
print("Before the error")
raise ValueError("Something went wrong")
print("This never runs")
print("Execution continues here — the error was suppressed!")__exit__ Return Value | Effect |
|---|---|
False (or None) | Exception (if any) propagates normally — the default, safest choice |
True | Exception is suppressed — use sparingly, only when you genuinely want to swallow specific errors |
Best practice: only return True when you've checked exc_type and are deliberately handling a specific expected exception — blindly suppressing everything hides real bugs, echoing the bare-except warning from Module 3, Chapter 4.
4. The @contextmanager Decorator — an Easier Way
Writing a full class with __enter__/__exit__ is verbose for simple cases. contextlib.contextmanager lets you write a context manager as a single generator function (Chapter 1) — the yield marks the boundary between setup and teardown.
from contextlib import contextmanager
import time
@contextmanager
def timer():
start = time.perf_counter()
yield # code BEFORE yield = __enter__, code AFTER = __exit__
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed:.4f} seconds")
with timer():
total = sum(range(1_000_000))Mapping to the Class-Based Version
─────────────────────────────────────────
@contextmanager
def timer():
start = time.perf_counter() ← runs during __enter__
yield ← the `with` block's BODY runs here
elapsed = ... ← runs during __exit__ (cleanup)
print(...)
─────────────────────────────────────────
@contextmanager
def open_file(path, mode):
"""A simplified re-implementation of what `open()` already does for you."""
file = open(path, mode)
try:
yield file
finally:
file.close() # guaranteed to run, even if the with-block raises
with open_file("notes.txt", "r") as f:
print(f.read())When to use which style: the class-based approach (__enter__/__exit__) is better when the context manager needs to hold significant state or be reused across many with blocks; @contextmanager is quicker for simple, one-off setup/teardown logic.
5. Practical Context Manager Examples
from contextlib import contextmanager
import os
@contextmanager
def change_directory(path):
"""Temporarily change the working directory, then restore it."""
original = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(original) # ALWAYS restore, even if the block raises
with change_directory("/tmp"):
print(os.getcwd()) # /tmp
print(os.getcwd()) # back to the original directory automatically@contextmanager
def temporary_setting(config, key, value):
"""Temporarily override a config value, restoring the original afterward."""
original = config.get(key)
config[key] = value
try:
yield
finally:
config[key] = original
settings = {"debug": False}
with temporary_setting(settings, "debug", True):
print(settings["debug"]) # True
print(settings["debug"]) # False — restored automaticallyThis "temporarily change something, guarantee it's restored" pattern is one of the most common real-world uses for custom context managers.
6. Type Hints — the Basics
Python remains dynamically typed (Module 1, Chapter 2) — type hints are optional annotations that document expected types without being enforced by the interpreter at runtime. They exist purely to help humans and tools (IDEs, mypy) catch mistakes earlier.
def greet(name: str) -> str: # name should be a str; function returns a str
return f"Hello, {name}!"
age: int = 25 # variable annotation
price: float = 19.99
is_active: bool = True
print(greet("Alice")) # "Hello, Alice!" — works normally
print(greet(42)) # ALSO runs without error! Hints are NOT enforced at runtime.Type Hints Are Documentation, Not Enforcement
─────────────────────────────────────────
def greet(name: str) -> str:
↑ ↑
"should be" "should return"
a hint, checked by TOOLS (mypy, IDEs), never by Python itself
─────────────────────────────────────────
Why bother, if Python won't enforce them? Type hints make function signatures self-documenting, let your editor autocomplete and catch mistakes as you type, and enable static checkers (Section 8) to catch bugs before you ever run the code — increasingly standard in professional Python codebases, including most ML libraries used later in this curriculum.
7. Type Hints for Collections & Optional Values
from typing import Optional
def process_items(items: list[str]) -> dict[str, int]:
"""Modern syntax (Python 3.9+): built-in generics, no typing.List needed."""
return {item: len(item) for item in items}
def find_user(user_id: int) -> Optional[str]:
"""Optional[str] means: returns either a str, OR None."""
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # returns None if user_id isn't found
result = find_user(1) # "Alice"
missing = find_user(99) # None — this is exactly why Optional[str] is accurate here# Union types (Python 3.10+ syntax) — a value that could be one of several types
def parse_id(value: int | str) -> int:
return int(value)
# Type aliases for readability in complex signatures
UserId = int
UserName = str
def get_username(user_id: UserId) -> UserName:
...| Hint | Meaning |
|---|---|
list[str] | A list containing strings |
dict[str, int] | A dict with string keys, int values |
Optional[str] (or str | None) | Either a string, or None |
int | str | Either an int or a string |
8. Checking Types with mypy
Type hints only become genuinely useful for catching bugs once you run a static type checker against them — mypy is the standard tool.
pip install mypy# script.py
def add(a: int, b: int) -> int:
return a + b
result = add(5, "10") # this RUNS fine in plain Python (no error at runtime)...$ mypy script.py
script.py:4: error: Argument 2 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)mypy catches the mismatch without ever running the code — purely by analyzing the hints. This is exactly the value proposition: bugs caught during development/CI, before the code ever reaches a user.
9. Summary & Next Steps
Key Takeaways
withis syntax sugar over an object's__enter__/__exit__methods —__exit__always runs, even if the block raises an exception, and can optionally suppress that exception by returningTrue.@contextlib.contextmanagerturns a generator function into a context manager: code beforeyieldis setup, code after is teardown (usually insidefinally).- Type hints (
def f(x: int) -> str:) are optional, unenforced-at-runtime annotations — their value comes from IDE support and static checkers likemypy, not from Python itself. Optional[T](orT | None) documents that a value might beNone— a common and important signal in function signatures.
Concept Check
- What does returning
Truefrom__exit__actually do to an exception raised inside thewithblock? - In a
@contextmanager-based generator function, what does the code afteryieldcorrespond to? - Why does
add(5, "10")run without error in plain Python even with adef add(a: int, b: int) -> int:signature, and what tool would catch the mismatch?
Next Chapter
→ Chapter 4: functools, itertools & Dataclasses
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index