◈ python mapVol 1 · Ch 14/14
Volume 1 Python, from the metal up · chapter 14

14The page you keep open

In Chapter 13 we taught Python what + should mean on objects of our own making. Thirteen chapters before that, all we had was a single bit. This last one isn't a lesson. It's the page you keep open after the lessons stop. Here's the plan, in one breath: every value type on one honest line, every structure in one decision table, the fifteen idioms that carry most programs, and twenty-four words nailed down tight enough to kill eight myths that outlive most beginners. And the whole way through we keep asking the one question that earns its keep. When you reach for a list, a dict, a generator, what are you really paying, and in what currency? By the end we fold all of it into six steps that turn the playlist into a working Playlist class. It knows "Blinding Lights" (200s) sorts ahead of "Titanium" (245s). And you walk out able to build without us, straight into Volume 2.

iolinked · chapter 14 — the checkpoints6 steps
$ sections covered in The page you keep open
01Every value, one line each
02The decision table
03Fifteen idioms that carry most programs
04Twenty-four words — and eight myths
05Build it: six steps to the playlist app
06The road on

01Every value, one line each

Let's start by throwing almost everything away. Fourteen chapters, and the whole skeleton stands on just three facts. Hold these and you can re-derive the rest at a whiteboard. One: in CPython, the interpreter you're actually running, every value is a boxed object on the heap. Not the bare number 200, but a little parcel wrapped around it. First a reference count: how many names point at this box. It's kept for one blunt reason. The instant it drops to zero, the last name is gone and CPython frees the box on the spot. Then a type pointer, which says what this thing is and therefore what it can do. And only then the payload itself. Two: names are references. A name never holds the value. Watch this: it holds the address of the box, the way a contact holds a phone number, not the person. Three: containers differ chiefly in how they find a thing. Mutability, and what's allowed to be a key, fall out behind that. And finding splits just two ways. By computed position: list[i] is base + i×stride, one piece of address arithmetic, one probe, O(1), never a walk (exactly ch0's numbered boxes). Or by hash: scanning slot after slot for a value you can't locate is O(n), unbearable at a million items. So instead you fold the key down to a bucket number and jump straight there. Every other thing you learned — slicing, dunder methods, comprehensions, is versus == — is one of these three facts wearing a costume.

THE ONE IDEA TO CARRY FORWARD
A value is a box, a name is a reference to a box, and a container is a rule for finding boxes. Hold those three and you can re-derive the rest of Python at the whiteboard. Lose them and no amount of memorised syntax will save you — you'll guess, and the guesses will be wrong in the same three places every time.

Here are the atomic values, each on a single honest line. These are the ones that aren't made of other values. Read the last column as a litmus test: it's the one experiment that pins the type down when your memory blanks.

TypeOne lineMutable?Litmus
inta whole number; Python grows the digits as far as you need — no overflow, evernosys.getsizeof(200)28
floata 64-bit IEEE decimal — fast, and quietly imprecise past 15-odd digitsno200.0 weighs 24 B
complexa pair of floats; the imaginary part is written j, never ino(1+2j).imag2.0
boolTrue / False — a relabelled 1 and 0; the falsy ones are '', 0, 0.0, None, empty collectionsnobool("False") is True
strimmutable text; every "edit" mints a brand-new string elsewhereno"Titanium"
bytesimmutable raw octets, each just a number 0–255 — the very switch-patterns ch0 began with; the tag most MP3s open withnob"ID3" → the bytes 73, 68, 51 the CPU also reads as the letters I D 3
list / dict / setthe mutable containers — whiteboards you rewrite in placeyesthe decision table, next
tuple / frozensettheir frozen twins — photographs, and therefore hashablenousable as dict keys
Nonethe absence of a value; what a bare return hands backtest with is None, never ==

Notice the sizes refuse to be intuitive. The number 200 costs 28 bytes. That's fourteen times the two bytes its value alone would need, because you're paying for the box, not the digit. Line those boxes up smallest to largest, and the whole space story of a computer fits on one rung-ladder.

From one bit to a million-key dictfigure
bit 0 or 1 1 bit ×8 byte 8 bits 1 B boxed int 200 header + digit 28 B 5 refs list of 5 refs + slack 104 B ×1,000,000 dict · 1M keys hash table 40.0 MiB each figure is the object's own box — never the boxes it points at
Fig 01 — The whole space story on one rung-ladder, measured on this very interpreter: 1 bit → an 8-bit byte → a 28-byte boxed int → a 104-byte five-slot list → a million-key dict whose hash table alone is 40.0 MiB. getsizeof weighs the box, not its contents — the million int values that dict references are counted nowhere in that number.

The ladder hides the deepest of the three facts, so let's crack a box open. Whatever type you pick — a whole number, a song title, a whole playlist — the layout underneath is the same three-part parcel: a refcount, a type pointer, and a payload. Only two things change: the tag on the box, and the way you reach into the payload. Step the slider through all ten and watch that sameness hold.

The value inspector — one box, ten costumes▸ drag me
one value on the heap — the same three-part box, whatever the type int FROZEN refcount ≥ 1 type → int payload 200 atom · no lookup getsizeof → 28 bytes a whole number — Python grows the digits as far as you need
int
Step through all ten. The three compartments never change — a refcount, a type pointer, a payload. Only two things move: the mutable/frozen tag on the box, and how you reach the payload — an atom needs no lookup, text and lists are reached by computed position (base + i×stride, one probe — not a walk), dicts and sets jump by hash.
Fig 02 — Every Python value is this box. Change the type and you swap the tag and the reach-in rule; the parcel around it is identical. That sameness is the object model.
ASK THE INTERPRETER, NOT YOUR MEMORY
Four calls settle any argument about a value. type(x) — what is it. id(x) — which box (its address). sys.getsizeof(x) — how heavy is its own box. dir(x) — what can it do (an int answers with 74 methods, a str with 81). Ten seconds at the REPL beats an hour of arguing from memory — and the REPL is never wrong.

Watch all three facts fall out of six lines. One object, two names, a mutation seen through both, and a hashability check that draws the container line:

interrogate.pypython
import sys

song  = ["Blinding Lights", 200]   # a fresh object, born on the heap
alias = song                       # alias copies the reference, not the list

print(type(song), sys.getsizeof(song))   # <class 'list'> 72
print(song is alias, song == alias)      # True True — one box, so identical
alias.append("The Weeknd")               # poke it through alias…
print(song)                              # ['Blinding Lights', 200, 'The Weeknd']

print(hash((200, 245)))                  # a real int — a tuple is hashable, so key-able
hash([200, 245])                         # TypeError: unhashable type: 'list'
Wait — if a name is only a reference and 200 is a boxed object, does a = 200; b = 200 build one box or two? One. CPython keeps a permanent cache of the small integers from −5 through 256, so both names point at the same pre-made 200 and a is b is True. Ask for 257 twice at separate prompts and you may get two different boxes — the cache stops at 256. The values you reach for most are quietly shared, never copied.
↺ reframe
You didn't finish this course to memorise syntax — you finished it to install a memory model. Forget a method name and dir() returns it in ten seconds. Forget that names are references, that values are boxed objects, that containers find by position or by hash — and every cheat sheet on earth leaves you stranded, because the bug isn't in a name you can look up. It's in the picture.

✗ The myth

"A variable is a box, and assignment drops the value inside it. Copy the variable and you copy the value."

✓ The reality

A variable is a name tag; the box lives elsewhere on the heap. Assignment ties the tag to a box — it never copies the box. That's the whole reason alias = song lets one append show up under both names: two tags, one box.

The deeper cut — what's really inside those 28 bytes, and one honest asterisk on the sizes

Prise open the int 200 and the 28 bytes account for themselves exactly. Two machine words are pure header: an 8-byte refcount and an 8-byte type pointer. That's precisely what a bare object() weighs (16 bytes, nothing but the header). Add an 8-byte length field and a single 4-byte digit to hold the value, and you land on 28. That header is the tax every value pays for the privilege of being a self-describing object. It's why 200 costs fourteen times its raw value.

Two labelled simplifications from above, now made honest. First, getsizeof weighs only the object's own box, never what it references. So the million-key dict reporting 40.0 MiB is just the hash table. The million int boxes it points at live elsewhere and aren't in that figure, so the true footprint is far larger. Second, that "refcount" compartment isn't always a live counter. None, True, and the small ints are immortal (PEP 683): their refcount is pinned at the maximum 4294967295 and never touched, because objects that outlive every program shouldn't waste cycles being counted. And a footnote to the table: bool is a genuine subclass of int. That's why True + True is 2 and True == 1 is True. The name tag is new, but the box underneath is an integer.

Values are the atoms. Every real program then asks the second question — list, dict, set, or tuple? One decision table answers it for good →

Wait — the number 200 needs just one byte of value, but its Python box weighs 28 — you pay 27 bytes of overhead for one byte of number. Strip the value away entirely and a bare object() still weighs 16 bytes: pure header, nothing inside. The box, never the digit, is what you carry.
SOME OBJECTS ARE IMMORTAL
None, True and every small int from −5 to 256 have their reference count frozen at 4294967295 — that is 2³² − 1, the maximum — and it is never touched. An object that outlives every program you will ever run should not waste one cycle being counted (PEP 683). Check it: sys.getrefcount(None) comes back pinned at the ceiling.
Wait — bool is not its own thing — it is a genuine subclass of int. True is just 1 wearing a name tag, so True + True is 2, sum([True, True, False]) counts your Trues, and True == 1 is True. The tag is new; the box underneath is an integer.
NO SUCH THING AS OVERFLOW
Most languages cap an integer and wrap around at the edge; Python just grows the digits. 2**1000 is a real, exact number here — its box swells to 160 bytes to hold it, and 10**100 fits in 72. There is no INT_MAX to crash into: the box stretches until the value fits, or you run out of RAM. Drop back to a single raw byte, though — a bytes slot, 0–255 — and the old ceiling returns: 200 + 245 = 445, past 255, overflows one byte — the exact pressure that forced ch0's multi-byte teams. Python's int is the box that refused that ceiling.

02The decision table

You have met all six now, one chapter at a time. Lined up shoulder to shoulder, they hand you a chooser that fits in a single breath. Need your songs in order and reachable by position? A list. A tiny record that must never change, and might itself become a key? A tuple. Want to look a value up by name, instantly, a million times an hour? A dict. Only ever asking "have I seen this before?" A set. Shoving and pulling at both ends of a queue? A deque. A flood of raw numbers where every byte is money? An array. Six questions, six answers. But the reason each answer is right lives in one table, and that table is worth carrying for the rest of your working life.

⚠ MOST BEGINNERS THINK…two frozen twins — opposite verdicts
A tuple is just a list that cannot change, so == reads what is inside: same items, same order, same answer. The frozen twin is the same value wearing a seal, and a seal is not a difference.
TYPE THIS — 10 SECONDS
>>> row = ["Blinding Lights", "The Weeknd", 200]
>>> row == tuple(row)
>>> row.__eq__(tuple(row))                 # what the list actually answered
>>> {200, 245} == frozenset({200, 245})    # the OTHER frozen twin
False
NotImplemented
True
Same relationship, opposite verdicts — because == is not one rule the language enforces. It is a question each type answers for itself, and that middle line is the list declining rather than refusing: NotImplemented is chapter 12's honest decline, met here from the other side. Python then asks the tuple, which declines too, and with nobody willing to judge, == falls back to the one question that always has an answer — same object? — so two boxes means False. The set pair was simply written the other way: set and frozenset compare by membership, and the seal is none of equality's business. Two labelled refinements, so this is not a half-truth. First, “different types are never equal” is a rough guide, not a law200 == 200.0 is True, because int and float share one numeric tower; that is the same fact chapter 7 leaned on when {1: "a", True: "b"} collapsed to a single entry. Second, the twins stay twins everywhere that walks rather than judges — sorted, in and unpacking treat list and tuple alike. Only == splits them, and it splits them the moment you reach for list(zip(…)) — the move that folds this chapter's parallel lists into rows. What comes back is rows of tuples, so a check against a list literal reads False with every single item matching.

Here is the quiet thing the table is really telling you: every row is a promise about time and space. A data structure is not a bag that holds your stuff. It is a bet about which single operation you will perform most, paid for in memory you don't get back. Find the row whose cheap column matches the move you'll make ten thousand times, and everything else falls into place. So let's read the table properly, cell by cell.

Six structures, five promises eachfigure
structure get one item add at end front ± x in s memory shape list O(1) O(1)★ O(n) O(n) pointers → boxed ints tuple O(1) frozen O(n) a list minus the slack dict O(1)★ O(1)★ O(1)★ hash table · heaviest set no index O(1)★ O(1)★ keys only, no values deque O(1) ends O(1) O(1) O(n) linked blocks, both ends array O(1) O(1)★ O(n) O(n) packed raw C values green = one jump, any size red = a scan that grows with n ★ = amortized / average · see the deeper cut
Fig 04 — The whole map on one page. Read a row left to right and you are reading a personality: dict buys instant lookup and pays in memory; deque is the only one cheap at both ends; array is the featherweight that refuses to grow at the front. No structure wins every column — that is the entire point.
HOW TO READ A CELL
O(1) means the cost is the same whether the pile holds ten items or ten million — the machine computes where to go and jumps there, exactly like the addressed boxes of chapter 0. O(n) means the cost grows in step with the pile — it has to walk the items to find your answer. The little is an honest asterisk: "usually O(1), with a rare bad day." We refine it at the bottom of this section.

Reading rows is one skill. Choosing one under pressure is another. So drive the slider below through six real jobs off the playlist, and watch the table collapse to a single lit answer each time. Notice how little of the row you actually consult. You look at the one column the job hammers, and the winner is obvious.

Pick the job — the structure lights up▸ drag me
the job 1 / 6 — look up a song’s length by its title, a million times a day list tuple dict set deque array dict → get O(1)★ · x in O(1)★ · memory: hash table hash the title, jump straight to its bucket — no scanning, no shifting.
1 / 6
Six jobs, six winners — each time, one column of the table decides it. The structure the job hammers is the one whose matching cell is green.
Fig 04b — Choosing is not memorising the whole grid. It is naming the operation you'll repeat, then picking the row that makes that operation cheap.
Wait — if a dict lookup is O(1) and a list search is O(n), why isn't everything a dict? Look one figure ahead: those instant buckets are bought with megabytes. A hash table refuses to get more than about two-thirds full — so collisions stay rare — which means it always hauls around a third of its slots deliberately empty. Speed and space are a trade, always, everywhere.

That trade is not hand-waving. You can weigh it. Build the exact same million integers four different ways, and watch the scale tip. Slide n from a thousand up to a million and the bars grow together, except the generator. It stays a sliver, because it never actually builds the million at all.

What a million numbers actually costs▸ drag me
building 1,000,000 integers — bytes actually allocated (tracemalloc peak) dict 81.3 MiB list 38.6 MiB NumPy 7.6 MiB generator 192 B — it never builds the million bars scaled to the dict · sizes are the measured 1M point, scaled per item
1,000,000
The ranking never flinches: dict > list > NumPy > generator, at every n. Watch the generator’s bar melt as the others climb — laziness is the only line that doesn’t pay per item.
Fig 04c — The memory pecking order to keep for life. Instant lookup (dict) is the priciest; packed raw values (NumPy) undercut the list about five-fold and the dict about ten-fold; a generator is essentially free because it computes each number just in time and forgets it.
SYNTAX · the container cardlist, dict, set, tuple — the literals, the methods that matter, slicing, and the doors between them
[a, b, c] list(iterable) — a row of numbered slots: ordered, mutable {k: v, k: v} dict(k=v) {} — key to value; {} is an empty DICT, never a set {a, b, c} set(iterable) set() — bare values, no colons; set() is the empty one (a, b, c) a, b, c (a,) — the COMMA builds a tuple; the parens are for your eyes lst.append(x) lst.insert(i, x) lst.extend(ys) — one on the end · wedged at i · each item of ys lst.pop() lst.pop(i) lst.remove(v) del lst[i] — pop hands the item back; remove hands back None lst.sort(key=f, reverse=True) lst.reverse() — IN PLACE, and both of them return None sorted(iterable, key=f) reversed(seq) — leave it alone and hand back something new x in lst lst.index(x) lst.count(x) len(lst) — walk · first position · how many · the header d[k] d.get(k) d.get(k, default) — demand (KeyError) · ask (None) · ask with a fallback d[k] = v d.update(other) d.pop(k, default) — insert-or-overwrite · merge · remove and return d[k] = d.get(k, 0) + 1 — COUNT k: the one dict line you keep for life d.setdefault(k, []).append(x) — GROUP: one bucket list per key, filled as you go for k, v in d.items(): d.keys() d.values() — a bare for over a dict hands you KEYS, never values s.add(x) s.discard(x) a | b a & b a - b a ^ b — union / intersection / difference / either-not-both seq[a:b] seq[a:b:step] seq[::-1] — a NEW sequence: a up to b-1 · stride · reversed x, y, z = t first, *rest = t — unpack; rest is ALWAYS a list, even when empty list(s) set(lst) tuple(lst) dict(zip(ks, vs)) — the doors between them; set() dedupes and drops order
the five-second choiceName the operation you will do ten thousand times. By key → dict. Membership → set. Order plus append → list. A fixed record → tuple.
mutate or copy.sort(), .append(), .reverse() change the object and return None. sorted(), reversed() and every slice build something new.
{} is a dictThere is no empty-set literal. set() is the only way to write one.
keys must be hashablestr, int, tuple, frozenset qualify. A list key raises TypeError: unhashable type: 'list'.
the slice always copieslst[:] is a new list of the same references — a shallow copy. The objects inside are still shared.
in costs differentlyx in lst walks the slots, O(n). k in d and x in s hash and jump, O(1). Same word, two different bills.
THE FOUR MEMORY LEVERS
When the profiler screams, chapter 11 handed you four moves and this figure is their scoreboard: reach for an array instead of a list (raw values, no boxing — the NumPy bar); __slots__ instead of a per-instance __dict__ (drop the hidden hash table on every object); a generator instead of a built list (lazy, a flat couple hundred bytes); and memmap when the data outgrows RAM entirely and has to live on the SSD.
↻ a structure is a frozen decision
You are not really choosing a container — you are choosing, ahead of time, which operation gets to be free. Pay for lookup and you get a dict; pay for order and you get a list; refuse to pay for anything but bytes and you get an array. The table isn’t a menu of things. It is a menu of regrets you’re willing to accept.

✗ The myth

The fastest structure is the best one, so reach for whatever has the most O(1)s — a dict — and use it everywhere.

✓ The reality

The most O(1)s is also the most bytes and the most bookkeeping. A dict of a million ints weighs twice a list of the same numbers and ten times a NumPy array. You choose by the one operation you repeat, not by the longest row of green.

The deeper cut — the five asterisks the table rounded off

The grid tells the truth, but it rounds the corners. Here are the exact edges, each one verified in a Python shell:

1 · Append is amortized O(1). Most appends drop into pre-bought slack, so they cost nothing. Once in a while the list outgrows its block, allocates a bigger one, and copies everything across. That's a single O(n) jolt. Spread that rare jolt over the thousands of cheap appends around it, and the average is still constant. Same story for the ★ on a growing array and on dict resizes: memory grows in jumps, not a smooth ramp. That's why the bar chart above is a clean model, not the jagged truth.

The bar chart above drew memory as a smooth ramp — but the prose just warned you that is a clean model, not the jagged truth. Here is the truth. Watch what a single list.append actually costs, one call at a time. Almost every append is free: it drops into slack the list bought in advance. Every so often the block fills, and one unlucky append has to allocate a bigger block and copy every element across — a genuine O(n) jolt. The trick of “amortized O(1)” is not that the jolts never happen. It is how staggeringly rare they are.

The jagged truth of list.append▸ drag me
cost of each append while growing one list to 1,000,000 items · both axes log cost of that append (element-moves) 1,000,000 1 1 n = 1,000,000 86 reallocations — the only appends that actually move the block, out of all 1,000,000. a gold spike = one reallocation: the block moves house, an O(n) jolt that instant real average: 8.4 moves per append — flat as n grows naïve “copy-all” model: 500 billion moves — 59,205× the real work
1,000,000
Slide n from ten to a million. Nearly every append lands in pre-bought slack and costs a single move — the flat green floor. Only at a gold spike does the block overflow and copy everything across: a real O(n) jolt, reaching right up toward the red line that assumes every append pays that price. Count the spikes — a million appends trigger just a few dozen, because each new block is bought about an eighth larger, so they arrive geometrically rarer. Rare tall jolts, averaged over the cheap appends between them, are all “amortized O(1)” ever claimed — never that no single append is slow.
Fig 04d — The asterisk on append, drawn. The green line is the honest headline — a small, flat cost that ignores the size of the list — and the gold towers are the price it quietly pays: a handful of full copies, spaced ever wider apart, that the average absorbs without a bump.

2 · Dict and set are O(1) average, O(n) worst case. If every key happened to hash into the same bucket, a lookup would degrade to a linear scan. To stop an attacker engineering exactly that, CPython salts string hashing with a per-process random seed. Run hash("Blinding Lights") in two fresh interpreters and you get two different numbers (fix PYTHONHASHSEED and they line up again).

3 · The small-int cache (−5…256) is a CPython implementation detail, not a language promise. int("256") is int("256") is True because both fetch the one cached object; int("257") is int("257") is False because each builds a fresh one. Never let is do the job of ==.

4 · sorted() is Timsort, and Timsort is stable: equal keys keep their original order. So sort the playlist by title first, then by artist. Inside each artist block the songs stay title-ordered for free: two cheap passes instead of one clever comparator.

5 · sys.getsizeof is shallow. It measures the container’s own box, never what the box points at. A three-element list holding three million-character strings reports just 80 bytes — because the list only stores three pointers; the three megabytes of text live elsewhere. Weigh a whole object graph and you need the deep walk, not this number.

THE FIVE-SECOND CHOICE
Name the operation you’ll do ten thousand times. Lookup by key → dict. Membership → set. Order with an append → list. Both ends → deque. Immutable record → tuple. Raw numbers, tight memory → array. That’s the whole chapter in one reflex.

You can now pick a structure in five seconds flat. Next: thirteen chapters of hard-won habits, pressed into fifteen idioms you’ll type for the rest of your life →

Wait — if a dict lookup is O(1) and a list search is O(n), why isn't everything a dict? Because a hash table refuses to get more than about two-thirds full — so collisions stay rare — which means it always hauls around at least a third of its slots deliberately empty. A million-key dict's table alone is 40.0 MiB. Speed is bought with space, every time.
WHAT O(1) REALLY PROMISES
O(1) means the machine computes where to go and jumps there — the same single probe whether the dict holds ten keys or ten million, exactly like the addressed boxes of chapter 0. O(n) means it has to walk. That one difference — jump versus walk — is the whole personality every row of the table is describing.
Wait — range(10**6) weighs the same 48 bytes as range(10) — because it stores a rule (start, stop, step), not a million numbers. A generator over a million items is 192 bytes, flat. Laziness is the one thing in Python that doesn't pay per item: it computes each value just in time and forgets it.
A TUPLE IS A LIST WITH THE SLACK CUT OFF
(200, 245) is 56 bytes; [200, 245] is 72. A list keeps spare slots so append stays cheap — it is built to grow. A tuple can never grow, so it refuses to pay for room it will never use. Frozen buys you lean.

03Fifteen idioms that carry most programs

An idiom is a decision someone already made well, frozen into a phrase. Not a trick, not clever code to show off. It's the opposite. It's the shortest way to say a thing so ordinary that the language grew a groove for it, and everyone who reads Python reads that groove at a glance. You could always write the long way round. An idiom is the community agreeing you shouldn't have to. Learn these fifteen and you'll recognise most of the everyday Python you'll ever meet, because most everyday Python is these fifteen, rearranged.

Here they are, running on the playlist we've carried since page one. It's now four songs long, so the loops have something to bite on:

idioms.pypython
playlist = [
    {"title": "Blinding Lights", "artist": "The Weeknd",           "seconds": 200},
    {"title": "Titanium",        "artist": "David Guetta ft. Sia", "seconds": 245},
    {"title": "One More Time",    "artist": "Daft Punk",           "seconds": 320},
    {"title": "Strobe",          "artist": "deadmau5",            "seconds": 634},
]

# a 1-based menu — no manual counter, no off-by-one
for n, song in enumerate(playlist, start=1):
    print(f"{n}. {song['title']} — {song['artist']}")

titles  = [s["title"] for s in playlist]                        # build a new list
short   = [s["title"] for s in playlist if s["seconds"] < 300]  # filter: the test rides AFTER the for
by_len  = sorted(playlist, key=lambda s: s["seconds"])          # new sorted list; playlist untouched
artists = {s["artist"] for s in playlist}                        # set comp — duplicates fall out for free
length  = dict(zip(titles, [s["seconds"] for s in playlist]))   # two parallel lists → one dict

with open("playlist.txt", "w") as f:                            # the file closes itself, even mid-crash
    for s in playlist:
        f.write(f"{s['title']}|{s['seconds']}\n")

Run that top to bottom and the menu prints 1. Blinding Lights — The Weeknd down to 4. Strobe — deadmau5. Then short comes out ['Blinding Lights', 'Titanium'], artists is a four-name set, and playlist.txt is written and closed. Not one i = 0, not one manual counter, not one f.close(). That absence is the idiom. Let's put the single richest line under a microscope, the comprehension, because once you can read one, you can read the family.

Anatomy of a comprehension — three parts, in a fixed orderfigure
[ s["title"] for s in playlist if s["seconds"] < 300 ] what to keep the loop — the source of s the filter — always AFTER the for [ "short" if s["seconds"] < 300 else "long" for s in playlist ] a ternary rides before the for — it isn't a filter, it's part of "what to keep"
Fig 3a — One anatomy, two placements that beginners swap by accident. A filter (if … with no else) drops items and lives after the for. A ternary (… if … else …) chooses a value and lives before the for, inside the expression. Wrong slot, wrong meaning.

A diagram is frozen, though, and a comprehension is a verb. So run it by hand. Below, the recipe on the left is short from the code above: keep the title of every song under 300 seconds. Step the slider and watch Python do exactly what you'd do with a highlighter. It walks the list top to bottom, holds each song against the test, and drops the survivors into a brand-new list on the right. Nothing clever happens. That's the point.

Step the comprehension — one item at a time through the filter▸ drag me
short = [ s["title"] for s in playlist if s["seconds"] < 300 ] playlist — walked top to bottom kept — a brand-new list Blinding Lights 200 s Titanium 245 s One More Time 320 s Strobe 634 s filter < 300 ? The recipe, unrun — nothing collected yet. kept = []
step 0 / 4
Push it to the end: the two songs under 300 s (Blinding Lights, Titanium) land in the new list; the two long ones (One More Time, Strobe) fall through the gate and vanish. The original playlist is never touched — a comprehension always builds, never edits.
Fig 3b — A comprehension is a for-loop wearing one line's clothing: source on the left, a test in the middle, a fresh list on the right. Everything else in the family — {…} for a set, {k: v …} for a dict, (… ) for a lazy generator — is this exact shape with different brackets.
SYNTAX · the flow cardevery shape that decides or repeats — chapters 4, 5 and 10, one line each
if cond: elif cond: else: — a stack of gates; at most one ever opens for x in iterable: — one turn per item the iterable hands over continue break — abandon THIS lap · leave the loop entirely else: — on a for or while: it ran with no break at all while cond: — re-asked before every turn; one line must move it while True: ... if done: break — the do-while shape: act first, test inside the lap range(stop) range(start, stop) range(a, b, step) — counts without storing; range(10, 0, -1) counts down for i, v in enumerate(seq, start=1): — position and value, numbered the way humans count for a, b in zip(xs, ys): — pairs by position, and stops at the SHORTER one [expr for x in xs] — TRANSFORM: n items in, n items out [x for x in xs if cond] — FILTER: the gate goes AFTER the for [a if cond else b for x in xs] — TERNARY: in the result slot, and it needs an else {k: v for x in xs} {expr for x in xs} — dict comp (a colon) · set comp (no colon) (expr for x in xs) — a generator: builds nothing until something pulls value = a if cond else b — the decision that IS a value, usable anywhere
the colonEvery header line ends in : and its body is indented. Miss it and you get SyntaxError before line 1 runs.
elif vs ifA second if is an independent question, asked no matter what. An elif is only asked once every gate above it has failed.
the guardA while needs one line that moves the world toward the exit — a -= 1, a pop(), a fresh input(). Without it, Ctrl+C is the only brake.
loop elseRead it as nobreak:. One break anywhere in the loop cancels it.
filter or ternaryif with no else is a filter and goes after the for. a if c else b chooses a value and goes before it. Wrong slot, wrong meaning.
zip is silentIt stops at the shorter sequence without a word. zip(a, b, strict=True) raises ValueError instead (3.10+).
↺ reframe
An idiom isn't shorter code — it's compressed intent. sorted(titles, key=len) says sorted by length in the same breath you read it; the hand-rolled loop makes the reader reverse-engineer that what out of five lines of how. You're not saving keystrokes. You're moving the meaning to the surface so the next person — usually you, in six months — reads intent instead of decoding mechanism.

That's one idiom read to the metal. Here are all fifteen on one card: the loops that count, the phrases that filter and glue, and the small guards that keep a program upright:

IdiomWhat it buys you
enumerate(songs, start=1)a numbered loop, 1-based, with no i = 0 bookkeeping and no off-by-one
[s["title"] for s in songs]build a new list in one pass; swap the brackets for a set or dict
[s for s in songs if cond]a filter goes after the for; a … if … else … ternary goes before it
{s["artist"] for s in songs}a set comprehension — duplicates collapse to uniques for free
dict(zip(titles, seconds))two parallel lists → one dict; zip stops at the shorter, silently
first, *rest = songsunpack a head off a sequence; rest is always a list, even when empty
songs[::-1] · songs[::2]a reversed copy · every second item — a slice is always a new list
songs.sort() vs sorted(songs)mutate in place, returns None · new list, from any iterable
sorted(titles, key=len)a function reference steers the sort — hand over len, never len()
", ".join(titles)glue n strings in O(n); += in a loop is O(n²) — see the deeper cut
with open(path) as f:the file closes itself, even if the block raises halfway through
try: int(input()) except ValueErrorharden every number that arrives from a keyboard or a file
if not songs:the emptiness check — '', [], {}, set(), 0, None are all falsy
while True: … breakloop until done, with the exit test wherever it naturally falls — the middle
x if cond else y · min(v+10, 100)a value chosen, or clamped to a cap, in one expression — no if block
if __name__ == "__main__":run-as-a-script guard; the block runs on python file.py, skips on import
Wait — if a comprehension is just a for-loop, why is it faster? It isn't magic and it isn't a different algorithm — same items, same O(n), same order. The saving is per-item overhead: an explicit loop looks up the .append method and calls it on every pass, while a comprehension compiles to a dedicated LIST_APPEND instruction with no lookup and no call. On this machine, filtering a 1,000-item list clocked ~25.5 µs as a comprehension versus ~28.2 µs as an append-loop — real, but modest. Reach for a comprehension because it reads as one thought, not because it'll rescue a slow program.
The parenthesis trap — the one that bites everyone
key=len hands the sort a function to call later, once per item. key=len() calls it now, with no argument — Python raises TypeError: len() takes exactly one argument (0 given) and never reaches the sort at all. Same rule in map, filter, a Timer, a button's on_click, every callback you'll ever wire up: pass the reference, no parentheses. Parentheses mean run it; the bare name means here's the thing to run.

✗ The myth

songs.sort() sorts the list and hands it back, so ordered = songs.sort() is how you get a sorted copy.

✓ The reality

.sort() rearranges songs in place and returns None — so ordered becomes None and you've quietly thrown your data on the floor. Want a copy? That's sorted(songs). Rule of thumb: the methods that mutate (.sort(), .append(), .reverse()) nearly all return None on purpose, precisely so this line looks wrong.

Three guards that replace an if
The last handful on the card are one-liners that quietly delete a branch. x if cond else y is an expression, so it drops straight inside an f-string or a comprehension where a statement can't go. min(volume + 10, 100) clamps to a ceiling and max(0, volume - 10) to a floor — a two-sided clamp is just both, max(0, min(v, 100)), no if in sight. And if not playlist: leans on Python's rule that empty things are falsy, so it reads as plain English: if there's no playlist. Each one trades a four-line branch for a phrase you take in at a glance.
The deeper cut — why += on strings is a trap, and the caveat that makes it a labelled one

"Glue strings with join, never += in a loop" is the idiom, and the reason is real. A Python string is immutable: it can never be edited in place. So acc += "x" can't extend acc. It has to allocate a whole new string and copy every character across. Do that inside a loop where the string grows 1, 2, 3, … up to n, and the copying totals roughly n²/2 characters. ", ".join(parts) instead measures the total once, allocates once, and copies each character a single time — that's O(n). Timed on this machine, building a string with += took 8.8 ms at 30,000 items, 33 ms at 60,000, and 127 ms at 120,000. It quadruples for each doubling, the unmistakable fingerprint of O(n²). join stayed flat at a couple of milliseconds.

Now the honest refinement, so this isn't a half-truth. CPython carries a special-case optimisation that can make s += x update in place when s is a plain string with exactly one reference to it. That sometimes flattens the curve to near-O(n). But it's an implementation detail. It's not part of the language, it's not present on every interpreter, and it evaporates the moment anything else touches s. The timings above show it failing to save the naive loop even on the interpreter that ships it. So the rule stands as written: reach for join, and never rely on the accident that occasionally rescues +=.

The deeper cut — what __name__ actually holds

Every Python file, when it runs, is handed a hidden variable called __name__, and Python sets it based on how the file was started. Run the file directly, with python playlist.py, and __name__ is the string "__main__". Import that same file from somewhere else, with import playlist, and __name__ is instead "playlist", the module's own name. Verified both ways on this machine: the direct run prints __name__ == '__main__' and the guarded block fires. The import prints __name__ == 'playlist' and the block stays silent. So if __name__ == "__main__": is really the question "am I the program the user launched, or a library someone else pulled in?" It's the switch that lets one file be both a runnable script and a reusable toolbox. Chapter 9 leans on it hard.

THE ONE IDEA TO CARRY FORWARD
You don't memorise idioms the way you memorise facts — you install them, the way you installed "turn left" until your hands did it without you. The goal isn't to recall that enumerate exists; it's to reach for it before the manual counter ever occurs to you. When these fifteen phrases become the first thing your fingers try, you've stopped translating your intent into Python and started thinking in it.

Idioms are your hands — the moves your fingers make without asking. Vocabulary is your mouth: twenty-four words defined in one line each, and eight stubborn myths worth leaving at the door before you go →

Wait — if a comprehension is just a for-loop, why is it faster? Not magic, and not a different algorithm — same items, same O(n), same order. The saving is per-item overhead: an explicit loop looks up .append and calls it every pass, while a comprehension compiles to a dedicated LIST_APPEND instruction with no lookup and no call. Real, but modest — reach for it because it reads as one thought, not to rescue a slow program.
ZIP STOPS AT THE SHORTER — SILENTLY
dict(zip(titles, seconds)) is a tidy two-lists-into-one-dict move, but zip quits the instant either side runs out. Three titles against four seconds keeps three pairs and drops the fourth with no warning at alllen(list(zip([1,2,3], [9]))) is 1. Handy, and a quiet way to lose data.
Wait — ", ".join(...) over += isn't a style preference — it is a complexity fix. A string is immutable, so acc += s can't extend acc; it allocates a whole new string and recopies every character so far. Spell an n-character string that way and you ask Python to copy about n²⁄2 characters — half a million for a 1,000-char string. join copies each character once: 1,000.
Trace3 step the machine — idea · code · memory move together
A comprehension is a loop — desugared, then run once
[s.title for s in pl if s.fav] is not magic syntax — it is a for-loop folded onto one line. It desugars to out=[]; for s in pl: if s.fav: out.append(s.title). Watch the one-line form on the left and the unrolled loop on the right move together as a single pass builds the list.
4 songsfilter s.favkeeps 2
Comprehension ≡ loop — one pass, side by sidedesugar · rundesugar
Same three parts, two shapes — keep, loop, filter
the comprehension
[ s.title for s in pl if s.fav ]
what to keepthe loopthe filter
the same loop, unrolled
out = []
for s in pl:
if s.fav:
out.append(s.title)
s
s.fav ?
out[]
In plain words
Under the hood
OPALU · the operation
Program · idioms.py
variables · type
Memory · the list being built
registers — this iteration
out list · grows by append
what's happening
beat 1 / 1

04Twenty-four words — and eight myths

Documentation stops being fog the day a handful of words snap into exact senses. Not synonyms, not vibes, but the single meaning this course leaned on, chapter after chapter, with no drift. Below are twenty-four of them. And here is the secret that makes them learnable: they are not twenty-four unrelated facts. They are twenty-four views of one object. Take the smallest real fact from the playlist we've carried since page one — "Blinding Lights" runs 200 seconds — and store it as secs = 200. That one int is enough to demonstrate almost every word in the list: what it is, where it lives, how Python finds it, and what happens when you try to change it.

So before the list, look at the object itself. Everything the vocabulary names is somewhere in this one picture: the box, the name pointing at it, the little header that makes it cost more than a bare number, and the properties (immutable, hashable) that decide what you're allowed to do with it.

THE ONE IDEA TO CARRY FORWARD
A glossary looks like memorisation and isn't. Learn one object — how Python stores a value and finds it again — and the rest of these words are its shadows: reference is how you reach it, refcount is how it's freed, immutable is what happens when you "edit" it, hashable is whether it can key a dict. Master the box and you've mastered the list.
Six words pinned to one object — the int 200figure
secs a name reference → an address one object on the heap type: int refs: 1 200 0b11001000 · 28 bytes object — the whole box boxed — +header, so 28 B refcount — 0 frees it immutable — edit makes a new id reference — the name secs stores the object's address, never the object itself hashable — hash(200) is 200 and never wavers, so 200 can key a dict or join a set
Fig 04 — Six of the twenty-four words, all pinned to a single int. If you can label this picture cold — box, header, name, count, id, hash — you already own a quarter of the vocabulary.

Now the full list, grouped not alphabetically but by the mental model each word belongs to. The object comes first, then the containers, then when work happens, then functions and scope, then the machinery under classes. Read each line as the exact sense this course used.

The object model. An object is one chunk of the heap: a value plus the type stamped on it that says what the value can do. Our 200 is an int object. It is boxed: the bare number rides beneath a type pointer and a reference count. That's why sys.getsizeof(200) reports 28 bytes, not one. A reference is a name — or a list slot, or a dict value — that stores the object's address, never the object itself. The refcount is CPython's cleanup meter: it tallies how many references aim at the object and frees it the instant that count hits zero. The garbage collector is the backup sweep for the one case refcounting can't catch: objects that reference each other in a cycle, propping both counts above zero forever. The small-int cache is a shortcut. CPython builds every integer from −5 to 256 once at start-up, so every 200 in your whole program is the same object (a = 200; b = 200; a is b is True). An object is immutable when it can never change — int, str, tuple. "Editing" one silently builds a new object with a new id, so x = 200; x = x + 1 moves x to a different box. It is mutable when it changes in place and keeps its id — list, dict, set, the whiteboard you wipe and reuse. And it is hashable when it has a stable hash. hash(200) is 200, and that stable hash is exactly the ticket to be a dict key or a set member. Immutables qualify, a list does not (hash([200, 245]) raises TypeError).

What the containers really are. A dynamic array is a single contiguous block that quietly over-allocates spare slots, then relocates itself to a bigger block when it outgrows them. That's what a list is, which is why indexing is instant yet the block occasionally has to move house. A hash table stores items in buckets addressed by hash(key), so a lookup jumps straight to the bucket instead of scanning. That's what dict and set are. Big-O is the shorthand for how cost grows as the data grows, constants thrown away: O(1) is flat no matter the size, O(n) climbs in step with it, and O(n²) is the nested-loop trap you feel the moment n gets real.

When the work happens. Something is lazy when it computes each value on demand instead of up front. range(10**6) weighs the same 48 bytes as range(10), because it stores a rule, not a million numbers. Its opposite is eager: it builds everything now and pays now. A generator is the workhorse of laziness: a function frozen mid-run by yield, handing back one value each time you ask, remembering its place, spent once exhausted. A comprehension is a loop folded into one expression that builds a list, dict or set — [s for s in lengths if s > 210] — eager by default. Its lazy cousin is the generator expression in round brackets.

Functions and where names live. Functions are first-class: a function is just another object, so you hand its name around with no parentheses. sorted(songs, key=len) passes len itself for Python to call later. Every call gets a stack frame: the scratch space pushed on for that one invocation, holding its local names, popped and forgotten the instant the call returns. Frames pile on the stack while every object they point to lives out on the heap. When Python meets a bare name it resolves it by LEGB — Local, then Enclosing, then Global, then Built-in, first match wins. A closure is a function that keeps using names from the scope it was defined in, even after that scope has returned. The enclosing value rides along with it. And pass-by-object-reference is how arguments actually travel: a call binds the parameter to the very same object the caller holds. So mutating it is shared but rebinding the name is local. That's the fact that dissolves the whole "by value vs by reference" argument.

SYNTAX · the function carddef, defaults, the two stars in both directions, return, lambda — chapters 9 and 10 on one card
def name(): — Type 1: no slots, no answer. Runs for its effect def name(a, b): — Type 2: named slots, filled left to right def name(a, b=default): — Type 3: slots AND an answer. Defaults sit LAST """One line, imperative: what it does.""" — the docstring: the FIRST statement, or it is not one return value — hand ONE object back, and end the call right there f(1, 2) f(b=2, a=1) f(1, b=2) — positional · keyword · mixed, positionals first def f(a, b=[]): — THE TRAP: one list, built once, shared by every call def f(a, b=None): if b is None: b = [] — the fix: None is the sentinel; build it inside def f(*args): def f(**kwargs): — GATHER: spare positionals to a tuple, keywords to a dict def f(a, *args, key=d, **kwargs): — the full signature, in its only legal order f(*sequence) f(**mapping) — SPREAD: the same two stars, reversed, at the call return a, b x, y = f() — the COMMA packs one tuple; unpack it at the far end return (no return at all) — both hand back None. Test for it with is, never == sorted(xs, key=f) max(xs, key=f) — the NAME, no parentheses. key= is called once per item lambda x: expression — a def with the ceremony stripped: one expression, returned global name nonlocal name — declare BEFORE you assign; reading needs neither
the parenthesesgreet is the machine sitting cold; greet() is the machine running. Hand a callback the bare name — key=len, never key=len().
print or returnprint shows a value to a human; return hands one to the program. A function that prints its answer gives you None to compute with.
the mutable default[] is evaluated once, when def runs, and stored on the function object. Every default-taking call then shares that one list.
the order is lawPlain names, then *args, then keyword-only names, then **kwargs. Nothing may follow **kwargs.
local by assignmentOne name = … anywhere in a body makes that name local everywhere in it — decided at compile time, before a line runs.
mutation is not assignmentplaylist.append(x) changes the object both names share, so it needs no keyword — and the caller sees it.

The machinery under classes. A dunder is a double-underscore method — __init__, __len__, __repr__ — that Python calls for you at fixed moments, so len(x) really runs x.__len__(). Name mangling is the class-body rule that rewrites any __name into _ClassName__name behind your back. It's a collision guard between parent and child, emphatically not a privacy lock. And the MRO, the method resolution order, is the fixed route Python walks up through parent classes to find a method. bool.__mro__ is (bool, int, object), so a method missing on bool is sought on int, then object, in exactly that order.

SYNTAX · the class cardclass, self, inheritance, the four dunders that pay, @property and @dataclass — chapter 12, condensed
class Name: — one statement; running it builds one class object shared = value — class attribute: ONE copy, seen by every instance def __init__(self, param): — the initializer. Python calls it FOR you self.attr = param — instance attribute: one fresh set per object def method(self): — behaviour, living beside the data it works on obj = Name(arg) obj.attr obj.method() — build · read · run. The ( ) press GO class Child(Parent): — the parenthesis names the parent: add, override, extend super().__init__(a) — EXTEND: run the parent's setup on this same self isinstance(obj, Parent) issubclass(C, P) — is-a? ancestors count · the same question of classes def __repr__(self): return f"Name({self.f!r})" — repr(x), the shell, and EVERY container. Write this one def __str__(self): ... — print(x) and str(x); skip it and __repr__ stands in def __eq__(self, other): ... — x == y. Without it, == falls back to identity def __len__(self): return len(self.items) — len(x), and truthiness for free def __lt__(self, other): ... — x < y, and sorted(), min() and max() all start working return NotImplemented — the honest decline: let the other side try @property — a method that reads like data: obj.minutes, no ( ) @name.setter — fires on every obj.name = ... : the one place to guard @classmethod def from_x(cls, ...) @staticmethod — cls is the CLASS · neither self nor cls is passed @dataclass — writes __init__, __repr__ and __eq__ from the fields title: str — a field: name and type, no self, no assignment songs: list = field(default_factory=list) — a FRESH list per object — chapter 9's trap, cured @dataclass(frozen=True, order=True) — immutable and hashable · adds the four comparisons
selfParameter number one, nothing more exotic. obj.method(a) IS Class.method(obj, a) — the dot fills the first slot for you.
the two homesself.x = … writes into this object's drawer. A bare x = … in the class body writes one shared copy on the class. A class-wide tally must be moved as Name.count += 1.
is-a or has-aOnly inherit when you can say “a B IS A A” and mean every word. Otherwise hold it in a field — a Playlist has songs.
__repr__ firstIt is the fallback for everything, and containers print it whatever your __str__ says.
NotImplementedNot an error. Python reads it as “I do not handle this” and tries the other operand before raising TypeError.
when to hand-writeA dataclass is for objects whose job is to hold fields. Write the class by hand when it is mostly behaviour, or the setup has to validate.
When folklore and the interpreter disagree
Every myth below came from real course notes — written by someone who was teaching. That's the point: plausible, confident, and wrong. The cure is always one line at the REPL — run it. The interpreter outranks any note, any blog post, any Stack Overflow answer, and this course too. It is the only referee that never bluffs.
Eight myths, one referee — step through and watch each one die at the REPL▸ drag me
MYTH 1 / 8 — folklore from chapter 0 ✗ THE FOLKLORE Python is “just interpreted” — there is no compile step. THE REPL — THE ONLY REFEREE »»» type(compile("secs + 45", "src", "eval")) ✓ THE REALITY CPython compiles to bytecode first, then runs it.
1 / 8
Drag through all eight. Every folklore line was believed by someone competent; every REPL line and its output is real, run on this machine. Notice the pattern — you never argue with the myth, you just run the one line that settles it.
Fig 04b — The shape of every disagreement in programming: a confident claim on top, a one-line experiment in the middle, the verdict at the bottom. The REPL is the referee — memorise the habit, not the eight answers.
Wait — if every 200 is the same cached object, is every number? No — the cache runs only from −5 to 256. Build a 257 at runtime twice and you get two different objects: a is b comes back False even though a == b is True. That gap is the whole reason you compare values with == and reserve is for identity questions like x is None.
↺ reframe
A glossary isn't a word list to grind through — it's a compression. Twenty-four terms feels like twenty-four things to remember, but they're twenty-four labels on one picture: an object in a box, a name pointing at it, a rule for freeing it, a rule for changing it. Once the box clicks, you're not recalling definitions — you're just reading the same diagram from twenty-four angles.
The deeper cut — why is sometimes lies about small ints

"A runtime 257 is two different objects" is a labelled simplification, and here's the refinement. The small-int cache (−5 to 256) is one mechanism. The compiler has a second, sneakier one. Two identical literals in the same compiled code object — say 257 is 257 typed on one line — get folded into a single shared constant. So that comparison returns True even though 257 is far outside the cache. Split the two 257s across separate runtime statements and the illusion vanishes: now they're genuinely distinct objects and is returns False. The lesson isn't the boundary number. It's that is on integers is an implementation detail you must never lean on. It answers "same object?", which for values is CPython's private business. Ask "same value?" and the answer is always, portably, ==.

You've named the pieces and buried the myths. But a vocabulary you can recite still isn't a program that runs — so let's build one, end to end: six steps to the playlist app. →

Wait — hash(200) is 200, tidy and stable — but hash(-1) is −2, not −1. Deep in CPython's C code -1 is the value that means "a hash error happened", so the one integer that would naturally hash to −1 gets quietly bumped to −2. A famous edge case hiding inside the plainest function in the language.
__PRIVATE IS A GUARD, NOT A LOCK
Python has no privacy keyword. Inside a class body, any __name is rewritten to _ClassName__name behind your back — name mangling, a collision guard between a parent class and its child. So a.__balance raises AttributeError, yet a._Account__balance hands the value straight over. It hides the name; it never locks the door.
Wait — is and == answer different questions, and small ints make the gap visible. int("257") is int("257") is False — two fresh boxes — while int("256") is int("256") is True, because −5…256 are cached and shared. Compare values with ==; save is for identity questions like x is None.

05Build it: six steps to the playlist app

Reading built recognition: you nodded along, the words made sense. Building is the other thing entirely. It builds recall, the ability to summon an idea onto a blank screen with nothing prompting you. This section is fourteen chapters folded into one program. Six exercises, each standing on the one before it, each naming the chapters it draws from. You start with three flat lists. By step six, the playlist you have carried since chapter 2 is a real, saving, sorting, class-based app — the same two songs the whole way, just wearing a heavier and heavier coat.

THE RULE FOR ALL SIX
Type every solution from a blank file — no copying, no scrolling back to an earlier chapter. Stuck for fifteen minutes? Re-read only the section the exercise names, close it, and finish from memory. Recall is forged at exactly the moment it hurts to remember; importing the answer forges nothing.

Here is the trick that makes the whole ladder cohere: one small fact — "Blinding Lights" runs 200 seconds — travels through every step, changing its shape but never its value. First it is an item in a list. Then a slot in a tuple, then a value under a dictionary key, then an argument to a function, then a field in a line of a file, then an attribute on an object. Watch that single 200 follow you down the page.

One datum, six shapes — the 200 that never changesfigure
the same length of "Blinding Lights" — 200 s — wearing six different costumes 1 · lists titles = [ ... ] seconds = [200, 245] three parallel lists, one index 2 · tuples ("Blinding Lights", "The Weeknd", 200) one row, locked together 3 · dicts {"title": ..., "seconds": 200} named fields, no index to lose 4 · functions total_seconds(pl) → 445 (200+245) behaviour wrapped in a name 5 · a file line Blinding Lights| The Weeknd|200 survives the power going off 6 · a class Song("Blinding Lights", …, 200) data and behaviour, one thing
Fig 05 — Every step re-clothes the identical data. Learn to see the 200 travelling and the six chapters stop being six topics — they are one dataset growing a richer skin.

Step 1 · Parallel lists — chapters 1, 2, 5. Three lists that line up by index: titles, artists, seconds, five songs long, anchored on "Blinding Lights" (200) and "Titanium" (245). Print a 1-based menu with enumerate(titles, start=1), then the total run time as m:ss using sum(seconds) with // 60 and % 60. Check: a single 200-second song formats as 3:20, and the five-song total 1160 s prints 19:20.

Step 2 · Tuples and sorting — chapters 6, 10. Zip the three lists into rows: tracks = list(zip(titles, artists, seconds)). Loop with unpacking — for title, artist, secs in tracks: — then make a longest-first copy with sorted(tracks, key=lambda t: t[2], reverse=True) and pluck the single longest with max(tracks, key=lambda t: t[2]). Check: sorted hands back a brand-new list, so tracks[0] is still "Blinding Lights". The original order is untouched.

Step 3 · Dicts and sets — chapter 7. Rebuild every track as a self-labelling record with a comprehension: [{"title": t, "artist": a, "seconds": s} for t, a, s in zip(...)]. Collect the unique artists into a set, then count songs per artist in a dict using the classic counts[artist] = counts.get(artist, 0) + 1. Check: add a second Avicii track and the set stays the same size while Avicii's count ticks from 1 to 2. The two structures answer two different questions.

Step 4 · Functions and hardening — chapters 9, 13. Fold the moves into named tools: add_song(playlist, title, artist, seconds), total_seconds(playlist), songs_by(playlist, artist). Read new songs from input() and guard the seconds cast — try: secs = int(raw) / except ValueError: re-prompt. Check: typing abc where a number belongs must re-ask, never crash. An unguarded int("abc") raises ValueError and takes the whole app down with it.

Step 5 · A file that remembers — chapters 11, 13. save(playlist, path) writes one title|artist|seconds line per song inside with open(path, "w"). Then load(path) reads each line, does split("|"), and casts the seconds back to int. Wrap the read in try/except FileNotFoundError and return [] on a first run. Check: save, quit, rerun, load. And be sure the seconds compare equal as ints, not strings, or every later sort silently lies.

Step 6 · The class — chapters 11, 12. A Song class (or a @dataclass) with three attributes and a __repr__. Then a Playlist wrapping a list, exposing add(), total_seconds(), __len__ and __repr__ so len() and print() just work. Stretch: put __slots__ on Song and measure the per-instance size honestly (see the deeper cut), and make total_seconds a generator expression: sum(s.seconds for s in self.songs).

Drag the step control below and climb the staircase one rung at a time. Each step you reach lights gold. Every step beneath it — already built, already reused — glows green. Read off the chapters it leans on, and the exact check that tells you it works.

Climb the six steps — each one stands on the last▸ drag me
1 2 3 4 5 6 Parallel lists draws on chapters 1 · 2 · 5 check — a 200 s song prints 3:20
step 1 / 6
Slide from 1 to 6. Nothing below the current step is ever thrown away — a class in step six still totals seconds with the very // and % you wrote in step one. At the top, the flag turns green: the app is real.
Fig 05b — Six rungs, one app. The gold step is where you stand; the green steps are the finished floor you are standing on.
SYNTAX · the guard cardtry, raise, your own exception — and the string methods and format specs that write the report
try: — keep it to the smallest code you are volunteering to catch except SomeError: — Type 1: name the one failure you expect except SomeError as e: — Type 2: bind it. str(e), e.args, type(e).__name__ except (ErrorA, ErrorB): — Type 3: one clause, a tuple of classes except A: ... except B: — Type 4: first match wins, so the specific goes ABOVE else: — runs only when the try succeeded, and is not guarded finally: — runs on EVERY way out, even while an error is climbing with open(path) as f: — the finally you never have to remember to write raise ValueError(f"impossible length: {secs}s") — build it, launch it. Put the VALUE in the message raise — bare, inside a handler: re-throw, traceback intact raise PlaylistError("bad row") from e — translate a low-level failure; e lands in __cause__ class PlaylistError(Exception): — one line and you own a named failure class SongNotFound(PlaylistError): — a child, so callers choose how wide to catch s.strip() s.upper() s.lower() s.title() — each builds a NEW string; the original never moves s.split(sep) sep.join(pieces) — cut into a list · weld a list back into one string s.replace(old, new) s.find(sub) — every occurrence · its index, or -1 for not here s.startswith(p) s.endswith(p) — a clean True / False, no slicing arithmetic f"{v:.2f}" f"{v:,}" f"{v:02d}" f"{v:>10}" — decimals / thousands / zero-pad / width (< ^ > align) f"{v:>10.2f}" f"{v!r}" — width then precision · !r keeps the quotes on f"{m}:{s:02d}" — the m:ss you have typed all course: divmod(445, 60)
the bare netexcept: is except BaseException: in disguise. It swallows your Ctrl-C and your typos with equal enthusiasm. Name the class.
the order is lawtry, then except, then else, then finally. An else with no except above it is a SyntaxError.
which classRight type, impossible value → ValueError. Wrong type entirely → TypeError. Something only your world knows is broken → a class of your own, ending in Error.
EAFPThe attempt is the test. try: int(raw) beats any hand-written check — "-50".isdigit() is False, yet int parses it fine.
strings never moveEvery method above returns a new string. s.strip() on its own line changes nothing — you have to catch the result.
join, not +=+= in a loop reallocates and recopies, O(n²). ", ".join(parts) measures once, allocates once, copies once.
Wait — if I finished step five's save/load, why retype it in step six instead of just import-ing the file I already wrote? Because the file is not the prize — the retrieval is. Re-deriving total_seconds from a blank buffer is the rep that carves the memory; importing it hands your fingers the answer and carves nothing. The finished app is a by-product; the wired-in recall is the thing you actually keep.

Step six has an exact finish line. Type this out and make it print, character for character. If it does, you have a class, a container, and two dunder methods all cooperating:

playlist.pypython
night = Playlist("Friday Night")
night.add(Song("Blinding Lights", "The Weeknd", 200))
night.add(Song("Titanium", "David Guetta", 245))

print(len(night))             # 2    — your __len__ at work
print(night.total_seconds())  # 445  — 200 + 245
print(night)                 # Playlist('Friday Night', 2 tracks, 7:25)
↺ reframe
You did not practise six separate topics and staple them together. You extended one living object six times — the same playlist, promoted from a loose bundle of lists into a self-describing class. Lists, tuples, dicts, functions, files and classes were never six subjects to memorise; they are six costumes the one dataset can wear, and picking the right costume for the job is most of what "knowing Python" actually means.

✗ The myth

"I read all fourteen chapters and understood every page, so I know Python now." Understanding-while-reading feels like mastery — the words all landed.

✓ The reality

Reading proves only recognition. The proof of recall is a blank file: can you rebuild this playlist with nothing open to copy from? If yes, it is yours. If not, that gap is exactly the section to revisit — and the pain of finding it is the learning.
THE SILENT STRING TRAP
On load, split("|") hands you back strings — every field, seconds included. Forget the int(...) cast and nothing crashes; it just quietly rots. "200" == 200 is False, "90" > "200" sorts wrong because strings compare character by character, and your longest-first list scrambles with no error to warn you. Casting on the way in is not a nicety — it is the difference between a playlist that sorts and one that only looks like it does.
The deeper cut — why the __slots__ stretch fools a naive size check

The stretch goal says "put __slots__ on Song and compare per-instance size." Do it the obvious way and Python appears to punish you for optimising. That's because sys.getsizeof weighs only the object header in front of you, never the __dict__ it silently drags behind. This is a labelled simplification made honest: the real number is the sum of both.

slots.pypython
import sys
sys.getsizeof(plain)             # 48   — looks tiny...
sys.getsizeof(plain.__dict__)    # 272  — ...but it hauls THIS around too
hasattr(plain, "__dict__")     # True

sys.getsizeof(slotted)           # 56   — a heavier header, but...
hasattr(slotted, "__dict__")   # False — no per-instance dict at all

All-in, the plain Song costs 48 + 272 = 320 bytes (measured on this CPython 3.12). The slotted one costs 56 bytes and carries no dictionary. That is the real ~5× saving __slots__ buys you, invisible until you count the __dict__ that getsizeof refuses to follow. Multiply by a million songs and it stops being a curiosity.

WHEN IT ALL RUNS
You now have a program that reads, validates, saves, reloads, sorts and describes itself — the entire spine of a real application, built from the atoms of fourteen chapters. Keep this file. It is the most honest résumé line you can write: not "completed a Python course," but "here is a thing I built, and I can rebuild it from empty."

The app plays its last track and prints its tidy 7:25. So where do you point yourself next — what is worth learning after the language itself? →

THE CHECKS ARE EXACT — TRUST THEM
Every "check" in the six steps is a real number to test your code against. A 200-second song is 200//60 : 200%603:20; the pair 200 + 245 = 445 prints 7:25; the five-song 1160 s prints 19:20. If your app shows anything else, the // or % is wrong — not your memory.
Wait — add __slots__ to save memory and getsizeof seems to punish you: the slotted Song's own header is 56 bytes, bigger than the plain one's 48. The saving is invisible here — the plain object also drags a separate ~296-byte __dict__ that getsizeof refuses to follow. Count both and the slotted object is roughly 6× leaner. Multiply by a million songs and it stops being a curiosity.
Wait — "200" == 200 is False, and that one gap can rot a whole app. Load a saved playlist, forget the int(...) cast, and your seconds stay strings — nothing crashes, but "90" > "200" is True (strings compare character by character, and '9' beats '2'), so your longest-first sort silently scrambles. Cast on the way in, or it only looks sorted.
Trace3 step the machine — idea · code · memory move together
Growing the Playlist class — one member at a time
A class is not built in one breath — each method you add unlocks a piece of Python that then just works: __len__ powers len(pl), __iter__ powers for s in pl, __repr__ powers print(pl). Press Next and watch the body grow, member by member, until the finish‑line script prints 7:25. (In a real file you type all six methods, then run once — we grow the source only to show what each unlocks.)
6 members2 songs · 445sprints 7:25
class Playlist — grown one member at a timemember 1 / 6
class Playlist:
now this works
In plain words
Under the hood
INITwhat this member does
Program · playlist.py
night · instance
Memory · the night instance
registers — what the built‑ins read
night.songs list · the tracks inside
what's happening
beat 1 / 1

06The road on

Here is an honest map, folded open one last time. Fourteen chapters back you met a bit and could not yet see how it held the number 200. Now you can follow that 200 — the length of "Blinding Lights", byte 0b11001000 — from the switch it rides on, through the box in RAM, up to the Python object and the name that points at it. This course installed the nouns of the language — int, str, list, dict, the frame, the heap, the reference. And, rarer than any tutorial bothers with, it showed you where each one lives in memory. What a book cannot hand you is mileage: the hundred small programs where the model stops being a diagram you recall and becomes a reflex you feel. You don't learn to drive by memorising the car. You learn by driving.

TYPE THIS · the volume's capstone — save it, then run itjukebox.py — a dataclass, a class with dunders, custom errors, comprehensions and a menu loop
# jukebox.py -- one program, every chapter of Volume 1
from dataclasses import dataclass


class JukeboxError(Exception):
    """Base: anything this jukebox refuses to do."""


class NoSuchTrackError(JukeboxError):
    def __init__(self, number, top):
        super().__init__(f"there is no track {number} (1-{top})")
        self.number, self.top = number, top


@dataclass
class Track:                                   # ch 12: fields, __init__ and __eq__ for free
    title: str
    artist: str
    seconds: int
    plays: int = 0

    def __str__(self):                         # ch 2: the m:ss f-string spec
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"


class Playlist:
    def __init__(self, name, tracks):
        self.name, self.tracks = name, list(tracks)

    def __len__(self):                         # ch 12: len(pl) and truthiness
        return len(self.tracks)

    def __iter__(self):                        # ch 10: for t in pl, and every builtin
        return iter(self.tracks)

    def __repr__(self):
        minutes, secs = divmod(sum(t.seconds for t in self), 60)
        return f"Playlist({self.name!r}, {len(self)} tracks, {minutes}:{secs:02d})"

    def pick(self, number):                    # ch 13: refuse, do not shrug
        if not 1 <= number <= len(self):
            raise NoSuchTrackError(number, len(self))
        track = self.tracks[number - 1]
        track.plays += 1
        return track


def get_int(prompt, low, high):                # ch 9 + ch 13: the hardened ask
    while True:
        raw = input(prompt)
        try:
            number = int(raw)
        except ValueError:
            print(f"  {raw!r} is not a number - digits only, {low}-{high}.")
            continue
        if low <= number <= high:
            return number
        print(f"  {number} is outside {low}-{high}.")


def run(playlist):                             # ch 5: the menu loop
    print(f"{playlist.name} - {len(playlist)} tracks")
    for i, track in enumerate(playlist, start=1):
        print(f"  {i}. {track}")
    while True:
        number = get_int("pick a track (0 to stop): ", 0, len(playlist))
        if number == 0:
            break
        print(f"  now playing {playlist.pick(number)}")


# --- a staged keyboard, so this run reproduces on your machine too ---
typed = iter(["2", "encore", "9", "2", "1", "0"])

def input(prompt=""):                          # shadows the builtin for this demo only
    raw = next(typed)
    print(prompt + raw)
    return raw


night = Playlist("Friday Night", [
    Track("Blinding Lights", "The Weeknd", 200),
    Track("Titanium", "David Guetta", 245),
    Track("Levels", "Avicii", 340),
    Track("Strobe", "deadmau5", 637),
])

run(night)

played = [t for t in night if t.plays]                       # ch 10: filter
long_ones = {t.title for t in night if t.seconds > 300}      # ch 10: a set comprehension
by_length = sorted(night, key=lambda t: -t.seconds)          # ch 9: key= and lambda
counts = {t.title: t.plays for t in played}                  # ch 10: a dict comprehension

print()
print(repr(night))
print("played   :", ", ".join(t.title for t in played))
print("counts   :", counts)
print("over 5:00:", sorted(long_ones))
print("longest  :", by_length[0])

try:
    night.pick(9)
except NoSuchTrackError as e:
    print("refused  :", e)
Friday Night - 4 tracks
  1. Blinding Lights - The Weeknd (3:20)
  2. Titanium - David Guetta (4:05)
  3. Levels - Avicii (5:40)
  4. Strobe - deadmau5 (10:37)
pick a track (0 to stop): 2
  now playing Titanium - David Guetta (4:05)
pick a track (0 to stop): encore
  'encore' is not a number - digits only, 0-4.
pick a track (0 to stop): 9
  9 is outside 0-4.
pick a track (0 to stop): 2
  now playing Titanium - David Guetta (4:05)
pick a track (0 to stop): 1
  now playing Blinding Lights - The Weeknd (3:20)
pick a track (0 to stop): 0

Playlist('Friday Night', 4 tracks, 23:42)
played   : Blinding Lights, Titanium
counts   : {'Blinding Lights': 1, 'Titanium': 2}
over 5:00: ['Levels', 'Strobe']
longest  : Strobe - deadmau5 (10:37)
refused  : there is no track 9 (1-4)
Save it as jukebox.py and run it. This is the one, and it is the honest test of the whole volume: there is nothing in these fifty lines you have not already met, and nothing left out either. Read it top to bottom and count the chapters going past. @dataclass writes Track's __init__ and __repr__ for you (12); JukeboxError and its one child give the machine a way to refuse without lying (13); __len__ and __iter__ are what make len(night), for t in night, sum(…) and sorted(…) all work on an object you wrote yourself (12, 10); get_int is chapter 9's function wrapped around chapter 13's try. The four report lines are comprehensions and a key= lambda (10). And the {minutes}:{secs:02d} that prints 4:05 is the format spec from chapter 2, unchanged since the second week.

Two moments are worth stopping on. The first is __iter__. Its body is one line: return iter(self.tracks). The instant it exists, sum(t.seconds for t in self), sorted(night, key=…), enumerate(playlist, start=1) and [t for t in night if t.plays] all start working. None of those four know anything about your class. You did not teach Python four tricks. You joined a protocol it already had, and everything downstream of that protocol came free. The second is the split between get_int and pick. get_int refuses to hand back nonsense from a keyboard: encore is not a number, 9 is out of range, and neither one reaches the playlist. pick refuses anyway, with NoSuchTrackError, because a method does not get to assume its caller was careful. Guard at the keyboard and at the door, and the same program survives a user, a file and another programmer.

The staged typed list and the local input exist only so this run reproduces exactly on your machine. Delete both and it reads the real keyboard, unchanged. Three things to try. Add a skip command that plays the next track without asking. Give Playlist a __getitem__ so night[0] works, then check whether for t in night still runs when you delete __iter__ (it will — find out why). Then close the file, open an empty one, and write it again from the cards above. That second run, from nothing, is the one that proves it is yours.

Part 2 exists for exactly that. Not more theory, but real programs, traced line by line, with the stack and the heap redrawn at every single step. A number-guessing game. A round of hangman. An email-slicer that pulls the name out of an address. You won't have to imagine the frame being pushed when a function is called, or the object appearing on the heap when a list is built. You'll watch it happen, one line at a time, and catch the exact instant a bug is born.

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "save.py", line 6, in <module>
    f.write(song["seconds"])
TypeError: write() argument must be str, not int
A text file is a river of characters, never a shelf for objects. Handed the int 200, write would have to decide how you want it spelled — 200? 0200? 3:20? — and it refuses to guess on your behalf. It is really saying: everything leaving this handle must already be text, and turning a number into text is your decision, not mine.
spell it on the way out — f.write(f"{song['title']}|{song['seconds']}\n")
Traceback (most recent call last):
  File "reload.py", line 6, in <module>
    print("saved:", f.read())
                    ^^^^^^^^
io.UnsupportedOperation: not readable
Nothing here is closed — the handle is wide open, and the two lines you already wrote reach the disk safely, because with still flushes on its way out of an exploding block. The mode string was a contract, not a hint: "w" asks for a write-only handle, so there is simply no reading machinery bolted to it. And the quieter half already happened. Opening in "w" truncates the file to zero bytes at the open itself, before one character goes in — we ran this against a three-song file and came back to two, and the third song was gone before the crash, not because of it. It is really saying: this door only opens one way, and opening it is itself a destructive act.
saving and checking are two separate openings — and when you meant add to rather than replace, the mode is "a", never "w"
Traceback (most recent call last):
  File "jukebox.py", line 13, in <module>
    print(f"{night.name} - {len(night)} tracks")
                            ^^^^^^^^^^
TypeError: object of type 'Playlist' has no len()
You met this exact sentence in chapter 2, on len(200), where an int genuinely had nothing to count. Here it is the opposite: the list you want counted is sitting right there in self.tracks. Same sentence, opposite cause — because len() never rummages inside an object hunting for something countable. It calls __len__ on the type and reports what comes back. It is really saying: this type joined no protocol, so I was handed no way to ask.
a builtin that “does not work on your class” is almost always a missing dunder, not a missing feature — the capstone above already has this socket wired
NOW WRITE IT YOURSELFthe final exam — five tasks, one per card, nothing new
Five tasks, one per card, and no new ideas in any of them. Open a blank file called final_exam.py and keep the cards above open. That is what a cheat sheet is for. Start from rows = [("Blinding Lights", "The Weeknd", 200), ("Titanium", "David Guetta", 245), ("Levels", "Avicii", 340), ("Strobe", "deadmau5", 637)].

1 · flow. Build a lengths dict of title to seconds with a dict comprehension. Then print a 1-based menu with enumerate(rows, start=1), each title padded to a 16-wide column and each length shown as m:ss. Check: row 4 prints 10:37, not 10:7.

2 · containers. Write unique_artists(rows). It returns the artists with no duplicates, in alphabetical order, and its body is one line. Check: the answer is a list, not a set. deadmau5 sorts last, because lowercase letters come after uppercase ones.

3 · functions. Write describe(title, *tags, sep=" | ", **meta). It glues the title, every tag and every key=value pair into one string. Call it by spreading a list and a dict into it. Beside it write add_to(title, shelf=None) with the sentinel fix. Check: two calls to add_to with no shelf give you two separate one-item lists — not one list holding both titles.

4 · classes. A @dataclass(order=True) called Song, with seconds first and title second, plus an mmss @property. Then a plain Shelf class with __len__ and a __repr__ that names its longest song. Check: max(shelf.songs) works with no key= at all. Ask yourself which field is doing the comparing, and why the field order matters.

5 · guards. Write parse_row(" titanium | David Guetta | 245 "). It splits on |, strips every field, title-cases the title and casts the seconds. When that last field is something like 5:40, raise your own RowError from the original ValueError. Check: the handler can still read type(e.__cause__).__name__ and get ValueError. One hint for the whole exam and no more: every shape you need is on the cards. If you are inventing syntax, you are on the wrong card.
show the solution
# final_exam.py -- five tasks, one card each
from dataclasses import dataclass

rows = [("Blinding Lights", "The Weeknd", 200),
        ("Titanium", "David Guetta", 245),
        ("Levels", "Avicii", 340),
        ("Strobe", "deadmau5", 637)]


# ---------- 1 - flow card: enumerate, an f-string spec, a dict comprehension ----------
lengths = {title: secs for title, artist, secs in rows}
for i, (title, artist, secs) in enumerate(rows, start=1):
    print(f"{i}. {title:<16}{secs // 60}:{secs % 60:02d}")
print("lengths :", lengths)


# ---------- 2 - containers card: a set comprehension, then sorted ----------
def unique_artists(rows):
    return sorted({artist for _, artist, _ in rows})

print("artists :", unique_artists(rows))


# ---------- 3 - functions card: gather, spread, and the None sentinel ----------
def describe(title, *tags, sep=" | ", **meta):
    parts = [title, *tags, *(f"{k}={v}" for k, v in meta.items())]
    return sep.join(parts)

def add_to(title, shelf=None):            # the trap is shelf=[] - one list, shared forever
    if shelf is None:
        shelf = []
    shelf.append(title)
    return shelf

extra = ["synthwave", "80s"]
info = {"bpm": 171, "key": "Fm"}
print("describe:", describe("Blinding Lights", *extra, **info))
print("fresh   :", add_to("Titanium"), add_to("Levels"))


# ---------- 4 - classes card: a dataclass, a property, two dunders ----------
@dataclass(order=True)
class Song:
    seconds: int
    title: str

    @property
    def mmss(self):
        return f"{self.seconds // 60}:{self.seconds % 60:02d}"

class Shelf:
    def __init__(self, songs):
        self.songs = list(songs)
    def __len__(self):
        return len(self.songs)
    def __repr__(self):
        return f"Shelf({len(self)} songs, longest {max(self.songs).title!r})"

shelf = Shelf([Song(secs, title) for title, _, secs in rows])
print("shelf   :", shelf, "| len", len(shelf), "| shortest", min(shelf.songs).mmss)


# ---------- 5 - exceptions + strings card: split, strip, raise ... from e ----------
class RowError(ValueError):
    """This text is not a playlist row."""

def parse_row(line):
    title, artist, raw = (part.strip() for part in line.split("|"))
    try:
        seconds = int(raw)
    except ValueError as e:
        raise RowError(f"bad seconds field in {line!r}") from e
    return {"title": title.title(), "artist": artist, "seconds": seconds}

print("parsed  :", parse_row("  titanium | David Guetta | 245 "))
try:
    parse_row("Levels|Avicii|5:40")
except RowError as e:
    print("refused :", e, "| cause:", type(e.__cause__).__name__)

# ------------- what it prints -------------
1. Blinding Lights 3:20
2. Titanium        4:05
3. Levels          5:40
4. Strobe          10:37
lengths : {'Blinding Lights': 200, 'Titanium': 245, 'Levels': 340, 'Strobe': 637}
artists : ['Avicii', 'David Guetta', 'The Weeknd', 'deadmau5']
describe: Blinding Lights | synthwave | 80s | bpm=171 | key=Fm
fresh   : ['Titanium'] ['Levels']
shelf   : Shelf(4 songs, longest 'Strobe') | len 4 | shortest 3:20
parsed  : {'title': 'Titanium', 'artist': 'David Guetta', 'seconds': 245}
refused : bad seconds field in 'Levels|Avicii|5:40' | cause: ValueError
THE ONE IDEA TO CARRY FORWARD
From here on, nothing new is smaller than what you already hold. Every chapter ahead is a fresh arrangement of the same atoms — bytes in boxes, objects on the heap, frames on the stack. You are not restarting at the bottom of a harder book. You are turning the model you built into software that ships.
the course was the on-ramp — this is the road it feedsfigure
bits → bytes → objects → the stack & heap — and then out onto the road Part 1 you are here the model, metal → objects Part 2 traces, line by line Volume 2 shipped software Vol 3 · Vol 4 · the Lab
Fig 06a — The course was the on-ramp. Part 2 traces the objects you built through real programs; Volume 2 turns them into software that ships; Volumes 3 and 4 and the Lab are the country beyond. One road, three speeds.

And Volume 2 — the working programmer — picks up on this exact spot and turns the model into shipped code, through six rooms. Step into each one below. Not one of them adds a new atom. A file is bytes leaving RAM for the disk. An import is one namespace borrowing another. A thread is frames taking turns on the same CPU. Every "advanced" topic is a rearrangement of things you already watched happen. That's why none of it needs more than what you now know.

the six rooms of Volume 2 — step through them▸ drag me
each door is a rearrangement of atoms you already own — step through 1 files & persistence 2 modules & import 3 type hints 4 testing & debug 5 threads & async 6 capstone Data that outlives the process — a file gives your playlist a life after the power cut. playlist in a var → next run: NameError · in a file → still there
1 / 6 — files & persistence
Slide through the six rooms. Each caption is a real payoff, and the line under it is a fact you can run today — the same objects, bytes and frames from this course, simply put to work.
Fig 06b — Volume 2 has six doors, not one locked gate. The highlighted room is never a new beginning — it's the model you already built, aimed at a job.
NOW WRITE IT YOURSELFextend the jukebox — an up-next queue, from chapters 12 and 13 only
Now the harder half, and the one that feels like programming: take the jukebox you just typed and grow it. The feature is an up-next queue. It holds the tracks lined up to play, capped at three, so nobody can hog the machine. It needs two chapters at once, and no third.

From chapter 12, give Playlist a __contains__, so "Titanium" in night answers True and "Sandstorm" in night answers False. That is the same in you have used on lists since chapter 6, now answered by a method you wrote. From chapter 13, add two new children under the existing JukeboxError: one for a title that is not on the playlist, one for a queue that is already full. queue(title) raises them. It never hands back None or False for the caller to trip over three lines later.

Three constraints make this an exercise rather than typing practice. The caller may not contain a single if that inspects an error: the class does the deciding, one except clause each. A future colleague must be able to catch everything your jukebox refuses in one clause, without catching anything Python raises. That sentence alone tells you what the two new classes inherit from. And queue must find the track by title without a manual index loop. The shape you want is a generator expression handed to next(…, None), straight off the flow card.

Run it on ["Titanium", "Sandstorm", "Levels", "Strobe", "Blinding Lights"] with a limit of three. You should see one of each outcome: three queued, one unknown title, one full queue. Then drain the queue with a while loop and check that the play counts landed on the right tracks. When that runs, you have added a feature to a program you wrote, using a protocol and an exception family you designed yourself. That is, more or less, the job.
show the solution
# jukebox_queue.py -- one new feature: an up-next queue, capped at three
from dataclasses import dataclass


class JukeboxError(Exception):
    """Base: anything this jukebox refuses to do."""

class NoSuchTrackError(JukeboxError):
    def __init__(self, number, top):
        super().__init__(f"there is no track {number} (1-{top})")
        self.number, self.top = number, top

class UnknownTitleError(JukeboxError):          # NEW - ch 13
    def __init__(self, title):
        super().__init__(f"{title!r} is not on this playlist")
        self.title = title

class QueueFullError(JukeboxError):             # NEW - ch 13
    def __init__(self, limit):
        super().__init__(f"the queue is full at {limit}")
        self.limit = limit


@dataclass
class Track:
    title: str
    artist: str
    seconds: int
    plays: int = 0

    def __str__(self):
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"


class Playlist:
    def __init__(self, name, tracks, limit=3):
        self.name, self.tracks = name, list(tracks)
        self.limit = limit
        self.up_next = []                       # NEW - the queue itself

    def __len__(self):
        return len(self.tracks)

    def __iter__(self):
        return iter(self.tracks)

    def __contains__(self, title):              # NEW - ch 12: powers `in`
        return any(t.title == title for t in self)

    def __repr__(self):
        minutes, secs = divmod(sum(t.seconds for t in self), 60)
        return f"Playlist({self.name!r}, {len(self)} tracks, {minutes}:{secs:02d})"

    def find(self, title):                      # ch 10: next() with a default
        track = next((t for t in self if t.title == title), None)
        if track is None:
            raise UnknownTitleError(title)
        return track

    def queue(self, title):                     # NEW - the feature
        if len(self.up_next) >= self.limit:
            raise QueueFullError(self.limit)
        self.up_next.append(self.find(title))
        return self.up_next[-1]

    def play_next(self):
        if not self.up_next:
            return None
        track = self.up_next.pop(0)
        track.plays += 1
        return track


night = Playlist("Friday Night", [
    Track("Blinding Lights", "The Weeknd", 200),
    Track("Titanium", "David Guetta", 245),
    Track("Levels", "Avicii", 340),
    Track("Strobe", "deadmau5", 637),
])

print("in?      :", "Titanium" in night, "|", "Sandstorm" in night)

for title in ["Titanium", "Sandstorm", "Levels", "Strobe", "Blinding Lights"]:
    try:
        print("queued   :", night.queue(title))
    except QueueFullError as e:
        print("refused  :", e)
    except UnknownTitleError as e:
        print("refused  :", e)

while (track := night.play_next()) is not None:
    print("playing  :", track)

print("counts   :", {t.title: t.plays for t in night if t.plays})
print("one net  : caught", end=" ")
try:
    night.queue("Nothing At All")
except JukeboxError as e:
    print(type(e).__name__, "-", e)

# ------------- what it prints -------------
in?      : True | False
queued   : Titanium - David Guetta (4:05)
refused  : 'Sandstorm' is not on this playlist
queued   : Levels - Avicii (5:40)
queued   : Strobe - deadmau5 (10:37)
refused  : the queue is full at 3
playing  : Titanium - David Guetta (4:05)
playing  : Levels - Avicii (5:40)
playing  : Strobe - deadmau5 (10:37)
counts   : {'Titanium': 1, 'Levels': 1, 'Strobe': 1}
one net  : caught UnknownTitleError - 'Nothing At All' is not on this playlist
↻ reframe
You didn't reach the end of Python. You reached the end of the map — and a map is worthless while you're standing still. Everything ahead is the same country you already surveyed: the byte, the object, the frame. Volume 2 just hands you the keys and points at the road.
Wait — if Python throws the annotation away the instant it runs — play("banana") didn't even flinch, and the hint seconds: int just sat there in play.__annotations__ as inert data — then what on earth is a type hint for? Because the check happens before your program runs, not during it. A tool called mypy reads those hints like a proofreader and refuses the manuscript when a str is about to land where an int was promised. The hint is a message to a machine that runs earlier than you do.
DOORS, NOT PREREQUISITES
The six rooms aren't a locked sequence to grind through in order. Reach for persistence the day your program needs to remember something; reach for testing the first time a bug you fixed sneaks back in. Each door opens when a real project knocks — not a moment before.

✗ The myth

Volume 2 is "harder Python" — new syntax, deeper magic, a fresh pile of things to memorise before you can build anything real.

✓ The reality

It introduces no new atom. Files are bytes leaving RAM for the disk; imports are one namespace borrowing from another; threads are frames taking turns on one CPU. Every "advanced" topic is a rearrangement of things you already watched happen at the byte level.
The deeper cut — the honest truth about the GIL, in one measurement

Here's a promise Volume 2 keeps and most tutorials dodge. Standard Python runs with a GIL — a global lock that lets only one thread execute Python bytecode at a time. Line up two threads on pure number-crunching and the clock tells the truth: the two together finish in almost exactly the same wall-time as running them one after the other. Measured on this very machine, two CPU-bound threads clocked a 1.02× "speedup" — which is to say, none at all. Threads do not multiply CPU-bound Python.

So why does the topic matter? Because most programs spend their lives waiting — on a disk, a network, a reply that hasn't come. While one thread waits, the GIL is free and another thread runs. That's the real win: one thread juggling thousands of idle waits, which is exactly what async is built for. And when you genuinely need many CPUs grinding at once, you step past threads to processes — a separate interpreter each, no shared lock. Volume 2 draws all three — threads, async, processes — with the same stack-and-heap pictures you already read fluently.

Five doors lead out of this room. The trace walkthroughs open the hood for real — the guessing game line by line, every frame pushed and every object born on the heap at the exact moment it happens. Volume 2 — the working programmer begins where every run ends: the process dies and takes the playlist with it, and chapter 15 gives your data a life after the power cut. Volume 3 is the deep one — algorithms & complexity, where you learn to make code fast and think like the 1%. Volume 4 builds the thirteen containers your data lives in, from the bytes up. And the Algorithms Lab hands you the controls — race binary search against linear, sort an array by hand, watch a recursion tree explode. The map is finished. Pick a door and drive →

SAY IT BACKthe chapter in five breaths
  1. Every value is a boxed object: the number 200 costs 28 bytes because you pay for the refcount and the type pointer wrapped round it, not for the digit — and a name only ever stores that box's address.
  2. Containers differ in how they find a thinglist[i] computes an address and probes once, a dict folds the key down to a bucket number and jumps — and mutability, key-ability and cost all fall out behind that single split.
  3. Choosing a structure is placing a bet on one operation: name the move you will make ten thousand times and the row picks itself, because every O(1) is rented, not owned — you pay the space for as long as you hold the structure — and a million-key dict's table alone is 40.0 MiB.
  4. Fifteen phrases carry most everyday Python, and each one is a choice already made for you — the day your fingers reach for enumerate before a counter occurs to you, the language has stopped being a lookup and become a habit.
  5. When memory and machine disagree, the machine is the referee, and the referee is always in the room: type names the thing, dir lists what it can do, and help reads back the docstring the function is still carrying — which is why the answer travels with the object instead of sitting in a blog post you have to go find.
You already owned the pieces: chapter 0 gave you the byte that 200 still rides on; chapters 6 and 7 gave you the list, dict and set this chapter only lined up shoulder to shoulder; chapter 9 gave you the function whose docstring help() reads back; chapter 12 gave you the dunders that make len(night) and for t in night legal at all; chapter 13 gave you the refusal that stops a bad track number ever reaching the playlist. This chapter handed you no new atom — it handed you the index, and the habit of asking the machine instead of your memory.
Wait — a type hint is thrown away the instant Python runs. play("banana") on def play(seconds: int) doesn't even flinch — the hint just sits in play.__annotations__ as inert data. So what is it for? A tool like mypy reads those hints before your program runs and refuses the code when a str is about to land where an int was promised. It is a message to a machine that runs earlier than you do.
THE HONEST TRUTH ABOUT THREADS
Two threads on pure number-crunching don't finish twice as fast — they finish in about the same wall-time as running them one after the other. Standard Python's GIL lets only one thread run bytecode at a time, so two CPU-bound threads clock roughly 1.0× "speedup" — none at all. Threads win only when programs wait — on a disk, a network, a reply — which, mercifully, is most of the time.
↺ reframe
You didn't reach the end of Python — you reached the end of the map. Every "advanced" topic ahead is a rearrangement of atoms you already watched happen: a file is bytes leaving RAM for the disk, an import is one namespace borrowing from another, a thread is frames taking turns on one CPU. Nothing ahead is smaller than what you already hold — it is the same country, aimed at a job.
Trace3 step the machine — idea · code · memory move together
One line, five floors down — text to bytecode to frame to heap to metal
Trace a single statement — Song("Titanium").play() — all the way down the Volume‑1 stack. Floor 2 holds the one genuinely new word: the CPU cannot execute your letters, so CPython first compiles the whole line into bytecode — a compact stream of one-byte instructions (LOAD, CALL, LOAD_METHOD…) that the interpreter's fetch‑decode‑execute loop actually runs. Watch the line start as characters on disk, compile to that bytecode, push a frame on the call stack, allocate objects on the heap, and finally flip switches in RAM. Press Next and light up one floor at a time.
5 floors1 statementlen 8 = 0b00001000
The whole stack — one line descendingsource
1
source textSong("Titanium").play() — 23 characters
text
2
bytecodeLOAD · CALL · LOAD_METHOD · CALL
ops
3
call stackframe: self, title live here
frame
4
heapSong object + str "Titanium"
objects
5
metal · RAM / CPU8 switches = one byte
bits
In plain words
Under the hood
READALU · the operation
Program · player.py
names · type
Memory · the running machine
registers — the running state
Song object class instance · lives on the heap
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 14, in working code

The page you keep open: fifteen small, whole programs that weave classes, dicts, comprehensions, exceptions and recursion into things you could actually ship. Press Next and watch each one run to its last line of output.

The playlist, revisited
The Song/Playlist anchor from earlier chapters, grown into a real object with methods you call.
Counting the world
Three ways to tally — the workhorses of every log parser, ballot box, and word cloud.
A tiny bank
Accounts in a dict, behaviour in methods, and an error that refuses to corrupt the ledger.
Group, total, report
The everyday summarizers — bucket things up, add them across, and print a tidy readout.
Machines that remember, calls that call themselves
State that changes with each event, and functions that lean on smaller copies of themselves.
end of chapter 14 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked