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.
- Design a program as a package of small, pure, documented functions
- Parse untrusted CSV input defensively and report errors with file and line numbers
- Load configuration from TOML or JSON with
tomllibandjson - Group, total, and compare data with dicts, tuples as keys, and sorting
- Use a custom exception type and exit codes to build a well-behaved CLI with
argparse - Work test-first against a professional-style pytest suite using Pixi
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
| Area | Requirement |
|---|---|
| Input CSV | Header row with Date, Description, Amount
in any case and order; extra columns ignored; UTF-8 with or without BOM. |
| Dates | YYYY-MM-DD or MM/DD/YYYY. |
| Amounts | Exact Decimal. Accept $, thousands commas, a leading
minus, or parentheses for negatives. Reject empty, non-numeric, NaN, and infinity. |
| Errors | Every 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. |
| Duplicates | Same 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. |
| CLI | summary (--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_amounthandles$, commas, minus, and parentheses, and returnsDecimalparse_amountraisesLedgerErrorfor empty, non-numeric,nan,infparse_dateaccepts both formats and raisesLedgerErrorotherwiseread_transactionsnormalizes headers, tolerates a BOM, ignores extra columns- Malformed rows raise
LedgerError("file.csv:LINE: ..."); missing files too pixi run grade p01 -k parsingis green
Milestone 2: Rules and categories (rules.py)
load_configreads.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 categorizereturns the first matching category or"uncategorized"categorize_allreturns new transactions and leaves the input unchangedpixi run grade p01 -k rulesis green
Milestone 3: Analysis (analysis.py)
monthly_summarygroups byt.monthand category, sortedfind_duplicatesgroups by a normalized(date, amount, description)keycheck_budgetsreports strictly-over months and categories, refunds includedpixi run grade p01 -k analysisis green
Milestone 4: Reports (report.py)
build_reportreturns counts, months, overruns, and duplicate groupswrite_jsonuses adefault=function forDecimalanddatewrite_summary_csvwritesmonth,category,totalwithnewline=""export_reportpicks the format by suffix and creates missing folderspixi run grade p01 -k reportis green
Milestone 5: The CLI (cli.py)
build_parserdefines four subcommands that each take one or more CSV pathsmain(argv)returns an exit code instead of callingsys.exit- Usage errors return 2;
LedgerErrorprintserror: ...to stderr and returns 1 budgetreturns 3 when anything is over budgetpixi run grade p01is 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
| Criterion | Excellent | Weight |
|---|---|---|
| Correctness | pixi run grade p01 passes every test. | 50% |
| Error handling | Only LedgerError escapes the library; messages name the file,
line, and value; no bare except; nothing swallowed silently. | 15% |
| Design | Small functions with one job; library code returns values and never prints; no global state. | 15% |
| Readability | Type hints and Google-style docstrings on public functions; clear names;
pixi run ruff check work/p01_budget_ledger is clean. | 10% |
| Usability | Output is aligned and readable; exit codes match the spec; the sample files produce sensible results. | 10% |
Stretch goals
--skip-bad-rows: report malformed rows on stderr and continue, counting them in the report.- Support exports with separate
DebitandCreditcolumns. - Regular-expression rules using
re.searchalongside keywords. - A
comparesubcommand showing two months side by side with percentage change. - A
[tasks]entry in apixi.tomlsopixi run ledger ...works.