◈ python mapVol 2 · Ch 30/30
Volume 2 Python, from the metal up · chapter 30

30The capstone — ship a real app

In Chapter 29 we took a slow program, ran a profiler over it, and made the one line that actually mattered fast. In this one we do the hardest thing in the whole volume. We take everything and ship it. Here's the plan. The two songs that have travelled with us since Chapter 0 — "Blinding Lights" at 200 seconds and "Titanium" at 245 — finally get a real home. That home is a playlist manager a stranger can install, start, trust, and change, built from nothing but the standard library. Every skill you proved once, alone, in a scratch file — the dataclass, the atomic save, the profiler pass — now has to meet the others in one running program. And the whole way through we keep asking the one question this volume has circled from the start: what actually crosses the boundary between your program and the disk, the kernel, the next process — the live object, or bytes? By the end we've shipped it. Six clean crossings — module, type, kernel, environment, correctness, performance — meet in a single app that survives being closed. Not one big skill. The sum of six small ones.

iolinked · chapter 30 — the checkpoints7 steps
$ sections covered in The capstone — ship a real app
01Assemble the pieces
02The model: dataclasses with checked hints
03Storage: durable, atomic, serialized
04Structure and environment
05Proof: tests and a debugging pass
06Responsiveness and the hot path
07What 'done' means

01Assemble the pieces

Let's start with the blank file. Open a brand-new folder, create main.py, and look at the cursor blinking at you. That cursor is asking a question no scratch file ever asked. You've spent this whole volume collecting good ideas: an atomic save from chapter 18, a dataclass from chapter 12, a profiler run from chapter 29. Every one of them worked, once, in a scratch file you've since deleted. Now they have to live together, and the cursor wants to know which line goes first? That's the real terror of the blank main.py. It's not that you're short on ideas. It's that you've got twelve of them and no shape to hang them on. Type them all into one file, top to bottom, and by this afternoon you own an 800-line function that runs but can never be read. Change the save format and you're re-reading the shuffle logic just to be sure you didn't break it.

The cure isn't discipline or good taste. Watch what it actually is: a mechanical property of how Python loads code. Once you see it, you can lean the whole app against it. "Separation of concerns" sounds like a slogan. Cash it out and it means something concrete: a unit you can import and exercise without dragging its collaborators into existence. Our app splits into exactly four files. The split is decided by one rule: arrows point down the dependency graph, never up, never in a circle.

the four files (a package named app/)text
app/
  __init__.py     # marks the folder as a package (can be empty)
  model.py        # pure data: Song, Playlist — imports NOTHING of ours
  engine.py       # operations on data: add, remove, shuffle, next_song
  storage.py      # bytes to objects and back — the ONLY file that touches disk
  __main__.py     # the command loop — the ONLY file that touches stdin/stdout

model sits at the bottom because it depends on nothing of ours. It is pure data, constructible in a test with no filesystem, no clock, no I/O. engine imports model. storage imports model. __main__ imports engine and storage. Every seam between them is a plain function signature, like add_song(playlist, song) or save_atomic(path, playlist). And every signature is something a test can call in one line. That is the whole payoff of the layout. Each idea gets placed exactly once, and you can reason about it without holding the other eleven in your head.

To trust this you have to know what an import actually does. The module boundary is not a folder convention. It is a real runtime boundary. A module is an object. The first time import app.model runs, CPython looks for the key 'app.model' in sys.modules, a plain dict that caches every module ever loaded. Not finding it, CPython locates the file and builds a new empty module object. Then it does something crucial: it inserts that object into sys.modules immediately, before running a single line of the body. Only then does it execute model.py top to bottom to populate it. The names you define land in model.__dict__. Every later import app.model, from anywhere, finds the key and hands back that exact same cached object. The file is never run twice.

arrows point DOWN only — an acyclic stack__main__ · command loopenginestoragemodel (leaf)cycle =half-builtmodulesys.modulesthe import cache'app.model' → <module>'app.engine' → <module>2nd import app.modelreturns cached — file NOT re-run
Fig — A strictly downward layering makes cycles impossible; sys.modules caches each module so a re-import hands back the same object instead of re-running the file.

Now watch why an up-arrow is not a style problem but a correctness bug. Suppose storage.py imports engine while engine.py imports storage. That's a cycle. Python starts importing engine, drops a half-built engine into sys.modules, runs its body, hits from app.storage import save, and dives into storage. Now storage's body hits from app.engine import next_song and finds engine already in the cache. So it does not re-import. It just reads next_song out of engine's namespace. But engine's body never reached the line that defines next_song. It is paused two frames up, mid-import. The name does not exist yet, so Python raises ImportError: cannot import name 'next_song'. That half-initialized module in sys.modules is the whole disease.

THE ONE IDEA TO CARRY FORWARD
Your architecture is your import graph. Keep the arrows pointing one way, toward the layer that depends on nothing, and "testable in isolation" stops being an aspiration — it becomes a fact you can check by importing a file alone and watching it not complain. A cycle is not ugly; it is a module that half-exists.
Wait — if two files really must share a name and the cycle is unavoidable, you do not have to break the design — you can move the import inside the function that needs it. A deferred import runs at call time, long after every module body has finished, so the cache is fully populated and the name exists. It works, but it is a smell: nine times out of ten the honest fix is to push the shared thing down into a lower module that both can import cleanly.

✗ The myth

"Splitting into files is cosmetic — the code runs the same whether it is one file or four, so it is just tidiness for humans."

✓ The reality

The split changes what you can test. Four acyclic modules mean engine can be imported and exercised with a hand-built Playlist and zero disk. One 800-line file means every test boots the whole program. The seams are where correctness gets checkable.

The bottom of the graph is model.py — pure data, depended on by everyone, depending on no one. So it had better be exactly right. Next: how to state the shape of a Song once and let the language manufacture the rest. →

02The model: dataclasses with checked hints

You are about to write the same three chunks of boilerplate a hundred times. An __init__ that does nothing but self.title = title; self.artist = artist; self.duration_s = duration_s. An __eq__ so two identical songs compare equal. A __repr__ so that when a test fails it prints something you can actually read instead of <Song object at 0x1f3a>. Hand-writing this is where silent bugs breed. Six months from now you add a tags field, update __init__, and forget to update __eq__. Now two songs that differ only in their tags test as equal, and a deduplication somewhere quietly eats one of them. What you want instead is to state the shape of a Song exactly once. That single statement becomes the source of truth that both the running program and the type checker read.

app/model.pypython
from dataclasses import dataclass, field

@dataclass(frozen=True)
class Song:
    title: str
    artist: str
    duration_s: int
    tags: tuple = ()

@dataclass
class Playlist:
    name: str
    songs: list = field(default_factory=list)

Those thirteen lines look declarative, but @dataclass is doing something you should never take on faith: it is writing code. A decorator is just a function. It receives the class object after the class body has finished executing, and it returns a class. dataclass reaches into the class's __annotations__. That's the ordered dict {'title': str, 'artist': str, 'duration_s': int, 'tags': tuple} that Python builds automatically from every name: type line in the body. From that dict it generates source text for __init__, __repr__, and __eq__. Then it compiles that text with exec and attaches the resulting functions to the class. You can see the evidence it leaves behind:

generated.pypython
>>> from app.model import Song
>>> Song.__init__.__doc__            # the signature dataclass synthesized
'Song(title: str, artist: str, duration_s: int, tags: tuple = ()) -> None'
>>> list(Song.__dataclass_fields__)   # the Field objects it parsed from the hints
['title', 'artist', 'duration_s', 'tags']
>>> Song('Blinding Lights', 'The Weeknd', 200)
Song(title='Blinding Lights', artist='The Weeknd', duration_s=200, tags=())

Now the sharpest point in the whole chapter, and the one most people never internalize: those type hints have two different audiences who treat them completely differently. At runtime, the annotations are inert. They are values sitting in a dict, and Python never checks them. Song(title=42, artist='x', duration_s=200) runs perfectly and stores the integer 42 where a str was promised. The enforcement is a separate, earlier pass. A static checker — mypy or pyright — reads those very same annotations before the program ever runs and flags the mismatch. So the field hints do double duty. They drive code generation, and they are the contract the checker verifies. The dict feeds both readers, but only one of them cares.

@dataclassclass Song:title: strplays: int__annotations__{'title': str, 'plays': int}eats the dict@dataclass generatesdef __init__(self,title, plays): …def __eq__(self, o): …def __repr__(self): …real source, from the annotationsstatic checker (before run)Song(title=42)✗ 42 is int, expected strruntime (ignores types)Song(title=42)stores 42 — no check
Fig — Typing name: type fills one annotations dict; the dataclass turns it into real methods, a checker enforces it, and the running program never looks.
THE MUTABLE-DEFAULT TRAP
Write tags: list = [] and dataclass raises ValueError: mutable default on purpose. A bare [] in the class body is one list, shared by every instance — append to one song's tags and every song grows a tag. The fix is field(default_factory=list): the factory is called inside the generated __init__, minting a fresh empty list for each instance. That is why our Playlist.songs uses it and why Song.tags dodges the whole issue with an immutable tuple.

Two more knobs, both earned. frozen=True makes Song generate a __setattr__ that raises FrozenInstanceError on any assignment, plus a __hash__. So a Song becomes a genuine value: immutable, hashable, safe to drop in a set or use as a dict key. That is exactly what you want for the thing that flows through your storage layer and back. And field order matters. A field with no default cannot follow one that has a default, because the generated __init__(self, a, b=1, c) would be an illegal signature. Python raises TypeError at class-definition time, before you ever construct anything.

Wait — frozen=True does not make a Song deeply immutable. If a field held a real list, you still could not rebind the field, but you could song.tags.append('rock') and mutate it in place — frozen guards the binding, not the object it points at. That is precisely why we made tags a tuple: an immutable field for a value type that is honest all the way down.

