Custom Exceptions & Context Managers
Custom Exceptions & Context Managers
Custom Exception Classes
Defining your own exceptions makes errors more informative and lets callers catch only the errors they care about:
class AppError(Exception):
"""Base class for all application errors."""
class ValidationError(AppError):
"""Raised when input validation fails."""
def __init__(self, field, message):
self.field = field
super().__init__(f"{field}: {message}")
class DatabaseError(AppError):
"""Raised when a database operation fails."""Having an AppError base class lets callers catch all your exceptions with except AppError while still allowing fine-grained handling when needed.
The with Statement
The with statement is Python's context manager protocol. It guarantees that setup and teardown code runs, even if an exception occurs:
with open("data.txt", "r") as f:
contents = f.read()
# f.close() is called automatically hereenter and exit
Any class that implements __enter__ and __exit__ can be used as a context manager:
class ManagedResource:
def __enter__(self):
print("Acquiring resource")
return self # returned as the 'as' target
def __exit__(self, exc_type, exc_val, exc_tb):
print("Releasing resource")
return False # False = don't suppress exceptionsIf __exit__ returns True, the exception is suppressed; return False (or None) to let it propagate.
contextlib.contextmanager
The @contextmanager decorator turns a generator function into a context manager — far less boilerplate than writing a class:
from contextlib import contextmanager
@contextmanager
def timer():
import time
start = time.perf_counter()
try:
yield # code inside the with block runs here
finally:
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed:.4f}s")The yield splits the function into setup (before) and teardown (after). The finally guarantees the teardown runs even on exception.
Practical Error-Handling Patterns
- Fail fast: validate inputs at the boundary, not deep inside.
- Wrap third-party errors: catch library exceptions and re-raise as your own custom exceptions.
- Log then re-raise: log the error for debugging, then propagate it.
- Use context managers for any resource that needs explicit cleanup (files, network connections, locks, database transactions).
Code Examples
class ShopError(Exception):
"""Base class for all shop errors."""
class OutOfStockError(ShopError):
def __init__(self, item, requested, available):
self.item = item
self.requested = requested
self.available = available
super().__init__(
f"'{item}' out of stock: requested {requested}, only {available} left"
)
class InvalidItemError(ShopError):
pass
inventory = {"apple": 5, "banana": 2}
def purchase(item, quantity):
if item not in inventory:
raise InvalidItemError(f"Unknown item: '{item}'")
if inventory[item] < quantity:
raise OutOfStockError(item, quantity, inventory[item])
inventory[item] -= quantity
return f"Purchased {quantity}x {item}"
# Test each scenario
for item, qty in [("apple", 3), ("banana", 5), ("mango", 1)]:
try:
print(purchase(item, qty))
except OutOfStockError as e:
print(f"Stock error: {e} (field: {e.item})")
except InvalidItemError as e:
print(f"Item error: {e}")
except ShopError as e:
print(f"General shop error: {e}")Custom exceptions carry structured data (item, requested, available) as attributes, enabling programmatic handling. The ShopError base class allows callers to catch all shop-related errors with a single except clause.
class DatabaseConnection:
def __init__(self, host):
self.host = host
self.connected = False
def __enter__(self):
print(f"Connecting to {self.host}...")
self.connected = True
return self # available as the 'as' variable
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connected:
print(f"Disconnecting from {self.host}")
self.connected = False
if exc_type is not None:
print(f"Handling exception: {exc_type.__name__}: {exc_val}")
return False # do not suppress the exception
# Normal use
print("--- Normal ---")
with DatabaseConnection("localhost:5432") as db:
print(f"Connected: {db.connected}")
print("Running query...")
# Exception during use
print("\n--- With error ---")
try:
with DatabaseConnection("remote:5432") as db:
raise RuntimeError("Query failed")
except RuntimeError:
print("Caught RuntimeError in outer scope")__enter__ runs when entering the with block and its return value is bound to the as variable. __exit__ receives exception info (or three Nones if no exception occurred) and always runs, ensuring the connection is closed.
from contextlib import contextmanager
import time
@contextmanager
def timer(label="Block"):
start = time.perf_counter()
print(f"{label}: starting")
try:
yield # the with-block body executes here
except Exception as e:
print(f"{label}: exception — {e}")
raise
finally:
elapsed = time.perf_counter() - start
print(f"{label}: finished in {elapsed:.6f}s")
@contextmanager
def temporary_value(d, key, value):
"""Temporarily set a dict key, then restore original."""
original = d.get(key)
d[key] = value
try:
yield d
finally:
if original is None:
del d[key]
else:
d[key] = original
# Use the timer
with timer("Sum computation"):
total = sum(range(100_000))
print(f" sum = {total}")
# Use the temporary dict modifier
config = {"debug": False}
print("Before:", config["debug"])
with temporary_value(config, "debug", True) as cfg:
print("During:", cfg["debug"])
print("After:", config["debug"])@contextmanager turns a generator into a context manager. Code before yield is __enter__; code after yield is __exit__. The try/finally ensures cleanup even when exceptions occur inside the with block. The actual elapsed time will vary on your machine.
Quick Quiz
1. What value should __exit__ return to suppress an exception?
2. What is the purpose of a base exception class (e.g. AppError(Exception))?
3. In a @contextmanager function, where does the with-block body execute?
Was this lesson helpful?