Project: CLI Expense Tracker
Build a complete, tested command-line program from scratch, using nearly every chapter of this book: expenses add 12.50 food, expenses list, expenses summary.
You will learn
- How to split a program into a package of focused modules.
- How to model data with a dataclass, a
StrEnum, and integer cents. - How to store data safely as JSON, with clear custom errors.
- How to build a real CLI with
argparsesubcommands, exit codes andstderr. - How to test the whole thing with
unittest, and install it as a command.
The plan
We'll build tracker, a small package in playground/. (The finished version is in playground/reference/ if you get stuck; clone the playground from coder-hop/python-book-playground if you don't have it.) It replaces the little tracker package from Chapter 8's exercise.
Design decisions, and the chapter each one comes from:
| Decision | Why | Chapter |
|---|---|---|
| Store money as integer cents | no float rounding errors in totals | 2 |
Category as a StrEnum | typos become errors, and members still behave like strings | 14 |
Expense as a @dataclass | a free __init__, __repr__ and __eq__ | 11 |
| A JSON file, written via a temp file | human-readable, and never left half-written | 9, 13 |
A custom StorageError with chaining | clear messages without losing the cause | 10 |
| Generators for filtering | lazy, and easy to combine | 12 |
main(argv=None) returning an exit code | testable without running a subprocess | 8, 13 |
Step 1: the data model (models.py)
This module knows nothing about files or the command line. It only describes the data. Keeping it free of input/output makes it easy to test and reuse.
Category.parseis a classmethod that converts user text into an enum member. It replaces the enum's terse error with a helpful one, andfrom Nonehides the internal cause (Chapter 10).", ".join(cls)works becauseStrEnummembers are strings.parse_amountvalidates the text by hand withpartitionandisdigit, and accepts both12.5and12,50. Its docstring contains a doctest.format_moneyusesdivmodon the absolute value, then:,and:02dformat specs (Chapter 9).to_dictandfrom_dictconvert to and from JSON-friendly types: dates become ISO strings and enums become their values.
Step 2: storage (storage.py)
- EAFP: just try to read the file.
FileNotFoundErrormeans "first run", so return an empty list. - Any parsing problem (bad JSON, a missing key, an unknown category) becomes one
StorageError, chained withfrom excso the original cause stays visible to anyone debugging. savewrites toexpenses.json.tmpfirst, thenreplace()s the real file in one step. A crash halfway through writing can never corrupt your data.max(..., default=0)handles the empty list when picking the next id.
Step 3: reports (report.py)
selectis a generator with keyword-only filters (*, Chapter 5).totals_by_category(select(...))never builds an intermediate list.defaultdict(int)starts each total at 0, andsorted(..., key=lambda kv: kv[1], reverse=True)puts the biggest first. Since dicts keep insertion order, the result stays sorted.bardraws a tiny text chart with string repetition (Chapter 2).
Step 4: the command-line interface (cli.py)
- Subcommands:
add_subparsersgives each command its own arguments, soexpenses add -hshows only the options foradd. - A closure:
_arg(convert)returns a wrapper function that remembersconvert(Chapter 5). argparse calls it for each argument, and anArgumentTypeErrormakes argparse print our message instead of a generic one. - Functions as values:
COMMANDSmaps names to functions, a "dispatch table". Each command returnsTrueif the data changed, somainonly saves when it needs to. (Amatch args.command:block would work just as well.) - Exit codes and stderr: expected problems print
error: โฆtostderr, andmainreturns 1. Bugs are not caught, so they still show a full traceback, which is exactly what you want while developing. main(argv=None): argparse readssys.argvwhenargvisNone, so tests can pass their own list instead.
Step 5: the package files (__init__.py, __main__.py)
python3 -m tracker runs tracker/__main__.py. sys.exit(main()) turns main's return value into the process exit code.
Step 6: use it
Run these from playground/. --file demo.json keeps the experiment separate from your real data, and --date makes the output repeatable.
Errors are friendly, and the exit codes are right:
And the data file is plain, readable JSON:
Step 7: tests (test_tracker.py)
The tests call main([...]) directly, with a temporary data file, and capture the output with redirect_stdout and redirect_stderr. subTest reports each bad input separately when one of them fails.
Step 8: install it as a real command
The [project.scripts] entry creates an expenses executable in the venv's bin/ that calls tracker.__main__:main, and uses its return value as the exit code.
More examples
1. Using the package as a library
2. A damaged data file is reported, not crashed on
3. Exporting to CSV
4. Monthly totals with Counter
5. The same dispatch, written with match
Both styles are fine. The dict version makes adding a command a one-line change, and the match version is easier to extend with argument patterns.
Recap
- Split by responsibility: model (pure data) โ storage (files) โ report (calculations) โ CLI (input and output).
- Integer cents, an enum and a dataclass make invalid data hard to create. Validate at the edges, where user input comes in.
- Catch expected errors, show them on
stderrwith exit code 1, and let bugs raise. - Write
main(argv=None)and return codes, so tests can call it directly. python3 -m trackerfor development;pyproject.tomlwithpip install -e .for a real command.
Exercises
All of these extend your playground/tracker. Add a test for each feature you build.
Exercise 1 ๐ข easy build it ยท playground
Add a --version option to the main parser that prints expenses 0.1.0, using tracker.__version__.
Show solution
argparse's "version" action prints the text and exits with code 0. %(prog)s is replaced by the program name.
Exercise 2 ๐ข easy build it ยท playground
Add a GIFTS category. What else needs to change for it to work everywhere?
Show solution
Nothing else needs to change. Parsing, the error message's list of choices, storage and reports all read the enum. That is the payoff of keeping the categories in one place.
Exercise 3 ๐ข easy explain why
Why does cmd_delete raise LookupError instead of printing the error itself?
Show solution
It keeps the command focused on its job and leaves the decision about how to report to main, which turns every expected error into the same error: โฆ line on stderr with exit code 1. Tests can also check the behaviour without parsing printed text.
Exercise 4 ๐ข easy predict the output
What does parse_amount("3.5") return? And parse_amount("3.")?
Show solution
350 ("5".ljust(2, "0") is "50"), and 300 (an empty fraction becomes "00"). Both are reasonable. If you'd rather reject "3.", add a check and a test.
Exercise 5 ๐ก medium build it ยท playground
Add an edit ID command with optional --amount, --category and --note. Only the given fields change.
Show solution
Optional arguments default to None, so is not None tells "not given" apart from an empty note "".
Exercise 6 ๐ก medium build it ยท playground
Add budgets: a budget.json like {"food": 30000} (in cents). Make summary show โ over next to categories that went over budget.
Show solution
str(category) gives "food", which matches the JSON keys. The budget file is optional (EAFP), like the data file.
Exercise 7 ๐ก medium refactor it ยท playground
Replace the COMMANDS dict with a match statement in main (see "More examples" #5). Do all the tests still pass?
Show solution
Yes, they should. The tests only call main([...]) and check its output and exit code. That is the point of testing behaviour rather than internals: you can refactor freely. Run python3 -m unittest to confirm.
Exercise 8 ๐ก medium build it ยท playground
Add a --search TEXT filter to list that matches notes case-insensitively, by adding a text keyword to select(). Write a test for it.
Show solution
Because the filter is keyword-only with a None default, existing callers don't change.
Exercise 9 ๐ด hard build it ยท playground
Add an import FILE.csv command that reads the CSV format from "More examples" #3, assigns new ids, and reports every bad row (with its line number) together at the end as one ExceptionGroup, while still importing the good rows.
Show solution
This combines csv.DictReader, EAFP validation, add_note, and ExceptionGroup (Chapter 10) for a "report everything at once" import.
Exercise 10 ๐ด hard mini-project ยท playground
Add a stats command that prints, for the chosen --month: the number of expenses, the total, the average, the biggest expense (with its note), the busiest day, and a sparkline of the daily totals using the characters โโโโโ
โโโ. Use statistics, Counter, max(key=โฆ) and a generator. Add tests.
Show solution
Every piece comes from an earlier chapter: Counter and statistics (13), a generator expression with join (12), max with a key (6), and integer-cents money (2). Test it with a small, fixed dataset, as TestReport does.
Official docs
- The Python Tutorial (everything used in this project)
- argparse: sub-commands
- unittest ยท contextlib.redirect_stdout
- Writing your pyproject.toml