The model is exact and value-typed. But it lives in RAM, and RAM is volatile — chapter 0's oldest warning. To outlive the process it has to become bytes on a disk, durably, without a crash ever leaving a smear. Next: the save that has only two outcomes. →

03Storage: durable, atomic, serialized

Here is the scenario that makes this section matter more than any other. The user adds "Titanium" to a fifty-song playlist and hits save. Half a second later the battery dies, or the kernel panics, or someone trips over the power cord. They reboot and open the app. What do they see? With the obvious code — open(path, 'w') then json.dump — the answer is often a corrupt, truncated JSON file that crashes the loader. Here's why: open(path, 'w') truncates the real file to zero bytes the instant it opens, then streams the new bytes in. A crash anywhere in that window leaves a half-written smear where fifty good songs used to be. You did not lose the one edit. You lost everything. What you need is a save with exactly two possible outcomes from the reader's view: the complete old playlist or the complete new one. And the data must be genuinely on the disk platter, not merely promised.

That is two independent guarantees, and you need both. The first is atomicity via rename. On POSIX, rename(2) over an existing path is atomic: the directory entry is repointed from the old inode to the new one in a single indivisible operation. Any reader who opens the target sees one inode or the other, never a blend. So the recipe is: write the entire new content to a temporary file in the same directory, then rename the temp over the target. Same-directory is not optional — a rename across filesystems is secretly a copy-then-delete, which is not atomic, and Python's os.replace silently falls back to that if the temp is elsewhere. Use os.replace, not os.rename: on Windows os.rename raises if the target exists, while os.replace mimics POSIX overwrite semantics on every platform. That one choice is the cross-platform contract.

The second guarantee is durability via fsync. This is the one people skip, and it is the one the power cut punishes. A plain write() only copies your bytes into the kernel's page cache. They are in RAM, and the disk may not physically see them for seconds. Crash before that writeback and the data is gone, even though write() returned success. os.fsync(fd) blocks until the storage device confirms the bytes are on stable media. Here's the subtle part almost everyone misses. You must fsync the file so the data blocks are durable. You must also fsync the containing directory so the rename itself — the new directory entry — is durable. Skip the directory fsync and a crash can leave your file's data perfectly safe while the directory still points at the old name.

RECAP · the files-and-data cardchapters 15 to 18 on one card — the six modes, the encoding law, the two-step that survives a power cut, json and csv
-- THE SIX MODES. one line, six different promises about the file that is already there. with open(path, "r", encoding="utf-8") as f: -- read text. the file must already exist. with open(path, "w", encoding="utf-8") as f: -- write text. TRUNCATES to empty, first. with open(path, "a", encoding="utf-8") as f: -- append. every write is forced to the end. with open(path, "x", encoding="utf-8") as f: -- exclusive create. refuses to clobber. with open(path, "rb") as f: -- the b suffix: bytes. no codec, no newline fix. with open(path, "r+", encoding="utf-8") as f: -- the + suffix: read AND write, no truncate. f.read() f.read(n) f.readline() f.readlines() -- four ways in. the position moves under you. for line in f: use(line.rstrip("\n")) -- THE loop: lazy, flat memory, any file size f.write(text) f.writelines(rows) print(t, file=f) -- only print() adds the newline for you -- THE ENCODING LAW. say it on every open, every time. encoding="utf-8" -- leave it out and the PLATFORM guesses: utf-8 on Linux, often cp1252 on Windows. two machines, two files. errors="strict" -- the default. raises UnicodeDecodeError. errors="replace" / "ignore" -- one U+FFFD per bad byte / drop it, silently, forever errors="backslashreplace" / "surrogateescape" -- keep it as text \xNN / smuggle it, byte-perfect from pathlib import Path p = Path("data") / "songs.txt" -- / joins. you never type a separator again. p.parent p.name p.stem p.suffix -- the four parts of a name p.exists() p.is_file() p.is_dir() -- ask before you assume p.write_text(s, encoding="utf-8") p.read_text(encoding="utf-8") -- open+do+close, one call -- DURABILITY. two calls, and the order is a law, not a style. f.flush() -- step 1: YOUR heap -> the kernel's page cache. ALWAYS FIRST. os.fsync(f.fileno()) -- step 2: the kernel -> the medium. BLOCKS. close() will flush. close() will NOT sync. -- THE ATOMIC SAVE. four steps, none optional. fd, tmp = tempfile.mkstemp(dir=folder) -- 1. same directory = same filesystem f.write(data); f.flush(); os.fsync(fd) -- 2. the temp file's DATA is durable os.replace(tmp, path) -- 3. ONE atomic name flip fsync_dir(folder) -- 4. persist the rename itself (POSIX) except BaseException: os.unlink(tmp); raise -- leave no litter behind -- SERIALISATION. the live object goes out as bytes, and comes back rebuilt. json.dumps(obj) / json.loads(text) -- the 's' is for STRING json.dump(obj, f) / json.load(f) -- no 's': you are talking to a FILE json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False) -- readable, diffable, not \u00f6 -- tuple -> list. int key -> "1". set / datetime -> TypeError. json.dumps(obj, default=fn) -- fn(o) for every value dumps cannot map with open(p, "w", newline="", encoding="utf-8") as f: -- csv: newline="" ALWAYS csv.writer(f).writerows(rows) -- csv.DictWriter(f, fieldnames=[...]).writeheader() for row in csv.reader(f): ... -- row is a LIST of str. every cell. including "245". for row in csv.DictReader(f): ... -- row is a DICT; line 1 names the keys pickle.dumps(obj) -> bytes pickle.loads(blob) -> a live object -- 'wb'/'rb', never encoding= -- THE LAW: never loads() bytes you did not write yourself.
"w" is a weaponIt truncates the target to zero bytes the instant it opens, before one new byte is written. That gap is the crash window this whole section exists to close.
say the encodingOmit encoding= and the platform picks: UTF-8 on Linux, often cp1252 on Windows. Same code, two machines, two different files.
flush is not fsyncflush() drains Python’s buffer into the kernel. Only os.fsync blocks until the device confirms. close() flushes; close() never syncs.
same directoryThe temp file must sit beside the target. Across a filesystem, os.replace becomes copy-then-delete, and copy-then-delete is not atomic.
replace, not renameos.rename raises if the target exists on Windows; os.replace overwrites on every platform. One word is the whole cross-platform contract.
json is not PythonA tuple comes back a list, an int key comes back "1", and a set or a datetime raises TypeError.
csv hands you strEvery cell arrives as a str, "245" included, so you convert on the way in. And newline="" is not decoration.
the pickle lawpickle.loads does not read data. It runs a program. Never point it at bytes you did not write yourself.
The durability gap — when does a "saved" file actually reach the disk?Drag · interactive
write() returned “success” — but where are the bytes really? Then the power dies… ≈ 30 s is how long a file you just “saved” can live in RAM only sitting in the page cache before the disk physically sees it. write() — no fsync kernel writeback ≈ 30 s LOST still in page cache write() + os.fsync() fsync forces disk ≈ 5 ms SAFE on the platter 1 ms 10 ms 100 ms 1 s 10 s 60 s ⚡ power loss in RAM only — a power cut erases it on the platter — survives the crash
10.0 s
Drag the crash instant. A full 10.0 s after “saved,” the plain write() file is GONE and only the fsync’d one survives. Same bytes, same rename — the only difference is one os.fsync() call, which is why a save that skips it passes every test on a machine that never crashes at the wrong microsecond.
Fig — write() only reaches the kernel’s page cache; without fsync the bytes can linger in RAM for up to ~30 s before writeback. Drag the power-loss moment and watch which identical save actually made it to the platter.
app/storage.pypython
import os, json, tempfile
from dataclasses import asdict
from app.model import Song, Playlist

def save_atomic(path, playlist):
    data = json.dumps(asdict(playlist), indent=2).encode("utf-8")
    folder = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=folder, suffix=".tmp")  # SAME dir
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()               # Python buffer -> kernel page cache
            os.fsync(f.fileno())    # kernel -> physical disk
        os.replace(tmp, path)       # atomic pointer flip, one tick
        dfd = os.open(folder, os.O_RDONLY)
        try:
            os.fsync(dfd)           # make the rename itself durable (POSIX)
        finally:
            os.close(dfd)
    except BaseException:
        os.unlink(tmp)              # never leave a stray temp on failure
        raise

Underneath sits the serialization layer. It exists because json.dump cannot take a Song — JSON has no concept of your types. So you bridge through the dict. asdict(playlist) flattens the whole dataclass tree into nested dicts and lists that JSON understands. This is the volume's law in miniature: the live object is flattened to a byte stream on the way out. On load you must reverse it, because JSON round-trips a Song back into a plain dict. Something has to turn that dict back into a Song and re-validate it:

app/storage.py — the way backpython
def load(path):
    with open(path, "rb") as f:
        raw = json.loads(f.read())          # dicts and lists, no Songs yet
    songs = [Song(**{**s, "tags": tuple(s["tags"])}) for s in raw["songs"]]
    return Playlist(name=raw["name"], songs=songs)  # rebuilt + revalidated
a ⚡ = crash at that instantNAIVEopen('w')target → 0 bytesempty / partialdanger zonedata GONEATOMICwrite tmpfsync tmpos.replaceflip pointer · 1 tickfsync dircrashanywhere →original OKdirectory { name → inode }data.json →inode 12 oldinode 47 newrename redraws ONE pointer
Fig — Truncating the target risks losing everything; writing a temp, fsyncing, then os.replace swaps one directory pointer so any crash still finds the original whole.
THE ONE IDEA TO CARRY FORWARD
A safe save is two promises, not one. Rename gives you all-or-nothing — the reader never sees a half-file. fsync gives you it's-really-there — the bytes survive a power cut. Skip either and you have a save that usually works, which is the most dangerous kind, because it passes every test you run on a machine that never crashes at the wrong microsecond.
Wait — on Windows you cannot os.open a directory to fsync it — that call raises PermissionError. But the atomicity still holds, because Windows implements os.replace on top of MoveFileEx with a replace-existing flag, which the filesystem journals. So the portable app guards the directory fsync in a try and skips it where the OS won't allow it — the rename is still atomic; you have merely traded one flavor of durability guarantee for the platform's own.

