The capstone is a small but complete program: a command-line tool that records expenses in a JSON file and reports on them. It uses a dataclass for the record, a separate module for file storage, argparse for the interface, and a package layout that keeps every layer testable on its own.
What does the finished tool do?
Four commands, each one obvious: add an expense, list what is stored, remove one by id, and summarise the totals per category. The data lives in a plain JSON file that you can open in any text editor.
python -m ledger add --amount 12.50 --category food --note "lunch"
python -m ledger add --amount 3.20 --category transport --note "bus fare"
python -m ledger add --amount 45.00 --category food --note "weekly shop"
python -m ledger add --amount 120.00 --category utilities --note "internet"
python -m ledger list
python -m ledger list --category food
python -m ledger summary
python -m ledger remove --id 2
Here is what those commands actually printed when this project was run.
added #1 12.50 in food
added #2 3.20 in transport
added #3 45.00 in food
added #4 120.00 in utilities
#1 2026-10-27 12.50 food lunch
#2 2026-10-27 3.20 transport bus fare
#3 2026-10-27 45.00 food weekly shop
#4 2026-10-27 120.00 utilities internet
4 entries
food 57.50
transport 3.20
utilities 120.00
total 180.70
The date on each line is the day the command ran. The numbers come straight from the JSON file, so the tool can be used from the shell, from a cron job, or from another Python script that imports the package.
How is the project laid out?
ledger/
├── __init__.py # marks the folder as a package
├── __main__.py # python -m ledger starts here
├── models.py # what an expense is
├── storage.py # reading and writing the JSON file
├── commands.py # add, list, remove, summarise
└── cli.py # argument parsing and printing
Each file answers one question. models.py says what the data looks like. storage.py says how it is saved. commands.py says what the tool can do. cli.py says how the user asks for it. The dependencies point one way only - the CLI knows about the commands, the commands know about storage and models, and the models know about nothing but the standard library.
That is the arrangement from the previous lesson about splitting a program across files, applied to something you would actually ship. A test for the totals does not need to touch the disk; a test for the CLI does not need to know how the file is stored.
The file you cannot see in the listing is commands.py, and it holds the four operations - add_expense, list_expenses, remove_expense and totals_by_category. Each one takes a path and returns plain data, and each one is short enough to read in one breath. Keeping them out of cli.py is what lets you call them from a script, a notebook or a test without going through argparse.
How does an expense survive a round trip through JSON?
The record is a dataclass, and the two methods that matter are to_dict and from_dict. They are the only place where the in-memory shape and the stored shape meet.
# ledger/models.py
"""The Expense record and how it is converted to and from plain data."""
from dataclasses import asdict, dataclass
from datetime import date
@dataclass
class Expense:
"""A single expense entry."""
id: int
amount: float
category: str
note: str = ""
day: str = ""
def __post_init__(self) -> None:
"""Fill in today's date when the caller did not supply one."""
if not self.day:
self.day = date.today().isoformat()
def to_dict(self) -> dict[str, object]:
"""Return the record as a plain dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, object]) -> "Expense":
"""Build an Expense from a dictionary read from JSON."""
return cls(
id=int(data["id"]),
amount=float(data["amount"]),
category=str(data["category"]),
note=str(data.get("note", "")),
day=str(data.get("day", "")),
)
Two details make this robust. The conversions in from_dict are explicit - int(...), float(...), str(...) - so a JSON file written by hand with "amount": "12.50" still loads as a number. And .get(...) with a default means an old file that predates the note field still loads instead of raising KeyError.
How does the file layer stay separate?
Storage is two functions and a path. Neither of them knows what an expense means, and neither of them prints anything.
# ledger/storage.py
"""Reading and writing the JSON file that holds the expenses."""
import json
from pathlib import Path
from .models import Expense
DEFAULT_PATH = Path("ledger-data") / "expenses.json"
def load(path: Path = DEFAULT_PATH) -> list[Expense]:
"""Return every expense stored in the JSON file."""
if not path.exists():
return []
raw = json.loads(path.read_text(encoding="utf-8"))
return [Expense.from_dict(item) for item in raw]
def save(expenses: list[Expense], path: Path = DEFAULT_PATH) -> None:
"""Write every expense back to the JSON file."""
path.parent.mkdir(parents=True, exist_ok=True)
payload = [expense.to_dict() for expense in expenses]
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def next_id(expenses: list[Expense]) -> int:
"""Return the next free id for the list."""
return max((expense.id for expense in expenses), default=0) + 1
Three things in there earn their place. path.parent.mkdir(parents=True, exist_ok=True) creates the data folder on first use, so the tool works on a fresh checkout with no setup step. load returns an empty list for a file that does not exist, which means "no expenses yet" and "the file is missing" are the same situation to the rest of the program. And indent=2 keeps the file readable, because a data file you cannot read is a data file you cannot repair by hand.
How does argparse build the interface?
argparse gives you a parser, a set of subcommands and a help screen for free. The trick is to build the parser in its own function, so the rest of the module never touches the argument objects.
# ledger/cli.py - excerpt
"""The command line interface of the ledger tool."""
import argparse
from pathlib import Path
from . import commands
from .storage import DEFAULT_PATH
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for every command."""
parser = argparse.ArgumentParser(
prog="ledger",
description="Track expenses in a JSON file.",
)
parser.add_argument("--data", default=None, help="path to the JSON data file")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="add an expense")
add.add_argument("--amount", type=float, required=True, help="amount spent")
add.add_argument("--category", required=True, help="category such as food or travel")
add.add_argument("--note", default="", help="optional note")
listing = sub.add_parser("list", help="list the stored expenses")
listing.add_argument("--category", default=None, help="only show this category")
remove = sub.add_parser("remove", help="remove an expense by id")
remove.add_argument("--id", type=int, required=True, help="id of the expense to remove")
sub.add_parser("summary", help="show the totals per category")
return parser
type=float and type=int do the validation for you: the argument is converted before your code sees it, and a bad value is rejected with a usage message instead of a traceback. required=True on the subparsers means running python -m ledger with no command prints the help text rather than doing nothing.
Then main dispatches on the command name and returns an exit code. Returning 1 for "no expense with that id" is what lets a shell script react to a failure, and it is why the tool can be used inside other automation.
Notice that main prints, but it does not decide. The formatting of a row - the padding, the column widths, the # prefix - lives in the printing code, while the decisions about what to print live in commands.py. That separation is what keeps a change to the output format from touching the storage layer.
What does the JSON file look like?
Because the storage is plain JSON, you can read the file with anything - including a five-line Python script, with no import of your own package.
import json
from pathlib import Path
records = json.loads(Path("ledger-data/expenses.json").read_text(encoding="utf-8"))
for record in records:
print(f"#{record['id']} {record['day']} {record['amount']:>8.2f} {record['category']}")
The same trick works for totals, which is a good way to check that the tool and the file agree.
import json
from collections import defaultdict
from pathlib import Path
records = json.loads(Path("ledger-data/expenses.json").read_text(encoding="utf-8"))
totals: dict[str, float] = defaultdict(float)
for record in records:
totals[record["category"]] += record["amount"]
for category, total in sorted(totals.items()):
print(f"{category:<12} {total:>8.2f}")
print(f"{'total':<12} {sum(totals.values()):>8.2f}")
Two design choices make this file safe to grow. Nothing is written until a command asks for it, so a typo in a command name cannot damage the data. And every write replaces the whole file rather than appending to it, which means there is exactly one version of the truth on disk - if you want a history, that is a different design, and it deserves a different file format.
What to remember
- Split a program by responsibility - model, storage, commands, interface - and let the dependencies point one way.
- Use a dataclass with
to_dictandfrom_dictas the only bridge between objects and JSON. - Keep file paths in one module so the rest of the program never hard-codes a filename.
- Let
argparsehandle types, required arguments and the help screen. - Return meaningful exit codes, and make the stored format plain enough to read with any other tool.
Try it yourself: Copy this project and run it end to end. Then extend it in three steps. First, add a --month filter to the list command and a --month option to summary. Second, add a total column to the summary output that includes a fixed 20 percent tax. Third, write three pytest tests: one that add_expense assigns the next free id, one that remove_expense returns False for an unknown id, and one that totals_by_category sums two expenses in the same category - each using the tmp_path fixture so no test touches the real data file.