Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Capstone — Quarry  ›  Capstone Project

Capstone — Quarry, End to End

35 minEvery piece of the arc in one run: find, read with retries, decode, extract, stream into SQLite, log what went wrong, report
You're building a piece ofQuarry — end to end
This piece — run_quarry(): Finds, reads, decodes, extracts, deduplicates and stores a directory of logs, and reports what it couldn't read.
Scenario Monday's yard holds a week of logs from three services: one in an old Windows encoding, one on a mount that drops the first read, one on a share that's gone. Quarry has to store every real record once and say exactly what it couldn't read.
Your task
Write feed(root, read, tally) and run_quarry(files). files maps paths to hex bytes. Plant them in a temporary directory, wrap the reader as retry(3)(timed(timings)(make_reader(root))), and stream records from find_sources → read → decode → extract_fields into SQLite with one executemany over the feed generator. Skip blank lines; count and log (WARNING quarry.extractor "skipped <file>:<line>") lines that don't match; store a line seen before in any file only once, counting it as a duplicate; give up on a file whose read still fails (ERROR quarry.yard "gave up on <file>"). Return {"sources": {file: encoding}, "failed": [...], "stored", "skipped", "duplicates", "levels" (GROUP BY level), "attempts" (read attempts timed), "log" (WARNING and above, as "LEVEL logger: message")}. Example: one file with a started line → {"sources": {"app.log": "utf-8"}, "failed": [], "stored": 1, "skipped": 0, "duplicates": 0, "levels": {"INFO": 1}, "attempts": 1, "log": []}.

Capstone — Quarry, End to End

In short: nine sections built nine separate pieces of Quarry; none has run together yet. This capstone is the wiring: one run_quarry(files) that walks a directory of messy logs, reads each through a retrying ring, decodes and matches every line, streams the good ones into SQLite, and returns a report — including everything it couldn't read. The code is mostly written; your job is the order, which is also where real pipelines break.

Six boxes in a row — directory, yard, decode, extract, dedupe/SQLite, report — with dashed notes showing a dead file turning into an ERROR, a bad line into a WARNING, and a repeat key into a duplicate instead of a second row

Nine pieces, one run: inspector freezes a record into a hashable key, feed passes records one at a time, ring wraps the reader in retry/timed, yard finds and decodes files, vault inserts into SQLite as parameters, extractor turns a line into named fields, shipment is the package layout below, black box sorts failures, test rig proves the report true. Every box below is live.

Drawing diagram…

Lay the pieces out as a package

The browser runs one file, so the starter keeps the nine pieces as banners, in package order:

quarry/
├── errors.py       QuarryError, SourceError
├── yard.py         plant, find_sources, decode, make_reader
├── extractor.py    LOG, extract_fields
├── ring.py         retry, timed
├── blackbox.py     ListHandler, FORMAT
├── vault.py        SCHEMA, INSERT
└── cli.py          feed, run_quarry, main   (this capstone)

The dependency rule still holds: cli.py imports everything, nothing imports cli.py. So run_quarry can be tested with nothing printed, while main() stays a thin layer that only prints. The if __name__ == "__main__": guard lets the file run as a script without doing anything when it's merely imported.

Stream straight into the database

The feed is a generator: it yields one record, then waits. The vault inserts with executemany. Hand one to the other:

What does this print?
import sqlite3

pulled = []

def rows():
    for n in range(3):
        pulled.append(n)
        yield (n,)

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (n INTEGER)")
feed = rows()
print(len(pulled), end=" ")
conn.executemany("INSERT INTO t VALUES (?)", feed)
print(len(pulled))

executemany pulls any iterable one row at a time, so Quarry never holds a file's records in a list — each line is matched, checked for duplicates and inserted before the next is even read:

python

The trace callback prints each statement as SQLite runs it, and the feed's own prints land between the inserts — a million-line log costs the same memory as three lines. So every tally the feed keeps (skipped, duplicates, failed) is only final once executemany returns — read it after the with conn: block, never before.

Wrap the reader before it can lie

make_reader(root) returns read_source(path): a disk on a bad day. A flaky- file fails its first read, a down- file fails every read, both raising SourceError. The ring's retry(3) and timed(timings) wrap it — and the order you apply them in decides what timed measures:

What does this print?
calls = []