The app now saves the way a database does. But a program a stranger cannot start might as well not exist. Next: how Python finds and runs your package, and how to hand someone an environment they can rebuild byte for byte. →

04Structure and environment

"It works on my machine." That sentence is where most projects die, and it usually dies three different ways. A stranger clones your repo and gets ModuleNotFoundError: No module named 'app', because your imports quietly assumed the current directory. Or it starts but misbehaves, because their installed library versions drifted from the ones you developed against and never wrote down. Or, most banal and most fatal, they simply cannot figure out how to start it. What you need here is a program someone who has never spoken to you can reproduce and launch: one unambiguous start command, and an environment they can recreate exactly. "Works on my machine" has to become "works on every machine," and both halves have a concrete mechanism.

Start with running. A package is a directory containing __init__.py. The command python -m app tells the interpreter to import the package named app, then execute its __main__.py as the entry point. Why does -m beat python app/__main__.py? Because of what each does to sys.path[0], the first place Python looks for imports. With -m, Python sets sys.path[0] to the current working directory. So app is on the path, and an absolute intra-package import like from app.engine import next_song resolves cleanly. It also sets __package__, so relative imports work too. Run the loose script instead and sys.path[0] becomes the script's own folder, app/. That puts engine.py on the path but not the app package itself, so from app.engine fails with ModuleNotFoundError. The -m convention is not ceremony. It is the concrete reason your imports resolve.

SYNTAX · argparse — the front door that reads what the user typedArgumentParser, positionals, --flags, type=, default= — and a -h page written for you, for free
import argparse p = argparse.ArgumentParser(prog="clip", description="trim one track") p.add_argument("title") -- POSITIONAL. no dashes, matched BY ORDER. p.add_argument("-s", "--seconds", type=int) -- OPTION. --seconds 45, or -s 45. p.add_argument("--fade", action="store_true") -- FLAG. present -> True, absent -> False. type=int -- argparse calls int() on the raw string, for you default=30 -- what the attribute holds when the user says nothing help="..." -- the sentence -h prints. %(default)s interpolates the default. required=True -- on an OPTION only. a positional is required by being one. nargs="*" -- collect the rest into a list choices=[...] -- reject anything not on the list, before your code runs args = p.parse_args() -- reads sys.argv[1:] args = p.parse_args(argv) -- ...or a LIST you hand it. this is what makes main() testable. args.title args.seconds -- a Namespace. one attribute per name, dashes become underscores. -- SUBCOMMANDS: one parser per verb, each with its own -h. what git and pip do. sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND") a = sub.add_parser("add", help="add one track") a.add_argument("duration_s", type=int) -- args.cmd == "add", args.duration_s == 245 -- chapter 19 did this by hand. the same job, and every line of it is yours to maintain: cmd = sys.argv[1] if len(sys.argv) > 1 else "help" secs = int(sys.argv[3]) -- and a raw ValueError traceback is what a typo earns the user
positionalNo dashes in the name, so it is matched by position and required by being one. "title" becomes args.title.
optionThe leading dashes make it optional and named. -s and --seconds are two spellings of one slot, and the long one names the attribute.
action="store_true"A flag carries no value. Present is True, absent is False, and you never write default=False yourself.
type=Any callable. argparse runs it on the raw string and turns a failure into a clean usage line, never a traceback in the user’s face.
parse_args(argv)Hand it a list and main() becomes testable from pytest in one line — no subprocess, no surgery on sys.argv.
-h for freeYou never write it. The parser builds usage from the arguments you declared, prints it, and exits 0.
exit code 2A usage error goes to stderr and exits 2, the shell’s convention for “you typed it wrong.” Success exits 0.
add_subparsersOne parser per verb, each with its own arguments and its own -h. That is the shape of git commit and pip install.
you type
# ---------- clip.py ----------
"""clip.py -- argparse, the whole shape, on one small command."""
import argparse, sys

p = argparse.ArgumentParser(prog="clip", description="trim one track")
p.add_argument("title", help="the track to trim")            # POSITIONAL: required, by order
p.add_argument("-s", "--seconds", type=int, default=30,      # OPTION: type= runs int() for you
               help="how long to keep (default: %(default)s)")
p.add_argument("--fade", action="store_true", help="fade the last second out")
args = p.parse_args()                    # reads sys.argv[1:] unless you hand it a list

print("sys.argv   =", sys.argv)          # ch 19's raw list, still exactly where it was
print("args       =", args)              # a Namespace: one attribute per name you declared
print(f"trimming {args.title!r} to {args.seconds}s"
      f"{' with a fade' if args.fade else ''}")


# ---------- the terminal ----------
$ python clip.py -h
$ python clip.py Titanium
$ python clip.py Titanium --seconds 45 --fade
$ python clip.py Titanium -s forty
you see
$ python clip.py -h
usage: clip [-h] [-s SECONDS] [--fade] title

trim one track

positional arguments:
  title                 the track to trim

options:
  -h, --help            show this help message and exit
  -s SECONDS, --seconds SECONDS
                        how long to keep (default: 30)
  --fade                fade the last second out

$ python clip.py Titanium
sys.argv   = ['clip.py', 'Titanium']
args       = Namespace(title='Titanium', seconds=30, fade=False)
trimming 'Titanium' to 30s

$ python clip.py Titanium --seconds 45 --fade
sys.argv   = ['clip.py', 'Titanium', '--seconds', '45', '--fade']
args       = Namespace(title='Titanium', seconds=45, fade=True)
trimming 'Titanium' to 45s with a fade

$ python clip.py Titanium -s forty
usage: clip [-h] [-s SECONDS] [--fade] title
clip: error: argument -s/--seconds: invalid int value: 'forty'
   (exit code 2)
where beginners trip
  • The -h page printed (default: 30) only because help= said %(default)s. Nothing else knows your defaults.
  • -s forty did not raise ValueError at the user. type=int failed inside the parser, so they got a usage line and exit 2.
  • sys.argv is untouched. argparse reads the list; it never consumes it, so chapter 19’s raw view is still there.
  • The attribute name is the long option with dashes turned into underscores: --db is args.db, duration_s is args.duration_s.
  • Leave required=True off add_subparsers and typing the bare program name runs with args.cmd = None, which is a crash waiting for a user.
  • A positional with no nargs is always supplied, so its default= never fires. Reach for an option the moment a value is genuinely optional.
  • Call parse_args() with no argument in a library function and you have hard-wired the terminal into your code. Pass the list.
app/__main__.pypython
import sys
from app.engine import add_song, next_song
from app.storage import load, save_atomic

def main(argv):
    cmd = argv[1] if len(argv) > 1 else "help"
    # ... dispatch cmd to the right engine function ...
    return 0

if __name__ == "__main__":      # true only when launched as the entry point
    sys.exit(main(sys.argv))

Prove the difference to yourself rather than believing it. Print the three values that decide import resolution under each launch:

launch_probe.pypython
import sys
print("sys.path[0] =", sys.path[0])
print("__package__ =", __package__)
print("__name__    =", __name__)

# python -m app     -> sys.path[0] = CWD,     __package__ = 'app'   -> imports work
# python app/__main__.py -> sys.path[0] = app/, __package__ = None    -> 'from app...' FAILS
how it startspython -m appsys.path[0] = CWD (project root)✓ from app.engine import …python app/__main__.pysys.path[0] = /proj/app (inside pkg)✗ ModuleNotFoundError: apphow it reproducessystem python.venv/bin/python ⇢ systempyvenv.cfg ⇢ basesite-packages/ (isolated)lock: flask==2.3.3 --hash=sha256:…stranger'smachinesite-packagesversions == , hashes ==
Fig — python -m app puts the root on the path so the package imports; a venv plus a hash-pinned lockfile then rebuilds the exact same environment elsewhere.
RECAP · the project cardchapters 19 to 22 on one card — a module, the guard, a package, the search path, the launch, the venv and the pyproject
-- A MODULE IS A .py FILE. nothing to declare, nothing to register. import tracks -- the FILE NAME, minus .py. no path, no quotes, no extension. import tracks as tk -- the same object, under a local name you pick from tracks import as_clock -- binds the ATTRIBUTE's value, not the module from tracks import * -- binds every public name. you did not read the list. tracks.__name__ __doc__ __file__ __package__ __spec__ -- what import handed you for free dir(tracks) vars(tracks) -- sorted names / the namespace dict -- THE GUARD. a plain string comparison. nothing more. if __name__ == "__main__": -- the PROGRAM face: runs only as the entry point sys.exit(main(sys.argv[1:])) LIBRARY = ... -- top level is the LIBRARY face: every importer gets it python tracks.py -> __name__ == "__main__" -- guard TRUE python -m tracks -> __name__ == "__main__" -- guard TRUE import tracks -> __name__ == "tracks" -- guard FALSE -- A PACKAGE IS A DIRECTORY HOLDING __init__.py. myproject/ -- the REPO. no __init__.py. never imported. pyproject.toml -- the identity card. read by TOOLS, never by import. jukebox/ -- THE PACKAGE. this folder is what ships. __init__.py -- the facade. empty is legal. runs FIRST, every time. __main__.py -- the front door: python -m jukebox tracks.py -- the leaf: imports nothing internal formats/ -- a SUBpackage: a package inside a package __init__.py tests/ -- OUTSIDE the package. imports it like a stranger. from . import tracks from .tracks import as_clock -- level 1: a sibling from ..tracks import as_clock -- level 2: strip one name, append __all__ = ["Track", "load"] -- curates `import *` and NOTHING else. it is not a lock. -- THE LAUNCH. one convention, and it is the reason your imports resolve. python -m jukebox -- sys.path[0] = the CWD, __package__ = 'jukebox' -> imports work python jukebox/__main__.py -- sys.path[0] = jukebox/, __package__ = None -> they FAIL python -m jukebox play 2 -- the extra words land in sys.argv[1:] sys.path -- ONE ordered list of folders. not a registry, not a database. sys.modules["json"].__file__ -- WHICH file won. the shadow test, in one line. random.py, saved beside you -- YOUR file beats the stdlib. the fix is a rename, not an install. importlib.util.find_spec("X") -- None means no folder on sys.path answers to that name __pycache__/beat.cpython-312.pyc -- one .pyc per IMPORTED module. deleting it is always safe. -- THE ENVIRONMENT. a path trick you can inspect, not a sandbox. python -m venv .venv -- its own site-packages + a pyvenv.cfg pointing home sys.prefix != sys.base_prefix -- am I in a venv? the DEFINITION, not a guess. python -m pip install rich==13.7.0 -- THE LAW: the pip owned by the python you just named python -m pip list / show / uninstall -- what is on THIS interpreter's shelf, right now python -m pip install -e . -- install THIS project, editable: the source stays live python -m pip freeze > requirements.lock -- exact versions. add hashes and drift becomes an error. [project] -- PEP 621. every tool reads this table. name = "jukebox" version = "0.1.0" requires-python = ">=3.9" dependencies = ["rich==13.7.0"] -- YOUR Requires-Dist. the resolver reads this. [build-system] requires = ["setuptools>=61"] build-backend = "setuptools.build_meta"
a module is a fileNo manifest, no export list, no build step. Save tracks.py beside your code and import tracks finds it.
the guard is a string test__name__ is a string the interpreter sets, and if reads it like any other. There is no magic in the line at all.
-m versus the file-m sets sys.path[0] to the working directory and __package__. The loose script sets neither, so both import styles die.
__init__.pyIt is the package’s own module body and it runs first, every single time. Empty is legal, and for a small package it is usually right.
__all__ is not a lockIt curates from pkg import * and nothing else. from pkg.storage import _tmp_name still works, because Python has no private.
tests live outsidePut tests/ beside the package, never inside it. Then the suite imports your code exactly the way a stranger will.
python -m pippip is a program PATH happened to find; -m is the pip this interpreter owns. Only one of them can be wrong.
a venv is a path trickActivating just prepends a folder to PATH. The real switch is sys.prefix, computed from the venv python’s own location.
pin, do not hopeA line reading rich means “some version, we’ll see.” rich==13.7.0 plus a hash is what makes two installs match months apart.

