python/volume-2/chapter-30ch 30 / 64 · 18 min

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. This chapter does 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 — get a real home. It's 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 work alongside 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.

Assemble the pieces The model: dataclasses with checked hints Storage: durable, atomic, serialized Structure and environment
scroll to begin

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 difficulty of the blank main.py. The problem isn't 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. It's a mechanical property of how Python loads code, and once you see it you can lean the whole app against it. "Separation of concerns" sounds like a slogan. Spell 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, and one rule decides the split. 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 — add_song(playlist, song), 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, because 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 is the interpreter's cache of every module ever loaded. It isn't there yet, so CPython locates the file, builds a new empty module object, and inserts it 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 — 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. storage's body then 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 problem.

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 is to state the shape of a Song exactly once, so that single statement is 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 that receives the class object after the class body has finished executing, then returns a class. dataclass reaches into the class's __annotations__ — 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__, 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; 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. That's because 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 file 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 data that is 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 simple. 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 is the subtle part almost everyone misses. You must fsync the file so the data blocks are durable, and 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.

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, and 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 it 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, simplest and most fatal, they just cannot figure out how to start it. What you need here is to make the program reproducible and launchable by someone who has never spoken to you: 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? It comes down to 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.

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 taking it on faith. 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.

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 two things. 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 magic. A plain assert a == b in ordinary Python, when it fails, 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 — a hook into the very sys.modules loading machinery from section 1 — that intercepts each test module as it is compiled, walks its AST, and 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 special assert keyword. It is Python's own import system, bent to a purpose, and that is exactly why the same assert run under plain python stays silent.

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, 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.

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() — the builtin that honors PYTHONBREAKPOINT and lands you in pdb — right before the fault. 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. 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. So 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 need multiprocessing (a second interpreter, its own GIL, genuine parallelism, paid for with pickling across the process boundary — the volume's law again: only bytes cross), or you 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, so it finds the actual hot leaf. cumtime is the time including everything the function called, so it finds the expensive subtree. 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: 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 traceable to a boundary this volume taught you to cross, each one 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 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.

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.

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, section 1). The type boundary (dataclasses generating code, and static checking reading the same hints, section 2). The process/kernel boundary (fsync and rename syscalls that turn a live object into durable bytes, section 3). The environment boundary (venv and lockfile, section 4). The correctness boundary (tests and pdb, section 5). The performance boundary (the GIL and the profiler, section 6). A program a stranger can trust is exactly a program where all six crossings are clean and each one is checkable.

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, and checklist habits that let a stranger trust it. Each one 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.com
Written by Ajai Raj