Workshop · Python · Intermediate
PY100 Python I
Python I is the course between "I can make it work" and "I would ship this". Forty-eight steps over eight sessions cover idiomatic Python and its tools, dataclasses and enums, comprehensions and generators, exceptions and context managers, pytest in depth, files, JSON, dates and sqlite3, and packaging — all in your own VS Code seat with the AI tutor beside you and a machine check at the end of every step. The capstone is Stockroom: a tested inventory library with a command-line front end.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick PY100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.
Who it is for
People who can already write a small program in Python or any other language — loops, functions, a file — and want to write Python the way a team expects it: typed, tested, structured into modules, and honest about errors. Developers arriving from Java, C#, Rust or TypeScript start here; session 1 re-teaches the syntax quickly.
Bridge. Coming from JV050, DN050, RS050, NG050: the concepts you already proved there are credited here (8 of them), and the first session re-teaches the syntax.
Where it leads. PY300 Python, advanced: services, concurrency and proof
What you will be able to do
- Lay out a Python project, run it as a module, and keep it clean with type hints and basedpyright
- Model data with dataclasses, enums, Optional and Decimal money, with equality and ordering that behave
- Transform collections with comprehensions, generators, itertools and sorted keys instead of accumulator loops
- Raise and catch exceptions narrowly, define your own, release resources with context managers and log events
- Write pytest suites with fixtures, parametrize and tmp_path, test first, and measure coverage as a floor
- Read and write CSV and JSON, handle dates with time zones, and store rows in sqlite3
- Split code into a package with a __main__, a README and an argparse front end
- Build a tested library plus CLI from a spec, with an exit-code contract, that survives inputs you did not write
Syllabus
Every session ends in a lab the machine checks on your own code. Open a session's mini steps to read each lesson, demo and hands-on task; the badge shows how far the engine can verify it: checked · T3 a check runs over your files, proven · T4 the grader also tries inputs you did not see, tutored · T0 done on your word. Each step has its own link (for example #s1-1).
- 1. Idiomatic Python and the tools
- 2. Data modelling
- 3. Collections and iteration in depth
- 4. Errors and resources
- 5. Testing
- 6. Files, data formats and time
- 7. Modules, packages and docs
- 8. Capstone: Stockroom
Session 1 · Idiomatic Python and the tools
Six steps that make the seat a workshop. A project folder with a package and a tests folder, run as a module with python3 -m. Type hints on every function and basedpyright reading them before the program runs. The idioms that mark Python written by someone who knows it: truthiness, unpacking, enumerate and zip, the main guard. The debugger in VS Code: a breakpoint, the variables pane, step over and step in. Strings in depth: join, split, methods and format specs. Live in the seat: a script that runs but fails basedpyright, a loop rewritten with zip, a bug found in two steps with the debugger, and a messy script tidied into a module.
Lab Tidy a supplied messy script into python/py100/s1/tidy.py: typed functions, a main() under the main guard, idiomatic loops, and zero basedpyright errors, with the same output as before. Session 1 checkpoint.
6 mini steps · ~51 min
1.1A project, not a script8 minchecked · T3
Predict
python3 -m app.report runs which file?
Lesson
A real project is a folder: a package (a folder with __init__.py) for the code, tests/ beside it, and a way to run it with python3 -m package.module from the project root so imports work the same in the program and the tests.
Demo
The tutor lays out python/py100/demo/shop/ with __init__.py, report.py and a tests folder, runs python3 -m shop.report from python/py100/demo, then runs it from inside the folder to show the import error.
Hands-on
Create python/py100/s1/hello_pkg/__init__.py and hello_pkg/greet.py with a main() that prints "hello from a package", guarded by if __name__ == "__main__". Run python3 -m hello_pkg.greet from python/py100/s1.
Explain
Why run with -m instead of the file path?
Check
hello_pkg runs as a module — checked (T3):
seat_fileseat_run1.2Type hints and basedpyright9 minchecked · T3
Predict
def total(xs: list[int]) -> int: return sum(xs) called as total(["1", "2"]). What happens?
Lesson
Type hints say what a function takes and returns. Python ignores them at run time; basedpyright reads them in the editor and in the terminal and finds the mismatch before the program runs. Hint every public function; let the checker work for you.
Demo
The tutor opens python/py100/demo/typed_demo.py with two unhinted functions and a wrong call, runs basedpyright demo/typed_demo.py, adds hints, and watches the squiggle and the terminal error appear and then vanish after the fix.
Hands-on
Write python/py100/s1/typed.py with three hinted functions — mean(xs: list[float]) -> float, label(n: int) -> str, pick(d: dict[str, int], key: str) -> int | None — and a main that prints one call of each. basedpyright s1/typed.py must report 0 errors.
Explain
What does a type checker catch that tests might not?
Check
typed.py is fully hinted and basedpyright-clean — checked (T3):
seat_fileseat_lintseat_run1.3Idioms: truthiness, unpacking, enumerate and zip8 minchecked · T3
Predict
for name, price in zip(["pen", "ink"], [125, 450]): … — what is the second pair?
Lesson
if items: instead of if len(items) > 0; a, b = pair; for i, x in enumerate(xs); for a, b in zip(xs, ys); the main guard. These are not tricks, they are how Python reads to a Python programmer, and the linter expects them.
Demo
The tutor rewrites python/py100/demo/idioms_demo.py line by line: an index loop becomes enumerate, two parallel lists become zip, a len() check becomes truthiness, and a swap with a temp becomes a, b = b, a.
Hands-on
Rewrite python/py100/samples/s1/clunky.py into s1/idioms.py using enumerate, zip, truthiness and tuple unpacking, keeping the output identical: a numbered price list and "Cheapest: pen". No range(len(...)) left.
Explain
Why is range(len(xs)) a smell in Python?
Check
idioms.py uses enumerate and zip, no range(len()) — checked (T3):
seat_fileseat_runcraft1.4The debugger8 minchecked · T3
Predict
You set a breakpoint inside a loop body. How many times does the debugger stop?
Lesson
Click the gutter to set a breakpoint, press F5 with the Python configuration, and the program stops there: the Variables pane shows every value, Step Over runs one line, Step Into enters a call. It beats a dozen prints and leaves no mess behind.
Demo
The tutor opens python/py100/samples/s1/offby.py, which prints a wrong total, sets a breakpoint in the loop, steps three passes watching total, spots the line that resets it, and fixes it without adding a print.
Hands-on
Copy python/py100/samples/s1/offby.py to s1/debugged.py. Using only the debugger, find why the average is wrong and fix it; the output must be "Average: 25.0". Leave a one-line comment at the fixed line saying what the bug was.
Explain
What did the Variables pane show you that the output could not?
Check
debugged.py prints Average: 25.0 with a fix comment — checked (T3):
seat_fileseat_run1.5Strings in depth8 minchecked · T3
Predict
", ".join(["a", "b", "c"]) gives?
Lesson
split and join are inverses; strip, startswith, partition and casefold do the common cleaning; f-string specs handle width, alignment, thousands and precision; a multi-line f-string with triple quotes builds a report block. Strings are immutable, so every method returns a new one.
Demo
The tutor parses a line " SKU-001 ;Notebook; 2.50 " in python/py100/demo/strings_demo.py with split(";") and strip(), rebuilds it with join, then prints a three-column table with one f-string per row.
Hands-on
Write python/py100/s1/parse_line.py with a hinted function parse(line: str) -> tuple[str, str, str] that splits on ";" and strips each part, and a main that parses " SKU-001 ;Notebook; 2.50 " and prints the parts joined by " | ".
Explain
Why does join live on the separator and not the list?
Check
parse_line.py splits, strips and joins — checked (T3):
seat_fileseat_run1.6Lab: tidy the messy script10 minchecked · T3
Predict
Moving top-level code into main() under the main guard changes the output how?
Lesson
The lab is a refactor with a fixed target: the same output, but typed functions, a main() under the guard, idiomatic loops, clear names, and zero basedpyright errors. Refactoring means changing the shape without changing the behaviour, and the check holds you to it.
Demo
The tutor tidies the first third of python/py100/samples/s1/messy.py live — extract a function, add hints, replace an index loop — running it after each move to prove the output is unchanged, and runs basedpyright at the end.
Hands-on
Copy python/py100/samples/s1/messy.py to s1/tidy.py and refactor it: functions load_prices(), cheapest(), and format_table() with hints, a main() under the guard, enumerate/zip where indexes were, no globals mutated. Output must equal samples/s1/messy_expected.txt; basedpyright 0 errors.
Explain
What did you check after each refactoring move?
Check
tidy.py: same output, typed, idiomatic, lint-clean — checked (T3):
seat_fileseat_lintseat_run
Session 2 · Data modelling
Six steps on giving data a shape. dataclasses for records with named, typed fields and a generated __init__, __repr__ and __eq__. Enums for a closed set of values the checker can see. Optional and the discipline of handling None at the edge, once. Decimal money inside a frozen Money value object that refuses to add two currencies. Equality, ordering and hashing: frozen dataclasses in sets and as dict keys, sorting with key functions. Live in the seat: a tuple-based record replaced by a dataclass, an invalid category rejected by the enum, a None that reaches too far, and Money("1.10") + Money("2.20").
Lab Write the Item model: a Category enum, a frozen Money value object and an Item dataclass with validation in __post_init__, plus a test file that proves equality, ordering and the rejections. Session 2 checkpoint.
6 mini steps · ~51 min
2.1dataclasses: records with names8 minchecked · T3
Predict
@dataclass class P: x: int; y: int = 0. What does P(1) == P(1, 0) give?
Lesson
A dataclass turns a list of typed fields into a class with __init__, __repr__ and __eq__ written for you. Fields with defaults come last; field(default_factory=list) for mutable defaults; asdict() to serialise. It replaces the tuple whose positions you kept forgetting.
Demo
The tutor converts a (sku, name, qty) tuple in python/py100/demo/dc_demo.py to a dataclass, prints its repr, compares two equal items, then adds a mutable default wrongly and reads the error dataclasses raise.
Hands-on
Write python/py100/s2/record.py with @dataclass class Item: sku: str; name: str; qty: int = 0; tags: list[str] = field(default_factory=list). In main print Item("A1", "Pen"), then Item("A1", "Pen") == Item("A1", "Pen", 0), then asdict(Item("B2", "Ink", 3)).
Explain
Why default_factory=list and not tags: list[str] = []?
Check
record.py defines Item as a dataclass with a safe default — checked (T3):
seat_fileseat_run2.2Enums: a closed set of values8 minchecked · T3
Predict
class Category(Enum): PAPER = "paper"; Category("pens") does what?
Lesson
An Enum names the only values a field may hold. Category.PAPER has a .name and a .value; Category("paper") parses text and raises ValueError for anything else; the type checker knows every member. Use StrEnum when the value must read as text.
Demo
In python/py100/demo/enum_demo.py the tutor defines Category as a StrEnum, iterates its members, parses "paper" and "pens", and shows basedpyright flagging a comparison against a plain string typo.
Hands-on
Write python/py100/s2/category.py with class Category(StrEnum): PAPER, WRITING, FURNITURE (values lower-case). A hinted parse_category(text: str) -> Category that accepts any case. In main print parse_category("Paper"), list(Category), and catch parse_category("toys") printing "unknown category: toys".
Explain
What does the enum give you that a set of strings does not?
Check
category.py parses and rejects — checked (T3):
seat_fileseat_run2.3Optional: saying maybe in the type8 minchecked · T3
Predict
def find(sku: str) -> Item | None … and then find("Z9").name. What does basedpyright say?
Lesson
Item | None in a signature says "this may be absent" and the checker then insists you handle it: an if x is None early return, or raise, at the edge — once. Deep inside the code, functions take Item, never Item | None. None is not an error code to pass around.
Demo
The tutor writes find() returning Item | None in python/py100/demo/optional_demo.py, uses the result without a check to get the basedpyright error, then handles it at the call site with an early return and a message.
Hands-on
Write python/py100/s2/lookup.py: a dict of three Items by sku, find(sku: str) -> Item | None, and describe(sku: str) -> str that returns "not found: <sku>" for None and "<name> x<qty>" otherwise. Main prints describe("A1") and describe("Z9"). basedpyright 0 errors.
Explain
Why handle None once at the edge instead of checking everywhere?
Check
lookup.py handles the None once and is lint-clean — checked (T3):
seat_fileseat_lintseat_run2.4Money as a value object9 minchecked · T3
Predict
Money is a frozen dataclass with amount: Decimal. What does m.amount = Decimal("1") do?
Lesson
Money is a frozen dataclass holding a Decimal amount and a currency code, quantized to cents on creation, with __add__ that refuses two currencies and a classmethod from_str. A value object: equal when its fields are equal, never changed, always valid.
Demo
The tutor builds Money in python/py100/demo/money_demo.py, adds Money("1.10") + Money("2.20") to get exactly 3.30, tries to add USD to EUR and reads the ValueError, then shows the float version drifting after a thousand additions.
Hands-on
Write python/py100/s2/money.py: @dataclass(frozen=True) class Money with amount: Decimal and currency: str = "USD"; __post_init__ quantizes to 0.01; __add__ raises ValueError on currency mismatch; classmethod from_str(text). Main prints Money.from_str("1.10") + Money.from_str("2.20"), then the sum of Money.from_str("0.10") ten times, then catches USD + EUR printing "currency mismatch".
Explain
Why quantize in __post_init__ rather than when printing?
Check
money.py adds exactly and refuses mixed currencies — checked (T3):
seat_fileseat_runcraft2.5Equality, ordering and hashing8 minchecked · T3
Predict
Two equal frozen dataclass instances are added to a set. len(set) is?
Lesson
__eq__ comes free; __hash__ only when frozen (or eq=False); order=True gives <, >, sorted() by field order. Usually you want sorted(items, key=lambda i: (i.category, i.name)) instead: an explicit key is clearer than a class-wide order.
Demo
In python/py100/demo/order_demo.py the tutor puts two equal frozen Items in a set, tries to hash a non-frozen one, sorts a list by a two-field key, and shows order=True sorting by sku first because it is the first field.
Hands-on
Write python/py100/s2/ordering.py: a frozen Item(sku, name, qty); build four items with one duplicate; print len(set(items)); print the names sorted by (qty descending, name); print the skus of items with qty 0 as a sorted list.
Explain
When would you prefer order=True to a sort key?
Check
ordering.py dedupes, sorts by key and filters — checked (T3):
seat_fileseat_run2.6Lab: the Item model10 minchecked · T3
Predict
Item(qty=-1) should?
Lesson
The model the rest of the course builds on: Category (StrEnum), Money (frozen, Decimal), and Item(sku, name, category, qty, unit_price: Money, reorder_at) validated in __post_init__ (non-empty sku, qty >= 0, reorder_at >= 0) with a total_value() method. Tests prove each rule.
Demo
The tutor sketches python/py100/demo/model_demo.py, writes the failing test for the negative quantity first, then __post_init__, then total_value with Money multiplication, running pytest after each move.
Hands-on
Write python/py100/s2/model.py with Category, Money and Item as specified and s2/test_model.py with at least five tests: equality of equal items, negative qty rejected, empty sku rejected, bad category rejected, total_value of 3 x 2.50 == Money("7.50"). All green, basedpyright clean.
Explain
Why validate in __post_init__ and not in a separate validate() method?
Check
The Item model validates and its five tests pass — checked (T3):
seat_fileseat_lintseat_testcraft
Session 3 · Collections and iteration in depth
Six steps on moving data through pipelines instead of accumulator loops. List, dict and set comprehensions with conditions. Generators and laziness: yield, generator expressions, and why sum(x for x in …) never builds a list. itertools: groupby after sorting, chain, islice, accumulate. Sorting by key, multi-key and descending without reverse tricks, and stability. Counter, defaultdict and dict.setdefault for counting and grouping. Live in the seat: a six-line loop collapsed into one comprehension, a generator over a million numbers using no memory, groupby without the sort giving the wrong groups, and a top-three report.
Lab Write the report pipeline: functions that group items by category, total each group's value, and return the top-n groups, written as comprehensions and generators with tests over a supplied data set. Session 3 checkpoint.
6 mini steps · ~51 min
3.1Comprehensions8 minchecked · T3
Predict
[x * 2 for x in range(5) if x % 2 == 0] gives?
Lesson
[expr for x in xs if cond] builds a list in one readable line; {k: v for …} a dict; {x for …} a set. Use a comprehension when the body is one expression; when it needs two statements, write the loop. Never comprehend for side effects.
Demo
The tutor collapses three loops in python/py100/demo/comp_demo.py — a filtered list, a name-to-price dict, a set of categories — into comprehensions, then shows one that got too clever and splits it back out.
Hands-on
Write python/py100/s3/comp.py over a list of (name, category, cents) tuples: a list of names with cents > 300, a dict name -> cents, a set of categories. Print each (sorted where unordered). Three comprehensions, no for statements.
Explain
When should a comprehension become a loop again?
Check
comp.py uses three comprehensions and no for statements — checked (T3):
seat_fileseat_runcraft3.2Generators and laziness9 minchecked · T3
Predict
def nums(): yield 1; yield 2. g = nums(); print(next(g), next(g), next(g))?
Lesson
A function with yield returns a generator that computes one value at a time, on demand. (x for x in xs) is the expression form. Pass them to sum, max, any or a for loop and nothing is stored; call list() only when you need everything at once.
Demo
In python/py100/demo/gen_demo.py the tutor sums the squares of ten million numbers with a list (watch memory) and with a generator, then writes read_lines(path) with yield that strips as it goes.
Hands-on
Write python/py100/s3/gen.py with a generator evens(limit: int) yielding even numbers below limit, and a generator read_nonblank(path) yielding stripped non-empty lines. Main prints sum(evens(1_000_001)) and the count of lines from samples/s3/notes.txt.
Explain
Why does the generator version use almost no memory?
Check
gen.py defines two generators and sums a million evens — checked (T3):
seat_fileseat_run3.3itertools: groupby, chain, islice8 minchecked · T3
Predict
groupby(["a", "b", "a"]) yields how many groups?
Lesson
itertools.groupby groups runs of equal keys, so sort by the key first. chain joins iterables, islice takes a slice of any iterable, accumulate gives running totals. Each returns an iterator: consume it once.
Demo
The tutor groups items by category in python/py100/demo/itertools_demo.py, first without sorting (wrong groups), then with sorted(key=), then takes the first three of a chained sequence with islice and prints running totals with accumulate.
Hands-on
Write python/py100/s3/groups.py: from a list of (category, cents) tuples, print "category: count" per category using sorted + groupby, then the first three running totals of the cents in original order with accumulate and islice.
Explain
Why must the data be sorted before groupby?
Check
groups.py groups correctly and prints three running totals — checked (T3):
seat_fileseat_run3.4Sorting by key, stably8 minchecked · T3
Predict
sorted(items, key=lambda i: (-i.qty, i.name)) orders how?
Lesson
sorted and list.sort take key=, a function returning what to compare; tuples give multi-level order; negate numbers to descend one field while others ascend. Python's sort is stable, so sorting twice (minor key, then major) also works. operator.attrgetter and itemgetter name keys without lambdas.
Demo
In python/py100/demo/sort_demo.py the tutor sorts items by name, by (category, -cents), with attrgetter("category", "name"), and proves stability by sorting by name then by category and reading the result.
Hands-on
Write python/py100/s3/sortkeys.py over five (name, category, cents) tuples: print names sorted by cents descending then name; print names sorted by (category, name) using itemgetter; print the two cheapest names using sorted and a slice.
Explain
What does stability buy you?
Check
sortkeys.py sorts three ways with keys — checked (T3):
seat_fileseat_run3.5Counter, defaultdict and dict patterns8 minchecked · T3
Predict
Counter("banana").most_common(1) gives?
Lesson
Counter counts anything iterable and ranks with most_common; defaultdict(list) groups without the if-key-missing dance; dict.setdefault does the same once; d.get(k, 0) + 1 is the plain form. Choose the one that says what you mean.
Demo
The tutor counts categories three ways in python/py100/demo/counting_demo.py — get, setdefault, Counter — then groups names by category with defaultdict(list) and prints the result sorted.
Hands-on
Write python/py100/s3/counting.py over the five tuples from 3.4: print Counter of categories as "category count" lines by most common then name; print a defaultdict(list) grouping of names by category, categories sorted, names sorted within.
Explain
When is defaultdict a bad idea?
Check
counting.py uses Counter and defaultdict — checked (T3):
seat_fileseat_run3.6Lab: the report pipeline10 minchecked · T3
Predict
top_n(groups, 2) on totals {a: 10, b: 30, c: 20} returns?
Lesson
Three pure functions over Items from 2.6: by_category(items) -> dict[Category, list[Item]] built with defaultdict or groupby; value_by_category(items) -> dict[Category, Money] with a comprehension and sum; top_n(values, n) -> list[tuple[Category, Money]] sorted descending. Tests over samples/s3/items.json.
Demo
The tutor writes the three functions in python/py100/demo/pipeline_demo.py as comprehensions, loads the sample items, prints the top two, and writes one test per function.
Hands-on
Write python/py100/s3/pipeline.py with by_category, value_by_category and top_n as specified, loading samples/s3/items.json through a load_items() helper, and s3/test_pipeline.py with at least four tests including an empty list and a tie. python3 s3/pipeline.py prints the top three as "category total".
Explain
Why return data from the pipeline functions rather than printing the report inside them?
Check
The report pipeline groups, totals and ranks with tests — checked (T3):
seat_fileseat_runseat_testcraft
Session 4 · Errors and resources
Six steps on failure done well. The exception hierarchy and catching the narrowest class that fits. Your own exception types for the failures your domain has, with fields that carry the facts. raise … from to keep the cause, and when to re-raise. Context managers: with for anything that must be released, and contextlib for writing your own. The logging module: levels, a formatter, and events with fields instead of print. Live in the seat: a bare except hiding a typo for ten minutes, a ValueError wrapped in a LoadError with the row number, a file left open, and a log line that tells you what happened at 02:14.
Lab Write the robust loader: load_items(path) that raises LoadError with the row number and reason for bad rows, logs each rejection, closes its file in every case, and is proven by tests. Session 4 checkpoint.
6 mini steps · ~51 min
4.1The hierarchy and catching narrowly8 minchecked · T3
Predict
except OSError catches FileNotFoundError?
Lesson
Exceptions form a tree: Exception at the root of what you should catch, ValueError, KeyError, OSError and friends below. Catch the narrowest class that describes what you can handle. except Exception at the top of a program is for logging and exiting, not for continuing.
Demo
The tutor opens python/py100/demo/hierarchy_demo.py, prints FileNotFoundError.__mro__, catches OSError around an open, then shows a bare except swallowing a NameError typo and the program "working" while doing nothing.
Hands-on
Write python/py100/s4/narrow.py with read_count(path: str) -> int that returns the line count, returns -1 on FileNotFoundError only, and lets other errors through. Main prints read_count("samples/s3/notes.txt") and read_count("nope.txt"), then calls read_count on a directory (".") and lets the IsADirectoryError propagate.
Explain
Why let the directory error propagate?
Check
narrow.py catches only FileNotFoundError — checked (T3):
seat_fileseat_runcraft4.2Your own exception types8 minchecked · T3
Predict
class LoadError(ValueError) … except ValueError catches a LoadError?
Lesson
Define exceptions for the failures your domain has: class LoadError(Exception) with fields such as row and reason set in __init__, and a clear __str__. Callers can tell your errors from Python's and read the facts instead of parsing a message.
Demo
In python/py100/demo/customerr_demo.py the tutor writes LoadError with row and reason, raises it from a parser, catches it, prints e.row and e.reason, and then shows a caller catching only LoadError while a KeyError still surfaces.
Hands-on
Write python/py100/s4/errors.py: class StockError(Exception); class LoadError(StockError) with row: int and reason: str, __str__ "row <row>: <reason>"; class NotEnoughStock(StockError) with sku and wanted. Main raises and catches each, printing str(e) — two lines.
Explain
Why a base StockError above the two?
Check
errors.py defines a small exception hierarchy — checked (T3):
seat_fileseat_run4.3raise from, re-raise, finally8 minchecked · T3
Predict
except ValueError as e: raise LoadError(3, "bad") from e. What does the traceback show?
Lesson
Translate low-level errors into your own with raise X from e so the cause is kept. A bare raise inside except re-raises the current error after you log it. finally runs whether or not an error happened — the place for cleanup that has no context manager.
Demo
The tutor wraps a Decimal parse error in LoadError in python/py100/demo/raisefrom_demo.py, shows the chained traceback, then logs and re-raises with bare raise, then uses finally to print "closing" on both paths.
Hands-on
Write python/py100/s4/translate.py: parse_cents(text: str, row: int) -> int that wraps ValueError in LoadError(row, f"bad amount {text!r}") with from; main parses "250" and "abc" printing the int and then str(e); then a function that re-raises with bare raise after printing "logged".
Explain
What is lost if you raise LoadError without from?
Check
translate.py chains and re-raises — checked (T3):
seat_fileseat_run4.4Context managers9 minchecked · T3
Predict
An exception is raised inside a with open(...) block. Is the file closed?
Lesson
with calls __enter__ before the block and __exit__ after it, success or error. Files, locks, sqlite connections and temporary directories all support it. Write your own with @contextlib.contextmanager: setup, yield, cleanup in a finally.
Demo
The tutor writes a timer context manager in python/py100/demo/ctx_demo.py with contextmanager and a finally, times a loop, raises inside the block to prove the elapsed time still prints, and shows two files in one with statement.
Hands-on
Write python/py100/s4/ctx.py: a @contextmanager called working_dir(path) that changes into path and always changes back; and timed(label) that prints "<label>: done" in its finally. Main uses both, raising ValueError inside timed and catching it outside; the output shows "demo: done" before the error message.
Explain
Why put the cleanup in finally inside the context manager?
Check
ctx.py defines two context managers that clean up on errors — checked (T3):
seat_fileseat_run4.5Logging instead of print8 minchecked · T3
Predict
logging.basicConfig(level=logging.WARNING); logging.info("x"). Does x appear?
Lesson
logging gives levels (DEBUG, INFO, WARNING, ERROR), a logger per module via getLogger(__name__), a format with time and level, and logger.exception inside except to include the traceback. Print is for the program's output; logging is for what happened while it ran.
Demo
In python/py100/demo/log_demo.py the tutor configures a format with time, level and name, logs at three levels, changes the level to see lines appear and vanish, and uses logger.exception to capture a traceback.
Hands-on
Write python/py100/s4/logged.py: a module logger, basicConfig at INFO with format "%(levelname)s %(name)s: %(message)s" to stderr, a function that logs info "loading <path>", warning "skipped row 3" and, inside an except, exception "failed". Main prints exactly one stdout line "done".
Explain
Why log to stderr and print results to stdout?
Check
logged.py logs to stderr and prints one line — checked (T3):
seat_fileseat_run4.6Lab: the robust loader10 minchecked · T3
Predict
A CSV has a bad amount on row 4 of 10. With strict=False, what should load_items return?
Lesson
load_items(path, strict=True) reads a CSV of items into the 2.6 model: in strict mode the first bad row raises LoadError(row, reason) chained from the cause; in lenient mode bad rows are logged at WARNING and skipped. The file is opened with with; a missing file raises FileNotFoundError untouched.
Demo
The tutor builds load_items in python/py100/demo/loader_demo.py in both modes over samples/s4/items_bad.csv, shows the chained traceback in strict mode and the warning lines in lenient mode, then writes the test for each path with pytest.raises and caplog.
Hands-on
Write python/py100/s4/loader.py with LoadError and load_items(path: str, strict: bool = True) -> list[Item] as specified, and s4/test_loader.py with tests: clean file loads all; strict raises LoadError with row == 4; lenient returns 9 and logs one warning (caplog); missing file raises FileNotFoundError.
Explain
Why is strict the default?
Check
The robust loader raises, logs and closes, with four passing tests — checked (T3):
seat_fileseat_testcraft
Session 5 · Testing
Six steps on tests that earn their keep. How pytest discovers tests and what a good test name says. Fixtures for shared setup and conftest.py for sharing them. parametrize to turn a table of cases into one test body. tmp_path and monkeypatch for files and environment without touching the real ones. Test first: a kata done red-green-refactor. Coverage as a floor, measured with the standard library's trace module, and what a missed line is telling you. Live in the seat: a fixture replacing four copied lines, a parametrized test with eight ids, a test that writes into tmp_path, and the trace report naming an untested branch.
Lab Write the test suite for the model and the pipeline: fixtures in conftest.py, parametrized rejection cases, a tmp_path file test, and a floor of covered lines; the grader adds hidden tests. Session 5 checkpoint.
6 mini steps · ~51 min
5.1Discovery, names and fixtures8 minchecked · T3
Predict
A fixture named items is requested by three tests. How many times does it run by default?
Lesson
pytest collects test_*.py files and test_* functions. A test name is a sentence: test_negative_qty_is_rejected. A fixture is a function decorated @pytest.fixture whose return value a test receives by naming it as a parameter; put shared ones in conftest.py.
Demo
The tutor refactors four tests in python/py100/demo/fixtures_demo/ that each build the same three items into one fixture in conftest.py, runs pytest -v to show the names, and uses scope="module" to show the fixture running once.
Hands-on
Create python/py100/s5/conftest.py with a fixture items() returning three Items from the 2.6 model, and s5/test_fixtures.py with three tests that use it: the count is 3, total value equals the expected Money, and the dearest item is the one you expect.
Explain
What does a fixture give you over a helper function?
Check
A conftest fixture feeds three passing tests — checked (T3):
seat_fileseat_test5.2parametrize: one body, many cases8 minchecked · T3
Predict
@pytest.mark.parametrize("text,cents", [("2.50", 250), ("0", 0), ("1.005", 101)]) — how many tests?
Lesson
parametrize turns a table into tests: each row runs the body independently and fails independently, with an id you can read. Use ids= or pytest.param(..., id=) for names, and parametrize the error cases too with pytest.raises inside.
Demo
In python/py100/demo/param_demo.py the tutor converts five near-identical tests of parse_cents into one parametrized test with ids, adds two failing rows on purpose to show two red ids, and fixes the function.
Hands-on
Write python/py100/s5/test_param.py: a parametrized test of Money.from_str with at least five rows including "0.10", "2.505" and "1000"; and a parametrized test with pytest.raises(ValueError) for at least three bad inputs ("abc", "", "1,00"). Use readable ids.
Explain
Why is one parametrized test better than one test with a loop inside?
Check
test_param.py runs eight or more parametrized cases — checked (T3):
seat_fileseat_test5.3tmp_path and monkeypatch8 minchecked · T3
Predict
A test writes items.csv into tmp_path. Where is the file after the run?
Lesson
tmp_path hands each test its own empty folder, so file code is tested for real without touching the repo. monkeypatch sets environment variables, attributes and the working directory for one test and undoes it after. Together they replace most mocking.
Demo
The tutor tests load_items in python/py100/demo/tmp_demo.py by writing a two-row CSV into tmp_path, then uses monkeypatch.setenv to drive a function that reads STOCK_PATH, and monkeypatch.setattr to replace a slow function with a stub.
Hands-on
Write python/py100/s5/test_files.py: a test that writes a three-row CSV into tmp_path and asserts load_items returns 3; a test that writes a bad row and expects LoadError; a test using monkeypatch.setenv("STOCK_PATH", …) for a helper stock_path() -> str you add to s5/paths.py.
Explain
Why not test against a CSV committed in the repo?
Check
test_files.py uses tmp_path and monkeypatch — checked (T3):
seat_fileseat_test5.4Test first: the reorder kata9 minchecked · T3
Predict
In red-green-refactor, when do you write the production code?
Lesson
Red: write one failing test for the next behaviour. Green: the smallest code that passes. Refactor: tidy with the tests green. The kata: reorder_quantity(item) returns 0 when qty > reorder_at, else enough to reach 2 × reorder_at; a disabled item always returns 0.
Demo
The tutor does the first two cycles live in python/py100/demo/kata_demo.py — the above-threshold case, then the below-threshold case — committing to the smallest step each time and refactoring the duplicated expression once both pass.
Hands-on
Do the kata in s5/reorder.py and s5/test_reorder.py, one test at a time: above threshold → 0; at threshold → reorder_at; below → 2*reorder_at - qty; disabled → 0. Keep the history in a comment at the top of the test file: the order you wrote the tests in.
Explain
What did the smallest-step rule stop you doing?
Check
The reorder kata has four green tests — checked (T3):
seat_fileseat_fileseat_test5.5Coverage as a floor8 minchecked · T3
Predict
100% line coverage means?
Lesson
python3 -m trace --count --missing --summary (standard library) reports lines that never ran. Use it to find untested paths, set a floor so coverage cannot silently fall, and never chase 100% by writing tests with no assertions.
Demo
The tutor runs the trace module over the reorder kata in python/py100/demo/, reads the .cover file with >>>>>> marking the missed else branch, writes the missing test, and re-runs to a clean report.
Hands-on
Run the trace module over s5/test_reorder.py; find any line in s5/reorder.py marked as not run; add a test that covers it (or note in s5/COVERAGE.md that none were missed) and record the final line count and missed count in s5/COVERAGE.md.
Explain
Why is a coverage floor more useful than a coverage target?
Check
COVERAGE.md records the trace result — checked (T3):
seat_fileseat_test5.6Lab: the suite10 minproven · T4
Predict
The grader adds a hidden test that your suite must sit beside. What does it need from you?
Lesson
The session lab is a suite over the 2.6 model and the 3.6 pipeline: fixtures in conftest.py, parametrized rejections, a tmp_path round trip through load_items, and the trace report with zero missed lines in model.py. The grader runs hidden tests against the same modules.
Demo
The tutor reviews a sample suite in python/py100/demo/suite_demo/ against a checklist — independence, names, parametrization, one assert idea per test — and shows a hidden-style test failing on a model that accepts an empty sku.
Hands-on
Assemble python/py100/s5/suite/: conftest.py, test_model.py (parametrized rejections, equality, total_value), test_pipeline.py (grouping, totals, top_n with a tie, empty input), test_loader.py (tmp_path round trip). At least 15 tests, all green; model.py fully covered per trace.
Explain
Which of your tests would catch a model that silently clamps negative quantities to zero?
Check
The suite passes with 15+ tests and the hidden tests pass — proven (T4):
seat_fileseat_fileseat_test
Session 6 · Files, data formats and time
Six steps on data at the edges. pathlib for paths that compose and read themselves. CSV with DictReader and DictWriter and the header as the contract. JSON with json.load and json.dump, dataclasses through asdict, and Decimal through a default hook because JSON has no decimals. Dates and times: aware datetimes, zoneinfo, ISO 8601 in and out, and arithmetic with timedelta. sqlite3 from the standard library: a table, parameterised inserts, a query, a transaction. Live in the seat: a path built with /, a CSV round trip, Decimal refused by json.dumps and then handled, a naive datetime compared with an aware one, and ten items inserted and summed in SQL.
Lab Write the import/export tools: items in from JSON, out to CSV, saved to and loaded from sqlite3, with timestamps in UTC, and tests over tmp_path for each round trip. Session 6 checkpoint.
6 mini steps · ~52 min
6.1pathlib7 minchecked · T3
Predict
Path("data") / "items.csv" gives?
Lesson
pathlib.Path joins with /, knows .name, .suffix, .parent and .exists(), reads and writes with read_text and write_text, and lists with glob. Take Path | str in signatures and convert once with Path(p). Paths are relative to the working directory, not the file.
Demo
In python/py100/demo/paths_demo.py the tutor builds a path, prints its parts, writes a file with write_text, globs *.csv in samples, and uses mkdir(parents=True, exist_ok=True) for an output folder.
Hands-on
Write python/py100/s6/paths.py: ensure_dir(p: Path) -> Path creating parents; list_csv(folder: Path) -> list[str] of file names sorted. Main creates s6/out/, writes out/hello.txt with "hi", prints list_csv(Path("samples/s6")) and the text read back.
Explain
Why prefer Path over string concatenation?
Check
paths.py creates a folder, lists CSVs and reads back — checked (T3):
seat_fileseat_run6.2CSV in and out8 minchecked · T3
Predict
DictWriter.writerow({"sku": "A1"}) with fieldnames ["sku", "name"] writes?
Lesson
DictReader gives dict rows with the header as keys; DictWriter takes fieldnames and writeheader() then writerow per dict. Open with newline="". The header is the contract between writer and reader, so define fieldnames once as a constant.
Demo
The tutor writes items to CSV with DictWriter in python/py100/demo/csvio_demo.py, reads them back, shows a quoted comma surviving the trip, and the ValueError from an unexpected key.
Hands-on
Write python/py100/s6/csvio.py: FIELDS constant; write_items(items: list[Item], path: Path) -> None and read_items(path: Path) -> list[Item] using the 2.6 model and Money. Main writes three items to s6/out/items.csv, reads them back and prints "round trip ok" if equal.
Explain
Why is the header a contract?
Check
csvio.py round-trips items through CSV — checked (T3):
seat_fileseat_run6.3JSON and the Decimal problem9 minchecked · T3
Predict
json.dumps({"price": Decimal("2.50")}) does?
Lesson
json.load/dump move between text and dicts/lists; dataclasses go out through asdict and come back through a from_dict you write. Decimal is serialised as a string with default=str and parsed back with Decimal(text), so money keeps its digits through the trip.
Demo
In python/py100/demo/jsonio_demo.py the tutor dumps an Item, hits the TypeError on Money, adds default=, writes the file with indent=2, reads it back with a from_dict and asserts equality.
Hands-on
Write python/py100/s6/jsonio.py: item_to_dict(item) -> dict with the price as a string; item_from_dict(d) -> Item; save_items(items, path) and load_items(path) with indent=2. Main saves three items to s6/out/items.json, loads them and prints "json ok" if equal, then prints the first price as text.
Explain
Why a string and not a float for money in JSON?
Check
jsonio.py round-trips items with Decimal as text — checked (T3):
seat_fileseat_runcraft6.4Dates, times and zones9 minchecked · T3
Predict
datetime.now() < datetime.now(timezone.utc) — what happens?
Lesson
Always aware: datetime.now(timezone.utc), ZoneInfo("Europe/London") for local display, .isoformat() out and datetime.fromisoformat() in, timedelta for arithmetic, date for calendar days. Store UTC, convert at the edge, never parse with string slicing.
Demo
The tutor parses "2026-03-01T09:30:00+00:00" in python/py100/demo/time_demo.py, converts it to two zones, adds 45 days, formats with strftime, and shows the TypeError from mixing naive and aware.
Hands-on
Write python/py100/s6/times.py: parse_iso(text) -> datetime that rejects naive input with ValueError; days_until(when, now) -> int; to_zone(when, zone: str) -> str ISO text. Main parses "2026-03-01T09:30:00+00:00", prints it in "Asia/Singapore", prints days_until from "2026-01-01T00:00:00+00:00" (59), and catches a naive parse.
Explain
Why store UTC and convert on display?
Check
times.py parses, converts and counts days — checked (T3):
seat_fileseat_run6.5sqlite3 basics9 minchecked · T3
Predict
cur.execute(f"SELECT * FROM items WHERE sku = '{sku}'") — what is wrong?
Lesson
sqlite3 is in the standard library: connect(path), execute with ? parameters, executemany for batches, fetchall, and with conn: for a transaction that commits or rolls back. Create the table with IF NOT EXISTS; store money as integer cents.
Demo
In python/py100/demo/sqlite_demo.py the tutor creates items(sku TEXT PRIMARY KEY, name, qty INTEGER, unit_cents INTEGER), inserts three rows with executemany, sums qty * unit_cents in SQL, then rolls back a failed batch inside with conn.
Hands-on
Write python/py100/s6/store.py: init_db(conn) creating the items table; save_items(conn, items); load_items(conn) -> list[Item]; total_value_cents(conn) -> int computed in SQL. Main uses an in-memory database (":memory:"), saves three items, prints the total cents and the count loaded.
Explain
Why compute the total in SQL rather than in Python?
Check
store.py saves and loads items through sqlite3 with parameters — checked (T3):
seat_fileseat_runcraft6.6Lab: import and export10 minchecked · T3
Predict
Items go JSON → sqlite → CSV. Where must the money be converted between Decimal and cents?
Lesson
One module, four boundaries: import_json(path) -> list[Item], export_csv(items, path), save/load through sqlite3 with an imported_at UTC timestamp column, all tested over tmp_path and an in-memory database. Money crosses each boundary exactly once, in a named function.
Demo
The tutor wires python/py100/demo/transfer_demo.py end to end over samples/s6/items.json, inspects the sqlite table in the terminal, opens the CSV, and writes the round-trip test.
Hands-on
Write python/py100/s6/transfer.py with import_json, export_csv, save_items(conn, items, imported_at: datetime) and load_items(conn) -> list[Item], and s6/test_transfer.py with tests: JSON import count, CSV export header and rows (tmp_path), sqlite round trip equality, imported_at stored as ISO UTC text. At least 4 green.
Explain
Why store imported_at as ISO text in UTC?
Check
Import/export round-trips through JSON, sqlite and CSV with tests — checked (T3):
seat_fileseat_testcraft
Session 7 · Modules, packages and docs
Six steps on shape at the file level. A package with __init__.py and what to export from it. Imports: absolute over relative, no cycles, and the import at the bottom that signals a design problem. __main__.py so python3 -m stockroom works. Docstrings that say what and why, and a README that says how to run and test. argparse with subcommands and a main(argv) -> int that tests can call. Live in the seat: an import cycle and its traceback, a package run with -m, help text generated from the parser, and a README someone else could follow.
Lab Build the stockroom package skeleton: stockroom/ with model, storage, report and cli modules, a __main__, a README, and an argparse front end with two subcommands that run end to end over the sample data. Session 7 checkpoint.
6 mini steps · ~50 min
7.1A package and its __init__8 minchecked · T3
Predict
stockroom/__init__.py has: from .model import Item. What does from stockroom import Item do?
Lesson
A package is a folder with __init__.py; modules inside are files. __init__.py defines the public surface by re-exporting the few names users need and listing them in __all__. Keep it small; heavy work belongs in modules, not in import time.
Demo
The tutor creates python/py100/demo/shop/ with model.py and storage.py, re-exports Item and load in __init__.py with __all__, imports from the package in the terminal, and shows a slow top-level computation making every import slow.
Hands-on
Create python/py100/s7/stockroom/__init__.py re-exporting Item, Category, Money from .model (copy your 2.6 model into stockroom/model.py) with __all__. Write s7/use_pkg.py that does from stockroom import Item, Money and prints one Item's total_value.
Explain
What belongs in __init__.py and what does not?
Check
The stockroom package exports its model — checked (T3):
seat_fileseat_run7.2Imports without cycles8 minchecked · T3
Predict
report.py imports storage, storage.py imports report. What happens on import?
Lesson
Dependencies point one way: cli → report → storage → model. Use absolute imports (from stockroom.model import Item). A cycle is a design signal: extract what both need into a lower module. Imports go at the top; an import inside a function is a last resort, not a habit.
Demo
The tutor creates a cycle between report.py and storage.py in python/py100/demo/shop/, reads the "partially initialized module" error, and breaks it by moving the shared helper to model.py.
Hands-on
Add s7/stockroom/storage.py (load_items from JSON, reusing 6.3) and s7/stockroom/report.py (low_stock(items) -> list[Item] where qty <= reorder_at, sorted by sku) with absolute imports and no cycle. s7/check_imports.py imports both and prints "imports ok".
Explain
Where did the shared piece go when you broke a cycle, and why there?
Check
storage and report import one way — checked (T3):
seat_fileseat_fileseat_runcraft7.3__main__ and the entry point8 minchecked · T3
Predict
python3 -m stockroom runs which file?
Lesson
__main__.py makes a package runnable: it imports main from cli.py and calls sys.exit(main(sys.argv[1:])). cli.main(argv) -> int takes its arguments as a parameter so tests can call it without a subprocess and read the exit code.
Demo
The tutor adds __main__.py to python/py100/demo/shop/, runs python3 -m shop, then calls shop.cli.main(["--help"]) from a test and reads SystemExit's code.
Hands-on
Add s7/stockroom/cli.py with main(argv: list[str]) -> int that prints "stockroom 0.1" and returns 0, and s7/stockroom/__main__.py calling it. python3 -m stockroom from s7 prints the version and exits 0.
Explain
Why does main take argv instead of reading sys.argv itself?
Check
python3 -m stockroom runs — checked (T3):
seat_fileseat_fileseat_run7.4Docstrings and the README7 minchecked · T3
Predict
help(stockroom.report.low_stock) shows?
Lesson
A docstring under def or class says what the function does, its inputs, its result and what it raises — the how is the code. A README says what the project is, how to run it, how to test it, and the exit codes. Write both for the person who arrives in six months, who may be you.
Demo
The tutor writes docstrings for two functions in python/py100/demo/shop/report.py, runs help() in the terminal, then drafts the README with Run, Test and Exit codes sections.
Hands-on
Add docstrings to every public function in s7/stockroom/ (model, storage, report, cli) and write s7/README.md with headings "Run", "Test" and "Exit codes" and the exact commands.
Explain
What goes in the README that does not go in docstrings?
Check
README with Run, Test and Exit codes; docstrings on public functions — checked (T3):
seat_filecraft7.5argparse subcommands9 minchecked · T3
Predict
python3 -m stockroom report — with subparsers, how does main know which command ran?
Lesson
add_subparsers(dest="command", required=True) gives one parser per command, each with its own arguments and help. Map each command to a function that returns the exit code. The top-level --help lists the commands; each command's --help lists its arguments.
Demo
The tutor adds report and value subcommands to python/py100/demo/shop/cli.py, shows both help screens, and dispatches with a dict of command → function.
Hands-on
Extend s7/stockroom/cli.py: subcommands "report <path>" and "value <path>", each with help text; dispatch to functions returning int; return 2 with "Cannot read <path>" on FileNotFoundError. python3 -m stockroom --help lists both.
Explain
Why one function per subcommand?
Check
The CLI has report and value subcommands — checked (T3):
seat_fileseat_runseat_run7.6Lab: the package skeleton runs end to end10 minchecked · T3
Predict
Which module may import cli: model, storage, report or none of them?
Lesson
The skeleton that the capstone fills: stockroom/{model,storage,report,cli,__main__}.py, a README, and the two subcommands working over samples/s7/items.json — report prints low-stock lines "sku name qty/reorder_at" sorted by sku, value prints "Total value: <amount>". Exit codes 0 and 2.
Demo
The tutor runs python3 -m shop report and value in python/py100/demo over the sample, compares with samples/s7/expected_report.txt, and walks the import graph module by module to show it is a straight line.
Hands-on
Complete s7/stockroom so that python3 -m stockroom report samples/s7/items.json prints exactly samples/s7/expected_report.txt and value prints "Total value: 1457.25". Write s7/test_cli.py calling cli.main([...]) for both commands and the missing-file case. basedpyright clean on the package.
Explain
Name the import direction of your package in one line.
Check
The package reports and values the sample data with tests — checked (T3):
seat_runseat_runseat_testseat_lint
Session 8 · Capstone: Stockroom
Six steps that finish Stockroom from a written spec. Reading the spec into a module map and a test list. The domain: Stock holds Items by sku, adjust() moves quantity and refuses to go negative with NotEnoughStock. Storage: JSON import and export through the 6.3 functions, with a schema version. The low-stock report and a valuation in Money. The CLI with report, value, adjust and export subcommands and the exit-code contract 0/2/3. Then the proof: hidden tests and hidden data files from the grader. Live in the seat: the spec becoming a test list, a negative adjustment refused, the export round-tripping, and the published expected outputs matched line for line.
Lab Finish Stockroom so that every subcommand matches the published expected outputs on the sample data, your tests are green, and the grader's hidden tests and hidden files pass too. Course placement step.
6 mini steps · ~52 min
8.1The spec into a plan8 minchecked · T3
Predict
The spec says exit 3 when the JSON is valid but a record is invalid. Which layer decides that?
Lesson
Stockroom's spec (samples/s8/SPEC.md): items JSON with sku, name, category, qty, unit_price, reorder_at; commands report, value, adjust <sku> <delta>, export <csv>; exit 0 ok, 2 unreadable file, 3 invalid data or NotEnoughStock. Turn it into a module map and a numbered test list before coding.
Demo
The tutor reads the spec aloud, writes python/py100/demo/stockroom_plan.md with the module map and twelve test names, and marks which tests already exist from sessions 2–7.
Hands-on
Write s8/PLAN.md: a module map (which function lives where), the exit-code table, and a numbered list of at least ten test names you will write, each a sentence like test_adjust_below_zero_raises_not_enough_stock.
Explain
Why write the test list before the code?
Check
PLAN.md has a module map, exit codes and ten test names — checked (T3):
seat_file8.2The domain: Stock and adjust9 minchecked · T3
Predict
stock.adjust("A1", -5) when A1 has qty 3 should?
Lesson
Stock is a class over a dict[str, Item]: add(item), get(sku) -> Item | None, adjust(sku, delta) -> Item returning the updated frozen-copy item or raising NotEnoughStock / KeyError, items() sorted by sku. Items are replaced, not mutated, with dataclasses.replace.
Demo
The tutor writes Stock in python/py100/demo/shop/stock.py test first: add then get, adjust up, adjust down to zero, adjust below zero refused, unknown sku, running pytest after each pair.
Hands-on
Write s8/stockroom/stock.py with Stock as specified and s8/stockroom/test_stock.py with at least five tests including the refusal, the zero boundary and an unknown sku. Items are replaced with dataclasses.replace, never mutated.
Explain
Why replace items instead of mutating qty?
Check
Stock adjusts safely with five passing tests — checked (T3):
seat_fileseat_test8.3Storage with a schema version8 minchecked · T3
Predict
The JSON file has "version": 2 and your code reads version 1. What should load do?
Lesson
The file is {"version": 1, "items": [...]}. load_stock(path) -> Stock validates the version and each record, raising LoadError(index, reason) from the cause; save_stock(stock, path) writes it back with indent=2 and money as text. The same functions serve import and export.
Demo
The tutor adds the version envelope in python/py100/demo/shop/storage.py, loads samples/s8/items.json, breaks the version and a record to show both LoadErrors, and round-trips through tmp_path in a test.
Hands-on
Write s8/stockroom/storage.py with load_stock and save_stock as specified (reusing 6.3's item_to_dict/item_from_dict), and tests: round trip equality, bad version raises LoadError, bad record raises LoadError with the index, missing file raises FileNotFoundError.
Explain
Why put a version in the file at all?
Check
Storage validates version and records with four tests — checked (T3):
seat_fileseat_test8.4The low-stock report and the valuation8 minchecked · T3
Predict
An item with qty 5 and reorder_at 5 — is it on the low-stock report?
Lesson
low_stock(stock) -> list[Item] where qty <= reorder_at sorted by sku; format_low_stock(items) -> str with lines "<sku> <name> <qty>/<reorder_at>" and "No items below reorder point." when empty; total_value(stock) -> Money summing item.total_value(). Pure functions; the CLI prints.
Demo
The tutor writes the three functions in python/py100/demo/shop/report.py as comprehensions, prints the report for the sample, and checks it against samples/s8/expected_report.txt.
Hands-on
Write s8/stockroom/report.py with low_stock, format_low_stock and total_value as specified, and tests for the boundary (qty == reorder_at), the empty case, the sort order and the total over the sample file (Money("1457.25")).
Explain
Why does format_low_stock return a string rather than print?
Check
Report functions pass four tests — checked (T3):
seat_fileseat_testcraft8.5The CLI and the exit-code contract9 minchecked · T3
Predict
adjust A1 -50 when A1 has 3. Output and exit code?
Lesson
cli.main(argv) -> int: report, value, adjust <sku> <delta> (saves on success, prints the new line), export <csv>. FileNotFoundError → "Cannot read <path>" exit 2; LoadError and NotEnoughStock → the message on stderr, exit 3; no traceback ever reaches the user. Diagnostics go through logging.
Demo
The tutor completes python/py100/demo/shop/cli.py, runs the four commands over a copy of the sample, provokes each exit code, and shows the stderr line versus the stdout result.
Hands-on
Complete s8/stockroom/cli.py and __main__.py with the four subcommands and the contract; s8/stockroom/test_cli.py calls main([...]) for each command and each exit code over tmp_path copies of samples/s8/items.json (at least 6 tests).
Explain
Why map domain errors to one exit code rather than one per error?
Check
The CLI honours the 0/2/3 contract with six tests — checked (T3):
seat_fileseat_runseat_test8.6Lab: Stockroom on data you did not see10 minproven · T4
Predict
A hidden file has every item above its reorder point. What does report print and return?
Lesson
The placement check: the grader runs report, value and adjust over the published sample and over hidden files (an empty stock, an all-low stock, a file with a bad record, a version-2 file), and runs hidden tests against stock, storage and report. basedpyright must be clean on the package.
Demo
The tutor runs the reference over the four published cases in samples/s8/ and the expected outputs, explains that two hidden files mirror them with other data, and shows a hidden-style test for adjust at the zero boundary.
Hands-on
Make every published case in samples/s8/ pass byte for byte (report, value, the bad-record file exit 3, the version-2 file exit 3), keep all your tests green, then ask the tutor to check: hidden files and hidden tests run. Fix what it reports; never special-case a file name.
Explain
Which hidden case would catch a report that filters with < instead of <=?
Check
Stockroom passes published, hidden files and hidden tests — proven (T4):
seat_runseat_testseat_lintcraft
Outcome
By the end of the course the tutor's checks will have verified: a lint-clean, typed module for every session; eight labs — the tidied script, the Item model, the report pipeline, the robust loader, the test suite, the import/export tools, the package skeleton; and the Stockroom capstone, whose low-stock report, valuation and exit codes pass the published and hidden tests on data you did not see.
Certificate
Agile System Software Certificate — Python I (PY100) attests that you can design, test and package an idiomatic Python library with a command-line front end — typed, lint-clean, with narrow error handling and file, JSON and sqlite3 persistence — verified by a machine from your own seat.
To earn it:
- Pass every session lab check (1.6, 2.6, 3.6, 4.6, 5.6, 6.6, 7.6) on your own code.
- Pass the Stockroom capstone (8.6) including hidden tests and hidden data files.
- Score 80% or better on the 25-question reading quiz.
Gates (step · tier required):
Plus a 25-question reading quiz at 80%, retake after 48 h. Certificates are issued once the seat check runner is live; until then every step records what the engine saw.
Environment
A VS Code seat with Python 3.13, pytest, basedpyright and the standard library (sqlite3, json, csv, zoneinfo), offline; the debugger and the Testing view are set up. Your work lives in python/py100; the tutor's demo files live apart from yours and the tutor never edits your hands-on file while you are working on it.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick PY100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.