iolinked / Python, from the metal up — Volume 2
Python · Volume 2 · the working programmer

Python, from the metal up — Volume 2

Volume 1 gave you the machine model — objects, references, structures, frames. Volume 2 turns it into working software: programs whose data outlives the process, split across modules, typed, tested, and doing three things at once — and by the end you'll have built and shipped a real app with all of it.

Here's the gap nobody warns you about: everything you built in Volume 1 dies the instant the program ends. Every list, every dict, every object — gone with the process's RAM. The first chapter fixes that. The rest make your code survive something even harsher: other people, future you, and time.

15 · Files & persistence data that outlives the process 16 · Modules & import code split across files 17 · Type hints intent the tools can check 18 · Testing & debugging catch the bug before the user 19 · Threads, async & GIL doing three things at once 20 · The capstone ship the playlist app every stage uses everything before it — the capstone uses all of Volumes 1 and 2
The Volume 2 map — six stages, one destination: the playlist from Volume 1 becomes a real program — saved to disk, split into modules, typed, tested, and responsive. Same six role colours as Volume 1.

15Files & persistence

Every structure you built in Volume 1 — the playlist list, the song dicts — lived in your process's RAM, and chapter 0 told you the deal: RAM is rented. This chapter moves the playlist somewhere that survives the end of the program, the closed laptop lid, the power cut. By the end you will save the playlist to disk, read it back byte-for-byte, and know the two exact moments where a careless program silently loses data.

RAM forgets. Disk remembers.