def timed(func):
    def wrapper(*args):
        calls.append(func.__name__)
        return func(*args)
    return wrapper

def retry(times):
    def decorate(func):
        def wrapper(*args):
            for attempt in range(times):
                try:
                    return func(*args)
                except OSError:
                    pass
            raise OSError("gave up")
        return wrapper
    return decorate

failures = [1]

def read(path):
    if failures:
        failures.pop()
        raise OSError(path)
    return b"rows"

read = retry(3)(timed(read))
read("flaky-mount.log")
print(len(calls))

Quarry wants retry(3)(timed(timings)(make_reader(root))) — timed inside — so len(timings["read_source"]) counts attempts, showing a mount getting worse before it fails outright. Retry also logs each failed attempt on quarry.ring, so the report shows which files needed a second try.

Follow one line through every stage

A single line from a Windows machine, stepping through bytes, a guessed encoding, a regex match and a hashable key. Watch encoding settle on cp1252 after UTF-8 refuses the é:

step through it
1import re
2 
3LOG = re.compile(
4 r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) "
5 r"(?P<level>DEBUG|INFO|WARN|ERROR) \[(?P<component>[a-z][a-z0-9-]*)\] (?P<message>.+)"
6)
7seen = set()
8raw = "2026-09-01 11:00:00 INFO [extractor] café opened\r\n".encode("cp1252")
9for encoding in ("utf-8", "cp1252"):
10 try:
11 text = raw.decode(encoding)
12 break
13 except UnicodeDecodeError:
14 pass
15line = text.splitlines()[0]
16fields = LOG.fullmatch(line).groupdict()
17key = tuple(fields.values())
18is_duplicate = key in seen
19seen.add(key)
20record = {"source": "legacy.log", **fields}

splitlines() drops the \r\n, so a Windows line matches the same pattern as a Unix one, and fullmatch — not search — refuses a line with junk in front. The key is a tuple: a dict can't go in a set, but its values, frozen, can; it leaves out source on purpose, so the same line in app.log and archive/app.log counts as one event. {"source": ..., **fields} has exactly the names INSERT uses as placeholders, so a message like '); DROP TABLE records; -- goes in as a parameter, stored as text.

Sort trouble into three buckets

trouble example what the run does logged as
a source fails down-nas.log never reads retry twice, give up on that file, move on ERROR quarry.yard: gave up on down-nas.log
a line is bad not a log line skip it, count it, keep going WARNING quarry.extractor: skipped app.log:3
Quarry has a bug a KeyError in your own code nothing — let it crash with its traceback

Only SourceError is caught, and only around the read — catching Exception anywhere would turn a real bug into a healthy-looking report.

The run attaches a ListHandler (level WARNING) to the quarry logger; quarry.yard, quarry.extractor and quarry.ring all propagate up to it, so one handler hears all three. It has to come off when the run ends — this version forgets:

broken — fix it

The first run's report should hold only its own warning, but the second run's warning lands in it too. Make each run clean up after itself.

The grader calls run_quarry five times in one process; without the finally, the fifth report would carry all five runs' warnings.

Prove it before you trust it

A report is only as good as its tests. A small self-contained runner, two tests of the extractor:

python

The most useful tests are about the drawing's dashed arrows: report["failed"] == ["down-nas.log"] for a dead mount, and report["duplicates"] == 1 when archive/app.log repeats app.log — a run that silently drops a file looks just like one with nothing to read.

Cross-question. Why is feed a separate generator, not a loop inside run_quarry? It holds every decision — retry or give up, skip, dedupe — so it tests alone: list(feed(root, read, tally)), no database needed.

Quick practice

What does this print?
seen = set()
results = []
for fields in [{"level": "INFO", "msg": "ok"}, {"msg": "ok", "level": "INFO"}]:
    key = tuple(sorted(fields.items()))
    results.append(key in seen)
    seen.add(key)
print(results)
What does this print?
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (level TEXT)")
conn.executemany("INSERT INTO t VALUES (?)", [("WARN",), ("ERROR",), ("WARN",)])
print(conn.execute("SELECT level, COUNT(*) FROM t GROUP BY level ORDER BY level").fetchall())

🎯 Your turn

The starter holds every finished piece. You write two functions in cli.py:

