freedzhost.cloud

Python module 4: a real project, tested and finished

Level BeginnerEstimated time 17 hRead first Python module 3: functions, modules, libraries, debuggingA word you don't know? Glossary

Seventeen sessions to turn everything learned into a finished project: structure a program into files, give it a real command line, test it with pytest, then build the final project — a tool that reads your bank CSV exports and summarises your expenses. This is the module you trim when life interferes; but it is the one that proves you can code.

Day 43 — Structuring a project: folders and entry point

Goal: move from a single file to an organised project, with a clean entry point.

Theory (20 min)

So far, one program = one file. That holds up to ~100 lines; beyond, we split. The minimal structure of a Python project:

my-project/
├── tools.py         # the functions (calculations, file reads)
├── main.py          # the main program (inputs, display)
└── requirements.txt # the dependencies (day 35)

main.py uses tools.py's functions with a local import — importing your own file works like importing a standard-library module:

import tools

total = tools.price_with_vat(100, 0.2)

The detail that surprises everyone once: importing a file runs everything at its top level — including prints left at the bottom of the imported file. So that a file can serve both as a library (imported) and as a program (run), the launching code goes under the standard guard:

def main():
    print("Program launched.")

if __name__ == "__main__":
    main()

__name__ is a magic variable: it is "__main__" when the file is run directly, and the file's name when it is imported. The guard translates as: "only run main() if THIS is the file that was launched". You will see it in every serious Python project; from now on, you can read it.

Why if __name__ == "__main__":?

  1. It is required in every Python file
  2. So the launching code does not run when the file is imported
  3. To speed up startup
Answer

On import, __name__ is the module name: the block is skipped. Direct run: it is "__main__" and the block runs.

Where do reusable functions go in a structured project?

  1. In a separate module (tools.py), imported by the main program
  2. All in main.py
  3. In requirements.txt
Answer

Separating the library (tools) from the entry point (main) makes functions testable and reusable.

Practice (30 min)

  1. Create the billing folder with the two files:
# tools.py
def price_with_vat(net_price, rate=0.20):
    """Return the price including VAT (net_price in euros, rate between 0 and 1)."""
    return net_price * (1 + rate)
# main.py
import tools

def main():
    price = float(input("Net price? "))
    print(f"With VAT: {tools.price_with_vat(price):.2f} €")

if __name__ == "__main__":
    main()
  1. Success criterion: python main.py asks for a price and prints the taxed amount. The program is in two files, each with a readable role.
  2. Proof of the guard: in the REPL, import main — nothing prints, nothing is asked: main() did not run. Then type main.main(): the program answers.
  3. Remove the guard from main.py (leave the main() call at top level), redo import main in the REPL: the program runs at import. That is the bug the guard prevents. Put it back.

Agent tips: "split this script into tools.py + main.py with the __main__ guard": a standard refactor. Trap: it may leave top-level code in tools.py that runs at import. Proof: python -c "import tools" must print nothing.

Recap (10 min)

Three lines: why split, what import does to a local file, the exact meaning of the guard. Without notes: restructure an old script into two files.

Day 44 — argparse: command-line options

Goal: give your scripts real arguments: python script.py file.txt instead of input().

Theory (20 min)

Real tools do not ask questions: they take command-line arguments, written after the program name: python rename.py photos/ --prefix holiday. That is what makes a script chainable into other scripts. The argparse module (standard library) does the parsing:

import argparse

def main():
    parser = argparse.ArgumentParser(description="Count a file's lines.")
    parser.add_argument("file", help="the file to analyse")
    args = parser.parse_args()
    print(f"Analysing {args.file}")

if __name__ == "__main__":
    main()

You describe the expected arguments, argparse does the rest: it fills args.file, it generates the help automatically (python count.py --help prints a clean manual), and it politely refuses an incomplete call (usage: ... then an error, no traceback). An argument written without dashes (file) is positional: mandatory, recognised by its place.

An argument starting with dashes is an option: --prefix holiday. Declare it with parser.add_argument("--prefix", default="file") — if the user omits it, the default applies.

What does argparse produce for free?

  1. The program's tests
  2. The --help text and the polite refusal of incomplete calls
  3. The PDF manual
Answer

--help and the usage message are generated from the declarations.

Difference between file and --file in add_argument?

  1. Mandatory positional vs named option
  2. None
  3. The second is read-only
Answer

No dashes: positional, mandatory. With dashes: option, which may have a default.

Practice (30 min)

  1. Create count.py by completing the lesson's skeleton:
import argparse
from pathlib import Path

def main():
    parser = argparse.ArgumentParser(description="Count a text file's lines.")
    parser.add_argument("file", help="the file to analyse")
    parser.add_argument("--word", default="", help="count only the lines containing this word")
    args = parser.parse_args()

    lines = Path(args.file).read_text(encoding="utf-8").splitlines()
    if args.word:
        lines = [l for l in lines if args.word in l]
    print(f"{len(lines)} lines.")

