PyI course icon

Waking up Python…

Downloading the Python engine (~10 MB, first load only)…
keep reading the course anyway (code boxes stay read-only)
PyIDavid's Python IntermediatePyI · intermediate course · runs 100% in your browser

0.1From scripts to programs 🟢 Browser-safe

You finished Foundations able to write ~100-line programs: variables, loops, lists and dicts, functions, simple classes, files with try/except. This course takes you from there to 200–400-line tested, CLI-driven programs built from the standard library plus pytest. Same language — bigger workshop.

Foundations (you can)Intermediate (you will)
One-file scripts, ~100 linesMulti-file programs, 200–400 lines
Loops that build listsComprehensions, generators, lazy pipelines
Classes with __init__Dataclasses, dunders, properties, custom exceptions
print() debugginglogging, tracebacks, pytest, pdb
Run by pressing a buttonRun from a terminal: venv, argparse, real files
💡 Why structure is the whole game A 100-line script fits in your head all at once. A 400-line program doesn't — nobody's does. Modules, functions with one job, tests that pin behavior: these aren't ceremony, they're how you keep building when the program outgrows your working memory. Every technique in this course pays off the week your code stops fitting on one screen.

The loop stays — plus one new layer

Keep the Foundations habit verbatim: Read → Predict → Run → Tinker → Break → Fix → Build. Intermediate adds a layer between Predict and Run: Trace. Before running, ask "what is x at line N?" and work it out on paper. Solutions in this course show trace tables, not just answers.

