Capstone — The Black Box
In short: an aircraft's flight recorder doesn't prevent crashes — after
one, it says what the aircraft was doing in the minutes before. Quarry needs
the same thing: a bounded recorder that keeps the last stretch of every
log record, a console that only shows a person what matters, and one
function, run_safely, that catches whatever escapes the pipeline, sorts it
into "failed" (an anticipated QuarryError) or "crashed" (a bug), and
leaves behind the error, its chain of causes, where it happened, and the
recorder's last few lines — detaching the recorder on every way out, so the
next run starts clean.

Keep only the last few lines of a run
A list you trim by hand works; the standard library already has the exact
structure. A deque with a maxlen drops the oldest item the moment a new
one arrives:
from collections import deque
box = deque(["started"], maxlen=2)
box.append("line 0")
box.append("line 1")
box.appendleft("again")
print(list(box))For the black box that means appending on the right and never thinking about size again — the buffer always holds exactly the most recent stretch.
Give the box everything, the console only what matters
In lesson 2 you wrote ListHandler, which kept every formatted line. The
black box is the same idea with a bounded memory — and because it's a
handler, the pipeline doesn't know it exists. The console gets WARNING
and up; the box gets everything:
The console showed one line; the box holds the last three records at every level. Nobody wants to read the DEBUG lines of a successful run — but after a failure, those are exactly the lines that say what was going on. A handler's level can only narrow what reaches it, never widen it:
import logging
log = logging.getLogger("quarry.bb2")
log.handlers.clear()
log.propagate = False
log.setLevel(logging.WARNING)
seen = []
class Keep(logging.Handler):
def emit(self, record):
seen.append(record.levelname)
log.addHandler(Keep(logging.DEBUG))
log.debug("line 0")
log.warning("line 0 skipped")
print(seen)That's why run_safely sets the quarry logger to DEBUG before attaching
the box, and relies on each handler's level to decide who hears what.
Sort a crash from an anticipated failure, in the right order
run_safely is the one place allowed to catch everything, because it
doesn't swallow anything — it turns the exception into a report. The order
of the clauses still matters, the same rule as lesson 1:
A broken source is an anticipated failure, not a crash — but it's being reported as "crashed". Fix the handler so a QuarryError is "failed" and anything else is "crashed".
In run_safely you can use either shape: two clauses in the right order,
or one except Exception as exc: followed by isinstance(exc, QuarryError)
— the second keeps the report-building code in one place.
The report also names the function the exception was raised in, found by
walking the traceback: exc.__traceback__ starts at the frame that caught
it, and each tb_next steps one call deeper to the innermost frame:
The first name is <module>, where the try ran; the last is
summarise, where the division happened — run_safely wants only that
last one. The cause chain is the lesson-1 loop: follow __cause__, or
__context__ when it isn't suppressed, until nothing is left.
Read a report the way you'd read it at 09:00: status crashed, so it's a
bug, not bad data; raised in summarise; the recorder shows both rows were
skipped just before. The fix isn't in summarise's arithmetic — it's
deciding what a batch with no good rows should report.
Traps worth knowing
Detach in finally. A return inside except still runs finally
first, so one cleanup line covers success, failure and crash alike.
Copy the buffer out — list(box.records) — because the deque itself
belongs to a handler that's about to be detached. Don't let the report
crash: everything in it comes from the exception object you already hold,
so building it can't raise a second error in the middle of explaining the
first. Check the happy path too: a clean run returns "ok" and an empty
dump — the box is only opened after an accident.
Quick practice
from collections import deque
log = deque(maxlen=2)
for word in ["open", "read", "parse", "close"]:
log.append(word)
print(list(log))class QuarryError(Exception): pass
def outcome(exc):
try:
if exc:
raise exc
except QuarryError:
return "failed"
except Exception:
return "crashed"
return "ok"
print(outcome(None), outcome(QuarryError("x")), outcome(KeyError("y")))The box should be marked detached whichever way run_once leaves — right now a failing run skips it entirely, because the return inside except exits before that line runs.
🎯 Your turn
Build run_safely(lines). The starter holds Quarry's errors and its
pipeline: run(lines) logs through quarry.yard and quarry.vault, skips
bad rows with a warning, and lets a SourceError, a bug or a failed summary
escape. Finish BlackBox.emit, cause_chain and innermost, then:
- Get the
quarrylogger, set it toDEBUG, and attach aBlackBox(BOX_SIZE)formatted withFORMAT. - Call
run(lines). On success return{"status": "ok", "result": <run's result>, "error": None, "chain": [], "where": None, "dump": []}. - If it raises, return
"failed"for aQuarryErroror"crashed"otherwise, with"result": None, the class name,cause_chain(exc),innermost(exc)andlist(box.records)as the dump. - Detach the box on every way out.
run_safely(["id=a;ms=100", "", "id=c;ms=3"])
# → {"status": "failed", "result": None, "error": "SourceError",
# "chain": ["SourceError"], "where": "load_row",
# "dump": ["INFO quarry.yard: run started: 3 lines",
# "DEBUG quarry.yard: line 0: 'id=a;ms=100'",
# "DEBUG quarry.yard: line 1: ''"]}Press ▶ Run and hit ✓ Check. When it's green, the black box is done: a crash at 03:12 leaves an answer, not a mystery. ✅