if __name__ == "__main__":
    main()
  1. The line [l for l in lines if args.word in l] is a list comprehension: day 18's loop-append in one line. Taste it; a full lesson would be needed — just remember it exists.
  2. Success criterion: python count.py memo.txt prints the line count; python count.py memo.txt --word python counts only the lines containing "python"; python count.py --help prints the help; python count.py with no argument prints the usage and a clean error.
  3. Transform day 40's renamer: the folder becomes a positional argument, and --prefix replaces the hardcoded prefix. The script becomes reusable on any folder.

Agent tips: "add an argparse CLI to this script: positional file, optional --word": the agent knows argparse by heart. Trap: it may turn your input() calls into arguments and drop the interactive behaviour you wanted to keep. Proof: --help must show exactly the options YOU asked for.

Recap (10 min)

Three lines: why arguments rather than input, positional vs option, free --help. Without notes: a script taking a number as argument and printing its times table.

Day 45 — argparse: flags, types and choices

Goal: typed options, true/false switches, and lists of choices.

Theory (20 min)

Three argparse settings cover 95% of needs:

The type. By default, every argument arrives as a string (like input). parser.add_argument("--rate", type=float, default=0.2) converts for you — and politely refuses "abc" with a usage message, no try/except on your side.

The flag: an option with no value, whose mere presence means "yes":

parser.add_argument("--real", action="store_true", help="actually apply (otherwise: dry-run)")

args.real is False when the option is absent, True when present. This is the standard shape of day 40's dry-run: python tidy.py downloads/ --real.

Choices: parser.add_argument("--sort", choices=["name", "size", "date"], default="name") — any other value is refused with the list of possibles. No more silent typos.

Writing convention: long options take two dashes (--sort), and a one-dash shortcut is often added: parser.add_argument("-s", "--sort", ...) — both forms then work.

What is args.real (action="store_true") when the user does not write --real?

  1. An error
  2. None
  3. False
Answer

Absent: False. Present: True. Perfect for a default dry-run.

What is choices=[...] for?

  1. Refusing any value outside the list, with a clear message
  2. Showing a dropdown
  3. Sorting the arguments
Answer

argparse does the validating: your code only sees valid values.

