Python reports problems by raising exceptions rather than returning error codes. This chapter covers the four parts of a try statement, raising your own exceptions and designing exception hierarchies, context managers for reliable cleanup, and testing your code with pytest and the standard unittest module.
Exceptions and the try statement
When an exception is raised, normal execution stops and Python unwinds the call stack looking for a matching handler. If none is found, the program prints a traceback and exits. A try statement has up to four clauses:
def read_number(path: str) -> int:
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
print(f"{path} does not exist")
return 0
else:
try:
return int(f.read().strip())
except ValueError as e:
print(f"not a number: {e}")
return 0
finally:
f.close() # runs on success and on failure
Catch only the exceptions you can actually handle. A bare except: or a blanket except Exception: swallows genuine bugs along with the failure you expected. To handle several types the same way, list them in a tuple: except (KeyError, IndexError):.
Raising and defining exceptions
Use raise when input or state is invalid. For domain-specific failures, subclass Exception. A common pattern is one base class per library with more specific subclasses underneath, so callers can catch broadly or narrowly as they prefer.
class PaymentError(Exception):
"""Base class for payment failures."""
class InsufficientFunds(PaymentError):
def __init__(self, needed: int, available: int) -> None:
super().__init__(f"needed {needed}, only {available} available")
self.needed = needed
self.available = available
def pay(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise InsufficientFunds(amount, balance)
return balance - amount
try:
pay(1000, 5000)
except PaymentError as e:
print("payment failed:", e)
When you catch a low-level error and re-raise something more meaningful, write raise NewError(...) from e. The original cause stays attached to the traceback, which makes debugging much easier.
Context managers and with
Files, network connections and locks must be released after use. The with statement guarantees that cleanup happens when the block exits, even if an exception escapes it, and it reads far better than a hand-written try/finally. To write your own, the contextlib.contextmanager decorator turns a generator into a context manager.
import time
from contextlib import contextmanager
with open("notes.txt", "w", encoding="utf-8") as f:
f.write("hello\n")
# the file is already closed here
@contextmanager
def timer(label: str):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
with timer("summing"):
total = sum(range(1_000_000))
Testing with pytest
pytest is the most popular testing tool in the Python world. It discovers files and functions whose names start with test_, and it lets you use the plain assert statement; when an assertion fails, it shows you exactly which values differed.
# tests/test_pay.py
import pytest
from payments import InsufficientFunds, pay
def test_pay_reduces_balance():
assert pay(1000, 300) == 700
def test_pay_rejects_overdraft():
with pytest.raises(InsufficientFunds):
pay(100, 500)
@pytest.mark.parametrize("amount", [0, -10])
def test_pay_rejects_non_positive(amount):
with pytest.raises(ValueError, match="positive"):
pay(1000, amount)
def test_writes_file(tmp_path):
target = tmp_path / "out.txt" # a fresh temporary directory per test
target.write_text("ok", encoding="utf-8")
assert target.read_text(encoding="utf-8") == "ok"
python -m pip install pytest
pytest # run the whole suite
pytest -k overdraft -v # only tests matching "overdraft", verbosely
Requesting tmp_path simply by naming it as a parameter is an example of a fixture: reusable setup that pytest injects for you. You can define your own with @pytest.fixture.
unittest
The standard library also ships unittest, so it works without installing anything. Tests are methods starting with test_ on a subclass of TestCase, and checks use methods such as assertEqual. pytest runs unittest-style tests too, so the two can coexist in one project.
import unittest
from payments import pay
class PayTest(unittest.TestCase):
def test_pay(self):
self.assertEqual(pay(1000, 300), 700)
def test_negative(self):
with self.assertRaises(ValueError):
pay(1000, -1)
if __name__ == "__main__":
unittest.main()
Summary
- Handle failures with
try/except/else/finally, catching only exceptions you can deal with. - Raise exceptions for invalid input, build a small hierarchy from
Exception, and chain causes with from. - Manage anything that needs cleanup with
with and context managers. - Write tests with pytest's
assert, pytest.raises and fixtures; unittest remains available in the standard library.
0 comments
Sign in · Sign in to leave a comment.