Now the environment. A virtual environment is not magic and not a sandbox. It is a directory with its own python binary (or a symlink to one), its own site-packages, and a small text file, pyvenv.cfg, that points back at the base interpreter that created it. "Activating" it merely prepends the venv's bin (or Scripts) folder to your PATH, so the word python resolves there. The real switch is that this python computes sys.prefix from its own location, so it searches the venv's site-packages first. Isolation is a path trick, fully explainable and fully inspectable:

venv_probe.pypython
import sys
print(sys.prefix)         # INSIDE a venv: points at the venv folder
print(sys.base_prefix)    # always the base interpreter that built it
# prefix != base_prefix  =>  you are in a venv. Equal  =>  you are not.
A LOOSE REQUIREMENT IS NOT A LOCK
A line that reads requests says "some version, we'll see." A lockfile line reads requests==2.31.0 --hash=sha256:... — an exact version of every transitive dependency, plus a content hash so pip install rejects a tampered or drifted package. That precision is what makes two installs, months apart, produce byte-identical trees. Our app is standard-library only, so its lock is nearly empty of third-party deps — but you still pin the Python version itself, and fewer moving parts to pin is its own durability claim.
Wait — if the venv holds "its own python," why is it often only a few kilobytes? Because on most systems it is not a copy — it is a symlink (or a tiny shim on Windows) back to the base interpreter's executable. The standard library is not duplicated either; pyvenv.cfg tells the venv python where the base lib lives. What the venv genuinely owns is just its site-packages — the one folder where your installed dependencies go, kept apart from every other project's.

Now anyone can install it and start it. But can anyone trust it? Belief that the code is correct does not survive the next edit. Next: turning "I think it works" into a green suite you can run in one command — and a method for the day it goes red. →

05Proof: tests and a debugging pass

You believe the app is correct. Belief is not evidence, and worse, it does not survive the next change. You tune the shuffle to feel more random, and without noticing you shift an index by one, so next_song now skips the last track. Nothing errors. The suite is in your head, so it never runs. A user finds it three weeks later. What you need is twofold. First, a mechanical proof, runnable in one command, that the claims that matter still hold after every edit. Second, a repeatable method for the moment a test does go red, so "it's broken" becomes "the fault is on line 34, here is the exact bad value, and here is the test that will scream if it ever comes back." A green suite is the difference between a program you hope works and one you can prove works.

tests/test_engine.pypython
from app.model import Song, Playlist
from app.engine import add_song, next_song
from app.storage import save_atomic, load

def test_song_equality_is_by_value():        # model layer — pure, no I/O
    a = Song("Titanium", "Sia", 245)
    b = Song("Titanium", "Sia", 245)
    assert a == b and hash(a) == hash(b)

def test_next_wraps_to_start():                # engine layer — in-memory, no disk
    pl = Playlist("set", [Song("A", "x", 1), Song("B", "y", 2)])
    assert next_song(pl, current=1) == pl.songs[0]  # after last -> first

def test_save_load_round_trips(tmp_path):     # storage layer — tmp_path fixture
    pl = Playlist("set", [Song("Titanium", "Sia", 245)])
    p = tmp_path / "pl.json"
    save_atomic(str(p), pl)
    assert load(str(p)) == pl           # bytes out, bytes in, same object

pytest is three mechanisms stacked: collection, execution, introspection. Collection: it imports every test_*.py, finds functions named test_*, and builds a list of test items. Execution: it calls each one inside its own try/except. An uncaught exception — the AssertionError that a failed assert raises — marks that item failed and moves on. The third mechanism is the one worth opening, because it looks like sorcery. Take a plain assert a == b in ordinary Python. When it fails, it gives you nothing but the bare word AssertionError, no values. Yet pytest prints assert 3 == 4, with the actual operands. How?

Because pytest rewrites the bytecode of your assert statements at import time. It installs an import hook, tapping the very sys.modules loading machinery from section 1. That hook intercepts each test module as it is compiled and walks its AST. It recompiles every assert a == b into instructions that evaluate the subexpressions, stash their values, and format a rich message if the assertion is false. So pytest is quite literally editing your test's syntax tree to make failures legible. It is not a magic assert keyword. It is Python's own import system, bent to a purpose. That is exactly why the same assert run under plain python stays mute.

RECAP · the quality-gate cardchapters 23 to 29 on one card — mypy, pytest, the five log levels, the concurrency rule and the two instruments
-- TYPES (ch 23). the runtime stores them and checks not one of them. name: TYPE = value -- annotated assignment: the hint, then a NORMAL store def f(a: int, b: str = "x") -> bool: -- hint FIRST, then the default. the arrow is the return. int | None Optional[int] -- same meaning; the pipe is 3.10+ list[int] dict[str, float] set[str] tuple[str, int] tuple[int, ...] Track = tuple[str, int] -- an ALIAS: a name for a shape you keep retyping python -m mypy --strict . -- exit 0 clean, exit 1 errors found. CI reads the code. x: int = "no" # type: ignore[assignment] -- silence ONE error, by name -- TESTS (ch 24). pytest is a pip install, not a stdlib module. python -m pip install pytest python -m pytest -q tests/test_tracks.py -- the FILENAME is the query; test_* is the filter def test_total_sums_the_library(): -- ARRANGE, ACT, ASSERT. one behaviour, one function. assert total(shelf) == 785 -- the rewrite reprs both operands. one keyword, whole vocab. with pytest.raises(ValueError, match=r"impossible length"): -- the error path is behaviour too def test_saves(tmp_path): -- fixtures are injection BY PARAMETER NAME tmp_path, capsys, monkeypatch already exist @pytest.mark.parametrize("seconds,expected", [(0, "0:00"), (3600, "60:00")]) -- one body, N items python -m pytest -q -x -k "clock" --collect-only -- quiet / stop at red / filter / the inventory -- WHEN IT GOES RED (ch 25). read the traceback BOTTOM-first -- the last line names the disease; the block above, the site print(f"{secs=}") -- python copies the SOURCE TEXT as the label: secs=245 print(repr(title)) -- 'Titanium ' -- there. a trailing space str() was hiding. breakpoint() -- (Pdb) n s c p expr q -- the essential five assert CONDITION, MESSAGE -- YOUR code only. python -O deletes every one of them. log = logging.getLogger(__name__) -- one per MODULE. it names itself. log.debug / info / warning / error / exception -- 10 / 20 / 30 / 40 / 40 + the traceback logging.basicConfig(level=..., format="%(levelname)-8s %(name)s: %(message)s") -- ONE line, ONCE, in the entry point. never in a library. log.debug("row %d of %d", i, n) -- LAZY: the % runs only if DEBUG is enabled log.debug(f"row {i} of {n}") -- eager: the string is built even when it is thrown away -- WHICH TOOL (ch 26-28). one question: while this task runs, is the CPU BUSY or WAITING? WAITING, a few hundred at once -> threads -- ch 26. the GIL is released in the syscall. WAITING, tens of thousands -> asyncio -- ch 27. one epoll loop, tiny frames. BUSY (pure Python compute) -> processes -- ch 28. one GIL per core. BUSY (numpy / torch / a C ext) -> measure first -- it may already drop the GIL cpu = time.process_time(); wall = time.perf_counter() -- cpu/wall near 0 -> waiting. near 1 -> computing. tasks too small / data too big / state to be shared -- the three vetoes on processes -- MEASURE (ch 29). never by eye. t0 = time.perf_counter() ... dt = time.perf_counter() - t0 -- monotonic, ~100 ns python -m timeit -n 200 -r 7 -s "SETUP" "STMT" -- number amortises the clock, repeat the noise min(timeit.repeat(...)) / number -- report the FLOOR. interference only ever adds. python -m cProfile -s cumtime prog.py -- the expensive SUBTREE (inclusive) python -m cProfile -s tottime prog.py -- the hot LEAF (self time, callees excluded) st = pstats.Stats("out.prof"); st.sort_stats("tottime").print_stats(10) 0 correct -> 1 measure -> 2 algorithm -> 3 data structure -> 4 layout -> 5 micro -> 6 measure again
hints are inertAt runtime they are values in a dict and Python never looks at them. mypy is a separate program, reading the same source, before it runs.
the filename is the querytest_*.py is collected and helpers.py is invisible forever. A function named check_total silently never runs — no error, no warning.
the assert rewritepytest edits your test’s AST at import time so a failure prints both operands. Under plain python the same line stays mute.
fixtures inject by nameThe parameter name is the request. tmp_path, capsys and monkeypatch already exist; never hand-roll them.
green means N claims heldNothing more. Break one line of the code on purpose and re-run: still green means no test was ever checking that line.
bottom-firstThe last traceback line names the disease. The block above names the site. The origin is usually a few frames further up.
lazy logginglog.debug("row %d", i) runs the % only when DEBUG is on. The f-string spelling builds the message even when it is discarded.
the one questionBusy or waiting. Threads pay off exactly as far as your program waits; only processes move a CPU-bound wall clock.
tottime vs cumtimetottime excludes callees and finds the hot leaf. cumtime includes them and finds the expensive subtree. Read both.
report the minimumInterference only ever adds; it cannot give cycles back. Run N times, keep the floor, and trust ratios over absolute microseconds.
why pytest can say assert 3 == 4assert next_i == expectedimport hookrewrite ASTinstrumentedbytecode — keeps operandsrich messageassert 3 == 4plain python (no hook)AssertionError ← bare, uselessthe hunt — read the traceback UPWARDload_config() ← bad value born hereread_field()to_int() ← exception raised (crash)read up(Pdb) at load_config frame(Pdb) p raw'4o7' ← live local, the culprit
Fig — An AST-rewriting import hook lets pytest print the actual operands; then you read the traceback upward to the frame where the bad value was born and inspect its live locals with pdb.

