Agile System SoftwareLearn

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.

  • LevelIntermediate
  • Durationself-paced ~30–35 h
  • Size8 sessions · 48 mini steps · ~409 min of lessons
  • EnvironmentA 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
  • PrerequisitesPY050, or any beginner course in another language (JV050, DN050, RS050, NG050). Test out of PY050 with its step 8.5.
    Courses: PY050 or JV050 or DN050 or RS050 or NG050
  • CertificateAgile System Software Certificate — Python I (PY100)

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

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. 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_file seat_run

    #s1-1

  2. 1.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_file seat_lint seat_run

    #s1-2

  3. 1.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_file seat_run craft

    #s1-3

  4. 1.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_file seat_run

    #s1-4

  5. 1.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_file seat_run

    #s1-5

  6. 1.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_file seat_lint seat_run

    #s1-6

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
  1. 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_file seat_run

    #s2-1

  2. 2.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_file seat_run

    #s2-2

  3. 2.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_file seat_lint seat_run

    #s2-3

  4. 2.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_file seat_run craft

    #s2-4

  5. 2.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_file seat_run

    #s2-5

  6. 2.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_file seat_lint seat_test craft

    #s2-6

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
  1. 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_file seat_run craft

    #s3-1

  2. 3.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_file seat_run

    #s3-2

  3. 3.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_file seat_run

    #s3-3

  4. 3.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_file seat_run

    #s3-4

  5. 3.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_file seat_run

    #s3-5

  6. 3.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_file seat_run seat_test craft

    #s3-6

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
  1. 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_file seat_run craft

    #s4-1

  2. 4.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_file seat_run

    #s4-2

  3. 4.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_file seat_run

    #s4-3

  4. 4.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_file seat_run

    #s4-4

  5. 4.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_file seat_run

    #s4-5

  6. 4.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_file seat_test craft

    #s4-6

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
  1. 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_file seat_test

    #s5-1

  2. 5.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_file seat_test

    #s5-2

  3. 5.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_file seat_test

    #s5-3

  4. 5.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_file seat_file seat_test

    #s5-4

  5. 5.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_file seat_test

    #s5-5

  6. 5.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_file seat_file seat_test

    #s5-6

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
  1. 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_file seat_run

    #s6-1

  2. 6.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_file seat_run

    #s6-2

  3. 6.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_file seat_run craft

    #s6-3

  4. 6.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_file seat_run

    #s6-4

  5. 6.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_file seat_run craft

    #s6-5

  6. 6.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_file seat_test craft

    #s6-6

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
  1. 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_file seat_run

    #s7-1

  2. 7.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_file seat_file seat_run craft

    #s7-2

  3. 7.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_file seat_file seat_run

    #s7-3

  4. 7.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_file craft

    #s7-4

  5. 7.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_file seat_run seat_run

    #s7-5

  6. 7.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_run seat_run seat_test seat_lint

    #s7-6

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
  1. 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_file

    #s8-1

  2. 8.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_file seat_test

    #s8-2

  3. 8.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_file seat_test

    #s8-3

  4. 8.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_file seat_test craft

    #s8-4

  5. 8.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_file seat_run seat_test

    #s8-5

  6. 8.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_run seat_test seat_lint craft

    #s8-6

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.