Chapter 16 ยท Putting It Together

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 argparse subcommands, exit codes and stderr.
  • 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:

DecisionWhyChapter
Store money as integer centsno float rounding errors in totals2
Category as a StrEnumtypos become errors, and members still behave like strings14
Expense as a @dataclassa free __init__, __repr__ and __eq__11
A JSON file, written via a temp filehuman-readable, and never left half-written9, 13
A custom StorageError with chainingclear messages without losing the cause10
Generators for filteringlazy, and easy to combine12
main(argv=None) returning an exit codetestable without running a subprocess8, 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.

Step 2: storage (storage.py)

Step 3: reports (report.py)

Step 4: the command-line interface (cli.py)

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 stderr with exit code 1, and let bugs raise.
  • Write main(argv=None) and return codes, so tests can call it directly.
  • python3 -m tracker for development; pyproject.toml with pip 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