Notice tmp_path appearing as a parameter with no obvious source. That is a fixture, and fixtures are dependency injection by parameter name. Pytest sees an argument called tmp_path, finds the built-in fixture of that name, calls it, and passes its return value in. Here that value is a fresh per-test temporary directory that never touches your real data. A fixture that yields can run teardown after the test. The three tests above are deliberately layered. Model tests are pure equality on frozen values. Engine tests run on an in-memory playlist. Storage tests round-trip through a throwaway directory. Each layer is testable precisely because section 1 kept the import graph acyclic.

TYPE THIS · the capstone — five files, one shippable CLImedialib — a real command-line app a stranger could start, standard library only, no network
# ---------- the tree: make these folders and files, exactly ----------
medialib-project/
    pyproject.toml
    medialib/                 <- THE PACKAGE. this folder is what ships.
        __init__.py
        tracks.py             <- the model. imports nothing of ours.
        storage.py            <- the ONLY file that touches the disk
        cli.py                <- the ONLY file that reads argv or writes stdout
        __main__.py           <- the front door:  python -m medialib
    tests/
        test_medialib.py      <- OUTSIDE the package, like a stranger


# ---------- medialib/__init__.py ----------
"""medialib -- a tiny media library you can actually ship."""
__version__ = "1.0.0"


# ---------- medialib/tracks.py ----------
"""tracks.py -- the model. Pure data: it imports nothing of ours."""
from dataclasses import dataclass

CATALOGUE = {                        # stands in for the API a downloader would call
    "blinding-lights": ("Blinding Lights", "The Weeknd", 200),
    "titanium": ("Titanium", "David Guetta", 245),
    "levels": ("Levels", "Avicii", 203),
}


@dataclass(frozen=True)              # ch 23: the hints ARE the fields
class Track:
    title: str
    artist: str
    duration_s: int
    source: str = "local"

    @property
    def clock(self) -> str:
        m, s = divmod(self.duration_s, 60)
        return f"{m}:{s:02d}"        # ch 17: the format spec, after the colon


def matches(t: Track, needle: str) -> bool:
    n = needle.casefold()            # ch 17: casefold is for MATCHING
    return n in t.title.casefold() or n in t.artist.casefold()


def from_url(url: str) -> Track:
    """SIMULATED metadata fetch. No network: we look the slug up in CATALOGUE."""
    slug = url.rstrip("/").rsplit("/", 1)[-1]
    title, artist, secs = CATALOGUE[slug]
    return Track(title, artist, secs, source="web")


# ---------- medialib/storage.py ----------
"""storage.py -- the ONLY file that touches the disk."""
import csv, json, os, tempfile
from dataclasses import asdict
from medialib.tracks import Track


def load(path):
    if not os.path.exists(path):
        return []                                    # first run: an empty library
    with open(path, "r", encoding="utf-8") as f:     # ch 15: name the encoding
        return [Track(**row) for row in json.load(f)]


def save_atomic(path, tracks):
    data = json.dumps([asdict(t) for t in tracks], indent=2).encode("utf-8")
    folder = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=folder, suffix=".tmp")   # 1. SAME directory
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()                                # 2. python -> kernel
            os.fsync(f.fileno())                     #    kernel -> the platter
        os.replace(tmp, path)                        # 3. one atomic name flip
    except BaseException:
        os.unlink(tmp)                               # 4. leave no litter
        raise


def export_csv(path, tracks):
    with open(path, "w", newline="", encoding="utf-8") as f:    # newline="" ALWAYS
        w = csv.DictWriter(f, fieldnames=["title", "artist", "duration_s", "source"])
        w.writeheader()
        w.writerows(asdict(t) for t in tracks)
    return len(tracks)


# ---------- medialib/cli.py  (1 of 2): the parser ----------
"""cli.py -- argparse and printing. The only file that reads argv or writes stdout."""
import argparse, logging
from medialib import storage
from medialib.tracks import Track, matches, from_url

log = logging.getLogger("medialib")


def build_parser():
    p = argparse.ArgumentParser(prog="medialib", description="a tiny media library")
    p.add_argument("--db", default="library.json", help="where the library lives")
    p.add_argument("-v", "--verbose", action="store_true", help="turn on DEBUG logging")
    sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND")

    a = sub.add_parser("add", help="add one track by hand")
    a.add_argument("title")
    a.add_argument("artist")
    a.add_argument("duration_s", type=int)           # type= runs int() FOR you

    sub.add_parser("list", help="print the whole library")

    s = sub.add_parser("search", help="find tracks by title or artist")
    s.add_argument("needle")

    e = sub.add_parser("export", help="write the library out as CSV")
    e.add_argument("-o", "--out", default="library.csv")

    f = sub.add_parser("fetch", help="SIMULATED metadata fetch -- no network")
    f.add_argument("url")
    return p


# ---------- medialib/cli.py  (2 of 2): the dispatch ----------
def run(args):
    lib = storage.load(args.db)
    log.debug("loaded %d tracks from %s", len(lib), args.db)
    if args.cmd == "add":
        lib.append(Track(args.title, args.artist, args.duration_s))
        storage.save_atomic(args.db, lib)                    # ch 16, every write
        print(f"added {args.title!r} -- the library now holds {len(lib)}")
    elif args.cmd == "list":
        for i, t in enumerate(lib, 1):
            print(f"{i:>2}. {t.title:<16}{t.artist:<15}{t.clock:>6}  {t.source}")
        print(f"-- {len(lib)} tracks, {sum(t.duration_s for t in lib)}s total")
    elif args.cmd == "search":
        hits = [t for t in lib if matches(t, args.needle)]
        for t in hits:
            print(f"{t.title} -- {t.artist} ({t.clock})")
        print(f"-- {len(hits)}/{len(lib)} matched {args.needle!r}")
    elif args.cmd == "export":
        n = storage.export_csv(args.out, lib)
        print(f"wrote {n} rows to {args.out}")
    elif args.cmd == "fetch":
        t = from_url(args.url)                               # no socket is opened
        lib.append(t)
        storage.save_atomic(args.db, lib)
        print(f"fetched {t.title!r} by {t.artist} ({t.clock}) -- source={t.source}")
    return 0


def main(argv=None):
    args = build_parser().parse_args(argv)
    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING,
                        format="%(levelname)-8s %(name)s: %(message)s")
    log.debug("parsed %s", vars(args))
    return run(args)


# ---------- medialib/__main__.py ----------
"""__main__.py -- the front door. python -m medialib finds this by name."""
import sys
from medialib.cli import main

if __name__ == "__main__":          # a plain string comparison, nothing more
    sys.exit(main(sys.argv[1:]))


# ---------- the terminal: stand in medialib-project/ ----------
$ python -m medialib -h
$ python -m medialib add -h
$ python medialib/__main__.py list
$ python -m medialib -h
usage: medialib [-h] [--db DB] [-v] COMMAND ...

a tiny media library

positional arguments:
  COMMAND
    add          add one track by hand
    list         print the whole library
    search       find tracks by title or artist
    export       write the library out as CSV
    fetch        SIMULATED metadata fetch -- no network

options:
  -h, --help     show this help message and exit
  --db DB        where the library lives
  -v, --verbose  turn on DEBUG logging

$ python -m medialib add -h
usage: medialib add [-h] title artist duration_s

positional arguments:
  title
  artist
  duration_s

options:
  -h, --help  show this help message and exit

$ python medialib/__main__.py list
Traceback (most recent call last):
  File "C:\tmp\medialib-project\medialib\__main__.py", line 3, in <module>
    from medialib.cli import main
