Warm-up: from Day 3
From memory first.
1. Predict the line. Door.close isn’t even a method.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
class File:
def close(self):
print("closed")
class Door:
close = "not a method at all"
print(isinstance(File(), Closable), isinstance(Door(), Closable), isinstance(3, Closable))Check your prediction
True True FalseA runtime isinstance check against a protocol only asks whether the names
exist — not their types, not whether they’re callable. That’s why the Day 3
checkpoint said its tests could only check names, and why the rest is mypy’s
job. From Protocols: Say What You Use.
2. Fix. A “fast path” loader should still refuse bad data: this should print
refused: views must be a non-negative int.
class Positive:
def __set_name__(self, owner, name):
self.name, self.slot = name, "_" + name
def __get__(self, instance, owner=None):
return instance.__dict__[self.slot]
def __set__(self, instance, value):
if not isinstance(value, int) or value < 0:
raise ValueError(f"{self.name} must be a non-negative int")
instance.__dict__[self.slot] = value
class Post:
views = Positive()
def load(row):
post = Post.__new__(Post)
post.__dict__["_views"] = row["views"] # the "fast path"
return post
try:
load({"views": "lots"})
print("loaded")
except ValueError as error:
print("refused:", error)Check your fix
class Positive:
def __set_name__(self, owner, name):
self.name, self.slot = name, "_" + name
def __get__(self, instance, owner=None):
return instance.__dict__[self.slot]
def __set__(self, instance, value):
if not isinstance(value, int) or value < 0:
raise ValueError(f"{self.name} must be a non-negative int")
instance.__dict__[self.slot] = value
class Post:
views = Positive()
def load(row):
post = Post.__new__(Post)
post.views = row["views"] # through the descriptor
return post
try:
load({"views": "lots"})
print("loaded")
except ValueError as error:
print("refused:", error)refused: views must be a non-negative intWriting into __dict__ goes behind the descriptor, so its check never runs.
Day 3’s bug hunt, the fast path that let a string in.
3. Explain. No code: what can a type checker catch that tests can’t — and what can tests catch that a checker can’t?
Check your explanation
A checker catches mismatched shapes — wrong types, misuse in callers’ code,
a protocol a real class doesn’t satisfy — before anything runs, including in
code no test exercises. Tests catch behaviour: that save commits, that bad
values are refused. Neither replaces the other. From Day 3 Checkpoint: What
the Checker Can’t See.
Today
So far the library raises ValueError for a bad field, LookupError for a
missing row, and lets sqlite3.IntegrityError straight through. Each choice is
defensible on its own, and together they leave a caller with no way to say
“catch anything this library raises” — and no way to tell your ValueError
from one thrown by their own code three frames down.
One base class, and no more than you need
class ORMError(Exception):
"""Base class for every error raised by this library."""
class ValidationError(ORMError, ValueError): ...
class NotFound(ORMError, LookupError): ...
class IntegrityError(ORMError): ...
ORMError is the whole point: except ORMError is a complete answer to “this
library failed”. Below it, one class per situation a caller might handle
differently. Not per message, and not per call site — if two errors would
always be caught together, they’re one class with two messages.
Don't insert classes in the middle later
A hierarchy is a promise about what catches what. Adding NotFound under
ValidationError later, because both are “bad input”, silently changes which
except clauses fire in code you’ve never seen. Get the shape right early and
then only add leaves.
Keep the built-in meaning
Each error also inherits from the built-in it used to be:
class ValidationError(ORMError, ValueError): ...
class NotFound(ORMError, LookupError): ...
This is what makes the change backwards-compatible. Code written against
yesterday’s library says except ValueError and keeps working, while new code
says except ValidationError.
class ORMError(Exception):
"""Base class for every error raised by this library."""
class ValidationError(ORMError, ValueError):
"""A value a field refused."""
def check(value):
if not isinstance(value, int):
raise ValidationError(f"views must be an int, not {type(value).__name__}")
return value
for catcher in (ORMError, ValueError, Exception):
try:
check("lots")
except catcher as error:
print(f"except {catcher.__name__:12} caught {type(error).__name__}: {error}")
print("MRO:", " -> ".join(cls.__name__ for cls in ValidationError.__mro__))It is a genuine trade, not a free win. except ValueError around a call into
your library will now also swallow your validation errors, which a caller may
not have intended. The alternative — a clean break — is worse for exactly one
release and better forever after, so the usual answer is: inherit from the
built-in now, document that you’ll drop it in the next major version, and mean
it.
LookupError for NotFound is worth a second look, because KeyError is the
obvious alternative. KeyError prints its argument with repr and nothing
else — KeyError: 99 — and it means “this key isn’t in this mapping”. A row
that doesn’t exist isn’t a mapping lookup, and the message matters more than
the family. LookupError is the parent of both KeyError and IndexError, and
says what you mean without inheriting that formatting.
Translating driver errors
sqlite3.IntegrityError escaping from save() is a leak of the same kind as
sqlite3.Connection in a signature: it ties every caller to today’s driver.
So the library wraps it:
if isinstance(error, errors.DRIVER_INTEGRITY_ERRORS):
raise errors.IntegrityError(str(error)) from error
Two details carry the weight.
from error sets __cause__, so the traceback shows both:
sqlite3.IntegrityError: UNIQUE constraint failed: post.title
The above exception was the direct cause of the following exception:
errors.IntegrityError: UNIQUE constraint failed: post.title
Nothing is hidden. A caller handles errors.IntegrityError; a maintainer
debugging a weird case still sees exactly what the driver said. Compare
yesterday’s raise ... from None in Field.__get__: there the inner KeyError
was an implementation detail with no diagnostic value. Here the inner exception
is the diagnosis.
class ORMError(Exception):
pass
def with_cause():
try:
raise KeyError("_views")
except KeyError as error:
raise ORMError("field is not set") from error
def hidden():
try:
raise KeyError("_views")
except KeyError as error:
raise ORMError("field is not set") from None
def implicit():
try:
raise KeyError("_views")
except KeyError:
raise ORMError("field is not set")
for function in (with_cause, hidden, implicit):
try:
function()
except ORMError as error:
print(f"{function.__name__:12} __cause__={error.__cause__!r} "
f"__context__={error.__context__!r}")implicit — raising inside an except without saying anything — still records
the original as __context__, and the traceback reads “During handling of the
above exception, another exception occurred”. That’s the default, and it’s
usually fine internally; from error upgrades it to a deliberate statement, and
from None is the only way to actually hide something.
Which driver exceptions? The library can’t import sqlite3 — Day 3 was
about not tying itself to one driver — so it can’t name them. Instead, whoever
owns the connection registers them:
DRIVER_INTEGRITY_ERRORS: tuple[type[BaseException], ...] = ()
def use_driver(*, integrity: type[BaseException]) -> None:
global DRIVER_INTEGRITY_ERRORS
DRIVER_INTEGRITY_ERRORS = (integrity,)
db.connect() calls it. This is module-level mutable state, which is a real
cost: two drivers in one process would fight over it, and a test that registers
one leaks into the next. A bigger library solves this with a dialect object
passed around explicitly. Day 7 revisits it as one of the design trade-offs
worth writing down rather than hiding.
Design the hierarchy
Build the errors for a small HTTP client, so the tests describing how callers want to handle them pass. Read the tests first — they’re the specification, and they imply which errors share a parent.
import unittest
class ClientError(Exception):
"""TODO: base for everything this client raises."""
# TODO: Retryable, Timeout, ConnectionFailed, HTTPError(status),
# NotFound and ServerError. The tests decide how they relate.
class Errors(unittest.TestCase):
def test_everything_is_a_client_error(self):
for error in (Timeout, ConnectionFailed, HTTPError, NotFound, ServerError):
self.assertTrue(issubclass(error, ClientError), error.__name__)
def test_retryable_errors_share_a_parent(self):
"""A caller should be able to catch "worth retrying" in one clause."""
for error in (Timeout, ConnectionFailed, ServerError):
self.assertTrue(issubclass(error, Retryable), error.__name__)
self.assertFalse(issubclass(NotFound, Retryable))
def test_a_timeout_is_also_the_builtin_one(self):
self.assertTrue(issubclass(Timeout, TimeoutError))
def test_http_errors_carry_their_status(self):
error = HTTPError(503, "Service Unavailable")
self.assertEqual(error.status, 503)
self.assertIn("503", str(error))
self.assertIn("Service Unavailable", str(error))
def test_not_found_knows_its_own_status(self):
self.assertEqual(NotFound("/posts/1").status, 404)
self.assertIn("/posts/1", str(NotFound("/posts/1")))
def test_a_server_error_is_any_5xx(self):
self.assertEqual(ServerError(503).status, 503)
self.assertTrue(issubclass(ServerError, HTTPError))
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Errors)
)Hint 1: the idea
The tests are the spec, and they group errors by how a caller will handle
them, not by where they come from. Look for the grouping that cuts across the
obvious one: which errors should a single except clause catch as “worth
trying again”? A class can have two parents — that’s how one error sits in two
groups.
Hint 2: a first step
Build the plain tree first: ClientError, then Retryable under it, then
Timeout and ConnectionFailed under Retryable. Run; the first tests pass.
Then HTTPError with a status — a class-level default lets NotFound know
its own status without being told.
Show one solution
class ClientError(Exception):
"""Base for everything this client raises."""
class Retryable(ClientError):
"""Worth trying again: nothing about the request itself was wrong."""
class Timeout(Retryable, TimeoutError):
"""The server took too long."""
class ConnectionFailed(Retryable):
"""The connection could not be made."""
class HTTPError(ClientError):
"""The server answered with an error status."""
status = 0
def __init__(self, status=None, message=""):
self.status = type(self).status if status is None else status
super().__init__(f"{self.status} {message}".strip())
class NotFound(HTTPError):
status = 404
def __init__(self, path=""):
super().__init__(None, path)
class ServerError(HTTPError, Retryable):
"""Any 5xx."""The interesting design decision is Retryable, and the test drove it: callers
want one except clause for “try again”, and that cuts across the obvious
grouping. A timeout and a 503 have nothing in common technically — one never
reached the server, the other came back from it — but they share the only thing
the caller cares about.
That’s the rule worth taking away: group by how it will be handled, not by
where it came from. ServerError(HTTPError, Retryable) sits in both
hierarchies, which is exactly what multiple inheritance is for here.
status as a class attribute with an instance override lets NotFound know
its own status while HTTPError(503, ...) still takes one. And Timeout
inheriting TimeoutError is the same backwards-compatibility move the ORM
makes: code already catching the built-in keeps working.
What you learned
- One base class per library, and one subclass per situation a caller handles differently.
- Inheriting the built-in your error replaces keeps existing
exceptclauses working through the change. raise ... from errorwhen the cause helps diagnosis,from Nonewhen it’s an implementation detail; doing neither still records__context__.- Driver exceptions must be translated at the boundary, and the library needs some way to learn about them that isn’t an import.