feed(root, read, tally), a generator. For each path from find_sources(root), with rel its path relative to root:

  1. read(path). On SourceError, log YARD.error("gave up on %s", rel), append rel to tally["failed"] and move on.
  2. decode the bytes; record tally["sources"][rel] = encoding.
  3. Number the lines from 1, skip blanks. If extract_fields returns None, add 1 to tally["skipped"] and log EXTRACTOR.warning("skipped %s:%d", rel, number).
  4. If the fields were seen before (any file), add 1 to tally["duplicates"]; otherwise yield {"source": rel, **fields}.

run_quarry(files): attach a ListHandler to logging.getLogger("quarry") (removed in finally), open a :memory: database, plant the files in a TemporaryDirectory, build read = retry(3)(timed(timings)(make_reader(root))), and conn.executemany(INSERT, feed(root, read, tally)) in one transaction. Return:

{"sources": {...}, "failed": [...], "stored": COUNT(*), "skipped": n,
 "duplicates": n, "levels": {level: count}, "attempts": len(timings["read_source"]),
 "log": box.lines}

Example: app.log holding one INFO [yard] started line gives {"sources": {"app.log": "utf-8"}, "failed": [], "stored": 1, "skipped": 0, "duplicates": 0, "levels": {"INFO": 1}, "attempts": 1, "log": []}.

Press ▶ Run to see main() print a report, then ✓ Check. Green means Quarry runs end to end: messy files in, a trustworthy report out. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA dict can't go in a set

Quarry sees the same record twice when an archive copy of a log sits beside the original. Write count_duplicates(records): records is a list of flat dicts of strings, and you return how many of them are equal to a record that came earlier. Key order doesn't matter ({"a": "1", "b": "2"} equals {"b": "2", "a": "1"}). A dict is unhashable, so freeze each one into something a set will take.

mutabilitysets
DrillWhich side of the retry is the stopwatch on?

The starter gives retry(times), timed(timings) and make_flaky(failures), a reader that fails its first failures calls. Write timings_recorded(order, failures): build the reader with retry(3), putting timed on the inside ("timed-inside": retry(3)(timed(timings)(raw))) or the outside ("timed-outside"), call it once with "mount.log", and return {"timings": how many durations were recorded, "gave_up": True if SourceError escaped}.

decoratorsexceptions
BuildFrom bytes to fields, one file

Write extract_all(hex_data) for one file's bytes (as hex). Decode them with the starter's decode, then run every non-blank line through LOG.fullmatch. Return {"encoding": ..., "levels": {level: count}, "skipped": [line numbers that didn't match, first line = 1]}. Blank lines are neither counted nor skipped.

text-encodingregex-groups
BossTests that bite

Write your own tests for Quarry's extractor. Fill in check_extractor(extract) with plain asserts: extract(line) returns the five named fields or None. The starter's suite_catches(variant) runs your tests against the real extractor ("correct") and against four broken ones: "search" (accepts a log line with junk in front), "any-level" (accepts error and Info), "keeps-brackets" (the component comes back as "[vault]"), "loose-date" (accepts 2026-9-1). Your tests must pass on the correct one and fail on every broken one.

test-designmutation-testingregex-groups
CapstoneWho is shouting?

Write noisiest_component(files). files maps paths to hex bytes, as in the main task. Plant them in a temporary directory, stream every log line from every .log file (hidden folders skipped, any encoding) through extract_fields with a generator, and feed that generator straight into executemany on a :memory: table. Then ask SQL for the component with the most ERROR records, ties going to the alphabetically first. Return {"component": name, "errors": count}, or {"component": None, "errors": 0} if there are no errors.

sqlitegenerator-pipelinesfilesystem-pathstext-encoding
ExplainRetry it, skip it, or crash?

In the finished run, a read that fails is retried and then given up on, a line that isn't a log line is skipped with a warning, and anything else (a KeyError in Quarry's own code, say) is not caught at all. Explain why each kind of trouble gets different treatment, what the log is for in each case, and why the retry is a decorator around one stage rather than a try/except around the whole run.

run_quarry.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
run_quarry(files) → dictFind, read, decode, extract, deduplicate and store a directory of logs, and report what happened, including what went wrong.
try:
returned
Quarry — end to end0/1 pieces built
Each lesson builds one real piece of Quarry's end-to-end run. Green = built, glowing = you're here. Click any piece to jump to it.
Press Run to execute your code.
Press Check to run the tests for this lesson.