Part 1 · Foundations · Graded project

Project 1: Budget Ledger CLI

Build a real command-line tool that turns messy bank exports into a monthly spending report, flags double-imported charges, and tells you where you went over budget. Everything in Part 1 comes together here.

⏱ ~6–8 hoursPrerequisites: M05–M09

The scenario

Most banks let you download your transactions as a CSV (comma-separated values) file. In theory that makes budgeting easy. In practice every export is a little different: one bank writes Date, another DATE; dates come as 2026-01-05 or 01/05/2026; spending appears as -1,450.00 or, in accounting style, (1450.00); some files begin with an invisible byte-order mark; and occasionally a row is simply broken. When you download overlapping date ranges, the same charge appears twice.

You will build ledger, a tool you could actually use on your own money:

$ pixi run python -m ledger budget sample/checking-2026-01.csv --rules sample/rules.toml
2026-01 groceries: spent 273.07 of 250.00 (over by 23.07)
$ echo $?
3

The sample data lives in projects/p01_budget_ledger/sample/: two monthly exports (the second contains a deliberately corrupt row) and a rules.toml that maps keywords to categories and categories to monthly limits.

# rules.toml: keyword rules are checked top to bottom; the first match wins.
[rules]
rent = ["city rent"]
groceries = ["whole foods", "trader joe"]
dining = ["pizza", "trattoria", "cafe"]
income = ["payroll"]

[budgets]
groceries = 250
dining = 80

Learning goals

Functions (M05)

Each module exposes a handful of small functions with type hints and Google-style docstrings. Library functions return values; only the CLI prints.

Collections (M06)

Dicts of dicts for summaries, tuples as grouping keys, setdefault and defaultdict, sorting for stable output.

Exceptions (M07)

One custom LedgerError; low-level errors are translated with raise ... from exc; the CLI maps errors to exit codes.

Modules (M08)

A package split by responsibility, a __main__.py, and Pixi to run and grade it.

Files (M09)

pathlib, csv.DictReader with newline="", json with a default= encoder, tomllib in binary mode.

Feature specification

AreaRequirement
Input CSVHeader row with Date, Description, Amount in any case and order; extra columns ignored; UTF-8 with or without BOM.
DatesYYYY-MM-DD or MM/DD/YYYY.
AmountsExact Decimal. Accept $, thousands commas, a leading minus, or parentheses for negatives. Reject empty, non-numeric, NaN, and infinity.
ErrorsEvery malformed input raises LedgerError. Row errors start with file.csv:LINE:.
Rules[rules]: category → keyword list. Case-insensitive substring match; first rule in file order wins; otherwise "uncategorized".
Budgets[budgets]: category → non-negative number. Spending is the negated monthly total; overrun means strictly greater than the limit.
DuplicatesSame date, same amount, same description ignoring case and repeated whitespace. Groups keep input order.
Export.json: full report (money and dates as strings). .csv: month,category,total. Other extensions are a LedgerError.
CLIsummary (--month), duplicates, budget (needs --rules), export --out. Exit codes: 0 ok, 1 data error, 2 usage error, 3 over budget.

Architecture

Data flows in one direction. Each box is one module with one job, which keeps every function testable on its own: you can test monthly_summary without touching a file, and test parsing without a CLI.

flowchart LR
  CSV[(bank CSV files)] --> P[parsing.py
read_many] CFG[(rules.toml / .json)] --> R[rules.py
load_config] P -->|list of Transaction| C[rules.py
categorize_all] R -->|Config.rules| C C --> A[analysis.py
monthly_summary
find_duplicates
check_budgets] R -->|Config.budgets| A A --> X[report.py
build_report
export_report] X --> OUT[(report.json / summary.csv)] CLI[cli.py main] -.calls.-> P CLI -.calls.-> R CLI -.calls.-> A CLI -.calls.-> X

The CLI is a thin shell: it parses arguments, calls the library, prints results, and converts a LedgerError into a message on stderr and exit code 1. Nothing else in the package prints.

Getting started with Pixi

pixi run projects          # list every project
pixi run start p01         # copy the starter into work/p01_budget_ledger/
pixi run grade p01         # run all grading tests against your work copy
pixi run grade p01 -k parsing   # only the tests whose names mention "parsing"

Open work/p01_budget_ledger/ledger/. Every function you write starts as raise NotImplementedError with TODO comments. The first grade run fails almost every test; that is the point. Each milestone turns a test file green.

Milestones

Milestone 1: Parsing (parsing.py)

Turn raw text into exact values, and refuse bad input loudly. This is where most real-world bugs live.

  • parse_amount handles $, commas, minus, and parentheses, and returns Decimal
  • parse_amount raises LedgerError for empty, non-numeric, nan, inf
  • parse_date accepts both formats and raises LedgerError otherwise
  • read_transactions normalizes headers, tolerates a BOM, ignores extra columns
  • Malformed rows raise LedgerError("file.csv:LINE: ..."); missing files too
  • pixi run grade p01 -k parsing is green

Milestone 2: Rules and categories (rules.py)

  • load_config reads .toml (binary mode) and .json
  • Rules keep file order; keywords are stored lower-cased
  • Budgets become Decimal; booleans, strings that are not numbers, and negatives are rejected
  • categorize returns the first matching category or "uncategorized"
  • categorize_all returns new transactions and leaves the input unchanged
  • pixi run grade p01 -k rules is green

Milestone 3: Analysis (analysis.py)

  • monthly_summary groups by t.month and category, sorted
  • find_duplicates groups by a normalized (date, amount, description) key
  • check_budgets reports strictly-over months and categories, refunds included
  • pixi run grade p01 -k analysis is green

Milestone 4: Reports (report.py)

  • build_report returns counts, months, overruns, and duplicate groups
  • write_json uses a default= function for Decimal and date
  • write_summary_csv writes month,category,total with newline=""
  • export_report picks the format by suffix and creates missing folders
  • pixi run grade p01 -k report is green

Milestone 5: The CLI (cli.py)

  • build_parser defines four subcommands that each take one or more CSV paths
  • main(argv) returns an exit code instead of calling sys.exit
  • Usage errors return 2; LedgerError prints error: ... to stderr and returns 1
  • budget returns 3 when anything is over budget
  • pixi run grade p01 is fully green

Hints

Why Decimal and not float?

0.1 + 0.2 is 0.30000000000000004 in binary floating point. Over hundreds of transactions those errors add up and totals stop matching your bank. Decimal("0.10") is exact. Always build a Decimal from a string; Decimal(0.1) captures the float's error. That is why budgets use Decimal(str(value)).

Adding file and line to an error message
try:
    amount = parse_amount(cells["amount"])
except LedgerError as exc:
    raise LedgerError(f"{source}:{line}: {exc}") from exc

from exc keeps the original error attached as __cause__, so a developer still sees the full story in a traceback, while the user sees one clear line.

Normalizing the CSV header

csv.DictReader reads the header lazily. Touch reader.fieldnames once (it is None for an empty file), then assign a cleaned list back: reader.fieldnames = [n.strip().lower() for n in reader.fieldnames]. Every row dict now uses the clean keys. Open the file with encoding="utf-8-sig" so a BOM does not end up glued to "date".

Grouping with a tuple key
groups = {}
for t in transactions:
    key = (t.date, t.amount, " ".join(t.description.casefold().split()))
    groups.setdefault(key, []).append(t)
duplicates = [g for g in groups.values() if len(g) > 1]
Testing a CLI that calls argparse

parser.parse_args raises SystemExit(2) on bad input (and SystemExit(0) for --help). Catch it in main and return exc.code. Because main takes argv as a list, tests call main(["summary", "file.csv"]) directly, no subprocess needed.

Rubric

CriterionExcellentWeight
Correctnesspixi run grade p01 passes every test.50%
Error handlingOnly LedgerError escapes the library; messages name the file, line, and value; no bare except; nothing swallowed silently.15%
DesignSmall functions with one job; library code returns values and never prints; no global state.15%
ReadabilityType hints and Google-style docstrings on public functions; clear names; pixi run ruff check work/p01_budget_ledger is clean.10%
UsabilityOutput is aligned and readable; exit codes match the spec; the sample files produce sensible results.10%

Stretch goals

Reflection quiz