✅ Predict before you Run — every box Type a guess as a comment (# I think this prints ...) before pressing Run. Wrong predictions are the fastest lessons in programming; this course is engineered to provoke them.
⚠️ A true infinite loop freezes the tab while True: with no break locks the page — that's the price of real Python in a tab. If it happens, reload: your code is auto-saved. Module 3 teaches lazy (possibly infinite) generators; every one of those boxes is guarded with islice or a counter.

What this course is not

Scope guard, stated once so no lesson smuggles these in: no async mastery, no metaclasses or descriptors, no Django/Flask production, no pandas/numpy track, no ML, no PyPI publishing pipeline (local pip install -e . at most). Suggestions in that direction belong in extensions.

Where you're headed

ModuleQuestion it answers
1 · Pythonic DataHow do I stop writing loops for everything?
2 · Functions as ObjectsWhat can I do once functions are values?
3 · Lazy PythonHow do I stream data bigger than memory?
4 · OOP That PaysWhich object tricks earn their complexity?
5 · Files, Data & StorageHow does data survive the program ending?
6 · Tooling & QualityHow do professionals run Python?
7 · Power ToolsHow do decorators and context managers really work?
8 · CapstonesCan I ship three portfolio programs?

0.2Entry diagnostic — are you ready? 🟢 Browser-safe

Ten small tasks below, covering Foundations Modules 2–6. Rules: no hints, no peeking — do each from memory, then check against the solution. Count a task as passed only if your unaided version works.

  • 7+/10: continue to Module 1.
  • 5–6/10: skim the linked Foundations modules first (table below), then continue.
  • Below 5: redo Foundations Modules 2–6 properly, then come back. Module 1 assumes all of this is fluent.

Exercise 0.1 — Ten tasks from memory

Fix or finish each TODO so the box prints the expected values in the comments. Every task runs as-is (wrongly, or with a crash) — a crash just means "not solved yet".

💡 Hints
  • 1: if score >= 60:else: … (prose uses >=; in code just type it).
  • 2: range(1, 11) stops before 11.
  • 3: colors[0] and colors[-1].
  • 4: counts["cherry"] = counts.get("cherry", 0) + 1.
  • 5: def greet(name="friend"): — then call greet() and greet("Ada").
  • 6: d = json.loads(data).
  • 7: methods need self as the first parameter.
  • 8: b = Basket(), then b.add("apple").
  • 9: try: the conversion, except ValueError: print the fallback.
  • 10: with open("note.txt", "w")write(...), then again with "r" and read().
Show solution

If you scored under 7 — revisit map

TasksTestsRevisit in Foundations
1–2decisions, loopsModule 2 · Control Flow
3–4lists, dictsModule 3 · Data Structures
5–6functions, modulesModule 4 · Functions & Modules
7–8classes, methodsModule 5 · Objects & Classes
9–10errors, filesModule 6 · Files & Errors

0.3Your two machines — browser and terminal 🔴 Terminal-required

This course runs on two machines: the browser tab you're in now, and the terminal on your own computer. Rule of thumb for the whole course: browser for concepts, terminal (or the desktop edition) for real files, CLIs, venv, and pytest. Every lesson wears one badge so you always know where you are:

BadgeMeaningYou…
🟢 Browser-safeRuns identically in this tab (Pyodide)press Run here
🟡 Browser-adaptedSame concept, shimmed I/O (in-memory files)run here, read the banner, redo on your PC with real files
🔴 Terminal-requiredNeeds a real CLI: venv, multi-file, pytestdo it on your PC — the browser box is read-only
🍪 Analogy — the library Your computer's file system is a library: shelves of files, a catalog, opening hours. The terminal is walking up to the librarian's desk and asking directly. This browser tab is a borrowed desk — same books, same reading skills, but you can't rearrange the shelves and everything is reshelved when you leave. This lesson is your first visit to the desk.

Step 1 — check your Python

Open a terminal (Terminal on macOS/Linux/ChromeOS, PowerShell on Windows) and run:

⚠️ python vs python3 On macOS/Linux (and some Windows setups) bare python means Python 2 or nothing at all. Always type python3 — and if even that fails, install Python 3.9+ from python.org, then come back. The desktop edition of this course must be launched the same way: python3 py-intermediate.py.

Step 2 — one project, one venv

A venv is a private Python playground per project: its own packages, no interference between projects. Make one where you'll keep course work:

OSCreateActivate
macOS / Linux / ChromeOSpython3 -m venv .venvsource .venv/bin/activate
Windows (PowerShell)py -m venv .venv.venv\Scripts\Activate.ps1
🖥️ Open in terminal — do this now
  1. mkdir pywork && cd pywork — one folder for all course work.
  2. python3 -m venv .venv — create the playground (once per folder).
  3. Activate it (table above) — your prompt gains a (.venv) prefix.
  4. python -m pip install pytest — expected output ends with Successfully installed pytest-....
Stuck? Re-read the error last line first (next lesson) — or ask with the full transcript pasted.
⚠️ Never sudo pip, never skip the venv sudo pip install ... (or bare pip install outside a venv) sprays packages into your system Python, where the OS depends on exact versions. Inside an active venv, plain pip is safe — the (.venv) prefix is your proof.
✅ The python -m habit python -m pip ... and python -m pytest always use that python's packages — even if you forgot to activate. If a command ever "can't find" an installed package, re-run it as python -m ... first.

Step 3 — how a grown-up program is laid out

From Module 6 on, starters look like this — a folder, not a file. The browser shows them flattened; your PC uses the real tree:

Why the odd if? It means "run this only when the file is executed directly, not when imported for tests". Module 6 makes it second nature.

One more machine worth knowing: the desktop edition (python3 py-intermediate.py) sits between the two — same lessons, but code runs in a real subprocess with real files that persist in ~/.pyintermediate/workspace/. Browser for concepts, desktop/terminal for reality.

0.4Tracebacks — read the last line first 🟢 Browser-safe

You met tracebacks in Foundations; here they become your primary debugging tool. Anatomy of one — schematic, with notes where a real one names files:

Read bottom-up: the last line names the error type and cause; the frames above are the call chain that got there, outermost first. Then fix, rerun, repeat.

🐞 Crash lab 1 — the typo (NameError) Run this, then read the last line first: NameError names the unknown variable, and modern Python even suggests the fix.
Show fix

Last line: NameError: name 'username' is not defined. Did you mean: 'user_name'? — the variable is user_name (with underscore), the printout says username. Fix one character class:

🐞 Crash lab 2 — the crash travels (ValueError through frames) This one works once, then crashes. The last line says ValueError — but the bug (bad input) is two frames up. Read bottom-up to find your line.
Show fix

Frames point at int(f_str) (library-adjacent) ← to_celsius(raw)report("hot"): the bad value entered at the last line. Guard the boundary where outside data comes in:

Module 4 upgrades this to custom exceptions with raise from; Module 6 makes the boundary a CLI that never crashes ugly.

✅ When the traceback points inside a library, your bug is the last frame in YOUR file int("hot") "fails" inside int — but int is fine; you handed it "hot". Scroll up past library frames to the deepest line of your own code. That's the fix site, almost every time.

Exercise 0.2 — Fix it from the traceback

The box below crashes. Its traceback is printed underneath (as it appears when run). Find the fix site, fix it, rerun clean. Expected final output: average of passing: 75.0.

💡 Hints
  • Last line first: list + int is illegal — line 5 tries passing + s, where s is one number.
  • The list method that adds one item is append.
  • After the fix, check the math by hand: passing scores are 80, 90, 55 — average 75.0.
Show solution
🚀 Extensions (optional) +robustness: what if no score passes (empty passingZeroDivisionError)? Return 0.0 or a message instead. +feature: also return the count of passing scores. +ship: Module 6 turns this into a CLI flag (--cutoff 50 scores.csv) — keep the function pure so the CLI stays thin.

Module 0 done. You know what "intermediate" means, where you stand, where code runs, and how to read a crash. Module 1 starts the real upgrade: replacing loops with readable idioms.

1.1Comprehensions — loops in one line 🟢 Browser-safe

Module 1 replaces hand-written loops with readable idioms. First and most-used: the comprehension — a loop that builds a list, set, or dict in a single expression. Trace this one before running:

The shape is always [expression for item in source] — read it as "give me expression for every item". Same pattern builds the other containers, with an optional filter:

Nested — and when NOT to

Two loops nest left-to-right (same order as writing them out). Use it for genuinely flat transformations — and reach for a plain loop the moment a reader would squint:

💡 Why comps win Data work is 90% "shape this pile into that pile". Comprehensions say the shape in one glanceable line — no starter list three lines above the loop, no append to hunt for. When the transformation doesn't fit one line, that's the comp telling you to write the loop (or a function). Both are respectable; guessing wrong is a style issue, not a bug.
🐞 Crash lab — never mutate while iterating Deleting inside a loop shifts everything left under your feet — items get skipped silently. Predict the output, run, count the survivors.
Show fix

Output is [1, 2, 3, 4, 5] — a 2 and a 4 survived. After removing the first 2, the second 2 slid into the checked slot and was never examined. Build a new list instead of mutating:

1.2enumerate, zip, sorted — loops that count and pair 🟢 Browser-safe

Need an index? Don't count by hand — enumerate counts for you. Need two lists side by side? zip pairs them. Need order? sorted with a key beats every hand-rolled sort:

✅ sorted returns new; .sort rearranges sorted(data) hands you a fresh list, data unchanged. data.sort() shuffles data in place and returns None. Default to sorted unless you deliberately want mutation — one fewer surprise.
⚠️ zip stops at the SHORTER list — silently Guard it: assert len(names) == len(scores) before zipping, or (Python 3.10+) zip(names, scores, strict=True), which raises instead of dropping. Silent data loss is the worst kind of bug — make length mismatches loud.

1.3Unpacking — *args and **kwargs 🟢 Browser-safe

Assignment can split a collection apart in one move. The star * soaks up "everything else" — in assignment, in calls, and in function signatures:

✅ Read signatures with stars in them def f(a, b, *rest): two required, then any extras as a tuple. def f(**kw): any keywords as a dict. def f(*, flag): everything after a bare * MUST be passed by keyword. You'll read these in every library; Module 6's CLIs write them.

1.4Counter, defaultdict, namedtuple 🟢 Browser-safe

Three collections tools that delete whole categories of loop. You tasted Counter in Module 0 — here's the trio doing real grouping and record-keeping:

🐞 Crash lab — the missing key (KeyError) Counting by hand crashes on the first new word — the lookup runs before the key exists. Read the last line, then fix it two ways.
Show fixes

Last line: KeyError: 'a'. Fix 1 — .get with a default. Fix 2 — stop hand-rolling and use the lesson's tools:

⚠️ Dict comprehensions silently eat duplicate keys {w: len(w) for w in ["a", "bb", "a"]} keeps only the LAST "a" — no error, data just gone. When keys might repeat, that's a Counter (count them) or a defaultdict(list) (keep them all), never a plain dict comp.
✅ namedtuple now, dataclass later namedtuple is the 30-second record: immutable, tuple-compatible, named fields. When you need defaults, validation, or methods, Module 4's @dataclass is the upgrade path — same idea, grown up.

1.5Idioms — join, truthiness, any/all 🟢 Browser-safe

The small habits that mark intermediate code: build strings with join, lean on truthiness, ask any/all instead of flag variables:

✅ Falsy in one line None, False, 0, "", [], {} are all falsy — everything else is truthy. So if items: means "non-empty", and value or default means "default when missing-ish". Careful: 0 or 5 is 5 — if zero is a valid value, test is None explicitly.

1.6Checkpoint — quiz and exercises 🟢 Browser-safe

Five questions, then three builds. The quiz wants 4/5; the exercises want working code.

Exercise 1.1 — Loop to comp (paraphrase)

Rewrite the loop as a single comprehension. Expected: [0, 6, 12, 18].

💡 Hints
  • Shape: [expression for item in source if condition] — three slots, in that order.
  • Expression triples, condition keeps evens.
Show solution

🚀 Extensions (optional): +feature: also collect the odds tripled into a second comp. +robustness: make it a function triple_evens(nums) that returns [] for an empty input. +ship: time both versions with time.perf_counter on 1M numbers.

Exercise 1.2 — The mystery surcharge (debug)

This till reader crashes on real data. Run it, read the last line, fix the lookup so unknown fruit costs 0. Expected total: 8.

💡 Hints
  • Last line first: KeyError: 'plum' — the comp meets a fruit with no price.
  • Lesson 1.4's fix: .get with a default of 0.
Show solution

🚀 Extensions (optional): +feature: also print a Counter of the basket. +robustness: collect the unknown fruits into a list and print "unpriced: ..." instead of silently charging 0. +ship: read the basket from input() split on commas.

Exercise 1.3 — Top-words reporter (build)

Finish the reporter: normalize the text, count, show the top n. ~25 lines when done.

💡 Hints
  • TEXT.lower(), then loop the punctuation chars replacing each with "" — or chain .replace(".", "") calls.
  • Counter(words).most_common(2) gives [("apple", 4), ("banana", 2)] — unpack in a loop to print pretty lines.
Show solution

🚀 Extensions (optional): +feature: take n from the user (guard the int() like Module 0). +robustness: skip words shorter than 3 letters. +ship: this exact core returns inside Capstone B — keep it pure (no input inside the counting).

2.1First-class functions — plus lambda discipline 🟢 Browser-safe

First-class means functions are ordinary values: store them in dicts, pass them as arguments, return them from functions. The moment you stop seeing def as special syntax and start seeing functions as things you can hand around, a shelf of techniques opens:

And the tiny anonymous sibling — lambda makes a one-expression function with no name. Discipline matters more than syntax here:

💡 Why first-class pays rent Dispatch tables kill sprawling if/elif chains (commands, menu handlers, file-format readers). Passing key= functions customizes sorted/min/max without rewriting them. Returning functions builds factories (next lesson). Every decorator and callback in Modules 6–8 is just this idea, dressed up.
✅ The one-line lambda rule If the lambda fits on one line and you'd never reuse or test it, keep it. Longer, conditional (lambda x: a if c else b), or needed twice → def. Linters flag assigned lambdas (f = lambda: ...) — that's just a def with extra steps.

2.2Closures — functions that remember 🟢 Browser-safe

A closure is a function that remembers variables from where it was born — even after that scope finished. State without a class, without globals:

🐞 Crash lab 1 — late binding: every button remembers the SAME variable The classic. Three lambdas close over i — the variable, not its value. By call time the loop is done. Predict, run, watch [0, 1, 2] not appear.
Show fix

Output is [2, 2, 2] — all three read i after the loop left it at 2. Freeze each value with a default argument (defaults evaluate at def time):

🐞 Crash lab 2 — the shared mutable default Default arguments evaluate once, when the def runs — not per call. A list default becomes one list shared by every call. The first call looks fine; the second reveals the haunting.
Show fix

Sentinel idiom: default to None, build fresh inside. (Immutable defaults — numbers, strings, tuples — are always safe.)

2.3map/filter, itertools, functools 🟢 Browser-safe

map/filter apply a function to a pile — but after Module 1 you already own the clearer spelling. Rule: existing named function and no lambda needed → map is fine; otherwise the comp reads better:

itertools is the standard pile-plumbing kit. Three members cover 90% of uses — islice (take some, lazily), chain (one stream from many), groupby (runs of equal keys):

⚠️ groupby only groups CONSECUTIVE equals Feed it unsorted data and one grade splinters into several groups — no error, just wrong groups. Always sorted(data, key=key) first with the same key (as above). If the input can't be sorted, accumulate into a defaultdict(list) (Lesson 1.4) instead.

And functools: lru_cache memorizes pure functions, partial freezes some arguments to mint specialists. Note the spelling — calling lru_cache(...) on a function, no @ needed (that syntax is Module 7; this is the same machinery):

✅ lru_cache demands hashable args (and purity) Cached functions must be pure (same args → same result, no side effects) with hashable arguments — pass a list or dict and it raises TypeError: unhashable type. Tuples instead of lists at cached boundaries; keep I/O out.

2.4Checkpoint — quiz and exercises 🟢 Browser-safe

Five questions, then three builds. Quiz wants 4/5.

Exercise 2.1 — Chain to dict (paraphrase)

Replace the if/elif chain with a dispatch dict (Lesson 2.1). Behavior must stay identical.

💡 Hints
  • Define add/mul/pw helpers (or reuse operators) and map names to them: ops = {"add": ..., ...}.
  • Then run is one line: return ops[op](a, b). Unknown op? KeyError — honest, like Lesson 1.4.
Show solution

🚀 Extensions (optional): +feature: add "sub". +robustness: raise a friendly ValueError("unknown op: ...") for bad names (Module 4 polishes this). +ship: read op a b from input().split().

Exercise 2.2 — Three handlers, one bug (debug)

Should print handler 0, handler 1, handler 2 — prints one name thrice. Late binding (Lesson 2.2); freeze it.

💡 Hints
  • Same trap as Crash lab 1: all three read i after the loop.
  • Freeze with a default: lambda i=i: ....
Show solution

🚀 Extensions (optional): +feature: store handlers in a dict keyed by name. +robustness: write a make_handler(i) factory (closure, no defaults trick). +ship: dispatch table of real commands (see 2.1).

Exercise 2.3 — Ranked leaderboard (build)

Finish the ranker: sort by score descending, medal the top 3, print a table. ~20 lines when done.

💡 Hints
  • key=lambda p: p[1], reverse=True — Python's sort is stable, so the 95-tie keeps input order.
  • for rank, (name, score) in enumerate(ordered, start=1): unpacks two levels at once.
Show solution

🚀 Extensions (optional): +feature: medal emojis for ranks 1–3. +robustness: handle the empty-players case. +ship: Capstone B ranks reporters this way — keep the sort key a named function.

3.1The iterator protocol — iter, next, done 🟢 Browser-safe

Every for loop you've ever written is sugar for three steps: iter() gets an iterator, next() pulls values, StopIteration ends it. Two roles: an iterable can make fresh iterators (lists, strings, files); an iterator is single-use and stateful:

The protocol in ten lines — __iter__ returns an iterator, __next__ returns values until it raises StopIteration (Module 4 explains dunders fully; here just watch the machinery):

💡 Why laziness matters A list of 10 million rows eats ~800MB; an iterator over the same rows eats bytes — values are made one at a time, used, forgotten. Logs, CSVs, network streams: real data doesn't fit in memory, it flows. This module is how you process flows without drowning.
🐞 Crash lab 1 — iterators are single-use Consume an iterator and it's gone — the second pass isn't an error, it's silently empty. The quietest bug family in this module.
Show fix

Keep the re-usable iterable around and call iter() per pass — or materialize one list if it fits memory:

3.2yield — ever-pausing functions 🟢 Browser-safe

A function with yield doesn't run when called — it returns a generator, paused at the top. Each next() resumes after the last yield and pauses at the next one. Nothing is computed until asked:

Pause-forever means infinite sequences are fine — as long as the consumer takes only what it needs. itertools.islice (Lesson 2.3) is the guard rail. This box is safe because of that last line:

⚠️ Never list() an infinite (or giant) generator list(evens()) hangs forever building a list that never ends — same frozen tab as Module 0's while True, same cure (reload; code is saved). Rule: infinite sources only ever feed islice, counters, or break-guarded loops. True infinites appear in this course only behind such guards.

One generator delegating to another uses yield from — "yield everything this produces", including cleaning up after it:

🐞 Crash lab 2 — reusing a spent generator Same disease as Crash lab 1, wearing generator clothes: the function call makes a fresh generator, but the variable holds a spent one.
Show fix

Call the function again for a fresh generator — nums() is the factory, g was one product:

3.3Genexps and lazy pipelines 🟢 Browser-safe

Parentheses instead of brackets make a comprehension lazy — a genexp that yields values on demand and never builds the list. Same syntax, ~100x less memory:

The payoff: chain small generators into a pipeline — each stage transforms the stream, nothing lands in memory whole. Here with an in-memory stand-in for a file (Module 5 brings real ones):

Bridge to Module 7: generators with cleanup run their finally when close()d — even if the consumer quits early. contextlib.closing pairs them with with:

✅ Pipeline habits One job per stage, stages composed in the final loop. Debugging? Tap the stream mid-pipe with islice(stage, 3) to peek without consuming the world. Naming a stage well (errors, not filter2) is half the readability.

3.4Checkpoint — quiz and exercises 🟢 Browser-safe

Five questions, then three builds. Quiz wants 4/5.

Exercise 3.1 — Eager to lazy (paraphrase)

Same answer, constant memory: convert the list-building version to a genexp. Both print 332833500.

💡 Hints
  • sum(...) takes any iterable — brackets to parens, drop the list.
  • Verify with sys.getsizeof if curious (Lesson 3.3).
Show solution

🚀 Extensions (optional): +feature: parameterize the limit. +robustness: prove laziness with a generator that prints when resumed (Lesson 3.2). +ship: sum a 10M range both ways and compare peak memory qualitatively.

Exercise 3.2 — Min after max (debug)

Should print max 9 then min 1 — crashes instead. One variable, used twice, single-use: fix it two ways.

💡 Hints
  • max consumed the iterator — Crash labs 1–2, same disease.
  • Fix A: keep the reusable [5, 1, 9, 3] list, iterate twice. Fix B: data = list(...) once, reuse the list.
Show solution

🚀 Extensions (optional): +feature: also print the mean. +robustness: handle the empty-input case (both raise ValueError — catch it or default it). +ship: min/max in ONE pass with a loop (two passes waste streams you can't rewind).

Exercise 3.3 — Batched chunks (build)

Finish batches(): yield successive lists of size n from any iterable. Last batch may be short. ~15 lines when done.

💡 Hints
  • batch = list(islice(it, n)) — then if not batch: return, else yield batch, in a while True.
  • return inside a generator just stops it (no value — that part is Module 7-adjacent, ignore for now).
Show solution

🚀 Extensions (optional): +feature: pad the last batch with None instead of shortening. +robustness: reject n < 1 loudly. +ship: Capstone B batches DB inserts this way — keep it generic over any iterable.

4.1@dataclass — records without boilerplate 🟢 Browser-safe

Most classes are records: named fields, equality by value, readable repr. Hand-writing __init__ + __repr__ + __eq__ for every record is toil — @dataclass generates it from the field list:

Defaults are fine — except mutables, which need a factory. Plus frozen=True for immutable records:

💡 Why records rule real codebases Config entries, API responses, table rows, test fixtures — programs pass shaped data across every boundary. Dataclasses make the shape explicit, comparable, and printable for free. When a record later needs validation or behavior, it grows methods in place (Lessons 4.4–4.5) instead of being rewritten.
⚠️ Mutable defaults are REJECTED (loudly, this time) items: list = [] inside a dataclass raises ValueError: mutable default ... use default_factory at class-creation time — the same shared-list haunting as Lesson 2.2's crash lab, but caught immediately instead of in production. field(default_factory=list) (or dict, set) is the only spelling.

4.2Dunders that pay — str, repr, eq, len 🟢 Browser-safe

Python has ~100 dunders; four earn their keep weekly. __str__ is for humans (print), __repr__ for developers (debuggers, containers, logs) — ideally an unambiguous echo of construction:

__len__ unlocks len() (and truthiness: empty means falsy). __eq__ compares by data — but it quietly disables hashing:

🐞 Crash lab — __eq__ without __hash__ Defining __eq__ sets __hash__ to None — Python assumes mutable-by-value objects shouldn't hash. Sets and dict keys then explode. Predict which line dies.
Show fix

Last line: TypeError: unhashable type. If equal objects are truly interchangeable, hash from the same fields __eq__ compares:

Rule: __eq__ without __hash__ means "unhashable, keep out of sets". Fine for mutable entities — fatal surprise for value objects. (Dataclasses: eq=True, frozen=True generates both.)

4.3Composition over inheritance 🟢 Browser-safe

Two ways to reuse behavior. Inheritance (IS-A): a subclass reuses and overrides. Composition (HAS-A): an object holds helpers and delegates. Inheritance is taught first and reached for most — composition wins more often. The tell: new behavior as a new subclass per combination (DiscountedCart, TaxedCart, DiscountedTaxedCart…) versus a policy plugged into one class:

🍪 Analogy — the assembly line Inheritance breeds a new car model for every option package. Composition bolts stations onto one chassis: engine station, paint station, discount station. New requirement? Add or swap a station (a held object, a policy function) — the chassis never forks. Capstones 7–8 are built this way: thin shells around swappable cores.

Inheritance earns its place for shared interfaces with substitution: an Admin IS-A User wherever login code expects a User, honoring the same method contracts. Foundations' Student(Person) qualifies — a student can stand in for a person. A cart with discount options doesn't: that's configuration wearing a subclass costume. Heuristic: reach for composition first; inherit only when callers should treat the child as the parent.

4.4@property — validated attributes 🟢 Browser-safe

Plain attributes accept anything (score.points = -40 — sure, why not). @property keeps the obj.attr syntax but routes access through methods — validation at the boundary, with storage in a private backing field:

🐞 Crash lab — the setter that calls itself Inside the setter, self.points = ... re-invokes the setter — assignment to a property is a setter call, forever. Same for the getter reading self.points.
Show fix

Last line: RecursionError. Property methods must touch the backing field (_x), never the property name:

4.5Custom exceptions — raise from 🟢 Browser-safe

Domain errors deserve domain types: callers catch the meaning (OverdraftError), not ValueError soup they can't distinguish from bugs. And raise ... from ... chains the cause, so tracebacks tell the full story:

⚠️ Bare except buries bugs — catch what you expect except Exception: (and its worse sibling, bare except:) catches typos, logic errors, and typos-in-error-handling alongside the error you meant. Catch the specific exceptions a block can honestly produce (ValueError at input boundaries); let everything else crash loudly where you'll see it. Module 6's logging gives the third option: record-and-continue, deliberately.
🐞 Crash lab — the except that lies A blanket except around a typo returns a confident wrong answer — no traceback, no clue, just 0 where 30 belongs.
Show fix

No exception is expected here, so no try belongs here at all — delete the net and let typos crash in daylight:

4.6Checkpoint — quiz, lab, exercises 🟢 Browser-safe

Five questions, one refactor lab, two exercises. Quiz wants 4/5; the lab is the real exam.

Lab 4 — Refactor the Bank (build)

Foundations' bank account, grown up: a dataclass with validated balance, domain errors, and a move log. Build order:

  1. OverdraftError(Exception) — one line, domain type.
  2. @dataclass Account with owner: str and log via default_factory; __post_init__ opens the log.
  3. balance property + setter validating 0..1000000 on a _balance field.
  4. deposit/withdraw methods that log ("in"/"out", amount); withdraw raises before logging.
  5. __str__ as "ada: 75 (2 moves)".
💡 Hints
  • Property + dataclass mix freely: the property is a class attribute, not a field (no annotation → not a field).
  • Withdraw order: check funds (raise) → set balance → log. Log only completed moves.
  • moves = len(self.log) - 1 (the "open" entry isn't a move).
Show solution

🚀 Extensions (optional): +feature: a transfer(other, amount) method (withdraw + deposit, one log each). +robustness: reject negative deposits loudly. +ship: this account returns in Capstone A — keep I/O out of it.

Exercise 4.1 — Dicts to dataclass (paraphrase)

Player records as dicts work until something misspells a key. Convert to a dataclass; behavior identical, typos impossible.

💡 Hints
  • @dataclass class Player: name: str; score: int = 0 — then attribute access replaces every ["..."].
  • A misspelled attribute now crashes (good!) instead of silently adding a key.
Show solution

🚀 Extensions (optional): +feature: add a level computed from score. +robustness: validate non-negative score via property. +ship: sort a roster with Lesson 2.1's key functions.

Exercise 4.2 — Unhashable roster (debug)

Should print 2 unique — dies on the set instead. One dunder missing; add it.

💡 Hints
  • Last line first: TypeError: unhashable type: 'Player'__eq__ nulled the hash.
  • def __hash__(self): return hash(self.name).
Show solution

🚀 Extensions (optional): +feature: add __repr__ so the set prints readably. +robustness: what if two players share a name but differ elsewhere — is name-hash still honest? +ship: rewrite as a frozen dataclass (both dunders free).

5.1pathlib — paths as objects 🟡 Browser-adapted

🟡 Browser-adapted: identical code both places — but this tab's files live in memory and vanish on close, while your PC keeps them in ~/.pyintermediate/workspace/ (desktop) or your project folder (terminal).

Stop gluing strings with + "/" +. A Path joins with /, knows its own name/suffix, and reads/writes in one call. This module's arc: a garden journal stored as date|text lines, upgraded lesson by lesson to JSON, then SQLite:

🐞 Crash lab — CWD is not the script's folder open("day1.txt") looks in the current working directory (where you ran), not beside your script. Different launch folders, different crashes — the classic "works on my machine".
Show fix

Last line: FileNotFoundError. Foundations habit — expect the miss, and anchor to a known directory instead of vibes:

5.2csv — commas are trickier than they look 🟡 Browser-adapted

🟡 Browser-adapted: same code, same output — on your PC the file persists for the next lesson; here, rerun the writer if you jumped in mid-module.

"rain, no watering".split(",") gives three fields from two — quoted commas, embedded newlines, and quotes defeat hand-splitting. The csv module handles all of it, including our pipe-delimited journal via delimiter:

⚠️ newline="" is load-bearing (and invisible until Windows) Without newline="", Python translates line endings on write and the csv reader sees phantom blank rows — on Windows first, everywhere eventually. Both open() calls above carry it; make it muscle memory for every CSV you touch. Same for encoding="utf-8" once names carry accents.

5.3json — the lingua franca 🟡 Browser-adapted

🟡 Browser-adapted: same JSON everywhere — but fetching it differs: this tab uses browser-native pyfetch, your PC uses requests. The parsing below is identical.

JSON is how programs trade data: dicts/lists ↔ text. dumps/loads work with strings, dump/load with files. Our journal, upgraded from pipes to structure:

5.4sqlite3 — a database in one file 🟡 Browser-adapted

🟡 Browser-adapted: :memory: databases behave identically everywhere. For persistence, swap in a filename — on your PC it becomes a real .db file; here, download it via the snippet in the prose.

SQLite is a full SQL database in a library — no server, one file, zero setup. Same journal, third upgrade: queryable rows instead of scanned text. Two non-negotiables: ? placeholders (never f-strings — injection and quoting bugs) and commit():

Persistence note: replace ":memory:" with "journal.db" and the database outlives the session — on desktop it lands in ~/.pyintermediate/workspace/; in this tab, files vanish on close, so treat the browser as the rehearsal and your PC as the archive.

5.5Cleaning dirty data — regex and dates 🟢 Browser-safe

Real data arrives filthy: ragged spacing, N/A where dates belong, quotes inside quotes. Two tools carry the cleaning shift — re for shape, datetime for time. The essentials, no more: search (anywhere), match (start only), findall (every hit):

🐞 Crash lab — greedy .* eats the middle .* matches as much as possible: first quote to LAST quote, swallowing the middle. Not a crash — silently wrong results, the regex rite of passage.
Show fix

Output: ['hi" then "bye'] — one giant match. Fix with lazy .*? (as little as possible), or better, a negated class that can't cross quotes:

Dates: strptime parses, isoformat stores — and naive datetimes (no zone) refuse to compare with aware ones. Parse, then stamp a zone immediately:

Show fix

Last line: TypeError on comparison. Rule: parse → zone immediately → store ISO. Assume UTC unless a zone is given:

⚠️ Validate at ingestion, not at analysis Bad dates and greedy captures compound downstream: one N/A date poisons every sort, average, and chart after it. The cleaning pass (parse → validate → normalize-or-reject) belongs at the door — every loader in Lessons 5.2–5.4 should reject or quarantine bad rows before they enter. Capstone B grades this.

5.6Checkpoint — quiz and exercises 🟢 Browser-safe

Five questions, then three builds. Quiz wants 4/5.

Exercise 5.1 — split breaks, csv doesn't (paraphrase)

The hand-split drops a field on quoted commas. Replace it with csv.reader; all three fields must survive.

💡 Hints
  • list(csv.reader(io.StringIO(line)))[0] — one row, three fields.
  • io.StringIO turns a string into a file-like object (Lesson 3.3's trick).
Show solution

🚀 Extensions (optional): +feature: parse three such lines with DictReader and a header. +robustness: handle a ragged line (2 fields) by reporting it, not crashing. +ship: this is Capstone B's front door — keep the reader separate from the analysis.

Exercise 5.2 — load vs loads (debug)

Should print {'a': 1} — raises AttributeError instead. One letter fixes it.

💡 Hints
  • Last line: AttributeError: 'str' object has no attribute 'read'load tried to read a file.
  • Rule of thumb: loads ← string in memory, load ← open file handle.
Show solution

🚀 Extensions (optional): +feature: pretty-print with indent=2. +robustness: catch json.JSONDecodeError on bad input (it's a ValueError subclass — Lesson 4.5). +ship: read the JSON from a real file with json.load(open(...)) — better, with open.

Exercise 5.3 — Journal loader (build)

Finish the loader: date|text lines in, validated entry dicts out, bad lines reported (not crashed on). ~25 lines when done.

💡 Hints
  • line.split("|", 1) — maxsplit 1, so pipes inside text survive.
  • try: datetime.strptime(...) except ValueError: print("SKIP:", line); continue — validate at ingestion (Lesson 5.5).
  • Store dt.date().isoformat() — canonical, sortable, SQLite-friendly.
Show solution

🚀 Extensions (optional): +feature: also reject empty text. +robustness: collect skips into a list and report a summary count. +ship: feed these entries straight into Lesson 5.4's INSERT — that pipeline IS Capstone B's core.

6.1Project setup — layout, venv, pip 🟡 Browser-adapted

🟡 Browser-adapted: concepts + transcripts here; the one runnable box uses the browser's micropip (desktop shows it as reference). Real venv/pip happen in your terminal, inside the pywork folder from Lesson 0.3.

Professional layout, full tour — one folder per program, tests beside code, dependencies written down:

In this tab there is no pip — but Pyodide ships micropip, real packages from real PyPI (internet needed). Same packages your terminal gets:

✅ Pin it or lose it pip freeze > requirements.txt after every install; pip install -r requirements.txt resurrects the environment anywhere. "Latest" today is broken tomorrow — exact versions are how six-months-later-you (and your teammates) reproduce a working setup.

6.2argparse — programs with a command line 🟢 Browser-safe

Buttons are for browsers; real tools take arguments. argparse turns a function call into a CLI with --help, types, choices, and defaults — free. The browser has no command line, so lessons use an ARGS stand-in (desktop/terminal call parse_args() bare for the real thing):

Types convert, choices reject, and rejections raise SystemExit (not a normal exception — it carries the process exit code):

6.3logging — prints with levels 🟢 Browser-safe

print debugging litters shipped code with chatter you can't switch off. logging gives every message a level — one dial (basicConfig(level=...)) decides what shows, per run, without touching code:

🐞 Crash lab — the silent logger No config, no output — and no error either. Default level is WARNING: your INFO dies quietly. ("But my warning printed?" — yes: WARNING+ squeaks through a last-resort handler. INFO just dies.)
Show fix

Only end of program printed. One line configures the dial — then the message flows:

6.4pytest — tests that guard your back 🔴 Terminal-required

🔴 Terminal-required: pytest runs on your PC (python -m pytest in the venv). This lesson's runnable boxes are the code under test (plain asserts run anywhere); the pytest runs themselves are transcripts — a passing run is never faked in-browser.

The whole framework in one habit: write the expectation as a bare assert in a test_*.py file, run, read failures. Start with the code — assertions hold here exactly as they hold under pytest:

Same assertions, dressed as a test file, run for real:

Read failures like tracebacks: the > line is the expectation, E lines are the evidence (1000000.0 == 1000000 — a float/int strictness lesson for free). Now the test-writing trap:

🐞 Crash lab — the order-dependent test (flaky) This test passes in the full suite and fails alone — it reads a file another test wrote. Shared paths are a CWD lottery: parallel runs, reordered runs, and colleagues' machines all roll differently.
Show fix

tmp_pathpytest hands every test a fresh, private temp directory. Hermetic: order-proof, CWD-proof, parallel-safe:

⚠️ A stray breakpoint() ships a freeze breakpoint() drops into pdb waiting on a human — in CI the run hangs until timeout; for users, the program just stops. It never belongs in committed code: write the test instead of the breakpoint, and sweep before pushing: pdb commands for the terminal when you do stop deliberately: next line, print a variable, continue, quit. But default to tests — breakpoints don't regress.

6.5Checkpoint — quiz and exercises 🟢 Browser-safe

Five questions, then three builds. Quiz wants 4/5.

Exercise 6.1 — Prints to levels (paraphrase)

Same output lines, grown-up plumbing: route each print through the matching level. Keep the format from Lesson 6.3.

💡 Hints
  • log = logging.getLogger("ex61"), then log.info/debug/warning(...) per line.
  • "trace detail" must VANISH (debug < INFO) — that's the test.
Show solution

🚀 Extensions (optional): +feature: add --verbose (argparse, Lesson 6.2) switching INFO to DEBUG. +robustness: log to a file too (filename="run.log"). +ship: every Capstone logs instead of printing — convert one now.

Exercise 6.2 — Read the failure (debug)

pytest says no. The transcript below is real output against the box's code — find the +1 that shouldn't be there.

💡 Hints
  • E assert 4.0 == 3: the function returns 4.0, the test wants 3.
  • sum([2, 4]) / len([2, 4]) is already 3 — what does the + 1 do?
Show solution

🚀 Extensions (optional): +feature: rename to average and guard empty input. +robustness: add the empty-raises test from Lesson 6.4. +ship: run it under real pytest in your venv — green feels different when it's yours.

Exercise 6.3 — Logged greeting CLI (build)

Finish the CLI: ARGS shim, --upper flag, logging instead of prints. ~20 lines when done.

💡 Hints
  • Lesson 6.2's parser, Lesson 6.3's logger — bolt them together, thin main() optional.
  • args = parser.parse_args(ARGS), then one log.info(...).
Show solution

🚀 Extensions (optional): +feature: add --count (Lesson 6.2). +robustness: use the real ARGS variable, not the literal list. +ship: this is Capstone A's skeleton — save it to main.py in your venv.

7.1Decorators — functions that upgrade functions 🟢 Browser-safe

After Modules 2 (first-class functions) and 3 (pausing functions), the power tools unlock. A decorator is Lesson 2.1 grown up: a function that takes a function and returns an enhanced one. The @ is pure sugar — @timer above a def just means "reassign the name through timer":

Decorators take arguments via a factory (a function returning the decorator), and they stack bottom-up — the closest one runs first:

🐞 Crash lab — the decorator that steals names Without @wraps, every decorated function reports the wrapper's name and loses its docstring — pytest shows wrapper, logs lie, help() goes blank. Not a crash: identity theft.
Show fix

One line: @wraps(func) copies name, docstring, and module onto the wrapper. Non-negotiable in every decorator you write:

✅ Stacking reads inside-out @a @b def f means f = a(b(f)): b wraps first, a sees b's output. Order bugs show as "my timing includes the retry waits" (timer outside retry) vs "each attempt timed separately" (timer inside) — both valid, pick deliberately.

7.2Context managers — setup, use, guaranteed cleanup 🟢 Browser-safe

with open(...) was your first context manager: setup, use, guaranteed cleanup. Custom ones come in two spellings — the class (__enter__/__exit__) and the generator (@contextmanager, pure Module 3):

🐞 Crash lab — __exit__ returning True swallows errors __exit__'s return value is a loaded gun: truthy means suppress the exception. Return True by accident (or "helpfulness") and errors vanish mid-program — the except Exception: pass of Lesson 4.5, wearing a tuxedo.
Show fix

Return False (or None — the default) unless you are deliberately implementing suppression (like contextlib.suppress, which says so in its name):

7.3Combined — timed, retried, logged 🟢 Browser-safe

The graduation exercise: both tools on one job. Small composable pieces — a @retry decorator, a timed context — around a flaky operation (simulated: no network needed). Notice the layers stay independent and testable:

✅ Compose small tools; don't build a mega-decorator One decorator per concern (retry or time or log), stacked deliberately, beats a @retry_time_log hydra nobody can test. Same rule as functions (one job) and pipeline stages (Lesson 3.3) — it keeps recurring because it keeps working. Capstone C leans on exactly this shape.

Exercise 7.1 — Logged retry (build)

Finish the decorator: retry with a log line per failure, giving up loudly after tries. ~15 lines when done.

💡 Hints
  • Lesson 7.1's retry, plus log = logging.getLogger("retry") and one log.warning(...) inside the except.
  • Keep last = err per failure; raise last after the loop (bare raise outside an except is illegal).
Show solution

🚀 Extensions (optional): +feature: back off (sleep attempt seconds between tries). +robustness: retry only chosen exception types (a parameter). +ship: Capstone C wraps its fetch exactly like this — lift it whole.

Modules complete. Three capstones remain: a CLI file tool, a CSV→SQLite reporter, and a tested mini-API — each one a 200–400-line portfolio program built from everything above.

8.1Capstone A — CLI file tool (jstat) 🔴 Terminal-required

🔴 Terminal-required (with 🟢 flattened preview below — the whole tool in one runnable box; the real thing is three files you build on your PC).

Spec. jstat reads a garden journal (date|text lines, Module 5) and reports: entry count, distinct words, top-N words. python main.py journal.txt --top 3 --verbose must exit 0 and log its steps; missing file exits 2 with a clean error, never a traceback.

BehaviorExample
main.py FILEprints entries / distinct / top-1
--top Ntop-N words with counts
--verboseDEBUG logging on
missing filefile not found: ... on stderr, exit 2

Build order.

  1. journal.py — pure load + stats (no I/O inside; testable!).
  2. tests/test_journal.py — three asserts; run python -m pytest after every step.
  3. main.py — thin shell: argparse, logging, file read, print report.
  4. Break it: empty file, missing file, 10k-line file. Fix, extend (below).
💡 Hints
  • main.py mirrors Exercise 6.3: parse, configure logging (verbose → DEBUG), try: Path(file).read_text() except FileNotFoundErrorparser.error(...) (exits 2 cleanly).
  • Empty file: most_common(1) on an empty Counter raises IndexError — guard it (return top "—", count 0).
  • Keep journal.py free of input/print/open — purity is what makes it testable.
Show solution (three files)

🚀 Extensions (optional): +feature: --by-date flag printing entries-per-day (Counter over dates). +robustness: skip malformed lines with a warning (Lesson 5.5). +ship: pip install -e . + a console entry point so jstat runs bare.

🐞 Crash lab — the empty file (IndexError) An empty journal means an empty Counter — and most_common(1)[0] on nothing is an IndexError. Edge cases first, always.
Show fix

Guard the empty case where the data is born — the solution's stats() already does: top, count = words.most_common(1)[0] if words else ("—", 0):

8.2Capstone B — CSV to SQLite reporter 🔴 Terminal-required

🔴 Terminal-required (with 🟢 flattened preview below — the pipeline in one runnable box; the real thing is three files + a real .db on your PC).

Spec. sales.csv (date,product,qty,price) in, revenue report out: per-product revenue ranked, top day overall. Dirty rows (bad numbers, missing fields) are skipped with a warning, never fatal. The DB file persists between runs.

Build order.

  1. report.py — pure clean + totals (dicts in, dicts out).
  2. tests/test_report.py — dirty-row skip, revenue math, empty input.
  3. main.py — argv (in-csv, out-db), logging, load → SQLite → print.
  4. Feed it 10k generated rows; watch it stay flat in memory (generators, Lesson 3.3).
💡 Hints
  • clean raises, the loader catches (validate at ingestion, Lesson 5.5): try/except (ValueError, KeyError)log.warning("skip row %d", n).
  • Revenue SQL: SELECT product, SUM(qty * price) AS revenue ... GROUP BY product ORDER BY revenue DESC; top day: same grouped by date, LIMIT 1.
  • Batch inserts with Lesson 3.3's batches() for the 10k-row stretch goal.
Show solution (three files)

🚀 Extensions (optional): +feature: --by-day full daily table. +robustness: stream the CSV with generators (never list(reader)). +ship: argparse.FileType vs manual open — compare error messages.

🐞 Crash lab — the apostrophe that broke the query F-string SQL dies on real names (O'Brien) — and invites injection. Placeholders never blink.
Show fix

Last line: OperationalError: near "brien": syntax error — the quote ended the string early. ? keeps code and data apart (the solution's loader already does):

8.3Capstone C — tested mini-API 🔴 Terminal-required

🔴 Terminal-required (with 🟢 flattened preview below — the pure routing core runs here; sockets need your PC).

Spec. A JSON API over http.server (stdlib only): GET /players lists, GET /players/NAME fetches, unknown → 404 JSON. The routing core is pure ((method, path) → (status, body)) and pytest-tested; the HTTP shell is thin.

Build order.

  1. api.py — pure route(), no sockets, no globals mutated in surprising ways.
  2. tests/test_api.py — list, fetch, 404s; green before any networking.
  3. server.py — 30-line BaseHTTPRequestHandler shell calling route.
  4. curl every route; add POST as the extension.
💡 Hints
  • path.strip("/").split("/") then match shapes: ["players"] vs ["players", name] — Lesson 1.3 unpacking does the reading.
  • Unknown player and unknown route are DIFFERENT 404 bodies — tests pin both.
  • server.py: json.dumps(body).encode(), Content-Type: application/json, Content-Length — then HTTPServer(("127.0.0.1", 8000)).serve_forever().
Show solution (three files)

🚀 Extensions (optional): +feature: POST /players with a JSON body (parse Content-Length, validate, 201). +robustness: log every request with Lesson 6.3 levels (INFO hits, WARNING 404s). +ship: persist STORE to SQLite (Lesson 5.4) so players survive restarts.

AAppendix A — regex quick reference 🟢 Browser-safe

Everything Lesson 5.5 taught, on one screen. Static reference — nothing to run.

TokenMatchesExample
\d \w \sdigit, word char, whitespace\d{4}-\d{2}-\d{2} a date
.any char (except newline)a.c matches "abc", "a-c"
* + ?0+, 1+, 0-or-1 of the previous\d+ one or more digits
{m,n}between m and n\w{2,8} short words
[...] [^...]any of / any but[^"]* "no quotes" (the pro move)
(...) |capture group / either-or(cat|dog)s?
^ $string start / end^\d+$ "only digits, whole string"
.*?lazy version — as LITTLE as possible"(.*?)" quoted fields

BAppendix B — itertools cheat sheet 🟢 Browser-safe

Lessons 2.3 and 3.3 introduced the big three; the rest of the kit, same lazy deal — everything returns iterators, nothing builds lists. Static reference.

ToolDoesExample → result
islice(it, n)first n itemsislice(count(), 3) → 0, 1, 2
chain(a, b)one stream from manychain("ab", [1]) → a, b, 1
groupby(data, key)runs of equal keys (sort first!)Lesson 2.3's grades
product(a, b)cartesian pairsproduct("AB", "12") → A1 A2 B1 B2
permutations(xs, n)orderingspermutations("ABC", 2) → 6 pairs
combinations(xs, n)selections, order ignoredcombinations("ABC", 2) → 3 pairs
repeat(x, n)x, n timesrepeat(0, 3) → 0, 0, 0
cycle(xs)repeat forever (guard with islice!)islice(cycle("AB"), 5)
count(n, step)infinite counterenumerate with any start/step
accumulate(xs)running totalsaccumulate([1,2,3,4]) → 1, 3, 6, 10
zip_longest(a, b)zip without dropping (fillvalue)the anti-truncation zip

CAppendix C — field guide and where next 🟢 Browser-safe

The errors you'll meet weekly, the commands you'll type daily, and the roads after this course. Static reference.

Error → meaning → fix

Last lineMeansFix (lesson)
NameErrortypo or wrong scoperead the name, check spelling (0.4)
TypeErrorright shape, wrong typeconvert, or guard the boundary (4.5)
KeyError / IndexErrormissing member.get / length check / guard empties (1.4, 8.1)
ValueErrorright type, bad valuevalidate at ingestion (5.5)
AttributeErrorobject lacks that namecheck type, check spelling (5.6: load/loads)
FileNotFoundErrorwrong folder assumedanchor to a known dir (5.1)
RecursionErrorself-calling property/functionbacking field, base case (4.4)
StopIteration (raw)manual next() past endlet for handle it (3.1)
unhashable type__eq__ without __hash__hash what you compare (4.2)

Terminal daily drivers

Where next — three lanes

LaneBuild nextLearn next
Webserve Capstone C properly; forms + templatesHTTP deeply, then one framework (Flask/FastAPI/Django)
Data10k-row reporters; charts from Capstone BSQL deeply, then pandas
Automationscheduled jstat-style tools; backupsargparse mastery, packaging, cron/systemd

Scope, one last time: async mastery, metaclasses/descriptors, production web, pandas/numpy tracks, ML, and PyPI publishing are beyond this course by design — each lane above teaches them in its own good time. What this course promised was 200–400-line tested programs from stdlib + pytest. You have three of those now. Go build the fourth.

🐍 Python Console Enter to run · ↑/↓ history · auto-indent after “:” · empty Enter ends a block · Ctrl+C cancels · Esc closes
>>>