ModuleNotFoundError: No module named 'medialib'
Type all five files, then run those three commands before you read another word. The structure of this app is Ajai’s, borrowed from the YouTube Downloader in his 99-project folder: read a target from the user, hand it to one function that knows how to fetch metadata, save the result, and put the whole launch behind if __name__ == "__main__". What we changed is the honest part. His script calls yt_dlp, which needs the network and a third-party install, and neither belongs in a chapter you must be able to run on a plane. So from_url keeps the seam exactly where his did and looks the slug up in CATALOGUE instead. That is a simplification, and it is labelled one: no socket is opened anywhere in this program. Swap those two lines for a real API call and nothing else has to change. That is the point of putting a fetch behind a function. Now look at what the three runs actually proved. The first printed a help page nobody wrote. Every line of it — the verb list, the option list, the descriptions — argparse reconstructed from your add_argument calls. The second shows each subcommand carrying its own -h. That is why git commit --help works, and why yours does too. The third is the one worth pinning to the wall. The identical code, launched as a loose file instead of with -m, dies with ModuleNotFoundError: No module named 'medialib'. Nothing is broken. sys.path[0] became medialib/, so cli.py is on the path and the package is not. from medialib.cli import main then has nowhere to look. That is section 4’s claim, failing in your own terminal. And notice the shape the four files make. tracks imports nothing of ours, storage imports tracks, cli imports both, __main__ imports cli. Arrows down, no cycle, every layer testable alone — which is exactly what we cash in next.
READ A TRACEBACK BOTTOM-FIRST
A traceback is a stack of frames printed innermost-last. The bottom line names the actual complaint — the exception type and the exact line that raised it. Everything above only retraces where Python was standing on the way there. So read the bottom first, understand the fault, then climb up frame by frame to find which caller handed the bad value down. The origin of the bug is usually a few frames above where it finally exploded.

When the line alone is not enough, stop scattering print statements and re-running. Drop breakpoint() right before the fault. It's the builtin that honors PYTHONBREAKPOINT and lands you in pdb. Now you are standing inside the frame with its locals still alive:

app/engine.py — the planted bugpython
def next_song(playlist, current):
    n = len(playlist.songs)
    nxt = (current + 2) % n     # BUG: off by one — should be current + 1
    import pdb; pdb.set_trace()  # pdb> p current, n, nxt  — see the live state
    return playlist.songs[nxt]
Wait — the fix is the cheap part; the durable artifact is the regression test. Once you find the off-by-one, write a test that fails against the buggy code and passes against the fixassert next_song(pl, current=1) == pl.songs[0]. The buggy + 2 makes that index wrap wrong; the correct + 1 makes it green. From now on the bug cannot silently return, because the moment someone reintroduces it the suite screams. The bug is pinned forever, and it cost you one function.

Green and installable and startable. Then a user loads fifty thousand songs and the prompt freezes for four seconds on every save. Correct-but-unusable is still a failure they feel. Next: keeping the loop responsive, and fixing the right slow line. →

06Responsiveness and the hot path

The app is correct and proven, and then a user loads a fifty-thousand-song library and the prompt freezes solid for four seconds on every save, or the interface locks while a background scan runs. Correct-but-unusable is a failure the user feels in their fingers. Two distinct needs collide here, and the honest engineer keeps them separate. First, the command loop must stay answering while heavy work happens. Second, when one function is genuinely slow, you must fix the right one — because your intuition about which line is slow is, reliably, wrong. Keep the app responsive; and measure before you touch a single line for speed.

Responsiveness runs straight into the GIL, CPython's Global Interpreter Lock. CPython executes one bytecode stream at a time. The GIL is the single baton, and only the thread holding it runs Python. So a pure-Python CPU-bound loop in your command thread — rescoring fifty thousand songs — holds the baton the entire time, and no other thread can run Python until it finishes. Spawning threads will not make that loop faster and will not free the prompt during the compute. But here is the hinge: the GIL is released around blocking I/O syscalls. When a thread calls read, write, fsync, or a socket op, CPython drops the baton before sleeping in the kernel and reacquires it on return. While one thread waits on the disk, another thread runs Python. So the correct tool depends entirely on the kind of work:

responsive.py — the honest decisionpython
import threading, multiprocessing

# I/O-bound (our fsync-heavy save): a thread KEEPS the loop responsive,
# because the worker sleeps in the syscall with the GIL released.
threading.Thread(target=save_atomic, args=(path, pl)).start()

# CPU-bound (rescoring 50k songs): a thread does NOT help — GIL held the
# whole time. Use a process: a second interpreter, its own GIL, real
# parallelism — paid for by pickling data across the process boundary.
with multiprocessing.Pool() as pool:
    scores = pool.map(score_song, library)   # args pickled out, results pickled back

There is the whole decision tree, and it is worth memorizing over any "just use threads" advice. If the heavy work is I/O — the fsync-heavy save, reading a big file — a background thread keeps the loop answering, because the worker is asleep in a syscall with the baton down. If the work is CPU-bound Python, threads are useless. You have two real options. One is multiprocessing: a second interpreter, its own GIL, genuine parallelism, paid for with pickling across the process boundary. That's the volume's law again: only bytes cross. The other is to make the loop cooperative, doing the work in bounded chunks and checking for input between them.

keep it answeringpromptFROZEN — never gets batonworkerCPU-bound: holds baton whole timepromptanswersworkerfsync/read → drops batonwhich?I/O-bound?→ threadCPU-bound?→ process / chunkfix the right linencalls tottime cumtime function1 0.01 0.02 inner_loopyou suspected this — tiny tottime9000 6.10 6.40 load_configreal culprit — huge ncalls
Fig — A CPU-bound thread keeps the GIL and freezes the UI while an I/O thread releases it on syscalls; and cProfile's tottime/ncalls point at the line that actually burns time.

Now the second need: never optimize by eye. cProfile is a deterministic profiler. It registers a callback on CPython's C-level call/return trace hook, so every Python function call and return is timed. You read its output with intent, using two columns that mean genuinely different things. tottime is the time spent inside a function, excluding its callees. It finds the actual hot leaf. cumtime is the time including everything the function called. It finds the expensive subtree. Here is the classic, humbling surprise. The function you were sure was slow has a tiny tottime, and the real cost is a trivial three-line helper called two million times.

profile_it.pypython
import cProfile, pstats

cProfile.run("save_and_rescore(library)", "prof.out")
st = pstats.Stats("prof.out")
st.sort_stats("tottime").print_stats(8)   # the hot LEAF — where time is actually burned
st.sort_stats("cumtime").print_stats(8)   # the expensive SUBTREE — who drags the most in
# the surprise: score_song has tiny tottime; a helper with ncalls=2,000,000 dominates.
THE ONE IDEA TO CARRY FORWARD
The optimization workflow is a loop with a gate: measure -> change the one function the profile named -> measure again -> keep the change only if tottime actually moved. An "optimization" that does not move the number is just added complexity you now have to maintain. Because the profiler adds overhead of its own, trust the relative ranking, not the absolute microseconds.
Wait — if the GIL releases around I/O, why does asyncio exist at all? Because threads pay an OS cost per thread and hand scheduling to the kernel, while an event loop multiplexes thousands of I/O waits in one thread with no baton-passing at all. For a handful of background saves, a thread is simpler and correct. The GIL story is the same underneath — both win by not holding the baton while waiting on the disk; they just differ in how many waits they juggle.

Correct, installable, startable, provable, responsive, and profiled. That is a lot of claims. The last question is the hardest, because it has no error message: how do you know when to stop? →

07What 'done' means

Every hobby project shares one failure mode: it is 90% done forever. The code runs on the author's laptop, has a couple of commented-out tests, and no one else can start it. This section closes the hardest need in the book, precisely because it produces no error message. That need is knowing when to stop, and having an objective, checkable definition of done that a stranger could verify without asking you a single question. "Done" has to mean something a third party can confirm, or it means nothing at all. So we frame it not as a feeling but as a set of falsifiable claims. Each one is traceable to a boundary this volume taught you to cross, and each one is runnable.

done.sh — the certificate you can runshell
# 1. INSTALLS from a lockfile — fresh venv reproduces the exact environment  (Ch 22)
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.lock        # matching versions + hashes, or it aborts

# 2. RUNS from one command — a real package with a __main__ entry point       (Ch 21)
python -m app --version                 # no cwd assumptions, no hidden setup

# 3. PROVES itself — model + engine + storage + the regression test        (Ch 24-25)
pytest -q                               # green, or you are not done

# 4. SURVIVES a power cut — kill mid-save, then the file still loads clean (Ch 16, 18)
python -m app save & sleep 0.05; kill -9 $!   # no cleanup handler runs
python -m app load                      # the LAST COMPLETE playlist, intact

# 5. STAYS usable under load — the one hot path was profiled and tuned     (Ch 26, 29)
python -m app profile-save              # tottime evidence, not a guess

Read those five claims slowly, because each is a promise you can break on purpose to check. Claim 1 — installs from a lockfile: a fresh venv plus pip install against the pinned lock reproduces the exact tree. You can verify it on a clean machine or fresh container, where the install succeeds with matching versions and hashes. Claim 2 — runs from one command: python -m app starts it with no working-directory assumptions, because section 4 made it a genuine package with a __main__. Claim 3 — proves itself: pytest is green and covers model, engine, storage, and the regression test from the debugging pass. So the claims that matter are mechanically checked rather than asserted in prose. Claim 4 — survives a power cut: because the save is atomic, you can kill -9 the process mid-fsync with no cleanup and confirm the file still loads as the last complete playlist. Claim 5 — stays usable under load: the loop answers during heavy work, and the one hot path was profiled and tuned with a before/after number, not a hunch.

TYPE THIS · the capstone, part two — the prooffive tests over four layers, then the seven-command session that shows the whole app working
# ---------- tests/test_medialib.py ----------
"""test_medialib.py -- one test per layer, each where that layer lives."""
from medialib import storage
from medialib.cli import main
from medialib.tracks import Track, matches, from_url