Practice (30 min)

  1. Take yesterday's count.py: add --sensitive (store_true) making the --word search case-sensitive (by default, compare both sides lowercased). Test both modes.
  2. Take rename.py (yesterday's argumented version): replace the real_mode variable with a --real flag. Success criterion: without the flag, the script only prints the plan; with --real, it renames. The dry-run became a command-line setting, not a code edit.
  3. Add to the renamer --sort with choices ["name", "size"] that sorts the file list before numbering (sorted(folder.glob(...)) by name, or key=lambda f: f.stat().st_size by size).
  4. Check the validation: --sort speed must be politely refused with the list of choices.

Agent tips: describing the CLI BEFORE the code ("positional: folder; options: --real flag, --sort in name|size") gives the agent a testable specification. Trap: it invents extra options "to help". Proof: --help must hold only your options, and every invalid choice must be refused.

Recap (10 min)

Three lines: type=, store_true, choices=. Without notes: the CLI of a km↔miles converter with a typed float positional value and a choice --to.

Day 46 — logging: the program's logbook

Goal: replace diagnostic prints with timestamped, filterable messages.

Theory (20 min)

Day 39's prints have two flaws: they vanish with the console, and they must be removed by hand. The logging module is Python's logbook: messages ranked by severity level, switched or filtered without changing the code:

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logging.debug("technical detail")
logging.info("file processed: report.pdf")
logging.warning("duplicate skipped: invoice.pdf")
logging.error("unreadable: archive.zip")

The five levels, in rising order: DEBUG (details for bug hunts), INFO (the program is progressing normally), WARNING (non-blocking anomaly), ERROR (failed operation), CRITICAL (the program cannot go on). The level= of basicConfig is a threshold: INFO shows INFO and everything more severe, hides DEBUG. On a bug hunt, lower it to DEBUG; in normal use, stay at INFO — without removing a single line.

The split that keeps a program readable: print for the result (what the user came for), logging for the journey (what happened along the way). The result goes to the screen; the log can go to a file: logging.basicConfig(filename="tidy.log", ...).

With level=logging.WARNING, which messages show?

  1. All of them
  2. WARNING, ERROR and CRITICAL only
  3. WARNING only
Answer

The level is a threshold: you see the chosen level and everything more severe.

What do you print, what do you log?

  1. print: the result for the user; logging: the journey for diagnosis
  2. The other way round
  3. Either, they are the same
Answer

Result on screen, log backstage — and the log can go to a file without touching the code.

Practice (30 min)

  1. Take tidy.py (days 41+45): replace the move prints with logging.info(...), the "no file" case with logging.warning(...), and keep ONE final print: the summary "8 files tidied".
  2. Configure basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") at the top of main.
  3. Success criterion: in dry-run, the INFO ... lines narrate the plan and the print gives the summary; switch the threshold to DEBUG and add a logging.debug showing each rename's exact target: it only appears at DEBUG level.
  4. Change basicConfig to write into tidy.log: the screen now shows only the summary, the file keeps the trace. Reread the file: you have a tool that remembers its runs.

Agent tips: "replace the diagnostic prints with levelled logging": the agent knows how. Trap: it bumps everything to ERROR or leaves prints mixed with logging. Proof: at WARNING threshold, a normal run says almost nothing; at DEBUG, it tells everything.

Recap (10 min)

Three lines: the five levels, the threshold, print vs logging. Without notes: a script with a debug, an info, a warning, whose threshold you change twice.

Day 47 — pytest: your first test

Goal: write and run an automatic test that proves a function does what it promises.

Theory (20 min)

Until now you checked your programs by hand: you run, you read, you judge. An automatic test writes that judgment once and for all: a small program that calls your function and checks the result. When you modify the code in three months, the test replays the proof in a second.

The standard framework is pytest (install it in the bubble: pip install pytest). A test is a function whose name starts with test_, stored in a test_*.py file:

# test_tools.py
import tools

def test_price_with_vat_20_percent():
    assert tools.price_with_vat(100) == 120.0

def test_price_with_vat_reduced_rate():
    assert tools.price_with_vat(100, 0.055) == 105.0

assert is a Python keyword: if the condition is true, it does nothing; if false, it raises an error that pytest catches and presents. You run every test in the folder with one command: python -m pytest. One dot per passed test, an F per failure, and for each failure a report showing the expected and the obtained value — a comparison traceback.

The discipline that goes with it: when you find a bug, first write the test that reproduces it (it fails, red), then fix (it passes, green). The test stays: that precise bug can never come back unnoticed.

What does assert x == 4 do when x is 5?

  1. Nothing
  2. It prints 5
  3. It raises an AssertionError that pytest reports as a failure
Answer

assert only speaks up when its condition is false.

How does pytest find your tests?

  1. test_*.py files, test_* functions
  2. You declare them in a config file
  3. It runs every function in the project
Answer

The naming convention is enough: pytest discovers them by itself.

Practice (30 min)

  1. In the billing folder (day 43), bubble active: pip install pytest, then pip freeze > requirements.txt (day 35's reflex).
  2. Create test_tools.py with the lesson's two tests. Run python -m pytest.
  3. Success criterion: "2 passed" shows in green.
  4. Provoke a failure: add def test_wrong(): assert tools.price_with_vat(100) == 999, rerun, read the report (expected vs obtained). Delete that test.
  5. The reproducible bug: a colleague claims "price_with_vat(0) crashes". Write def test_zero_price(): assert tools.price_with_vat(0) == 0.0 — it passes: the colleague was wrong, and the proof stays in the project.

Agent tips: "write the pytest tests for this function" is an excellent delegation — the tested code already exists, the agent invents nothing. Trap: it writes tests that check its own code rather than your intention. Proof: deliberately break the function (rate × 2); its tests must turn red.

Recap (10 min)

Three lines: what a test is, assert, the red-green dance. Without notes: three tests for your repeat() function from day 30.

Day 48 — pytest: edge cases and temporary files

Goal: test the edges (empty, zero, expected error) and the functions that touch files.

Theory (20 min)

A good test does not check the easy case: it hunts the edge cases — the borders where code goes wrong first. The classic list to review for every function: empty input ([], ""), zero, a single element, a negative value, a very large number, and the expected error.

For the expected error — checking that a function does crash when it should — pytest provides pytest.raises:

import pytest

def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        tools.divide(10, 0)

The test passes if the error arrives, fails if it does not. It is the other side of the mirror: you prove the guardrail holds.

To test a function that reads or writes files, never touch your real files: pytest offers tmp_path, a fresh temporary folder for each test, destroyed afterwards:

def test_count_lines(tmp_path):
    f = tmp_path / "notes.txt"
    f.write_text("a\nb\nc\n", encoding="utf-8")
    assert tools.count_lines(f) == 3

Declaring tmp_path as a test parameter is enough: pytest creates and fills it on demand. It is a fixture — a tool supplied on request. Each test starts from an empty folder: tests do not dirty one another.

What does a test with pytest.raises(FileNotFoundError) prove?

  1. That the function never crashes
  2. That the function does raise that error in that case
  3. That the file exists
Answer

The test passes when the expected error is raised: we check the guardrails.

Why tmp_path rather than a fixed test folder?

  1. Each test starts from an empty, isolated folder, without touching real files
  2. It is faster
  3. pytest requires it
Answer

Isolation: no leftovers from a previous test, no real file in danger.

Practice (30 min)

  1. Add to tools.py:
def count_lines(path):
    """Return the number of lines of a text file."""
    from pathlib import Path
    return len(Path(path).read_text(encoding="utf-8").splitlines())

def divide(a, b):
    """Return a / b; raises ZeroDivisionError when b is 0."""
    return a / b
  1. Write the tests: the lesson's test for count_lines (tmp_path), the raises test for divide, and three edge cases of your choice (empty file → 0; one line with no trailing \n → 1; divide 7, 2 → 3.5).
  2. Success criterion: python -m pytest shows 5+ passed, including the edge cases.
  3. Deliberately break count_lines (+ 1 on the return), rerun: two red tests show obtained vs expected. Fix it.

Agent tips: "list the edge cases of this function": the agent is good at enumerating borders. Trap: it tests the nominal case twice under two names. Proof: each test must fail if you break the line it targets — try it on one.

Recap (10 min)

Three lines: what an edge case is, raises, tmp_path. Without notes: tests for a password_ok function (8 characters minimum): ok, too short, empty.

Day 49 — raise: raising an error yourself

Goal: raise a clear error when a function receives the impossible, rather than returning a lame result.

Theory (20 min)

You have caught errors since day 26; today you create them. When a function receives an input it cannot work with, the worst move is returning a plausible but wrong result; the best is raising an error immediately, with raise:

def rate_for(service):
    """Return the tip rate for a level of service."""
    if service == "great":
        return 0.15
    if service == "ok":
        return 0.10
    if service == "bad":
        return 0.0
    raise ValueError(f"unknown service: {service!r}")

The program stops on a message saying exactly what is wrong, with the faulty value ({service!r} prints it with its quotes — handy to spot a stray space). Guiding principle: fail early, fail loudly. An error raised at the source is easy to fix; a wrong value travelling through ten functions before exploding is a traceback nightmare.

Who raises, who catches? Simple rule: the tool function raises (it has no other answer); the main program catches and translates for the user ("expected service: great, ok or bad"). The library signals, the interface comforts.

And it is testable: that was yesterday's pytest.raises. A documented raise + a test that proves it = a solid contract.

Why prefer raise over returning -1 to signal the impossible?

  1. It is faster
  2. raise is prettier
  3. The -1 can be used as a real result by a distracted caller; the error cannot
Answer

A sentinel value blends into real data; an exception interrupts and forces the case to be handled.

Who raises, who catches?

  1. The tool function raises, the main program catches and comforts the user
  2. The other way round
  3. Everyone catches everywhere
Answer

Signal closest to the cause, handle closest to the user.

Practice (30 min)

  1. Add rate_for (above) to your billing tools.py.
  2. Write the tests: the three service levels return the right rates, and with pytest.raises(ValueError): tools.rate_for("meh").
  3. Success criterion: 4 green tests, including the raises.
  4. On the program side: in main.py, call rate_for with user input, wrap it in try/except ValueError printing "expected service: great, ok or bad" and asking again (day 26's loop). Test with "meh" then "great": polite message, then computation.
  5. Reread: the raise is in tools (the library signals), the except is in main (the interface comforts). That is day 43's separation, applied to errors.

Agent tips: "where should this code raise an error rather than return a dubious value?": a good proofreading question for an agent. Trap: it adds raise everywhere, including on normal cases (end of file, legitimately empty list). Proof: each proposed raise must match a raises test that would make sense.

Recap (10 min)

Three lines: fail early and loudly, who raises / who catches, !r in messages. Without notes: a rectangle_area(length, width) function that raises ValueError on a negative side, tested.

Day 50 — The csv module, for real

Goal: read and write CSV with the dedicated module, quotes and separators included.

Theory (20 min)

On day 27 you split a CSV by hand and hit its limit: a field containing a comma breaks the split. The standard library's csv module handles all those cases (quotes, internal commas, other separators):

import csv

with open("account.csv", encoding="utf-8", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["label"], row["amount"])

Three novelties. csv.DictReader reads each line as a dictionary whose keys come from the header row: no more indices, no more fragile unpacking — row["amount"] reads by itself, and column order can change without breaking the program. newline="" is the officially recommended setting for CSV files (some fields may contain line breaks; this setting lets the csv module handle them). And every value stays a string: float(row["amount"]) remains your job.

To write: csv.DictWriter with the list of columns, writeheader() for the header, writerow(dict) per line.

The semicolon separator of some locales' exports: csv.DictReader(f, delimiter=";"). To find the right one: open the file in the editor and look at the first line — ten seconds that save an hour of debugging.

DictReader's advantage over split(",")?

  1. It is faster
  2. It handles quotes and names the columns: row["amount"] instead of fragile indices
  3. It converts numbers by itself
Answer

Robustness + readability. Values stay strings: converting is still yours.

Your bank export uses semicolons. What do you do?

  1. csv.DictReader(f, delimiter=";")
  2. Replace the ; with commas in the file
  3. Go back to split(";")
Answer

The module takes the separator as a parameter; never modify the source file.

Practice (30 min)

  1. Create bank.csv (note the semicolon and the label with a comma):
date;label;amount
2026-10-01;supermarket groceries;-42.50
2026-10-02;"restaurant, Le Petit";-23.00
2026-10-05;salary;2100.00
  1. Write read_bank.py: DictReader with delimiter=";", a loop printing date | label | amount per line, then the balance with an accumulator.
  2. Success criterion: three correct lines — the label "restaurant, Le Petit" comes out WHOLE (the internal comma stayed in its field) — and a balance of 2034.50.
  3. Redo the same read with split(";") in two lines of code and watch the damage on the restaurant line. That is why the module exists.
  4. Writing: create summary.csv with DictWriter, two columns (category, total) and two rows of your choice. Reopen it in a spreadsheet if you have one: it is a real table.

Agent tips: "read this CSV": demand DictReader, not a split. Trap: the agent guesses the separator without opening the file. Proof: paste the first three real lines and check that a field with an internal comma comes out whole.

Recap (10 min)

Three lines: DictReader/DictWriter, newline="", delimiter. Without notes: a name;city CSV of three lines, a program that prints "name (city)" per line.

Day 51 — Final project 1: the design

Goal: design the final tool on paper before writing a line: expenses, the summariser for your bank exports.

Theory (20 min)

The final project: a command-line tool that reads a bank account CSV export and prints expenses by category, the total and the biggest item. Everything needed is learned: DictReader (D50), the counting pattern (D22), argparse (D44-45), pytest (D47-48), the file structure (D43).

Design first, with day 28's three questions, written in the notebook:

  1. The data. A CSV with three columns: date;category;amount. In memory: a list of dicts (one expense = one dict), then a category → total dict for the summary.
  2. The actions. read the file (function), total per category (function), find the biggest expense (function), display (main program). Each function does one thing and is tested alone.
  3. The interface. python expenses.py bank.csv prints the summary; --category groceries filters; --threshold 50 only shows categories beyond an amount.

And the golden rule of projects: one step at a time, and each step stands on its own. Planning for the next six days: read (D52), total (D53), test (D54), the CLI (D55), robustness (D56), documentation (D57). A project that half-works beats a perfect project that does not run — and this is the one you trim if days were missed.

Why split read / total / display into three functions?

  1. To look more professional
  2. Each is tested alone, and the main program only orchestrates
  3. Python requires it beyond 50 lines
Answer

Testable functions and a thin main: that is the project's whole architecture.

Practice (30 min)

  1. Create the expenses folder: bubble (python -m venv .venv, activation), pip install pytest, pip freeze > requirements.txt.
  2. Create the data file bank.csv, eight varied lines, semicolon separator, at least three categories (groceries, transport, leisure), negative amounts for expenses:
date;category;amount
2026-10-01;groceries;-42.50
2026-10-02;transport;-30.00
2026-10-03;groceries;-17.80
2026-10-04;leisure;-12.00
2026-10-05;salary;2100.00
2026-10-06;transport;-4.20
2026-10-07;groceries;-55.10
2026-10-08;leisure;-9.99
  1. Write in the notebook the three functions' signatures (name, parameters, what they return) and the three CLI options. No code today: the design is the deliverable.
  2. Success criterion: you can narrate the journey of one CSV line until it appears in the summary, without looking at any code: read → dict → accumulator → display.

Agent tips: have your design challenged: "here is my project plan in three questions, find two weaknesses". Trap: the agent proposes adding features (charts! a database!) — refuse, the scope is yours. Proof: its two weaknesses must touch YOUR functions, not additions.

Recap (10 min)

Three lines: the data, the functions, the interface. Tomorrow we read the file.

Day 52 — Final project 2: reading the expenses

Goal: write read_expenses(), the function that turns the CSV into a list of clean dicts.

Theory (20 min)

First step: reading. The function takes a path, returns a list of clean dicts — date as text, category lowercased and trimmed, amount as a float:

import csv

def read_expenses(path):
    """Read the CSV (date;category;amount) and return the list of expenses.

    Each expense is a dict: date (str), category (str), amount (float).
    """
    expenses = []
    with open(path, encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f, delimiter=";"):
            expenses.append({
                "date": row["date"].strip(),
                "category": row["category"].strip().lower(),
                "amount": float(row["amount"]),
            })
    return expenses

Notice the philosophy: clean at the entrance. Strip, lower and float happen once, here; every function downstream works on clean data and no longer has to distrust it. Same principle as day 8's .lower() in comparisons, applied to a whole pipeline.

Why return a list rather than print? Because a function that returns data can be tested (D47) and reused; a function that prints does only one thing. Display will come, at the top, in main().

Practice (30 min)

  1. Create expenses.py (the project's library) with the lesson's function.
  2. Create a minimal main.py that calls the function and prints the number of lines read:
from expenses import read_expenses

def main():
    expenses = read_expenses("bank.csv")
    print(f"{len(expenses)} lines read.")
    print(expenses[0])

if __name__ == "__main__":
    main()
  1. Success criterion: "8 lines read." then the first dict, amount as a float (check: -42.5 without quotes in the dict's display).
  2. Add a ninth line to the CSV and rerun: nothing to change, the program follows.
  3. Test the fragility on purpose: put "abc" as one line's amount, rerun, read the ValueError — D56 will handle it cleanly; remove it for now.

Agent tips: an agent writes this function perfectly — BUT it often assumes the separator and column names without seeing the file. The classic CSV trap. Proof: the first printed dict must match YOUR bank.csv's first line field by field.

Recap (10 min)

Three lines: clean at the entrance, return data rather than print, the expense dict's structure. Without notes: rewrite the DictReader loop from memory.

Day 53 — Final project 3: totals per category

Goal: write totals_per_category() and biggest_expense() — the heart of the summary.

Theory (20 min)

Second step: the computations. The first is day 22's counting pattern, adapted: instead of counting occurrences, we add amounts. Same skeleton, different operation:

def totals_per_category(expenses):
    """Return a dict category -> total of amounts (negative = expenses)."""
    totals = {}
    for e in expenses:
        totals[e["category"]] = totals.get(e["category"], 0.0) + e["amount"]
    return totals

The second function looks for the expense with the lowest amount (the most negative). Two ways: a loop keeping the minimum "by hand", or the min() function with a key — min(expenses, key=lambda e: e["amount"]). The lambda is a one-line anonymous mini-function; read it "for each e, compare e["amount"]". The loop version is longer but entirely within your reach — write that one, look at the other out of curiosity.

Printing the summary lives in main(): sort the totals dict (sorted(totals.items(), key=lambda pair: pair[1]) sorts by ascending amount) and print line by line with day 12's :.2f f-string.

Practice (30 min)

  1. Add both functions to expenses.py (totals above; biggest_expense as a loop: initialise to None, walk, replace when e["amount"] < worst["amount"]).
  2. Complete main():
def main():
    expenses = read_expenses("bank.csv")
    totals = totals_per_category(expenses)
    print("Expenses per category:")
    for category, total in sorted(totals.items(), key=lambda pair: pair[1]):
        print(f"  {category}: {total:.2f} €")
    worst = biggest_expense(expenses)
    print(f"Biggest expense: {worst['category']} ({worst['amount']:.2f} €) on {worst['date']}")
  1. Success criterion: on day 51's bank.csv: groceries -115.40 €, transport -34.20 €, leisure -21.99 €, salary 2100.00 €, and the biggest expense is groceries (-55.10 €) on 2026-10-07. Check every total with a calculator — that is your real test today.
  2. Add a "groceries" expense to the CSV, rerun: the total and maybe the champion change, the code does not.

Agent tips: "find the biggest expense": the agent will pull out min(..., key=...). Trap: on an EMPTY list, min crashes — ask it for the empty-list behaviour, or impose "return None when empty". Proof: test with an expense-free CSV.

Recap (10 min)

Three lines: the counter turned sum, min with key vs loop, sorting a dict by value. Without notes: totals per category on a handwritten list of three dicts.

Day 54 — Final project 4: testing the functions

Goal: cover the project with pytest tests: reading with tmp_path, computations, edge cases.

Theory (20 min)

Third step: the proof. Checking with a calculator (yesterday) is exact but does not replay; tests do. Coverage rule for this project: every function gets at least one happy-path test and one edge test.

The test plan, written before the test code:

  • read_expenses: a CSV written in tmp_path → the list has the right number of dicts, the amount is a float, the category is lowercase. Edge: an empty file (header only) → empty list, no error.
  • totals_per_category: a list built by hand (no file!) → the totals are right. Edge: empty list → empty dict.
  • biggest_expense: three expenses → the right one comes back. Edge: decide and test the empty-list behaviour (returning None is a clean choice — but then main() must handle it: "no expense").

Note the computation-test trick: build the data in memory, a list of dicts written in the test. No file, no dependency on the CSV: the test targets the function and nothing else. A test must fail for ONE possible reason only.

Practice (30 min)

  1. Create test_expenses.py:
from expenses import read_expenses, totals_per_category, biggest_expense

CSV = "date;category;amount\n2026-10-01;Groceries;-42.50\n2026-10-02;transport;-30.00\n"

def test_read_expenses(tmp_path):
    f = tmp_path / "b.csv"
    f.write_text(CSV, encoding="utf-8")
    expenses = read_expenses(f)
    assert len(expenses) == 2
    assert expenses[0]["amount"] == -42.50
    assert expenses[0]["category"] == "groceries"

def test_totals():
    expenses = [{"category": "groceries", "amount": -10.0, "date": "2026-10-01"},
                {"category": "groceries", "amount": -5.0, "date": "2026-10-02"},
                {"category": "leisure", "amount": -3.0, "date": "2026-10-03"}]
    assert totals_per_category(expenses) == {"groceries": -15.0, "leisure": -3.0}

def test_biggest():
    expenses = [{"category": "a", "amount": -10.0, "date": "2026-10-01"},
                {"category": "b", "amount": -30.0, "date": "2026-10-02"}]
    assert biggest_expense(expenses)["category"] == "b"

def test_empty_list():
    assert totals_per_category([]) == {}
  1. Implement biggest_expense's empty-list behaviour (return None), write its test, and handle it in main().
  2. Success criterion: python -m pytest: 5 green tests, and python main.py still prints the right summary.
  3. Break a function (+ 1 on a total), rerun pytest: the right test turns red. Fix it.

Agent tips: "generate the tests for these three functions": ideal delegation, the code exists. Trap: tests that always pass (assert True in disguise). Proof: yesterday's breaking experiment — break the function, demand red.

Recap (10 min)

Three lines: one happy + one edge per function, in-memory data for computations, tmp_path for files. Without notes: write the test of a function that doubles amounts.

Day 55 — Final project 5: the command line

Goal: turn main() into a real CLI: file as argument, filters as options.

Theory (20 min)

Fourth step: the tool becomes commandable. Day 51's design said: positional file, --category to filter, --threshold to show only big categories. With argparse, it is mechanical:

import argparse

def main():
    parser = argparse.ArgumentParser(description="Summary of the expenses of a CSV export.")
    parser.add_argument("file", help="the CSV (date;category;amount)")
    parser.add_argument("--category", default="", help="show only this category")
    parser.add_argument("--threshold", type=float, default=0.0, help="hide categories whose total is above -threshold € (expenses are negative)")
    args = parser.parse_args()

The filtering then speaks with day 18's tools: build a new list keeping only what matches. For --category: a loop keeping only that category's expenses (lowercase comparison, already normalised at read time). For --threshold: when printing the totals, skip categories whose total is above the negative threshold (if total > -args.threshold: continue — amounts are negative, the threshold compares with a minus sign; draw it on an example: threshold 50 → do we hide groceries with its total of -115? No: -115 < -50, so it shows. We hide the SMALL expenses, those above -50).

The CLI is the last layer: the functions have not changed. That is the reward of day 43's structure: an interface is replaced without touching the engine.

Practice (30 min)

  1. Rewrite main() with the argparse above and both filters.
  2. Success criterion, three commands:
    • python main.py bank.csv → the full summary (same figures as day 53);
    • python main.py bank.csv --category groceries → only groceries, total -115.40 €;
    • python main.py bank.csv --threshold 50 → groceries and salary stay, transport and leisure disappear (their totals sit between 0 and -50).
  3. python main.py --help: reread the help like a user discovering the tool. Fix the help= texts that say nothing.
  4. Break the usage: forget the file → polite usage; --threshold abc → typed refusal. argparse works for you.

Agent tips: "add --category and --threshold to this CLI": the agent excels. Trap: threshold logic with negative amounts is a sign trap for it as for you. Proof: the success criterion's test set, figures checked by hand.

Recap (10 min)

Three lines: the CLI as a swappable layer, the two filters, the negative-amounts trap. Without notes: add --month (filter on the date's first 7 characters) to the tool.

Day 56 — Final project 6: robustness

Goal: make the tool survive real files: missing, broken line, wrong encoding.

Theory (20 min)

Fifth step: the real world. Your exercise bank.csv is clean; a real export never is. Three faults to handle, with known tools:

  • The file does not exist: FileNotFoundError → try/except in main(), message "file not found: …", clean exit. No traceback for a user.
  • A line has an unreadable amount: float("abc") raises ValueError. Two honest policies: strict (the program stops, naming the line) or lenient (it skips the line while counting it: "2 lines skipped" printed at the end). Lenient is more useful day to day, provided the skipped count is PRINTED — a silently swallowed line is a confidently wrong total.
  • Encoding: a Windows export in latin-1 breaks accents (UnicodeDecodeError) when read as utf-8. The proper fix exceeds this path; the vital minimum: catch UnicodeDecodeError and say "the file's encoding is not utf-8 — re-export or re-save it as utf-8".

And every robustness gets tested: missing file (a bogus path is enough), broken amount (an "abc" line in the test's CSV), skipped count checked. Untested robustness is an intention, not a feature.

Practice (30 min)

  1. In read_expenses, wrap the float() in a try/except ValueError: line skipped, counter skipped += 1; the function now returns the tuple (expenses, skipped) — adapt main() and the tests (day 20's unpacking: expenses, skipped = read_expenses(f)).
  2. In main(), try/except FileNotFoundError around the call, polite message, and return to exit without a traceback.
  3. Success criterion:
    • python main.py missing.csv → "file not found", no traceback;
    • a line "2026-10-09;groceries;abc" in bank.csv → the summary prints with "1 line skipped" as the last line;
    • pytest: everything stays green, plus two new tests (broken amount → skipped and counted; header-only file → 0 lines, 0 skipped).
  4. Also test the mixed case: a good line after the broken one must be read (the except did not stop the loop).

Agent tips: "make this script robust" is too vague: give the exact list of faults to handle (missing, unreadable amount, encoding). Trap: the agent catches a blanket Exception "just in case". Proof: provoke each fault one by one and check the exact message.

Recap (10 min)

Three lines: the three real-world faults, strict vs lenient, a skipped line is always counted. Without notes: add the UnicodeDecodeError try with its message.

Day 57 — Final project 7: documentation

Goal: finish the tool: README, docstrings, requirements — the complete package you can hand to someone.

Theory (20 min)

Sixth step: the wrapping. A finished tool can be given away: to a colleague, to a forum, to yourself in six months. Three parts:

The README.md (plain text, Markdown format): the first thing one reads. Five sections are enough:

  1. What: one sentence. "expenses summarises a bank CSV export per category."
  2. Install: the exact commands — create the bubble, activate it, pip install -r requirements.txt.
  3. Usage: the base command and each option, with a sample input and the real output.
  4. The expected CSV format: columns, separator, encoding — that is the input contract.
  5. Tests: python -m pytest.

Docstrings: go over each function — what it does, what it expects, what it returns (day 31). requirements.txt: pip freeze > requirements.txt one last time, up to date.

The sign of a good README: someone who has never seen the project reaches the expected output following only sections 2 and 3. Test it: close everything, open a fresh terminal, follow your README to the letter in a new folder. Whatever you forgot, you will discover — the file path, the separator, the bubble.

Practice (30 min)

  1. Write README.md with the five sections. For the sample output, copy the real output of python main.py bank.csv.
  2. Pass over the docstrings: every function in expenses.py has one (what it returns, the empty-list case included).
  3. pip freeze > requirements.txt.
  4. Success criterion (the colleague test): copy the project folder (without .venv) into a fresh folder, follow your README like a stranger: bubble, requirements, run, pytest. Everything must work without asking the author anything. Every snag = one line to add to the README.
  5. Delete the scratch copy. The project is finished.

Agent tips: "write this project's README": the agent does it well PROVIDED you give it the files — otherwise it invents options. Trap: an invented sample output that does not match the real program. Proof: the colleague test, run to the letter, on ITS version of the README.

Recap (10 min)

Three lines: the five sections, the input contract, the colleague test. Your first finished tool exists.

Day 58 — General review: the challenge

Goal: prove autonomy: a complete program, to an imposed specification, written alone from A to Z.

Theory (20 min)

No lesson: a challenge. The specification of words.py, a text analyser:

  • Takes a text file as positional argument (argparse), and a --top N option (type int, default 5).
  • Reads the file as utf-8 (polite message if missing).
  • Prints: the word count, the distinct word count, and the N most frequent words with their counts (case normalised, punctuation .,;:!? ignored — .strip(".,;:!?") on each word is enough).
  • The functions (read, count) live in a separate module, tested with pytest (tmp_path temporary file).

Example: on a file holding "the cat and the dog. The cat sleeps.", --top 2 prints 8 words, 5 distinct, then "the: 3" and "cat: 2".

Recommended method, all learned here: design in three questions in the notebook (5 min), two-file skeleton, reading first, counter next, CLI after, tests at each step. Time yourself: under two hours is an excellent score.

Practice (30 min, overflow allowed)

  1. Write the specification as a plan in the notebook, without coding.
  2. Code, step by step. No answer key: the tests and the example are your referees.
  3. Success criterion: the lesson's example gives exactly the announced output, pytest is green, and --top 1 on the same file prints only "the: 3".
  4. Stuck more than 20 minutes on a point? Reread that point's session (counter: D22; sort by value: D53; argparse: D44) — that is what the notebook is for: knowing where to look.

Agent tips: today, resist. Tomorrow, have an agent write the same program and compare both: structure, readability, traps. Yours will be simpler, and that is a compliment. Proof: both pass the same test set — including an empty text and a mixed-case word.

Recap (10 min)

Three lines: what flowed, what resisted, what you will reread tomorrow if needed.

Day 59 — Path review

Goal: close the loop: the checklist, the retrospective, and what comes next.

Theory (20 min)

Reread the checklist on the path's home page and tick honestly. Then measure the distance travelled: on day 1, opening a terminal was an ordeal; on day 59, you own a structured, tested, documented tool that processes real data. The notions still fuzzy are not a failure: they are your program for the next two weeks, and you now know a fuzzy point is solved by one hour-long session, not by a whole book.

To keep going, three honest leads:

  • Go deeper: the official tutorial, where you left it — you will now read it as a review of details, not as a foreign language.
  • Automate more: Automate the Boring Stuff with Python, the chapters on web scraping, Excel and PDFs — the direct continuation of your modules 3 and 4.
  • Build: pick a repetitive task from your real life (sorting, exports, renames, reminders) and tool it. A personal tool used every week maintains your skills better than ten exercises.

Practice (30 min)

  1. Tick the home page checklist. For each unticked box, note the session to redo.
  2. The retrospective in the notebook: the hardest session, the most useful one, the mistake you will not make again.
  3. Success criterion: present your final project to someone (or out loud, alone) in five minutes: what it does, how it is structured, how its tests prove it. If the presentation holds, the path is a success.
  4. Store the project folder somewhere durable. You will come back to it.

Agent tips: you now hold this site's superpower: knowing what an agent does when it "codes for you". The final rule will not change: delegate what you can proofread, demand the proof that runs, keep your hand on the design. All 59 sessions' tip boxes fit in that sentence.

Recap (10 min)

Three lines: what you can do, what you will do next week, who you will show the tool to. Congratulations.

Found this useful? Tell us what to measure next.

Comments

No account, no tracker: a comment is read by a human before it appears.