Run any Volume 1 program twice and the second run starts blank. That is not a Python quirk — it is physics plus housekeeping. The moment your process ends, the operating system reclaims every byte of its RAM for the next program (ch0's addressed boxes get new tenants). Cut the power and the DRAM cells lose their charge anyway. A file is different: it is a named sequence of bytes on a storage device — flash or magnetic — that keeps its state without power.

Python's bridge between the two worlds is open(). It asks the operating system for access to a file and hands you back a file object — a Python object (run type(f): it prints <class '_io.TextIOWrapper'>) that wraps the OS's file handle. Call .write() on it with a str; call .read() to get one back.

python
# save the playlist — "w" means: open for writing
with open("playlist.txt", "w") as f:
    f.write("Levels|Avicii|200\n")          # write() takes a str
    f.write("Wake Me Up|Avicii|247\n")      # \n ends each line
    f.write("Titanium|David Guetta|245\n")
# the with-block ended, so the file is closed and on disk

with open("playlist.txt", "r") as f:        # "r" means: open for reading
    print(f.read())                         # read the whole thing back
# Levels|Avicii|200
# Wake Me Up|Avicii|247
# Titanium|David Guetta|245
🛡️
Why with open, always
with runs the file object's __exit__ (ch11) like a finally block (ch13): the file gets closed no matter what. We tested it — inside the block we wrote one line, then raised ValueError("speaker on fire"). Afterwards f.closed printed True and the file held 'Levels|Avicii|200\n'. The crash could not leave the file dangling half-open.
your process (RAM) playlist (list of dicts) 0x7f3a… 0x7f4c… 0x7f51… process ends → OS reclaims every byte ✕ gone write() disk (persistent) playlist.txt — 69 bytes 4C 65 76 65 6C 73 7C 41 76 … reboot, power cut, new day: ✓ bytes still there
Fig — RAM is rented per process; a file is a named run of bytes that outlives it.

Two modes so far — "r" and "w". But "w" has teeth: it destroys before it writes →

Three modes, one trap: "w" erases first

The mode string tells open() your intent. "r" reads an existing file — if it isn't there, you get a real error; we ran it: FileNotFoundError: [Errno 2] No such file or directory: 'no_such_playlist.txt'. "a" appends: the file is created if missing, and every write lands at the end. "w" is the dangerous one: it creates the file if missing — and if the file exists, it truncates it, meaning it cuts the contents down to zero bytes. Watch a playlist die:

python
with open("playlist.txt", "a") as f:        # append: safe
    f.write("Hey Brother|Avicii|255\n")     # lands after Titanium
# file now holds all four songs — we printed it and checked

with open("playlist.txt", "w") as f:        # the mistake: "w" to add a song
    f.write("Strobe|deadmau5|634\n")        # the wipe already happened at open()

with open("playlist.txt", "r") as f:
    print(f.read())                         # Strobe|deadmau5|634
# four songs in, one song out — 93 bytes shrank to 21
Myth. "If my program crashes right after open('playlist.txt', 'w'), the old songs are still safe — I never wrote anything new."
Reality. Truncation happens at open() time, before any write. We ran open("playlist.txt", "w") and closed it without writing: os.path.getsize() reported 0. The instant open() returns, the old bytes are gone.
"r" Levels|Avicii|200… read only, cursor at start missing file → FileNotFoundError "w" (truncated to 0 bytes) wipe happens AT open(), then writes start from byte 0 "a" …|245 + keeps contents; every write appends at the end Rule of thumb: "r" to look, "a" to add, "w" only when you MEAN to replace the whole file.
Fig — "w" is not "write into" — it is "replace". The truncation fires before your first write.

Playlist restored, saved safely. Now read it twice in a row — the second read comes back empty →

The cursor: why reading twice returns nothing

Every open file object carries a cursor — a position marker recording how far into the file you have read or written. read() starts at the cursor, returns everything from there to the end, and leaves the cursor at the end. So a second read() starts at the end, finds nothing ahead of it, and returns ''. The file is fine; your position is spent.

python
f = open("playlist.txt", "r")   # the three-song file again
print(repr(f.read()))   # 'Levels|Avicii|200\nWake Me Up|Avicii|247\nTitanium|David Guetta|245\n'
print(repr(f.read()))   # ''  ← empty: the cursor is parked at the end
f.seek(0)               # teleport the cursor back to position 0
print(repr(f.read()))   # the full 66-character string again
f.close()

seek(0) moves the cursor back to the start; read(20) reads exactly 20 characters and stops — we ran it and got 'Levels|Avicii|200\nWa', cursor parked mid-word at character 20. Drag that cursor yourself:

InteractiveDrag the cursor — watch what read() can still see
playlist.txt — 66 characters via open("playlist.txt", "r") Levels|Avicii|200↵ Wake Me Up|Avicii|247↵ Titanium|David Guetta|245↵ cursor @ 20 f.read(20) → … f.read() → …
20
read() never looks backwards — it returns only what lies ahead of the cursor, then parks the cursor at the end.
open("r") read() → all 66 chars after read() read() → '' (nothing ahead) seek(0) read() works again
Fig — The "empty second read" is a spent cursor, not a lost file. seek(0) rewinds it.
Wait —
read() returned a 66-character string, but the file explorer says playlist.txt is 69 bytes. Who added three bytes?

Text is a costume — a file holds bytes

A file never holds a str, a list, or any Python object. It holds bytes, full stop. When you open in text mode (the default), Python straps a translator onto the byte pipe: every str you write is encoded to bytes on the way out, every read is decoded back (ch2: str vs bytes). Volume 1 chapter 0 told you "one letter per byte" — that was a labeled simplification, and here is the payoff. UTF-8 spends one byte on English letters and 2–4 bytes on the rest:

python
print(len("Tiësto"))               # 6 — six characters (a str)
print("Tiësto".encode("utf-8"))    # b'Ti\xc3\xabsto' — ë became TWO bytes
print(len("Tiësto".encode()))      # 7 — seven bytes

with open("tiesto.txt", "w", encoding="utf-8") as f:
    f.write("Tiësto")              # 6 characters in…

import os
print(os.path.getsize("tiesto.txt"))   # 7 — …7 bytes on disk

with open("tiesto.txt", "rb") as f:    # "rb" = binary mode: raw bytes, no translator
    print(f.read())                    # b'Ti\xc3\xabsto'

Skip encoding="utf-8" and Python guesses from the machine's locale. On this Windows machine the guess is cp1252, so reading the same file back without it printed Tiësto — same 7 bytes, wrong decoder, garbage name. That failure mode has a name: mojibake.

⚠️
Always write encoding="utf-8"
The default encoding is machine-dependent (locale.getpreferredencoding(False)cp1252 here; usually UTF-8 on macOS/Linux). A file written on one machine and read on another can decode into nonsense. PEP 686 plans to make UTF-8 the default in Python 3.15 — until then, spell it out every time.
🔎
The 69-byte mystery, solved
Text mode also translates newlines. On Windows each '\n' is written as two bytes, \r\n — binary mode showed us b'…|200\r\n…'. 66 characters + 3 newlines = 69 bytes; read() translates them back, so you only ever saw 66. On macOS/Linux the file is 66 bytes. Machine-dependent, by design.
'Tiësto' str — 6 characters .encode("utf-8") .decode("utf-8") b'Ti\xc3\xabsto' bytes — 7 of them write / read disk tiesto.txt text mode = this pipeline runs automatically · binary mode ("rb"/"wb") = you handle raw bytes yourself
Fig — Text mode is a translator bolted onto a byte pipe. Binary mode removes it.
characters (str) T i ë s t o bytes (UTF-8) 54 69 C3 AB 73 74 6F ë alone costs two bytes — 6 characters, 7 bytes
Fig — UTF-8 is variable-width: ASCII letters cost one byte, ë costs two. Chapter 0's "one letter per byte" was the ASCII-only special case.

Bytes explain what lands on disk. Now the scarier question — when. Your write may not be on the disk at all yet →

The write buffer: your data's last stop in RAM

Talking to a disk is orders of magnitude slower than talking to RAM, so Python does not send each write() to the disk immediately. It parks your characters in a write buffer — a chunk of the process's own RAM (8192 bytes by default here; io.DEFAULT_BUFFER_SIZE prints 8192) — and ships the whole batch when the buffer fills, when you call flush(), or when the file closes. Efficient, and dangerous: if the process dies before the shipment, the buffered tail dies with it. We killed a process for real:

python
# crash_test.py — os._exit() ends the process instantly: no close, no cleanup
import os
f = open("crash_test.txt", "w")
f.write("Levels|Avicii|200\n")          # → buffer (RAM)
f.write("Wake Me Up|Avicii|247\n")      # → buffer (RAM)
f.flush()                               # ship the buffer to the OS NOW
f.write("Titanium|David Guetta|245\n")  # → buffer again
os._exit(1)                             # killed here — Titanium never left RAM

# afterwards, from a fresh program:
# os.path.getsize("crash_test.txt")  →  42
# open("crash_test.txt").read()      →  'Levels|Avicii|200\nWake Me Up|Avicii|247\n'

Two lines survived — the two that were flushed. Delete the flush() line and run again: we did, and the file measured 0 bytes. All three writes were sitting in the buffer when the process died. Nothing reached the disk.

🔥
close() is not decoration
Until close() or flush() runs, your "saved" data exists only in process RAM — the very thing this chapter is escaping. This is the second reason with open is law: __exit__ closes, and closing flushes, even mid-crash (the ValueError test in the first section proved it).
program f.write(…) write buffer (RAM) 8192 bytes, fills up first flush() / close() / buffer full disk file survives the process process killed → everything LEFT of this line is destroyed right of it: safe
Fig — A write is not saved until it crosses the flush line. A kill erases the buffer, never the file.
InteractiveKill the process — see which lines survive
process RAM — write buffer Levels|Avicii|200 Wake Me Up|Avicii|247 Titanium|David Guetta|245 flush() moves lines → disk — crash_test.txt Levels|Avicii|200 Wake Me Up|Avicii|247 Titanium|David Guetta|245
#3
Small writes live in RAM until flush/close — a kill loses them all. (Byte counts as measured on this Windows machine, where each \n is stored as \r\n.)
The deeper cut

One simplification above: there are more stops than "buffer → disk". Text mode stacks a character buffer (TextIOWrapper) on top of a byte buffer (a BufferedWriter, the 8192-byte one; open a file with "rb" and type(f) reports <class '_io.BufferedReader'> — the byte layer without the text costume). And flush() hands your bytes to the operating system's page cache, not to the physical platter or flash cell. That cache survives your process being killed — which is why our flushed lines lived — but not a power cut. To ask the OS to push bytes to the physical device, you call os.fsync(f.fileno()); that call is why databases feel slow and lose nothing. For playlist files, flush-on-close is the right cost/safety trade.

Crash-safety handled. Time to save something richer than pipe-separated lines — the whole playlist dict, shape and all →

JSON: the dict → text → dict round trip

First, a better way to point at files. pathlib.Path turns path strings into path objects with behaviour:

python
from pathlib import Path
p = Path("gym_mix.json")                   # a path object, not a plain str
print(p.suffix, p.stem)                    # .json gym_mix
print(Path("exports") / "2026" / p.name)   # exports\2026\gym_mix.json  (/ on macOS+Linux)
print(p.exists())                          # False — nothing saved yet

Now the main event. Our playlist is a dict of lists of dicts — pipe-separated lines can't hold that shape. JSON (JavaScript Object Notation) is a text format whose vocabulary maps almost one-to-one onto Python's: objects↔dicts, arrays↔lists, plus strings, numbers, true/false/null. json.dump renders a dict into characters; text mode encodes them to bytes; disk stores them:

python
import json
playlist = {
    "name": "Gym Mix",
    "songs": [
        {"title": "Levels",     "artist": "Avicii",       "seconds": 200},
        {"title": "Wake Me Up", "artist": "Avicii",       "seconds": 247},
        {"title": "Titanium",   "artist": "David Guetta", "seconds": 245},
    ],
}
with open("gym_mix.json", "w", encoding="utf-8") as f:
    json.dump(playlist, f)                 # dict → characters → bytes → disk
print(p.stat().st_size)                    # 213 — the file exists, 213 bytes

print(p.read_text(encoding="utf-8"))       # the exact characters on disk
# (all 213 on one line; middle shortened here to fit the page):
# {"name": "Gym Mix", "songs": [{"title": "Levels", "artist": "Avicii",
#  "seconds": 200}, {"title": "Wake Me Up", … "seconds": 245}]}

with open("gym_mix.json", "r", encoding="utf-8") as f:
    loaded = json.load(f)                  # characters → a brand-new dict
print(loaded == playlist)                  # True  — same shape, same values
print(loaded is playlist)                  # False — a different object
print(id(playlist), id(loaded))            # 1455405821952 1455402595008 (yours differ)
↺ The thing people get backwards
Loading does not bring your object back. Your dict never left RAM and died with the process that made it. json.dump wrote a description in characters; json.load read that description and built a new dict from scratch — equal by ==, different by is and id() (ch3: two labels can only be the same if they point at the same object, and this object was born seconds ago).

JSON's vocabulary is smaller than Python's, and it fails in two different ways — one silent, one loud. We ran both:

python
print(json.loads(json.dumps((200, 247, 245))))
# [200, 247, 245]  ← tuple went in, LIST came out. Silent shape change.

json.dumps({"Levels", "Titanium"})
# TypeError: Object of type set is not JSON serializable

json.dumps(3 + 4j)
# TypeError: Object of type complex is not JSON serializable
💡
What about your own classes?
A Song instance (ch12) can't json.dump either — same TypeError. The standard move is a dict bridge: a method that builds a plain dict from the object, and a classmethod that rebuilds the object from a dict. Chapter 20 adds exactly that pair to the playlist program.
playlist → dict id … 5821952 born in run #1, dies with it loaded → dict id … 2595008 built fresh from characters == True is False {"name": "Gym Mix", "songs": [{"title": "Levels", … — 213 bytes disk: gym_mix.json json.dump json.load the characters are the only survivors — both dicts are mortal
Fig — The file carries a description, not the object. Load builds a new dict: equal, never identical.
The deeper cut: CSV, the other table format

Spreadsheets speak CSV — comma-separated values, one row per line. Never build it with string concatenation: the moment a field contains a comma, naive split(",") shreds it. The csv module quotes correctly (and its docs require newline="" on the open, so the module controls line endings itself). We ran this:

python
import csv
rows = [["title", "artist", "seconds"],
        ["Levels", "Avicii", 200],
        ["Wake Me Up", "Avicii", 247],
        ["Titanium", "Guetta, David", 245]]      # a comma INSIDE a field
with open("gym_mix.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(rows)

print(Path("gym_mix.csv").read_text(encoding="utf-8"))
# title,artist,seconds
# Levels,Avicii,200
# Wake Me Up,Avicii,247
# Titanium,"Guetta, David",245      ← quoted automatically

with open("gym_mix.csv", newline="", encoding="utf-8") as f:
    print(list(csv.reader(f))[3])
# ['Titanium', 'Guetta, David', '245']   ← note: '245' is a str now!

CSV has no types — every cell comes back a string, so int(row[2]) is on you. JSON keeps numbers as numbers; CSV wins only when a spreadsheet is the customer.

The playlist now survives the process — but the program grew: songs, saving, loading, formats, all crammed into one file. Chapter 16 slices it into modules, and shows exactly what Python does, line by line, the first time you write import

16Modules & import

In Chapter 15 the playlist learned to survive a power cut. But the whole program still lives in one ever-growing file. This chapter splits it across files: you will watch import do its four-step job — find, compile, run, cache — prove that a module's code executes exactly once per process, and give every project a private shelf of packages so versions never fight.

One file gets crowded

By the end of Chapter 15, playlist.py was doing three jobs at once: the JSON disk code, the total-time maths, the menu loop. Every feature makes the scroll longer, and reusing the disk code in another project means copy-paste — two copies that immediately start drifting apart.

Python's unit of splitting is the module. A module starts life as an ordinary .py file; the statement import playlist_store makes Python load that file and hand you one object holding everything the file defined. Keep the two levels separate: the file on disk holds bytes (ch 15), the module object lives on the heap (ch 0). So the program becomes three files in one folder — here are the two that matter today:

python — playlist_store.py
"""Disk code from Chapter 15, now living in its own file."""
import json                     # a module importing modules
from pathlib import Path

print("playlist_store.py is running, top to bottom")   # a tracer

def save_playlist(songs, path):
    Path(path).write_text(json.dumps(songs, indent=2), encoding="utf-8")

def load_playlist(path):
    return json.loads(Path(path).read_text(encoding="utf-8"))
python — playlist_app.py
import playlist_store                # the whole machinery fires here

songs = [
    {"title": "Levels",     "artist": "Avicii",       "seconds": 200},
    {"title": "Wake Me Up", "artist": "Avicii",       "seconds": 247},
    {"title": "Titanium",   "artist": "David Guetta", "seconds": 245},
]

playlist_store.save_playlist(songs, "playlist.json")   # reach through the module
back = playlist_store.load_playlist("playlist.json")
print(len(back), "songs back from disk")
terminal
$ python playlist_app.py
playlist_store.py is running, top to bottom
3 songs back from disk

The tracer print() at the top of playlist_store.py is planted on purpose: it reports the exact moment that file's code runs. It is about to earn its keep.

💡
You have used this machinery since Chapter 15
import json was a full module import — find, compile, run, cache. You just never watched it work. Today you watch.
playlist.py — one crowded file disk code (ch 15) stats maths menu loop next feature…? ▼ the scroll only grows split by job playlist_store.pyowns the disk playlist_stats.pyowns the maths playlist_app.pythe one you run import import
Fig — Split by job: two library modules and one runnable app. import stitches them back together at run time.

Same behaviour, three tidy files. But look again at the first line of playlist_app.py: it names no folder, no drive, not even the .py. How did Python find that file? →

Step 1: FIND — the sys.path search

import playlist_store gives Python only a name, so step 1 is a search. The list of places to look sits in sys.path — an ordinary list (ch 6) of folder paths, checked front to back:

python — show_path.py
import sys
for place in sys.path:      # a plain list of folder paths (ch 6)
    print(place)
terminal
$ python show_path.py
C:\…\ch16demo
C:\Users\ajair\anaconda3\python312.zip
C:\Users\ajair\anaconda3\DLLs
C:\Users\ajair\anaconda3\Lib
C:\Users\ajair\anaconda3
C:\Users\ajair\AppData\Roaming\Python\Python312\site-packages
C:\Users\ajair\anaconda3\Lib\site-packages
…

Real output from my machine, trimmed at two marked spots: the first path is shortened (it is simply the folder the script lives in) and the folds three more site-packages entries. Yours will differ — but the shape won't: slot 0 is the running script's own folder (that is how playlist_app.py found playlist_store.py with no path at all), then the standard library that shipped with Python, then site-packages, the shelf pip fills (§ end of chapter). The first folder holding a match wins and the search stops. Two staging notes for honesty: a few modules like sys are baked into the interpreter itself and answer before any folder is searched, and yes — that .zip is importable as-is. If every slot misses:

terminal
$ python bad_import.py
Traceback (most recent call last):
  File "C:\…\ch16demo\bad_import.py", line 1, in <module>
    import playlist_shop
ModuleNotFoundError: No module named 'playlist_shop'
⚠️
Never name a file after a module you use
I created a helper called random.py next to a script that does import random. Slot 0 wins, my empty file answers, and the real shuffle machinery is never found: AttributeError: module 'random' has no attribute 'choice' — CPython 3.12's actual words. The error points at random, so beginners hunt the bug everywhere except the filename.
InteractiveFirst match wins — rig the search
import random — who answers first? [0] your folder random.py (yours) [1…] stdlib Lib random.py (real) […] site-packages sys.path, searched left → right — the search stops at the first hit
your folder has no random.py
First match wins — sys.path order is the whole game.
sys.path — an ordinary list, searched front to back search order [0] the script'sown folder [1…4] standardlibrary folders [5…9] site-packages(pip's shelf) playlist_store.py ✓ stop no match in any slot → ModuleNotFoundError
Fig — Step 1 is a front-to-back list scan. First match wins — which is also why a local random.py can steal the name.

Python now holds a path to a file full of text. A CPU cannot execute text (ch 0). Step 2 fixes that →

Steps 2, 3, 4: compile, run once, cache

Step 2: COMPILE. My playlist_store.py is 408 bytes of source text, and Chapter 0's pipeline applies to every module: source → bytecode → PVM. Python compiles the file and saves the result beside it, in a folder it creates itself: __pycache__\playlist_store.cpython-312.pyc — module name, interpreter, version baked into the filename; 1,053 bytes on my disk. Next import, even days later in a fresh process, Python sees the source unchanged and skips straight to the saved bytecode. Only imported files get this: playlist_app.py, run directly, never appears in __pycache__.

Step 3: RUN. The bytecode executes — the whole file, top to bottom. The import json line runs, the def lines build function objects (ch 9), the tracer fires. And it happens once per process:

python — import_twice.py
import playlist_store   # first import: runs the file
import playlist_store   # second import: cache hit, runs nothing
print("imported twice, but the print above fired once")
terminal
$ python import_twice.py
playlist_store.py is running, top to bottom
imported twice, but the print above fired once

Step 4: CACHE. The finished module object goes into sys.modules, a dict (ch 7) mapping name strings to module objects. Every later import of that name is one dict lookup:

python — cache_check.py
import sys
print("json" in sys.modules)          # before we import it
import json
print("json" in sys.modules)          # after
print(sys.modules["json"] is json)    # the cache entry IS our module
terminal
$ python cache_check.py
False
True
True

The price gap is brutal: on my machine the first import playlist_store took about 14.5 ms (find, load bytecode, run the file — which itself imports json and pathlib); the second took about 0.001 ms. Machine-dependent numbers, but the ratio is the lesson: one dict lookup.

↺ The thing people get backwards
People read import x as "paste and run file x here, now" — so they fear that ten files importing playlist_store means ten runs, and they "re-import" a module hoping to refresh it. Backwards. import means: make sure this module object exists (build it if this is the first ask), then bind a name to it. Run count per process: at most one. Re-importing refreshes nothing — the cache answers.
InteractiveStep through an import — then import again
import playlist_store — the first time vs every time after 1 FINDsys.path scan 2 COMPILE__pycache__ .pyc 3 RUNtop → bottom, once 4 CACHEsys.modules dict top-level code ran: 0 time(s) imports satisfied: 0 ready — press step to run import #1
step 0 / 4
Top-level code runs once per process; every later import is a dict lookup.
playlist_store.py 408 bytes of source text compiler source → bytecode (ch 0) __pycache__\ playlist_store .cpython-312.pyc 1,053 bytes of bytecode next import: source unchanged? → skip the compiler, load the .pyc straight away
Fig — Step 2 pays once and leaves a receipt on disk. Real names, real sizes from my machine.
sys.modules (a dict, ch 7) 'json' → ▪ 'pathlib' → ▪ 'playlist_store' → … (48 at startup here) module object on the heap (ch 0) import #1: find → compile → run, then write one entry import #2 … #N: one dict lookup (~0.001 ms here)
Fig — The first import pays full price and writes one dict entry; every later import just reads it.
Wait —
before your first line even ran, Python had already imported dozens of modules for itself: python -c "import sys; print(len(sys.modules))" prints 48 on my machine (build-dependent). The interpreter bootstraps with the exact machinery you just learned?
The deeper cut

How does Python know the source is "unchanged"? The .pyc starts with a small header: a magic number identifying the bytecode format, a flags field, and — by default — the source file's last-modified time and size. On import, CPython compares that header against the real file; any mismatch triggers a recompile and a rewritten .pyc (PEP 552 also allows hash-based headers for reproducible builds). Run python -B and no .pyc is written at all — everything still works, you just pay the compile every process.

And if you must re-run a module in a live process, importlib.reload(playlist_store) executes the file again into the same module object. Careful: any label you bound with from playlist_store import load_playlist still references the old function object (label model, ch 3). This is why the honest workflow is simpler: restart the process.

So import handed playlist_app.py a "module object". What is that thing made of? Time to crack it open →

What you got: an object with a dict inside

Everything in Python is an object (ch 2) — modules included. Crack one open:

python — inspect2.py
import playlist_store                 # (its tracer line prints first)

print(type(playlist_store))
print(vars(playlist_store) is playlist_store.__dict__)
print([k for k in vars(playlist_store) if not k.startswith("__")])
print(vars(playlist_store)["save_playlist"])
terminal
$ python inspect2.py
playlist_store.py is running, top to bottom
<class 'module'>
True
['json', 'Path', 'save_playlist', 'load_playlist']
<function save_playlist at 0x000001D09E175BC0>

A module object's namespace is a dict — vars(playlist_store) is playlist_store.__dict__, the same object (that heap address in the last line changes every run). Its keys read like the file itself: json and Path were created by the import lines inside playlist_store.py, then come the two functions, plus dunder entries like __name__ and __file__. So playlist_store.save_playlist(...) is a lookup in that dict followed by a call. Which makes from ... import ... easy to say precisely — it does not import less:

python — from_import.py
from playlist_store import load_playlist   # watch the print fire anyway

import sys
print("playlist_store" in dir())          # did I get a label for the module?
print("playlist_store" in sys.modules)    # is the module object still there?
print(load_playlist is sys.modules["playlist_store"].load_playlist)
terminal
$ python from_import.py
playlist_store.py is running, top to bottom
False
True
True

Same four steps, same full run — the tracer proves it. The only difference is which label lands in your namespace: import x binds x to the module object; from x import y binds y straight to one object out of the module's dict (label model, ch 3).

Myth — "from playlist_store import load_playlist loads just that one function. Lighter, faster."
Reality — the whole file runs either way, and the whole module sits in sys.modules either way. from changes only the label you receive.
📝
Even the dunders are just dict entries
playlist_store.__file__ is the path Python found in step 1; playlist_store.__name__ is the module's name. No magic compartments — they sit in the same namespace dict as save_playlist.
module object 'playlist_store' — heap __dict__ — the namespace '__name__' → '…store' 'json' → module 'Path' → class 'save_playlist' → 'load_playlist' → function save_playlist function load_playlist playlist_store from: import playlist_store load_playlist from: from playlist_store import load_playlist
Fig — Two statements, one identical run. They differ only in which label your namespace receives — the whole module, or one object from its dict.

One dunder in that dict — '__name__' — gets a different value depending on how the file was started. That little fact powers a line you'll meet in every real Python project →

__name__: one file, two lives

Before running a file's code, the interpreter stamps __name__ by one rule: started directly (python playlist_stats.py), the module is named "__main__"; imported, it gets its own name. Watch both lives:

python — playlist_stats.py
"""Total-time maths for the playlist."""

def total_seconds(songs):
    return sum(s["seconds"] for s in songs)

print("my __name__ is", __name__)

if __name__ == "__main__":               # true only when run directly
    demo = [{"title": "Levels", "seconds": 200},
            {"title": "Wake Me Up", "seconds": 247},
            {"title": "Titanium", "seconds": 245}]
    print("demo total:", total_seconds(demo), "seconds")
terminal
$ python playlist_stats.py
my __name__ is __main__
demo total: 692 seconds

$ python use_stats.py        # a 2-line file: import playlist_stats + a print
my __name__ is playlist_stats
imported it; no demo ran

That is the entire secret of the guard: code under if __name__ == "__main__": runs only when the file is the program, never when it is someone else's building block. playlist_stats.py can carry its own demo drive and still be imported silently by playlist_app.py — no stray prints, no double work. (692 seconds, by the way: Levels 200 + Wake Me Up 247 + Titanium 245.)

🎯
Give every module a demo under the guard
A tiny "drive me" block at the bottom of each module — behind the guard — gives you a one-command smoke test per file. In Chapter 18 this instinct grows up into real tests.
playlist_stats.py carries the guard python playlist_stats.py import playlist_stats __name__ = '__main__' guard opens → demo total: 692 s __name__ = 'playlist_stats' guard stays shut → silence
Fig — Same file, two entrances. The interpreter stamps __name__ before a single line of yours runs; the guard reads the stamp.

Your shelf now holds three modules. Python ships with a few hundred more, and the internet holds hundreds of thousands. Where do they all live — and how do you keep them from fighting? →

Shelves: packages, the stdlib, pip and venv

One more word, then the map. A package is a folder of modules. You have been using one all chapter — json is not a single file:

python — where_json.py
import importlib.util
print(importlib.util.find_spec("json").origin)
terminal
$ python where_json.py
C:\Users\ajair\anaconda3\Lib\json\__init__.py

Importing a package finds the folder and runs its __init__.py — the same four steps. (Publishing your own package to PyPI is a real road; it's just not this book's road.) The standard library is the shelf that ships with Python — five picks, playlist-flavoured:

python — shelf_demo.py
import random, math, datetime, pathlib, json

songs = ["Levels", "Wake Me Up", "Titanium"]
print(random.choice(songs))                       # different every run
print(math.fsum([200, 247, 245]), "seconds")      # exact float sum
print(datetime.timedelta(seconds=692))            # seconds -> h:mm:ss
print(pathlib.Path("playlist.json").stat().st_size, "bytes on disk")
print(json.dumps({"title": "Levels"}))            # object -> text (ch 15)
terminal
$ python shelf_demo.py
Titanium
692.0 seconds
0:11:32
255 bytes on disk
{"title": "Levels"}

Everything beyond the stdlib comes from pip, which downloads third-party packages into site-packages — the tail slots of our sys.path printout. But a shelf has one slot per package name: it cannot hold requests 2.31 for project A and requests 2.32 for project B. The fix is venv: a private shelf per project. Built for real on my machine (the ls and pip --version outputs are captured; the last two lines are for your terminal — activation changes your live shell, and installs need internet):

terminal
$ python -m venv demo_env            # build the private shelf
$ ls demo_env/Lib/site-packages      # a brand-new shelf holds only…
pip
pip-24.2.dist-info
$ demo_env/Scripts/pip --version     # …and that pip serves THIS shelf
pip 24.2 from C:\…\demo_env\Lib\site-packages\pip (python 3.12)

demo_env\Scripts\activate            # run in YOUR terminal (macOS/Linux: source demo_env/bin/activate)
pip install rich                     # lands on this project's shelf, no other
🔥
One global shelf, many projects — a slow-motion crash
Install everything into your one system Python and eventually two projects demand different versions of the same package. The second install silently overwrites the first, and a project that worked yesterday breaks with no code change. One venv per project ends the fight before it starts.
The deeper cut

What does activate actually do? Mostly one humble thing: it puts the venv's Scripts (Unix: bin) folder at the front of your shell's PATH, so typing python or pip now resolves to the venv's copies. The venv's python then finds the pyvenv.cfg file sitting beside it and points sys.path's tail at the venv's own site-packages instead of the global one. That's why calling demo_env/Scripts/pip by full path worked above with no activation at all — activation is convenience, the path is the mechanism.

shelf 0 — your project folder playlist_store · playlist_stats · playlist_app the standard library — ships with Python random · math · datetime · pathlib · json … site-packages — pip fills this shelf requests · rich · … venv = a private site-packages each project_a\.venv\…\site-packages requests 2.31 project_b\.venv\…\site-packages requests 2.32 separate shelves — no version fight
Fig — Three public shelves feed sys.path — and venv gives each project a private copy of the third one.

The playlist program is now three honest modules — but look at save_playlist(songs, path). It will accept anything: a list, a string, the number 7. Hand it a song whose seconds are the string "200" and nothing complains — until the maths quietly breaks three files away. Chapter 17 puts the contract in writing, right on the label: type hints. →

17Type hints — intent the tools can check

The playlist program now lives in modules (ch 16), but every function still keeps a secret: what kind of object each parameter expects. In this chapter you write that intent down — seconds: int — watch Python ignore it completely, then meet the tool that reads it and catches wrong-type bugs before the program ever runs. By the end, one decorator will turn those written-down intents into three methods you no longer have to write.

Write the intent down

Chapter 3 settled it: a name is a typeless label. seconds = 200 ties the label to an int object; nothing stops the very next line from re-tying it to a string. The object has a type. The label never did.

Here is the Volume 2 twist: Python lets you write down what a label is meant to reference. Put a colon and a type after the name. Functions get the same treatment — each parameter takes its own : type, and an arrow before the final colon declares the return:

python
seconds: int = 200                      # "seconds is meant to reference an int"

def total(lengths: list[int]) -> int:   # takes a list of ints, hands back an int
    return sum(lengths)

print(total([200, 247, 245]))           # Levels + Wake Me Up + Titanium
# 692

The official word is annotation; everyone says type hint. Be precise about where it hangs: the hint describes the name, not the object. The int object 200 is the same object it was in Chapter 3 — no sticker on it, no extra bytes in it. The note is pinned to the label.

meant to reference: int seconds name — a label (ch 3) int · 200 object — on the heap, unchanged
Fig — the hint is a note pinned to the label. The object it references today is untouched — and, so far, unconsulted.
📝
Where the syntax came from
Type hints for function signatures were standardized by PEP 484 in 2015; the bare-name form (seconds: int = 200) followed in PEP 526, Python 3.6. Writing list[int] directly — brackets on the built-in type — works since Python 3.9 (the str | None bar of the later sections since 3.10), so every hint in this chapter is plain modern Python — no typing imports needed.

You just made a promise in writing. Let's break it and see who complains. →

Break the promise — nothing happens

Break it in the dumbest way possible: promise an int, deliver a string.

python
seconds: int = "oops"    # the hint says int; the object is a str
print(seconds)           # oops                      <- runs fine
print(type(seconds))     # <class 'str'>             <- really a str
print(__annotations__)   # {'seconds': <class 'int'>} <- where the hint went

No error. Not even a warning. CPython evaluated the hint (the name int), filed it in __annotations__ — an ordinary dict that modules and functions carry — and never looked at it again. Functions store theirs the same way: total.__annotations__ from last section prints {'lengths': list[int], 'return': <class 'int'>}, evaluated once when the def statement ran (ch 9), then left alone.

So a type hint is metadata: data about your program, stored inside your program, enforcing nothing.

↺ The thing people get backwards
Adding hints does not switch on runtime type checking. Python is exactly as dynamic after seconds: int as before it — every label can still be re-tied to anything. What hints add is a machine-readable place where your intent lives. The enforcement never comes from Python; the reading comes from outside — next section.
Myth — "Hints make Python faster: the interpreter uses the type info to optimize."
Reality — CPython 3.12 files hints as metadata and never consults them while executing. Same bytecode strategy, same speed. The payoff is at edit time, not run time.
what the PVM executes seconds: int = "oops" seconds now references the str object ✓ runs — prints oops __annotations__ — metadata {'seconds': <class 'int'>} filed once, at the moment the line executes filed ✗ never read back the assignment runs as if the hint weren't there — plus one dict write
Fig — the promise is filed in a drawer the running program never opens.

A promise nobody checks is decoration. For hints to be worth anything, something has to read the file — without running it. →

The checker — bugs caught before the run

That something is a type checker: a separate program that reads your source top to bottom, follows every hint, and reports each place where the promises contradict. It never executes a line. The two you'll meet everywhere are mypy (pip install mypy) and pyright, the engine behind VS Code's red underlines. Point mypy at last section's file:

shell
$ python -m mypy hints.py
hints.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
Found 1 error in 1 file (checked 1 source file)
Wait —
mypy never ran hints.py. How can it know what's in the file? It doesn't run anything: it reads the hint on the left, works out the type of the expression on the right, and compares. Reading beats running — reading covers every path at once.

Now the bug this chapter exists for. Chapter 15 read the playlist back from disk, and files hold text — a duration is a string until you convert it. Suppose one slips through:

python
def total(lengths: list[int]) -> int:
    return sum(lengths)

print(total([200, 247, "245"]))   # "245" — fresh from a file, never converted

Run it and CPython dies mid-sum — but only when that line executes, with exactly that data: TypeError: unsupported operand type(s) for +: 'int' and 'str'. The same file through the checker, before any run:

shell
$ python -m mypy playlist.py
playlist.py:4: error: List item 2 has incompatible type "str"; expected "int"  [list-item]
Found 1 error in 1 file (checked 1 source file)
🌙
The 2 a.m. difference
The runtime TypeError needs the bad data to arrive at that exact line — maybe on a Saturday, weeks after you wrote it. The checker flags the clash on every save, whole file at once, no data required.
checker reads the hints — error reported HERE program starts line 4 finally runs — TypeError HERE edit time run time
Fig — same bug, two catch points. The checker fires before the program even exists as a process; the traceback waits for the bad data to arrive.
playlist.py:4: error: List item 2 has incompatible type "str"; expected "int" [list-item] where: file + line severity the contradiction, in plain words error code — look it up, or silence it deliberately
Fig — a mypy report names the place, the clash, and a code. Nothing in it is a guess: it read your hints.
💡
You rarely type the command
Editors run pyright continuously in the background. The red squiggle under an argument is the checker — the same class of report, delivered while your hands are still on the keys.
InteractiveFeed the checker — swap the arguments
def add_song(title: str, seconds: int) "Levels" → title: str 200 → seconds: int checker (reads, never runs): Success: no issues found in 1 source file runtime: prints added Levels (200 s) — correct today, correct tomorrow
0 errors
The checker compares each argument's type to the parameter's hint — it catches a swap even when the printed output would look fine.

The checker earns its keep hardest on values that are only sometimes there. →

str | None — the honest hint

Chapter 7's .get returns None on a miss, and None is a real object (ch 2) with almost no methods. Ask the library for an artist it doesn't have:

python
def find_artist(title: str) -> str | None:   # honest: a str, OR None on a miss
    artists = {"Levels": "Avicii", "Titanium": "David Guetta"}
    return artists.get(title)                # .get -> None when the key is absent (ch 7)

artist = find_artist("Wake Me Up")           # not in this dict -> None
print(artist.title())
# AttributeError: 'NoneType' object has no attribute 'title'

The return hint str | None reads "a str, or None" — the bar means either. (Older code spells it Optional[str]; identical meaning. It does not mean "optional parameter" — it means "or None".) The moment the hint is honest, the checker forces the what if it's missing? question at every call site:

shell
$ python -m mypy library.py
library.py:6: error: Item "None" of "str | None" has no attribute "title"  [union-attr]
Found 1 error in 1 file (checked 1 source file)

The fix is a guard, and mypy follows it — inside the else, artist can only be a str (checkers call this narrowing):

python
if artist is None:                # 'is' — identity check, ch 4
    print("no artist on file")    # the miss case, handled on purpose
else:
    print(artist.title())         # here the checker KNOWS artist is a str
# prints: no artist on file      -- and mypy: Success: no issues found in 1 source file
⚠️
Maybes are everywhere
Every dict .get, every str.find miss, every "search that might fail" produces a maybe-value. Hint those returns honestly and the checker audits every caller you forgot to guard.
find_artist(…) -> str | None "Avicii" str None .title() ✓ prints Avicii .title() ✗ AttributeError the guard (is None?) goes here
Fig — one function, two possible objects. The hint str | None is what reminds every caller to build the gate before the red rail reaches .title().
InteractiveHit or miss — who catches it?
artist = find_artist("Levels") "Avicii" (str) print(artist.title()) checker: error: Item "None" of "str | None" has no attribute "title" runtime: prints Avicii — lucky: the key happened to be there this run
checker: 1 error
The checker's verdict never changes with the data — it judges every possible run, not today's.

Single values, done. But the playlist itself is a container — how do you hint what's inside? →

Containers — hint what's inside

A container hint answers two questions: which container, and what it holds. Square brackets carry the inside part:

python
playlist: list[tuple[str, str, int]] = [      # each row: (title, artist, seconds)
    ("Levels", "Avicii", 200),
    ("Wake Me Up", "Avicii", 247),
    ("Titanium", "David Guetta", 245),
]
durations: dict[str, int] = {"Levels": 200, "Wake Me Up": 247, "Titanium": 245}

print(durations["Levels"].title())            # an int has no .title() ...

Read tuple[str, str, int] position by position: slot 0 a str, slot 1 a str, slot 2 an int — a fixed shape, exactly what Chapter 6 said tuples were for. And remember what a list actually stores (ch 6): 8-byte references. list[int] declares what those references are meant to point at; the running list never checks. The checker does, and it follows hints through every lookup — it knows durations["Levels"] is an int:

shell
$ python -m mypy durations.py
durations.py:8: error: "int" has no attribute "title"  [attr-defined]
Found 1 error in 1 file (checked 1 source file)
list[int] int int int any length, every slot int dict[str, int] str int str int keys str → values int tuple[str, str, int] 0 1 2 str str int fixed length, a type per slot brackets say what the 8-byte references inside are meant to point at (ch 6)
Fig — the three container hints you'll write daily. The container never checks its contents; the checker checks every use of them.
The deeper cut

What is list[int] when the line runs? Run print(type(list[int])): it prints <class 'types.GenericAlias'>. Subscripting a class works because list defines __class_getitem__ — one of Chapter 12's dunders — which builds a small object remembering list and (int,). That object exists purely so tools can read it; no list ever consults it while appending. Two doors we're leaving closed on purpose: TypeVar (hints that stay generic across types — "a function that returns whatever type it was given") and Protocol (hinting by capability instead of class). The capstone in Chapter 20 types the whole playlist app with just what's on this page.

Every hint so far cost keystrokes and paid in caught bugs. One decorator flips the trade: hints that write code for you. →

@dataclass — hints that write your dunders

In Chapter 12 you wrote the same three dunders for every data-shaped class: an __init__ that copies parameters onto self, a __repr__ so printing is honest, an __eq__ so value comparison works. @dataclass reads the field hints and writes all three while the class statement executes:

python
from dataclasses import dataclass

@dataclass                    # reads the hints below, generates the dunders
class Song:
    title: str                # field 1 -> first __init__ parameter
    artist: str               # field 2
    seconds: int              # field 3

levels = Song("Levels", "Avicii", 200)   # the __init__ you didn't write
copy   = Song("Levels", "Avicii", 200)

print(levels)                 # Song(title='Levels', artist='Avicii', seconds=200)
print(levels == copy)         # True   -- generated __eq__ compares field values
print(levels is copy)         # False  -- still two objects, two labels (ch 3)

The free __repr__ prints every field; the free __eq__ makes == mean "same field values" while is still means "same heap object". And because the generated __init__ carries the hints, construction is checked too — Song("Levels", "Avicii", "200") runs without a squeak (the rule from section 2 never bends), but the checker reports:

shell
$ python -m mypy songs.py
songs.py:9: error: Argument 3 to "Song" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

So when do hints earn their keep? Wherever code has more than one reader — teammates, code review, you in six months. Wherever the crash site sits far from the mistake, which is precisely the big-program disease. And every time you type a dot: hint artist as str and your editor lists string methods instantly, because autocomplete is a type checker in a party hat. When are they ceremony? A five-line script you run once and delete — hinting it catches nothing the first run wouldn't.

🧭
A defensible default: hint the seams
Every def signature, every dataclass field, every module boundary from Chapter 16 gets a hint. Locals inside a ten-line function can fend for themselves — the checker infers them from the seams anyway.
field hints title: str artist: str seconds: int @dataclass runs at class creation __init__ __repr__ __eq__ the three dunders you wrote by hand in ch 12 — generated from the field list
Fig — hints as input, code as output. Write the fields once; the boilerplate is manufactured for you.

The checker just certified every type in the playlist program. It cannot certify a single answer: a total() that returns the wrong sum with the right type sails straight through. Catching wrong answers takes a different machine — Chapter 18 puts the program on a test bench. →

18Testing & debugging

The playlist program now survives restarts (ch15), lives in tidy modules (ch16), and declares its types (ch17). This chapter is where you stop hoping it works and start checking it works — automatically, on every change. You will write your first test file, read pytest's red and green for real, and learn the six-step loop that turns a 2 a.m. crash into a permanent upgrade.

The bug we already shipped once

Rewind to trace T15 (Vol 1 ch10). The prime checker, as first written:

python
def is_prime(n):
    for d in range(2, n):        # try every candidate divisor
        if n % d == 0:           # a clean split? not prime
            return False
    return True                  # survived the loop: prime

print(is_prime(1))               # → True   (wrong — 1 is not prime)
print([n for n in range(1, 20) if is_prime(n)])
# → [1, 2, 3, 5, 7, 11, 13, 17, 19]   ← 1 crashed the party
print(is_prime(0), is_prime(-7)) # → True True   (worse)

Walk n=1 like a trace: range(2, 1) is empty, so the loop body runs zero times, so execution falls through to return True. The fallback answer wins by default. "Loop never runs, default escapes" is a bug class that ships in real products constantly — and we only caught ours because we hand-traced it in Volume 1, then fixed it with a guard. Nothing pins that fix. Delete the guard in a refactor — it looks redundant, guards often do — and Python runs the broken version without a squeak.

trace T15, replayed — the path n=1 actually takes is_prime(1) the call range(2, 1) empty — zero laps loop body never runs return True fallback wins no exception, no warning — a confidently wrong answer
Fig — The bug's mechanics: an empty range means the "prime" verdict was never actually earned — it leaked out the bottom.
Wait —
we already found and fixed this in Volume 1. Why is it back? Because a fix without a test is a fix on parole. What we need is a claim about the code that the machine re-checks forever, for free.

Python has a one-word statement for exactly that →

assert — a claim with teeth

assert is a statement that takes an expression and, optionally, a message: assert claim, "message". Python evaluates the claim. Truthy (ch4)? Nothing happens — no output, no trace, the next line runs. Falsy? Python raises an AssertionError — a real exception riding the ch13 machinery — carrying your message.

python
assert is_prime(17)                             # true claim — silence
assert not is_prime(1), "1 must not be prime"   # false claim — bang
print("never reached")

Run against the unguarded is_prime, CPython 3.12 answers — arrows pointing at the exact sub-expression that lied:

terminal
Traceback (most recent call last):
  File "assert_demo.py", line 8, in <module>
    assert not is_prime(1), "1 must not be prime"   # false claim — bang
           ^^^^^^^^^^^^^^^
AssertionError: 1 must not be prime
assert claim, "message" truthy? yes nothing happens no output — next line runs no raise AssertionError ch13 machinery, your message a claim the machine checks — silence when true, a named crash when false
Fig — assert has exactly two exits: silence or AssertionError. Silence is the sound of a claim holding.
🚨
assert vanishes under -O
We ran the same file as python -O assert_demo.py: it printed never reached and exited cleanly — the -O flag strips every assert from the bytecode. So never use assert to validate user input or file data on a production path; raise real exceptions there (ch13). assert is for claims a correct program could rely on — and, above all, for tests.

One assert in a script dies on the first failure and takes the whole run with it. You want fifty claims, each run in isolation, all reported at once →

pytest — red, then green

A test runner is a program whose only job is to find your claims, run each one separately, and report. The standard choice is pytest — not in the stdlib, so: pip install pytest (this machine got 7.4.4). Discovery is pure naming convention: it collects files named test_*.py, and inside them, functions named test_*. Each test is a plain function making three moves — Arrange the data, Act on it once, Assert the result. Here is test_playlist.py, sitting next to our ch16 module playlist.py:

python
from playlist import total_seconds, is_prime

def test_three_songs():
    songs = [                                  # Arrange: build the world
        {"title": "Levels",     "seconds": 200},
        {"title": "Wake Me Up", "seconds": 247},
        {"title": "Titanium",   "seconds": 245},
    ]
    assert total_seconds(songs) == 692         # Act + Assert: poke once, pin the answer

def test_empty_playlist():
    assert total_seconds([]) == 0              # the sum of nothing is 0

def test_one_is_not_prime():
    assert not is_prime(1)                     # the T15 claim, made permanent

def test_seventeen_is_prime():
    assert is_prime(17)                        # and a claim from the happy middle

Rewind playlist.py to the morning before T15's fix — no guard — and run pytest in that folder (output below is real; paths and a plugin line trimmed):

terminal
======================= test session starts ========================
platform win32 -- Python 3.12.7, pytest-7.4.4, pluggy-1.0.0
collected 4 items

test_playlist.py ..F.                                         [100%]

============================ FAILURES ===============================
_____________________ test_one_is_not_prime _________________________

    def test_one_is_not_prime():
>       assert not is_prime(1)                     # the T15 claim, made permanent
E       assert not True
E        +  where True = is_prime(1)

test_playlist.py:15: AssertionError
==================== short test summary info ========================
FAILED test_playlist.py::test_one_is_not_prime - assert not True
==================== 1 failed, 3 passed in 0.05s ====================

Read the dot line: one character per test — . pass, F fail — and the failure report even shows where the True came from. Add the two-line guard (if n < 2: return False), run again:

terminal
test_playlist.py ....                                         [100%]
======================== 4 passed in 0.01s ==========================
your folder pytest scans it test_playlist.py name starts test_ → collected 4 test_ functions each runs in isolation ..F. one letter per test no registration, no config — the names ARE the wiring
Fig — pytest's discovery is a naming convention: test_*.py files, test_* functions, one verdict character each.
ARRANGE songs = [Levels 200 s, Wake Me Up 247 s, Titanium 245 s] build the world ACT total = total_seconds(songs) poke it once ASSERT assert total == 692 pin the answer
Fig — Arrange-Act-Assert: every good test is these three bands, in this order, and nothing else.
🏷️
The name is the sentence
test_one_is_not_prime reads as the claim it pins. When it fails months from now, the one-line summary — FAILED test_playlist.py::test_one_is_not_prime — tells you what broke before you open a single file.
The deeper cut

How did a bare assert not is_prime(1) print where True = is_prime(1), when the same statement in a plain script printed only our message? pytest imports test files through a hook that rewrites their assert statements into richer bytecode that records intermediate values before comparing. The evidence sits on disk: after a run, __pycache__ holds playlist.cpython-312.pyc (normal) next to test_playlist.cpython-312-pytest-7.4.4.pyc — the rewritten, pytest-tagged cache (we checked; both files are there). Further down this road live fixtures (shared setup), mocking (stand-in objects for slow or remote things), and CI (a robot that runs pytest on every push). None are needed yet — plain asserts in plain functions carry you a very long way.

Four tests, all green. But out of infinitely many possible inputs, which ones deserve a test? There's a checklist →

The edge-case drill

Bugs cluster at the edges of an input's range, not in the comfortable middle — T15's bug lived at n=1, not n=6. So drill every function with six probes: empty, zero, one, negative, huge, wrong type. Here is the drill on total_seconds and the fixed is_prime, every line actually run:

python
levels = {"title": "Levels", "seconds": 200}
levels_x_million = [levels] * 1_000_000

total_seconds([])                # → 0            empty: sum of nothing — sensible, now pin it
total_seconds([levels])          # → 200          one: no fencepost stumble
total_seconds(levels_x_million)  # → 200000000    huge: correct (a million dict lookups — ch19 will care)
total_seconds(None)              # → TypeError: 'NoneType' object is not iterable

is_prime(0), is_prime(1), is_prime(-7)   # → (False, False, False)   the guard holds all three
is_prime(15_485_863)             # → True — in 0.55 s on this machine (the millionth prime)

Note what the wrong-type probe revealed: the TypeError comes from Python's for asking None for items — Python chose that behaviour, not us. And the negative probe? A song with seconds=-5 just quietly shrinks the total. No crash, no answer — a question.

⚠️
The drill's real product is decisions
Is a negative duration impossible-by-contract (ch17), a crash, or a shrug? Right now total_seconds shrugs. The drill forces you to choose a behaviour and pin it with a test — before a user discovers you never chose at all.
total_seconds(…) empty — [] zero — a 0-second song one — [levels] negative — seconds=-5 huge — 10⁶ songs wrong type — None
Fig — Six probes, ten minutes. Most bugs live where the input stops being "normal".

Now watch the drill work as a suite — several claims holding one function from different angles at once:

InteractiveToggle the guard — watch four tests hold the line
def is_prime(n): if n < 2: return False for d in range(2, n): if n % d == 0: return False return True guard OFF — n=1 skips the loop, falls to True is_prime(1) == False ? FAIL is_prime(2) == True ? PASS is_prime(4) == False ? PASS is_prime(17) == True ? PASS
3/4 passing
One test pins one angle; the suite holds the guard in place from four directions at once.

The drill gives you candidates. But what deserves a test at all — and what does a wall of green actually certify? →

What to test — and what green really proves

Test the behaviour you own and the boundaries you chose. total_seconds returning 692 for our three songs: yours. Whether int + int adds correctly: not yours — CPython ships and runs its own test suite before every release, and re-proving list.append inside your project spends minutes proving nothing about your code. The subtle territory is the seam: your assumptions about their behaviour ("csv hands every field back as str") — those are yours to pin, and one of them bites us in the next section.

Myth — a green suite means the program is correct.
Reality — tests prove the presence of behaviour on the inputs you tried, never the absence of all bugs. Dijkstra said it in 1969; it is still undefeated.
Python + the stdlib int +, list.append, csv, sum tested upstream, before release don't re-test it your code total_seconds · is_prime · loader behaviour + boundaries test here the seam: your assumptions about their behaviour — pin those too
Fig — Spend tests where only you can break things: your code, and your assumptions at the seam.

Here is the honest seam, made tangible. This impostor passes our entire four-test suite — yet it is not a prime checker:

InteractiveSlide n — find where the impostor slips
the suite, run against the impostor: n = 4: they agree — the suite can't tell them apart impostor: n == 2 or n odd > 2 not prime truth: trial division + guard not prime 0 15 30
n = 4
Four green tiles pinned four points; the other twenty-seven inputs walked in free.

So every test is a bug you imagined in advance. The one you didn't imagine still arrives — as a traceback, at the worst possible hour →

Debugging is a loop, not a panic

The playlist now loads from playlist.csv through the ch15 loader — and the program dies (real output, path trimmed):

terminal
Traceback (most recent call last):
  File "app.py", line 4, in <module>
    print(total_seconds(songs))
          ^^^^^^^^^^^^^^^^^^^^
  File "playlist.py", line 4, in total_seconds
    total += song["seconds"]
TypeError: unsupported operand type(s) for +=: 'int' and 'str'

Method, not panic — six steps. 1 Read it bottom-up (ch13): last line first — what (adding int to str); one frame up — where (playlist.py line 4); above that — who called. 2 Reproduce it small: in the REPL, 200 + "247" raises the same TypeError: unsupported operand type(s) for +: 'int' and 'str' — two characters of code now stand in for the whole crash. 3 Form ONE hypothesis — singular: "the seconds came back from the CSV as text." Not five guesses at once; one, falsifiable. 4 Check it: print(repr(song["seconds"]))'247' — quotes, so str. (Or drop breakpoint() on the line above: Python's built-in door into the pdb debugger, where n steps and p prints.) 5 Fix at the boundary: a file holds bytes; the csv module decodes them to text and hands back str — restoring numbers is your job (ch15). So the loader converts once: row["seconds"] = int(row["seconds"]), and every downstream function sees ints. 6 The step everyone skips: add the test that would have caught it — a regression test, a claim pinning a bug you actually had so it can never quietly return:

python
from playlist import load_playlist

def test_loaded_seconds_survive_the_sum():
    songs = load_playlist("playlist.csv")   # Arrange: the real file, the real loader
    assert total_seconds(songs) == 692      # Act + Assert: str-seconds would crash here
terminal
test_playlist.py .....                                        [100%]
======================== 5 passed in 0.02s ==========================
Traceback (most recent call last): File "app.py", line 4 — print(total_seconds(songs)) 3 who called File "playlist.py", line 4 — total += song["seconds"] 2 where TypeError: unsupported operand … 'int' and 'str' 1 what read bottom-up: what → where → who called it (ch13)
Fig — The bottom line names the crime, the frames above it name the scene and the caller. Start at the bottom, always.
1 · read it bottom-up 2 · reproduce it small 3 · ONE hypothesis 4 · check it (print / pdb) 5 · fix at the boundary 6 · add the test method, not panic — every lap makes the net stronger
Fig — The loop closes where it started: the next crash meets a suite that already knows this trick.
💡
You've been debugging since T1
Every trace table in Volume 1 was predict → step → check: guess what the line does, run it, compare. Debugging is exactly that habit aimed at a mismatch — the bug lives at the first line where your prediction and Python's reality disagree. The skill has been building for seventeen chapters; now it has a name.
↺ The thing people get backwards
Most people think debugging ends with the fix. Backwards. The fix repairs one afternoon; the test repairs every future afternoon. A bug's most valuable product is the regression test it leaves behind — which is why the suite in this chapter grew from four claims to five, and why real projects' suites are diaries of every bug they ever survived.
🧭
One test is a claim; five are a net
Our five: three songs sum to 692, empty sums to 0, 1 is not prime, 17 is prime, and CSV-loaded seconds survive the sum. Every future change to playlist.py now runs the gauntlet in a few hundredths of a second.

Five tests, all green — and one of them whispered a warning: is_prime(15_485_863) answered correctly but took over half a second on this machine, and while it ground away, the program could do nothing else. A correct program can still freeze mid-thought. Chapter 19 opens exactly there: where the time goes, how to measure it, and how to keep the playlist responsive while the heavy work runs →

19Threads, async & the GIL

Chapter 18 proved the playlist app correct. Now give it a real job — fetch cover art for "Levels", "Wake Me Up" and "Titanium" — and it freezes for three straight seconds while the network answers. After this chapter you can overlap waits with threads, explain the GIL from chapter 3's refcounts instead of quoting folklore, buy true parallelism with processes, juggle thousands of waits with async/await — and know which tool to reach for before you write a line.

Two kinds of slow

Every slow program is slow in one of exactly two ways, and the fix for one is poison for the other. CPU-bound means the processor is the bottleneck: summing 20 million song durations keeps it busy every microsecond, and only more compute helps. I/O-bound means the processor is idle: you asked the network for the "Levels" cover, and the reply takes on the order of 100 ms to arrive. Chapter 0's numbers make that shocking — a CPU steps through instructions by the billions per second, so in the time one network reply crawls back it could have executed tens to hundreds of millions of instructions (machine-dependent, but always a huge number). Instead it sits there. Disk reads, database queries, input() — same shape: the program is "running", the CPU is doing nothing.

🧭
The one-question diagnosis
Would a faster network fix it? I/O-bound. Would a faster processor fix it? CPU-bound. Everything in this chapter follows from that answer.
CPU-bound — sum 20,000,000 durations CPU busy the whole time — only more compute helps I/O-bound — fetch one cover art over the network CPU idle — waiting for the reply send request parse reply Same wall-clock feel. Opposite bottleneck: one starves for compute, the other for an answer.
Fig — Two waits that look identical from the outside. In one the CPU is the problem; in the other it is a spectator.

If the CPU is a spectator for most of a download, could it run the other two downloads in the gaps? Python ships a module for exactly that →

Three downloads, one second

A thread is an independent line of execution inside one process: it gets its own call stack of frames (ch 9) but shares the same heap of objects (ch 0) with every other thread. The threading module lets you start more of them. One honest simplification before the code: a real download needs a network; time.sleep(1.0) is our stand-in for that one-second wait — it idles the CPU the same way a socket does, but reproducibly, so the timings below are about waiting, not about my Wi-Fi.

python
import threading
import time

titles = ["Levels", "Wake Me Up", "Titanium"]

def fetch_cover(title):
    time.sleep(1.0)          # stand-in for one network wait
    print(f"cover art ready: {title}")

# --- one after another ---
start = time.perf_counter()
for title in titles:
    fetch_cover(title)                 # wait, wait, wait
print(f"sequential: {time.perf_counter() - start:.2f} s")

# --- three threads ---
start = time.perf_counter()
workers = [threading.Thread(target=fetch_cover, args=(t,)) for t in titles]
for w in workers:
    w.start()                          # begin all three waits NOW
for w in workers:
    w.join()                           # block until each finishes
print(f"threaded:   {time.perf_counter() - start:.2f} s")

Note the shape: Thread(target=fetch_cover, args=(t,)) takes the function object (ch 9 — no parentheses) plus its arguments; start() launches it; join() waits for it. On this machine (yours will differ by a few hundredths):

output
cover art ready: Levels
cover art ready: Wake Me Up
cover art ready: Titanium
sequential: 3.00 s
cover art ready: Levels
cover art ready: Titanium
cover art ready: Wake Me Up
threaded:   1.00 s

Three one-second waits, one second of wall-clock — the time a clock on the wall measures, the time your user feels. The waits overlapped.

Wait —
"Titanium" finished before "Wake Me Up"? All three slept exactly 1.0 s. The operating system woke them in the order it pleased. The moment you start a second thread, you give up ordering — remember that when printouts interleave.
sequential — waits stack Levels Wake Me Up Titanium 0 s 1 s 2 s 3.00 s wall threaded — waits overlap Levels Wake Me Up Titanium 1.00 s wall
Fig — Waiting is the one thing you can do three of at once: the bars slide on top of each other and wall-clock collapses from 3.00 s to 1.00 s (measured above).

Free speedup! So point two threads at pure arithmetic and it halves too, right? Chapter 3's refcounts are about to say no — loudly →

The GIL, derived from a refcount

Recall chapter 3: every object carries ob_refcnt, a plain integer in its header, counting the references to it. Every name you bind, every list slot you fill, ticks some object's count up; every del, every scope exit, ticks one down; at zero, CPython frees the object on the spot. A tick is three machine steps: read the integer, add one, write it back.

Now add a second thread — same heap, remember. Suppose ob_refcnt is 1 and both threads bind a new name to that object at the same instant. Both read 1. Both compute 2. Both write 2. The truth is 3; memory says 2. Later, two of those references go away: 2 → 1 → 0 — and CPython frees an object a third name still points at. That is not an exception you can catch; that is a corrupted interpreter.

CPython's fix is blunt: one Global Interpreter Lock. At any instant, at most one thread in the process may execute Python bytecode, so no two ticks can ever collide. And here is why the download demo still flew: a thread about to wait — time.sleep, a socket read, a file read — is C code that releases the GIL before asking the operating system to pause it. Waiting needs no bytecode.

object header ob_refcnt = 1 thread A — INCREF ① read 1   ② add 1 → 2   ③ write 2 thread B — INCREF, same instant ① read 1   ② add 1 → 2   ③ write 2 memory says 2 — the truth is 3 two DECREFs later: 2 → 1 → 0 → object freed while a name still points at it
Fig — The lost update. Read–add–write is not one step, and a lost tick eventually frees a live object.
thread A holds the GIL — running bytecode thread B ready — queued for the GIL thread C sleeping on a socket — GIL released GIL one holder at a time waiting needs no bytecode
Fig — One token for the whole interpreter. Bytecode queues for it; waiting hands it back.
↺ The thing people get backwards
The GIL was not bolted on to slow your threads down. It exists so that every refcount tick can stay one cheap, unguarded integer operation. The alternative — a lock on every object, or atomic counters — would tax every line of single-threaded Python, which is most Python ever written. The GIL taxes only threads, and only their bytecode: waiting threads hand it back, which is why the downloads overlapped perfectly.
The deeper cut

How do threads share the lock fairly? Every 5 ms, CPython asks the running thread to release the GIL so another can take a turn — python -c "import sys; print(sys.getswitchinterval())" prints 0.005 on 3.12. Turns that fine feel simultaneous, the same illusion your OS uses to run 200 processes on 8 cores.

Two boundary stones. First: the GIL protects the interpreter's bookkeeping, not your program's logic — two threads updating the same playlist dict still need coordination (locks, queues), a craft beyond this book's road. Second: removing the GIL means making every tick atomic; the famous 1999 attempt reportedly ran single-threaded code at roughly half speed. That bill — paid by every program to benefit a few — not stubbornness, is why removal took decades. The note at the end of this chapter says where the story now stands.

That's the theory of the crime. Let's time it →

The crunch test: threads can't, processes can

crunch below is pure bytecode — 20 million loop iterations, no waits, about 0.6 s per call on this machine. If threads ran bytecode in parallel, two threads should finish two jobs in about 0.6 s of wall-clock.

python
import threading
import time

N = 20_000_000

def crunch(n):                 # pure bytecode: no waits anywhere
    total = 0
    for _ in range(n):
        total += 1
    return total

start = time.perf_counter()
crunch(N); crunch(N)           # one thread, both jobs back to back
one = time.perf_counter() - start

start = time.perf_counter()
a = threading.Thread(target=crunch, args=(N,))
b = threading.Thread(target=crunch, args=(N,))
a.start(); b.start()           # two threads, one job each
a.join(); b.join()
two = time.perf_counter() - start

print(f"one thread, both jobs: {one:.2f} s")
print(f"two threads:           {two:.2f} s")
output
one thread, both jobs: 1.21 s
two threads:           1.21 s

Identical, run after run (this machine; the pattern, not the digits, is the point). crunch never waits, so it never releases the GIL voluntarily; the two threads just take 5 ms turns at one stream of bytecode. Same total bytecode, same total time. The escape hatch is multiprocessing: a process is a whole separate running program — separate interpreter, separate heap, separate refcounts, and therefore its own GIL. The switch is nearly mechanical:

python
import multiprocessing
import time

# N and crunch(n) exactly as above

if __name__ == "__main__":         # required — see the warning below
    start = time.perf_counter()
    crunch(N); crunch(N)           # one process, both jobs
    one = time.perf_counter() - start

    start = time.perf_counter()    # timer includes process launch
    a = multiprocessing.Process(target=crunch, args=(N,))
    b = multiprocessing.Process(target=crunch, args=(N,))
    a.start(); b.start()           # two interpreters, one job each
    a.join(); b.join()
    two = time.perf_counter() - start

    print(f"one process, both jobs: {one:.2f} s")
    print(f"two processes:          {two:.2f} s")
output
one process, both jobs: 1.17 s
two processes:          0.68 s

Faster — but 0.68, not 0.585: launching a process is heavyweight, and data pays a toll at the border. Arguments and results cross between processes as pickled bytes through a pipe — chapter 15's law again: what leaves a process is bytes, never objects.

⚠️
The guard is not decoration
On Windows each child process starts a fresh interpreter and re-imports your module. Without the __main__ guard, the spawning line would run again inside the child; CPython slams the brakes with RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase (real output, run without the guard).
thread A thread B 0 s wall-clock unchanged Every ~5 ms CPython forces a handover. Turns, not teamwork: one GIL holder at any instant.
Fig — What two CPU-bound threads actually do: interleave. The red never overlaps, so the total never shrinks.
InteractiveSplit the crunch across threads — watch the dream diverge from the GIL
no-GIL dream CPython 3.12 0 s 0.6 s 1.2 s each colour below = a different thread taking its 5 ms-style turn with the GIL
More threads reslice the same 1.2 s of bytecode. Only the dream bar shrinks.

Processes buy real parallel compute — paid in startup time and copied bytes. But for the download problem there is something lighter than threads: no second anything →

async/await: one thread, parked frames

A thread is the operating system pausing and resuming your code wherever it likes. A coroutine is Python doing the same trick to itself, in one thread, at positions you mark. async def defines a coroutine function; calling it runs nothing — it builds a coroutine object, a not-yet-started call wrapped around its own frame. await marks the exact line where that frame may suspend: it is parked on the heap, not destroyed — chapter 9's frames doing chapter 10's generator pause-and-resume trick. While one frame is parked, the event loop — a scheduler running in your single thread — resumes whichever other coroutine's wait has finished. One thread, so the GIL never even changes hands.

python
import asyncio
import time

async def fetch_cover(title):
    await asyncio.sleep(1.0)      # park this frame; free the loop
    return f"cover art ready: {title}"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(     # run all three, collect results
        fetch_cover("Levels"),
        fetch_cover("Wake Me Up"),
        fetch_cover("Titanium"),
    )
    for line in results:
        print(line)
    print(f"wall clock: {time.perf_counter() - start:.2f} s")

asyncio.run(main())                     # start the event loop
output
cover art ready: Levels
cover art ready: Wake Me Up
cover art ready: Titanium
wall clock: 1.01 s

Three seconds of waiting, 1.01 s of wall-clock (this machine) — and unlike the threads, gather hands results back in the order you listed them, whoever finished first. One catch: the loop only switches at await. Swap asyncio.sleep for the blocking time.sleep and the measured wall-clock snaps back to 3.00 s — a C-level block the loop cannot see freezes everyone.

💡
Why one thread can hold 10,000 downloads
A parked frame is one small heap object. A thread needs its own call stack — a megabyte or more by default, machine-dependent — plus OS bookkeeping. That size gap is why async owns the many-waits end of the map: 10,000 open connections become 10,000 parked frames, not 10,000 stacks.
🔥
A coroutine you never await never runs
c = fetch_cover("Levels") downloads nothing. Really — print(type(c)) gives <class 'coroutine'>, print(c) gives <coroutine object fetch_cover at 0x00000207CCFB5600> (address machine-specific), and at exit CPython warns: sys:1: RuntimeWarning: coroutine 'fetch_cover' was never awaited. All real output. No await, no execution.
event loop one thread run → park → resume frame: fetch_cover('Levels') resumed — its 1.0 s timer just fired frame: fetch_cover('Wake Me Up') parked at await — waiting frame: fetch_cover('Titanium') parked at await — waiting wall-clock = longest wait, not the sum
Fig — Frames parked, not destroyed. The loop runs whichever coroutine is ready; three 1.0 s waits cost ~1.0 s (measured: 1.01 s).

Threads, processes, coroutines — three tools, one question left: which one, when? →

Choosing: waits versus work

The whole chapter compresses into one sorting rule. Count your waits and weigh your work. Hundreds or thousands of waits (a cover for every song in a 10,000-track library): async — one thread juggles them all for the price of parked frames. A few waits, or a library that blocks and offers no await: threads — the OS does the switching and waiting threads release the GIL. Real computation: processes — the only tool here that puts two cores to work on bytecode, if the win beats the copy-toll. And sequential remains the right default until a measurement says otherwise (ch 18's discipline).

Myth — "The GIL makes Python threads pointless."
Reality — Threads turned 3.00 s of downloads into 1.00 s, measured. The GIL forbids overlapping bytecode, not overlapping waits — match the tool to the wait and it rarely blocks you.
what dominates? many waits → async/await one thread, thousands of parked frames a few waits, blocking libs → threads waiting threads release the GIL real CPU work → processes own interpreter, own GIL — pays a copy-toll
Fig — The decision map: name the bottleneck first, then pick the tool built for it.
InteractiveThree downloads, four strategies — flip the workload and watch the game change
Levels Wake Me Up Titanium 0 s 1 s 2 s 3 s ≈ 3.00 s waiting (CPU idle) running bytecode process startup
The pattern: concurrency compresses waiting. Only processes compress computing.
🔭
Staged truth: the GIL story is being rewritten
Everything above describes standard CPython 3.12. Python 3.13 ships an additional experimental "free-threaded" build (PEP 703) with the GIL switched off — refcount ticks become thread-safe atomic operations, trading some single-thread speed for real thread parallelism. The derivation you just learned is exactly why that trade took thirty years to accept.

The playlist app now saves (ch 15), splits into modules (ch 16), declares its types (ch 17), proves itself (ch 18), and downloads without freezing. One thing left — the best part: putting every tool from both volumes into a single build. Chapter 20 assembles the complete app — four files, one job each: the model, the engine, the storage, the loop — and ships it →

20The capstone — ship the playlist app

This is the chapter where you stop doing exercises. You will assemble a complete, working app — model, engine, storage, command loop, tests — in five stages, four files, 171 lines, standard library only (plus ch18's pytest to run the proof). Every stage below was run for real; every output is pasted from an actual session, not imagined.

The blueprint — four files, one job each

Volume 1 gave you the machine. Chapters 15–19 gave you files, modules, hints, tests, and concurrency. Now spend all of it at once, on an app called musicbox: a playlist you talk to from the terminal, that still knows your songs tomorrow.

An app is just a program with a job description, and ours splits into four modules (a module is one .py file — ch16), each with exactly one reason to change. playlist.py knows what a song is and how a playlist behaves. storage.py knows how to turn that into bytes on disk and back (ch15). app.py talks to a human. test_playlist.py checks that the other three keep their promises. Look at the arrows in the figure: app.py imports the engine and the vault, storage.py imports the model, and nothing imports app.py. Dependencies flowing one way is nine-tenths of what the word architecture means.

app.py the face — talks to the human playlist.py the engine — Song + Playlist storage.py the vault — objects ⇄ JSON test_playlist.py the proof — pytest playlist.json · 328 bytes import import import import bytes
Fig — Four modules, one-way arrows. Nothing imports app.py: the face is replaceable; the engine is the app.

Build order: start with the smallest thing that can be true — one song. →

Stage 1 — the model: one Song, zero boilerplate

A song is three facts travelling together: a title, an artist, a duration in seconds. Chapter 17's @dataclass turns that sentence directly into a class — the top of playlist.py:

python
"""The engine: the Song model and the Playlist that manages it."""
from dataclasses import dataclass


@dataclass
class Song:
    title: str        # annotations become fields (ch17)
    artist: str
    seconds: int      # duration - an int, not a str

Nine lines, and @dataclass writes __init__, __repr__, and __eq__ for you (the dunder machinery from ch12). Prove it — this ran for real:

python
levels = Song("Levels", "Avicii", 200)
print(levels)                                   # __repr__, free
print(levels.seconds)                           # field access
print(levels == Song("Levels", "Avicii", 200))  # __eq__, free
print(levels is Song("Levels", "Avicii", 200))  # two objects (ch3)
output
Song(title='Levels', artist='Avicii', seconds=200)
200
True
False

== is True because the generated __eq__ compares fields; is is False because these are two separate objects at two addresses (the label model — Vol 1 ch 3). File that away: when Stage 3 loads songs back from disk, you will get equal-but-not-identical Songs, and a test in Stage 5 pins exactly that down.

levels Song object title artist seconds str 'Levels' str 'Avicii' int 200 @dataclass wrote __init__ · __repr__ · __eq__ for you
Fig — One name, one Song object, three references. The fields hold addresses of other objects — nothing is "inside" anything (ch3).

A Song can't add itself, count itself, or save itself. It needs an engine. →

Stage 2 — the engine: one object, two routes to it

The Playlist class keeps two structures pointed at the same Song objects: a list for play order (a dynamic array of 8-byte references — ch6) and a dict keyed by title for instant lookup (a hash table whose buckets record references — ch7). No Song is ever copied; each one simply has two routes leading to it. The rest of playlist.py:

python
class Playlist:
    def __init__(self, name: str) -> None:
        self.name = name
        self._songs: list[Song] = []           # keeps play order (ch6)
        self._by_title: dict[str, Song] = {}   # O(1) lookup (ch7)

    def add(self, song: Song) -> None:
        if song.title in self._by_title:
            raise ValueError(f"duplicate title: {song.title!r}")
        self._songs.append(song)
        self._by_title[song.title] = song      # update BOTH structures

    def remove(self, title: str) -> Song:
        if title not in self._by_title:
            raise KeyError(f"no song called {title!r}")
        song = self._by_title.pop(title)
        self._songs.remove(song)               # update BOTH structures
        return song

    def find(self, title: str) -> Song | None:
        return self._by_title.get(title)       # one hash, no scan

    def total_seconds(self) -> int:
        return sum(song.seconds for song in self._songs)

    def __len__(self) -> int:                  # so len(playlist) works
        return len(self._songs)

    def __iter__(self):                        # so for song in playlist works
        return iter(self._songs)

Walk the moves: add asks the index "seen this title?" before touching anything — a duplicate raises ValueError and the playlist stays clean. find is one hash and a bucket check, no scan. remove pops the dict entry and deletes from the list. The underscore prefix on _songs and _by_title is the "internal — hands off" convention from ch12. Run it:

python
road_trip = Playlist("Road Trip")
road_trip.add(Song("Levels", "Avicii", 200))
road_trip.add(Song("Wake Me Up", "Avicii", 247))
road_trip.add(Song("Titanium", "David Guetta", 245))

print(len(road_trip), "songs,", road_trip.total_seconds(), "seconds")
print(road_trip.find("Titanium"))
print(road_trip.find("Alone"))
road_trip.add(Song("Levels", "Impostor", 999))   # the trap
output
3 songs, 692 seconds
Song(title='Titanium', artist='David Guetta', seconds=245)
None
Traceback (most recent call last):
  ...
  File "...\playlist.py", line 20, in add
    raise ValueError(f"duplicate title: {song.title!r}")
ValueError: duplicate title: 'Levels'
_songs (list) [0] ● [1] ● [2] ● Song 'Levels' Avicii · 200s Song 'Wake Me Up' Avicii · 247s Song 'Titanium' David Guetta · 245s _by_title (dict) 'Levels' ● 'Wake…' ● 'Titan…' ● every ● is an 8-byte reference — the Songs are never copied
Fig — One Song object, two routes: the list route keeps order, the dict route makes find() one hash instead of a scan.
⚠️
Two structures, one truth
Every method that changes the playlist must touch both the list and the dict, or they drift apart. That's why mutation lives only inside add() and remove() — one door in, one door out.
remove("Levels") _by_title (dict) 'Levels' → ● _songs (list) [0] → Song 'Levels' 1. pop from dict 2. remove from list miss step 2 → a ghost: the list still plays it, but find() swears it doesn't exist
Fig — remove() must strike twice. Stage 5 writes the test that catches the ghost.
Wait —
two containers for three songs. Did the memory bill just double?
The deeper cut

No — you bought a second address book, not a second record collection. Measured on my machine (Python 3.12.7), for the three-song playlist above:

output
list : 88 bytes (shallow)
dict : 184 bytes (shallow)
same object? True

Both numbers are sys.getsizeof, which is shallow (ch11): they price only the reference tables, not the Songs. And road_trip._songs[0] is road_trip._by_title['Levels'] printed True — one object, two 8-byte references. The index costs a hash table; it never duplicates the music.

InteractiveScan vs index — drag the playlist bigger
for song in _songs: song.title == title? (worst case) up to 300 == checks _by_title[title] (hash → bucket) ~1 check, however big the playlist
300 songs
The scan grows with the playlist; the hash doesn't (ch7). The green bar is drawn at minimum visible width — at true scale it would be thinner than a hairline. (Ch7's fine print still applies: "~1" is the average; colliding keys can add a probe or two.)

Quit Python and all of this — list, dict, Songs — vanishes with the process's heap (ch3). Time to make it survive. →

Stage 3 — persistence, and the JSON wrinkle

Saving lives in its own module, storage.py — all 21 lines of it. asdict() (from ch17's dataclasses toolbox) flattens each Song into a plain dict; json.dump writes the lot as text (ch15):

python
"""Persistence: turn a Playlist into JSON text on disk, and back."""
import json
from dataclasses import asdict

from playlist import Playlist, Song


def save(playlist: Playlist, path: str) -> None:
    data = {"name": playlist.name,
            "songs": [asdict(song) for song in playlist]}
    with open(path, "w", encoding="utf-8") as f:   # with = ch11
        json.dump(data, f, indent=2)


def load(path: str) -> Playlist:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)                    # gives back dicts, not Songs
    playlist = Playlist(data["name"])
    for d in data["songs"]:                    # rebuild real objects
        playlist.add(Song(d["title"], d["artist"], d["seconds"]))
    return playlist

Here is the honest wrinkle, and it matters: json.load has never heard of Song. It returns plain dicts and lists — JSON is a text format with six shapes, not a Python object freezer. So load() rebuilds each Song by hand, and it does so through playlist.add(), which means a hand-edited file with a duplicate title gets rejected by the same rule as everything else. One door in. The proof run:

output
328 bytes on disk
<class 'dict'>            <- what json.load returns
<class 'playlist.Song'>   <- what OUR load() returns
692
RAM — process #1 Song 'Levels' Song 'Wake Me Up' Song 'Titanium' heap objects process exits → heap gone (ch3) {"title": "Levels",  "artist": "Avicii",  "seconds": 200} … 328 bytes · UTF-8 text RAM — process #2 dicts from json.load Song(d["title"], …) NEW objects, new addresses save() load() objects die with the process — bytes on disk survive it (ch15) loaded == original → True  ·  loaded is original → False
Fig — The round trip. What crosses the disk boundary is a description of your objects, never the objects themselves.
📄
A file still holds bytes
json.dump writes text, encoded UTF-8 (ch15). Your Song objects never leave the heap — a 328-byte description of them does. That's why load() must rebuild.
The deeper cut

JSON speaks exactly six shapes: object, array, string, number, true/false, null. Everything else gets bent or bounced — all three of these ran on 3.12.7:

python
>>> json.loads(json.dumps((200, 247)))     # tuple in…
[200, 247]                                 # …list out
>>> json.dumps({1: 'Levels'})              # int key in…
'{"1": "Levels"}'                          # …str key out
>>> json.dumps({'Levels', 'Titanium'})    # a set?
Traceback (most recent call last):
  ...
TypeError: Object of type set is not JSON serializable

Our app only ships strs and ints, so nothing bends. The day you need Python-only types, exact round-trips, or files too big to rewrite whole — that's the database road flagged at the end of this chapter.

The data now outlives the process. But nobody can drive it yet. Give it a face. →

Stage 4 — the loop that won't die

app.py makes three moves. Move one: load the saved playlist, or start fresh if there's no file yet — try/except FileNotFoundError (ch13), because a missing file is a normal Tuesday, not a disaster. Move two: a while True loop (ch5) reads a line and partitions it into a command and the rest. Move three: four branches — and the add branch wears armour.

python
"""The face: a command loop. Run it:  python app.py"""
from playlist import Playlist, Song
import storage

PATH = "playlist.json"


def parse_song(rest: str) -> Song:
    """'Levels; Avicii; 200' -> Song. Raises ValueError on bad input."""
    title, artist, seconds = (part.strip() for part in rest.split(";"))
    return Song(title, artist, int(seconds))


def main() -> None:
    try:
        playlist = storage.load(PATH)
        print(f"Loaded {len(playlist)} songs from {PATH}")
    except FileNotFoundError:                  # no file = first run (ch13)
        playlist = Playlist("Road Trip")
        print("No saved playlist - starting fresh.")

    while True:
        line = input("> ").strip()
        if not line:                           # empty line? just re-prompt
            continue
        command, _, rest = line.partition(" ") # 'find Levels' -> 'find', 'Levels'
        if command == "quit":
            storage.save(playlist, PATH)       # save happens HERE, once
            print(f"Saved {len(playlist)} songs to {PATH}. Bye.")
            break
        elif command == "list":
            for song in playlist:              # __iter__ walks _songs
                print(f"  {song.title} - {song.artist} ({song.seconds}s)")
            print(f"  total: {playlist.total_seconds()}s")
        elif command == "add":
            try:                               # the shield (ch13)
                song = parse_song(rest)
                playlist.add(song)
                print(f"  added {song.title!r} ({song.seconds}s)")
            except ValueError as err:          # bad split, bad int, duplicate
                print(f"  can't add that: {err}")
        elif command == "find":
            song = playlist.find(rest)
            if song is None:
                print(f"  no song called {rest!r}")
            else:
                print(f"  {song.title} - {song.artist} ({song.seconds}s)")
        else:
            print(f"  unknown command {command!r} (add/list/find/quit)")


if __name__ == "__main__":                     # script vs import (ch16)
    main()

Look at the shield. parse_song can fail two ways — the split(";") unpack gets the wrong count, or int() chokes — and playlist.add can fail a third way, on a duplicate. All three raise ValueError, so one except catches all three and the loop answers instead of dying. A real session (the text after each > is what I typed; the rest is the program):

terminal
$ python app.py
No saved playlist - starting fresh.
> add Levels; Avicii; 200
  added 'Levels' (200s)
> add Wake Me Up; Avicii; 247
  added 'Wake Me Up' (247s)
> add Titanium; David Guetta; 245
  added 'Titanium' (245s)
> find Titanium
  Titanium - David Guetta (245s)
> add Levels; Avicii; 200
  can't add that: duplicate title: 'Levels'
> add Levels 200
  can't add that: not enough values to unpack (expected 3, got 1)
> add Faded; Alan Walker; two hundred
  can't add that: invalid literal for int() with base 10: 'two hundred'
> quit
Saved 3 songs to playlist.json. Bye.

Three different attacks — a duplicate, a missing field, a word where a number belongs — and the loop shrugged three times. Those error strings are CPython 3.12's own words, surfaced instead of splattered.

line = input("> ") strip · partition → command, rest which command? list walk _songs find _by_title.get try/except shield add parse → engine quit save → break loop back — the program never crashes out ValueError → caught → answered exit
Fig — One lap of the loop. Every branch flows back to the prompt — except quit, which saves first and leaves on purpose.
🧨
Catch ValueError — never bare except:
A bare except: also swallows KeyboardInterrupt, so Ctrl+C stops working: the loop that won't die becomes the loop you can't kill. Name the exception you expect (ch13).

It survives bad input when you drive. Will it survive next month, when you edit remove() at midnight? Prove it. →

Stage 5 — the proof: test the traps

Chapter 18's rule: don't test that code works, test the places it would break. Our traps: a duplicate sneaking in, removing what isn't there, the two structures drifting apart, and the disk round trip. test_playlist.py:

python
"""The proof: tests for the traps. Run:  python -m pytest -q"""
import os

import pytest

import storage
from playlist import Playlist, Song

LEVELS = Song("Levels", "Avicii", 200)
WAKE = Song("Wake Me Up", "Avicii", 247)
TITANIUM = Song("Titanium", "David Guetta", 245)


def make_playlist() -> Playlist:          # plain helper - no fixtures needed
    playlist = Playlist("Road Trip")
    playlist.add(LEVELS)
    playlist.add(WAKE)
    return playlist


def test_add_and_total():
    playlist = make_playlist()
    assert len(playlist) == 2
    assert playlist.total_seconds() == 447


def test_duplicate_add_is_rejected():
    playlist = make_playlist()
    with pytest.raises(ValueError):        # the trap must spring (ch18)
        playlist.add(Song("Levels", "Impostor", 999))
    assert len(playlist) == 2              # and nothing slipped in


def test_remove_missing_raises():
    playlist = make_playlist()
    with pytest.raises(KeyError):          # Titanium was never added
        playlist.remove("Titanium")


def test_remove_updates_both_structures():
    playlist = make_playlist()
    playlist.remove("Levels")
    assert playlist.find("Levels") is None                 # gone from the index
    assert [s.title for s in playlist] == ["Wake Me Up"]   # and from the list


def test_save_load_round_trip():
    playlist = make_playlist()
    playlist.add(TITANIUM)
    storage.save(playlist, "roundtrip_test.json")
    loaded = storage.load("roundtrip_test.json")
    os.remove("roundtrip_test.json")             # clean up our test file
    assert [s.title for s in loaded] == ["Levels", "Wake Me Up", "Titanium"]
    assert loaded.total_seconds() == 692
    assert loaded.find("Levels") == LEVELS       # equal fields (ch12 __eq__)
    assert loaded.find("Levels") is not LEVELS   # but a NEW object (ch3)

The last two asserts are Stage 1's promise collected: the reloaded Levels is equal to the original and is not the original. Green run, for real:

terminal
$ python -m pytest -q
.....                                                                    [100%]
5 passed in 0.02s

Now the payoff. I deleted one line from remove() — the self._songs.remove(song) — exactly the ghost-song bug from Stage 2, and ran pytest again:

terminal
>       assert [s.title for s in playlist] == ["Wake Me Up"]   # and from the list
E       AssertionError: assert ['Levels', 'Wake Me Up'] == ['Wake Me Up']
E         At index 0 diff: 'Levels' != 'Wake Me Up'
E         Left contains one more item: 'Wake Me Up'
E         Use -v to get more diff

test_playlist.py:44: AssertionError
=========================== short test summary info ===========================
FAILED test_playlist.py::test_remove_updates_both_structures - AssertionError...
1 failed, 4 passed in 0.04s

The test caught the ghost before any user did, and even printed which song refused to leave. That is what the 56 lines buy: permission to change the engine without fear.

duplicate sneaks in add("Levels") twice ghost song remove() misses the list round-trip drift load ≠ what was saved test_duplicate_add _is_rejected test_remove_updates _both_structures test_save_load _round_trip each trap gets a guard posted on it — permanently
Fig — Tests don't prove the app works; they stand guard on the exact spots where it would break.
💡
Run it as a module
python -m pytest -q from inside musicbox/ — the -m also puts the current folder on the import path (ch16), so import playlist resolves with no ceremony.

Five stages, all green. Step back and look at what you're holding. →

Ship it — the whole app, running

Here is everything, and you have already read every line of it: playlist.py in Stages 1–2, storage.py in Stage 3, app.py in Stage 4, test_playlist.py in Stage 5. That's the complete source — 171 lines, nothing hidden; the app itself needs only the standard library (the proof runs on ch18's pytest).

musicbox/ ├─ app.py 53 lines · the face ├─ playlist.py 41 lines · the engine ├─ storage.py 21 lines · the vault ├─ test_playlist.py 56 lines · the proof └─ playlist.json 328 bytes · appears after your first quit
Fig — The shipped tree. Line counts are from the real files; the JSON file is born at runtime, not written by you.

The final exam is the second launch. Kill the process, start it again, and ask what it remembers:

terminal
$ python app.py
Loaded 3 songs from playlist.json
> list
  Levels - Avicii (200s)
  Wake Me Up - Avicii (247s)
  Titanium - David Guetta (245s)
  total: 692s
> quit
Saved 3 songs to playlist.json. Bye.

Every object from the last session is gone — this is a new heap, new addresses, new Songs rebuilt from 328 bytes of text. The music survived the death of the process. That's the whole point of Volume 2 in one line of output.

Myth — real software needs a framework, a database, and a build pipeline.
Reality — real software is code someone else can run, trust, and change. 171 lines of stdlib Python with tests and a save file qualifies. Frameworks buy convenience; they don't grant legitimacy.
↺ The thing people get backwards
People point at app.py and say "that's the app." Backwards. app.py is 53 lines of politeness — reading lines, printing answers. The app is the engine and the vault: the parts that know things and keep them. That's why the tests import playlist.py and storage.py but never app.py — and why you could delete the loop tomorrow, bolt on a web face instead, and lose nothing that matters.

Now watch your own architecture run. Fire a command and follow it through the layers you built:

InteractiveFire a command — watch it flow through the layers
terminal you type · it answers app.py partition · dispatch · try/except shield playlist.py the Playlist method runs _songs list · _by_title dict references move · nothing is copied storage.py ⇄ playlist.json objects become bytes · bytes become objects
press a command
Each command touches only the layers it needs: find never reads the disk, list never touches the index, and the disk sleeps until quit.

One page left: everything Volume 2 handed you, on a single card — and the road past it. →

The Volume 2 card — and where the road goes

Everything this volume added to your machine model, one row each:

You want to…Write thisRememberCh.
Save text to diskwith open(p, "w", encoding="utf-8") as f:with closes even on error; a file holds bytes15
Objects → JSON textjson.dump(data, f, indent=2)only str · int · float · bool · None · list · dict survive15
JSON text → objectsjson.load(f) … then rebuildyou get dicts back, never your classes15/20
Split code into filesimport storage · from playlist import Songa module runs once, then it's cached in sys.modules16
Script vs importif __name__ == "__main__":only the file you run gets "__main__"16
Label data shapesdef add(self, song: Song) -> None:hints don't run — checkers read them17
Bundle facts@dataclass__init__/__repr__/__eq__ written for you17
Prove behaviordef test_x(): assert … · pytest.raises(...)python -m pytest -q; watch it fail first18
Wait on many slow I/Osthreads · async/awaitthe GIL runs one bytecode stream; waiting releases it19
Burn many CPUsmultiprocessingseparate processes, separate heaps, real parallelism19

And ten terms for the Volume 1 glossary's new neighbours:

buffer
a holding area in RAM where written bytes wait before the OS flushes them to disk (ch15)
module
one .py file; runs once on first import, then served from cache (ch16)
cache
a stored result reused so work isn't redone — sys.modules, lru_cache (ch11/16)
annotation
the type label after : or ->; stored on the function/class, not enforced at runtime (ch17)
dataclass
a class whose __init__/__repr__/__eq__ are generated from its field annotations (ch17)
test
a function that asserts one promise your code makes; collected and run by pytest (ch18)
red/green
watch a test fail before making it pass — the only proof the test can actually catch the bug (ch18)
GIL
CPython's global interpreter lock: one thread executes bytecode at any instant (ch19)
coroutine
a function defined with async def; pauses at await instead of blocking the thread (ch19)
event loop
the scheduler that runs coroutines, switching between them at every await (ch19)

Where this goes next. Three roads leave this chapter. Databases: our save() rewrites all 328 bytes every time — fine for 3 songs, brutal for 3 million; when that bill comes due, sqlite3 is already sitting in the standard library. The web: a server is a while True loop that reads bytes from a network socket instead of input() — you already built the hard half. Sharing: a package is modules in a folder with a name tag; pip fetches other people's. This course won't walk those roads — deliberately. You now hold the machine model underneath all three, and that's the thing documentation always assumes and never teaches. You can take it from the docs now. That was the plan all along.

🧠
Swap the back end, keep the face
Replace storage.py with a database module offering the same save/load and neither app.py nor the engine tests change. One-way imports make modules swappable — that seam is where musicbox grows up.
bit ch0 objects ch2 names ch3 structures ch6–8 functions ch9 classes ch12 files ch15 modules ch16 tests ch18 musicbox — shipped ch20
Fig — The whole arc. Every box was a chapter; the last one is yours to keep.

Chapter 0 began with a switch that was either on or off. Twenty chapters later you typed quit and 328 bytes settled onto a disk — a playlist that will still be there tomorrow, written by an app you built, tested, and understood at every level. From a bit to a shipped app. Go build the next one. ∎