def test_track_is_a_value():                      # model: no disk, no clock
    a = Track("Titanium", "David Guetta", 245)
    assert a == Track("Titanium", "David Guetta", 245)
    assert a.clock == "4:05"


def test_search_is_case_folded():                 # model: still no I/O
    t = Track("Blinding Lights", "The Weeknd", 200)
    assert matches(t, "BLINDING") and not matches(t, "titanium")


def test_save_load_round_trips(tmp_path):         # storage: a throwaway directory
    db = str(tmp_path / "lib.json")
    lib = [Track("Titanium", "David Guetta", 245)]
    storage.save_atomic(db, lib)
    assert storage.load(db) == lib


def test_add_then_list(tmp_path, capsys):         # cli: argv in, stdout out
    db = str(tmp_path / "lib.json")
    assert main(["--db", db, "add", "Titanium", "David Guetta", "245"]) == 0
    main(["--db", db, "list"])
    assert "Titanium" in capsys.readouterr().out


def test_fetch_never_touches_the_network():       # the honest seam
    t = from_url("https://example.com/watch/blinding-lights")
    assert t.source == "web" and t.duration_s == 200


# ---------- pyproject.toml ----------
[project]
name = "medialib"
version = "1.0.0"
description = "a tiny media library, standard library only"
requires-python = ">=3.10"
dependencies = []

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"


# ---------- the terminal: seven commands, in this order ----------
$ python -m medialib add "Blinding Lights" "The Weeknd" 200
$ python -m medialib -v add "Titanium" "David Guetta" 245
$ python -m medialib fetch https://example.com/watch/levels
$ python -m medialib list
$ python -m medialib search LIGHT
$ python -m medialib export -o starter.csv
$ python -m medialib add "Levels" "Avicii" three
$ python -m pytest -q
$ python -m medialib add "Blinding Lights" "The Weeknd" 200
added 'Blinding Lights' -- the library now holds 1

$ python -m medialib -v add "Titanium" "David Guetta" 245
DEBUG    medialib: parsed {'db': 'library.json', 'verbose': True, 'cmd': 'add', 'title': 'Titanium', 'artist': 'David Guetta', 'duration_s': 245}
DEBUG    medialib: loaded 1 tracks from library.json
added 'Titanium' -- the library now holds 2

$ python -m medialib fetch https://example.com/watch/levels
fetched 'Levels' by Avicii (3:23) -- source=web

$ python -m medialib list
 1. Blinding Lights The Weeknd       3:20  local
 2. Titanium        David Guetta     4:05  local
 3. Levels          Avicii           3:23  web
-- 3 tracks, 648s total

$ python -m medialib search LIGHT
Blinding Lights -- The Weeknd (3:20)
-- 1/3 matched 'LIGHT'

$ python -m medialib export -o starter.csv
wrote 3 rows to starter.csv

$ python -m medialib add "Levels" "Avicii" three
usage: medialib add [-h] title artist duration_s
medialib add: error: argument duration_s: invalid int value: 'three'
   (exit code 2)

$ python -m pytest -q
.....                                                                    [100%]
5 passed in 0.03s
Run those seven in order, then read the session as one argument. The first command wrote a file that did not exist. It went the atomic way: temp file, fsync, os.replace. Pull the plug during any write and you get the previous complete library, never a smear. The second is the same command with -v. Those two DEBUG lines are chapter 25’s logger doing its job, and the level is a switch, not an edit. Without -v the threshold sits at WARNING and the lines cost nothing, because %-style logging never builds a message it will throw away. The third opens no socket. fetch hands a URL to from_url, which slices the slug and returns a Track with source="web". Only what lives behind that one function separates it from a real downloader. The fifth is the quiet one I would not skip. search LIGHT, in capitals, finds a track stored in lowercase, because matches calls casefold on both sides. That is chapter 17 earning its keep in a program strangers will type into badly. Then the seventh, which is the one that surprises people. We asked for a duration of three. The user got a usage line and exit code 2, not a ValueError traceback with our file paths in it. We wrote no validation for that at all. type=int did it, inside the parser, before run() was ever called. And the last line is why we can change any of this tomorrow. Five tests, one per layer, green in thirty milliseconds. The model needs no disk. Storage gets a fresh tmp_path per test. The CLI test calls main(["--db", db, "add", ...]) with a plain list: no subprocess, no sys.argv surgery. That one choice, parse_args(argv) over parse_args(), is what made the front door testable.
certificate of done6 boundaries crossedmoduletypekernelenvironmentcorrectnessperformanceinstallspip install -r lockCh22runspython -m appCh21provespytest — greenCh24–25surviveskill -9 mid-save → loads cleanCh16/18usableprofiled hot pathCh26/29
Fig — A finished project is five checkable claims — it installs, runs, proves, survives, and is usable — each earned by a chapter and stacked on the six boundaries the volume crossed.
NOW WRITE IT YOURSELFextend the app — a stats subcommand, built from two Volume 2 skills
The job. Add a sixth subcommand, stats, that prints a one-screen summary of the library: how many tracks, the total runtime as m:ss, a count and a percentage per source, and the longest track by name. The constraint that makes it an exercise. You must use two Volume 2 skills on purpose, and name them. Chapter 17’s format spec mini-language does the alignment and the percentage: no hand-padding, no round(x * 100). Chapter 29’s time.perf_counter bracket times the computation, reported through chapter 25’s lazy logger. That way the measurement shows up under -v and costs nothing without it. What to write, in order. One line in build_parser. One elif branch in run. One test in tests/test_medialib.py, using tmp_path and capsys, that builds a two-track library through main() and asserts on the printed text. Then answer two questions in writing. First: what does your command do on an empty library, and which line would have raised? Second: which file did you put the arithmetic in, and does that still leave storage the only module touching the disk? One hint and no more: the percentage type in a format spec already multiplies by 100 for you.
show the solution
# ---------- 1. medialib/cli.py -- three edits ----------

import argparse, logging, time                    # + time, for the ch 29 bracket

# ...in build_parser(), one line, above the fetch parser:
    sub.add_parser("stats", help="a one-screen summary of the library")

# ...in run(), one new branch, above the fetch branch:
    elif args.cmd == "stats":
        if not lib:
            print("the library is empty")         # the guard the empty case needs
            return 0
        t0 = time.perf_counter()                             # ch 29: the bracket
        total = sum(t.duration_s for t in lib)
        by = {}
        for t in lib:
            by[t.source] = by.get(t.source, 0) + 1
        longest = max(lib, key=lambda t: t.duration_s)
        print(f"{'tracks':<9}{len(lib):>7}")
        print(f"{'runtime':<9}{total // 60:>4}:{total % 60:02d}")   # ch 17: :02d
        for src, n in sorted(by.items()):
            print(f"{src:<9}{n:>7}{n / len(lib):>9.0%}")     # ch 17: the % type
        print(f"{'longest':<9}{longest.title} ({longest.clock})")
        log.debug("stats over %d tracks in %.3f ms", len(lib),
                  (time.perf_counter() - t0) * 1000)         # ch 25: lazy %


# ---------- 2. tests/test_medialib.py -- one more test ----------

def test_stats_counts_and_percentages(tmp_path, capsys):   # the new subcommand
    db = str(tmp_path / "lib.json")
    main(["--db", db, "add", "Titanium", "David Guetta", "245"])
    main(["--db", db, "fetch", "https://example.com/watch/levels"])
    capsys.readouterr()                                    # drop the add/fetch noise
    main(["--db", db, "stats"])
    out = capsys.readouterr().out
    assert "50%" in out and "7:28" in out and "Titanium" in out


# ---------- 3. the run, on the three-track library from the type-along ----------

$ python -m medialib stats
tracks         3
runtime    10:48
local          2      67%
web            1      33%
longest  Titanium (4:05)

$ python -m medialib -v stats
DEBUG    medialib: parsed {'db': 'library.json', 'verbose': True, 'cmd': 'stats'}
DEBUG    medialib: loaded 3 tracks from library.json
DEBUG    medialib: stats over 3 tracks in 0.025 ms
tracks         3
runtime    10:48
local          2      67%
web            1      33%
longest  Titanium (4:05)

$ python -m pytest -q
......                                                                   [100%]
6 passed in 0.05s


# ---------- the two skills, named ----------
#
#  ch 17, the format spec.  Every column is one spec, not one loop.
#      {'tracks':<9}   left-align in 9 columns      <  >  ^  are the whole alignment vocabulary
#      {total % 60:02d}  zero-pad to 2 digits       so 8 seconds prints as 08, never as 8
#      {n / len(lib):>9.0%}  the % TYPE             it multiplies by 100 and appends the sign
#          0.6666...  ->  "67%".  Written by hand that is round(n / len(lib) * 100) and a
#          str concat and a rjust -- three chances to be wrong, replaced by four characters.
#
#  ch 29, the bracket + ch 25, lazy logging.
#      t0 = perf_counter() ... perf_counter() - t0   is the ONLY honest stopwatch: monotonic,
#      ~100 ns, and only DIFFERENCES mean anything. time.time() would have reported 0.000000
#      here, because 25 microseconds is far under the Windows 15.6 ms tick.
#      log.debug("... %.3f ms", ...) does the formatting ONLY if DEBUG is enabled, so the
#      measurement is free in normal use and one flag away when you want it.
#
#
# ---------- question 1: the empty library ----------
#
#   max(lib, key=...) raises  ValueError: max() arg is an empty sequence.
#   sum() is fine (it returns 0) and the loop simply does not run -- max is the only
#   line that cannot survive an empty list. Hence the two-line guard at the top.
#   Note that `list` on an empty library already prints "-- 0 tracks, 0s total"
#   quite happily, which is exactly why the failure is easy to miss until a user finds it.
#
#
# ---------- question 2: where the arithmetic goes ----------
#
#   It went in cli.py, and that is defensible for a summary that exists only to be
#   printed: the numbers and their layout are one concern. It is also the FIRST thing
#   in this app that has grown a second reason to change -- the day you want the same
#   totals in `export`, or as JSON, the arithmetic has to move down into a new pure
#   module (say analytics.py, importing tracks and nothing else) with cli.py left
#   holding only the f-strings. storage stays the only module that touches the disk
#   either way: stats reads through storage.load and writes nothing at all.
#   The honest summary: correct today, and the exact place a sixth file will be born.

Here is the through-line to say out loud, the sentence this entire volume was built to earn: shipping is not one big skill — it is the sum of every boundary this volume taught you to cross. The module boundary: imports and sys.modules, from section 1. The type boundary: dataclasses generating code, and static checking reading the same hints, from section 2. The process/kernel boundary: the fsync and rename syscalls that turn a live object into durable bytes, from section 3. The environment boundary: venv and lockfile, from section 4. The correctness boundary: tests and pdb, from section 5. The performance boundary: the GIL and the profiler, from section 6. A program a stranger can trust is exactly a program where all six crossings are clean and each one is checkable.

NOW WRITE IT YOURSELFthe ship checklist — run your own app hostilely, then rank what you fix
The situation. medialib works. Five tests are green, the save is atomic, the help page is real. Tomorrow you hand the folder to a friend who has Python installed and has never seen your code. The task, in writing, before you touch anything. Produce the list of things you would do first. For each item, say which of the five done-claims it serves, and how you would check it — a command, not a feeling. Then do the harder half. Four of the five claims are not currently true of this app. One of those failures is sitting in plain sight in the code you already typed. Find them by running the app hostilely rather than reading it kindly. Launch it wrong. Give it a verb it does not know. Point --db somewhere that does not exist. Ask it for --version the way done.sh does. For each failure, write the exact command, the exact output, and whether the user gets a usage line or a traceback. Then say which one they deserve. Finally, rank your list. If you had twenty minutes and not two hours, which three items ship, and which get written down as debt? One hint and no more: done.sh claim 2 runs a command this app does not have.
show the solution
# ---------- part 1: the twenty-minute list, ranked, with its check ----------
#
#  1. A README with ONE start command.                            (claim 2)
#       Three lines is enough: python -m medialib -h, one worked example, the
#       python version you tested on. CHECK: hand it to someone and watch them
#       start it without asking you a question. That is the entire test.
#
#  2. --version, because the done-list already promises it.       (claim 2)
#       p.add_argument("--version", action="version",
#                      version=f"%(prog)s {medialib.__version__}")
#       CHECK: python -m medialib --version   ->  medialib 1.0.0, exit 0.
#
#  3. Pin the environment and write down the dev dependency.      (claim 1)
#       requires-python = ">=3.10" is a floor, not a pin. Record the interpreter
#       you actually tested (3.12.7) and put pytest where a stranger can find it:
#         [project.optional-dependencies]
#         dev = ["pytest==7.4.4"]
#       CHECK: python -m venv .venv ; python -m pip install -e ".[dev]" ; pytest -q
#       in a directory that is NOT yours. The runtime lock is nearly empty because
#       we used no third-party runtime deps at all -- that is a durability claim,
#       not an accident, and it belongs in the README.
#
#  --- past twenty minutes: written down as debt, not shipped tonight ---
#
#  4. Turn the four crashes in part 2 into usage errors.          (claim 3)
#  5. A directory fsync after os.replace, guarded for Windows.    (claim 4)
#  6. A kill -9 mid-save test, and a hot-path measurement.        (claims 4, 5)
#
#
# ---------- part 2: run it hostilely. four claims, four failures. ----------
#
#  CLAIM 2 -- "runs from one command"          FALSE
#
#    $ python -m medialib --version
#    usage: medialib [-h] [--db DB] [-v] COMMAND ...
#    medialib: error: the following arguments are required: COMMAND
#    (exit 2)
#
#    Read that twice. It did not say "unknown option --version". required=True on
#    add_subparsers fires FIRST, so the message names the wrong problem entirely.
#    A usage line, correctly -- but a misleading one. This is the failure that was
#    sitting in plain sight: done.sh runs this exact command in claim 2.
#
#  CLAIM 3 -- "proves itself"                  INCOMPLETE
#
#    $ python -m medialib fetch https://example.com/watch/nope
#      File ".../medialib/tracks.py", line 32, in from_url
#        title, artist, secs = CATALOGUE[slug]
#    KeyError: 'nope'
#
#    A raw traceback with our file paths in it, for a typo the user made. They
#    deserve:  medialib fetch: error: no metadata for 'nope'  and exit 2.
#    The suite is green because not one of the five tests calls from_url with a
#    slug that is missing. Green means five claims held. It never meant correct.
#
#    Same disease, second door:
#    $ python -m medialib --db nosuchdir/lib.json add A B 1
#    FileNotFoundError: [Errno 2] No such file or directory: '...\tmpjfzm4qb0.tmp'
#    mkstemp cannot create a temp file in a folder that does not exist. The user
#    typed a path; they should get a sentence about that path, not an Errno.
#
#  CLAIM 4 -- "survives a power cut"           PARTLY, AND UNPROVEN
#
#    save_atomic does write-tmp -> flush -> fsync -> os.replace. What it never does
#    is fsync the containing DIRECTORY, so on POSIX the rename itself can still be
#    lost. Section 3 said both halves are required. And nothing tests it: every
#    test runs on a machine that does not lose power at the cruel microsecond.
#    CHECK: start a save in a subprocess, kill -9 mid-write, reload in the parent,
#    assert you got the last COMPLETE library. Until that test exists, claim 4 is
#    a belief.
#
#  CLAIM 5 -- "stays usable under load"        UNMEASURED
#
#    Every write rewrites the whole JSON file and fsyncs it. At three tracks that
#    is invisible; at fifty thousand it is a full re-serialise per `add`. We have
#    not measured it once, so we cannot claim it. python -m cProfile -s tottime
#    -m medialib add ... on a large library is the twenty-minute answer, and the
#    number it prints decides whether there is anything to fix at all.
#
#
# ---------- part 3: the ranking, and why ----------
#
#   SHIP:  README, --version, the pinned dev dependency.
#     All three serve the same person: a stranger who has to install, start, and
#     verify without you in the room. They cost minutes and they are the only
#     items that change whether the app can be handed over AT ALL.
#
#   DEBT:  the three crash-to-usage fixes, the directory fsync, the kill -9 test,
#          the profile of a large library.
#     Each is real, each is cheap alone, and none of them blocks the handover.
#     Write them down where the next person can see them -- because the difference
#     between debt and a bug you forgot is whether it is written down.
#
#   And the one thing NOT on either list: new features. `stats` was fun. It is
#   also the exact move that keeps a project 90% done forever. Done is not a
#   feeling; it is five claims that are currently, verifiably true.
THE ONE IDEA TO CARRY FORWARD
Done is not "I feel finished." Done is "every claim on the list is currently, verifiably true." That reframing is the gift, because a checklist has a bottom. The instant all five commands pass on a machine that is not yours, you are allowed — required — to stop. The 90%-forever trap is what happens when done has no falsifiable definition; give it one and the project can actually end.

✗ The myth

"Shipping is a final push of polish at the end — a week of cleanup after the real work of writing features."

✓ The reality

Shipping is six boundary-crossings that were either built in from the start or bolted on painfully at the end. The app that is durable, structured, pinned, tested, and profiled did not get those in a final push — each was a decision made when its own chapter came due.

Wait — the power-cut proof feels theatrical, but it is the one claim you cannot fake. Tests run on a machine that never crashes at the cruel microsecond, so a save that is only usually atomic passes every test you write. kill -9 mid-fsync is the single check that actually exercises the crash window from section 3 — which is why "survives a power cut" earns its place on a done list that a stranger, not you, gets to sign.
↻ reframe
Look back at the two songs. In chapter 0 they were two durations, 200 and 245, living for a heartbeat at a real RAM address. Now they are rows in a file that survives a power cut, rebuilt from bytes on load, moved between processes as pickles, checked by a test, and served by a loop that stays responsive. Nothing about them changed. What changed is that you can now carry an object cleanly across every boundary between the CPU and another human — which is the entire art of the working programmer, and the whole of this volume, standing in one small app that a stranger can trust.

That is Volume 2 — the physics of every boundary a process must cross to be useful. You began inside one process where every name pointed straight at a real thing; you end able to flatten that thing to bytes and rebuild it faithfully anywhere. The exercises are over. You have shipped. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 30, in working code

Chapter 30 is the capstone: every mechanism from the whole volume, assembled into one small app you could actually ship. These twelve steppers walk the seams a real program crosses — an acyclic module graph, a data model with the right dataclass switches, persistence that survives a crash, and the test/profile/checklist habits that let a stranger trust it. Each is a runnable, deterministic proof rather than a paragraph of advice.

Wiring the modules — the import graph
A shippable app is many files, and how they import each other decides whether it starts at all. These three make the graph itself the object of study: a valid load order, the single-shared-object rule behind sys.modules, and the cycle that crashes imports plus the leaf-extraction that fixes it.
The data model — dataclasses done right
The app's core is a handful of records. @dataclass hands you the boilerplate, but the switches matter: what you get for free, the mutable-default trap, and why frozen buys you a hashable key.
Persistence — save it so it survives
A capstone that forgets everything on exit is a toy. These three cross the disk boundary correctly: a lossless object-to-JSON round-trip, an atomic replace that never destroys the old copy, and a look at the torn-file window the atomic pattern closes.
Ship it — test, profile, checklist
The last mile is trust: does green actually prove correctness, is the fast path really fast, and can a stranger verify the whole thing? These three close the loop — a test that catches what a shallow one misses, memoized work counted exactly, and the done-checklist as a script.
end of chapter 30 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked