Building it step by step
The longest lesson in the course. Work through it at the keyboard, running things as you go, and do not read ahead — the value is in building it, not in having read how.
Set the project up
Module 8's two-minute sequence:
mkdir expense-tracker && cd expense-tracker
git init
python -m venv .venv
source .venv/bin/activate
printf '.venv/\n__pycache__/\n*.pyc\n.env\n.pytest_cache/\n' > .gitignore
python -m pip install pytest ruff
# pyproject.toml
[project]
name = "expense-tracker"
version = "0.1.0"
requires-python = ">=3.12"
[project.scripts]
expenses = "expense_tracker.cli:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
mkdir -p src/expense_tracker tests
touch src/expense_tracker/__init__.py
python -m pip install -e .
git add -A && git commit -m "Set up project structure"
[project.scripts] is what makes expenses a real command. Everything is
committed before there is anything to get wrong.
Step 1: the Expense type
# src/expense_tracker/models.py
from dataclasses import dataclass
from datetime import date
VALID_CATEGORIES = ("food", "transport", "rent", "other")
MAX_NOTE_LENGTH = 200
@dataclass(frozen=True)
class Expense:
"""A single recorded expense. Amounts are stored in paise."""
id: int
amount_paise: int
category: str
date: date
note: str = ""
def __post_init__(self) -> None:
if self.amount_paise <= 0:
raise ValueError(
f"amount must be positive, got {self.amount_paise / 100:.2f}"
)
if self.category not in VALID_CATEGORIES:
raise ValueError(
f"unknown category {self.category!r}. "
f"Valid: {', '.join(VALID_CATEGORIES)}"
)
if len(self.note) > MAX_NOTE_LENGTH:
raise ValueError(f"note must be {MAX_NOTE_LENGTH} characters or fewer")
@property
def rupees(self) -> float:
"""The amount in rupees, for display only."""
return self.amount_paise / 100
def to_dict(self) -> dict:
return {
"id": self.id,
"amount_paise": self.amount_paise,
"category": self.category,
"date": self.date.isoformat(),
"note": self.note,
}
@classmethod
def from_dict(cls, data: dict) -> "Expense":
return cls(
id=int(data["id"]),
amount_paise=int(data["amount_paise"]),
category=data["category"],
date=date.fromisoformat(data["date"]),
note=data.get("note", ""),
)
Module 9's dataclass with __post_init__ validation, frozen=True so an
expense cannot be edited into an invalid state, and to_dict/from_dict for
the JSON boundary. The error messages name the offending value and the
acceptable ones.
Note .get("note", "") in from_dict — an older file without notes still
loads.
Try it in the REPL before moving on. Create a valid one, then a negative one, and read the message.
Step 2: storage
# src/expense_tracker/storage.py
import json
import os
from pathlib import Path
from .models import Expense
def data_file() -> Path:
override = os.environ.get("EXPENSES_FILE")
return Path(override) if override else Path.home() / ".expenses.json"
def load(path: Path) -> tuple[list[Expense], int]:
"""Return the stored expenses and the next id.
A missing file is treated as empty. A corrupt one raises, so we never
silently discard somebody's data.
"""
if not path.exists():
return [], 1
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
raise ValueError(f"{path} is not valid JSON: {error}") from error
expenses = [Expense.from_dict(item) for item in raw.get("expenses", [])]
return expenses, int(raw.get("next_id", len(expenses) + 1))
def save(path: Path, expenses: list[Expense], next_id: int) -> None:
"""Write expenses to disk, replacing atomically."""
payload = {
"next_id": next_id,
"expenses": [expense.to_dict() for expense in expenses],
}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(
json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
)
temporary.replace(path)
Three things worth pausing on.
The corrupt file raises rather than returning empty. Returning [], 1 would
mean the next save destroys the file. Module 6's rule: fail where the problem
is.
raise ... from error preserves the original for anyone debugging.
Writing to a temporary file and then replacing. Module 7 mentioned this as
safer. replace() is atomic on every major platform, so a crash mid-write
leaves the original intact rather than half a file. Three extra lines to make
data loss impossible.
Step 3: add, end to end
# src/expense_tracker/cli.py
import argparse
import sys
from datetime import date
from .models import Expense
from .storage import data_file, load, save
def cmd_add(args: argparse.Namespace) -> int:
path = data_file()
expenses, next_id = load(path)
expense = Expense(
id=next_id,
amount_paise=round(args.amount * 100),
category=args.category,
date=date.today(),
note=args.note or "",
)
expenses.append(expense)
save(path, expenses, next_id + 1)
print(f"Added #{expense.id}: {expense.category} ₹{expense.rupees:,.2f}"
+ (f" ({expense.note})" if expense.note else ""))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="expenses", description="Track expenses.")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="record an expense")
add.add_argument("amount", type=float)
add.add_argument("category")
add.add_argument("--note", default="")
add.set_defaults(func=cmd_add)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
return args.func(args)
except ValueError as error:
print(f"Error: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
$ expenses add 250 food --note "lunch"
Added #1: food ₹250.00 (lunch)
$ expenses add -50 food
Error: amount must be positive, got -0.50
This is the milestone. Data persists between runs. Check the file exists, then commit.
Three details:
round(args.amount * 100) converts rupees to paise at the boundary. Inside the
program it is always paise.
One try in main catches every ValueError from anywhere below and turns
it into a message. The commands raise; the CLI translates. That is the
separation module 9 recommended.
Returning an exit code — 0 for success, 1 for failure — is what makes a
command-line tool usable in a script.
Step 4 and 5: list, then filters
# src/expense_tracker/reporting.py
from collections import defaultdict
from datetime import date
from .models import Expense
def filter_expenses(
expenses: list[Expense],
category: str | None = None,
month: str | None = None,
) -> list[Expense]:
"""Filter by category and/or a YYYY-MM month string."""
result = expenses
if category:
result = [e for e in result if e.category == category]
if month:
result = [e for e in result if e.date.isoformat().startswith(month)]
return result
def total_paise(expenses: list[Expense]) -> int:
return sum(e.amount_paise for e in expenses)
def by_category(expenses: list[Expense]) -> dict[str, int]:
totals: dict[str, int] = defaultdict(int)
for expense in expenses:
totals[expense.category] += expense.amount_paise
return dict(totals)
def format_table(expenses: list[Expense]) -> str:
if not expenses:
return "No expenses found."
return "\n".join(
f" #{e.id:<4} {e.date} {e.category:<10} ₹{e.rupees:>12,.2f} {e.note}".rstrip()
for e in expenses
)
Every function here is pure — values in, values out, nothing printed and nothing read from disk. That is what makes the next lesson easy.
total_paise on an empty list returns 0, because sum([]) is 0. The edge
case handles itself.
Add the list command in cli.py calling filter_expenses and format_table.
Run it. Commit.
Step 6 and 7: summary and delete
def cmd_summary(args: argparse.Namespace) -> int:
expenses, _ = load(data_file())
if not expenses:
print("No expenses recorded yet.")
return 0
total = total_paise(expenses)
print(f"Total: ₹{total / 100:,.2f}\n")
for category, amount in sorted(
by_category(expenses).items(), key=lambda pair: pair[1], reverse=True
):
share = amount / total * 100
print(f" {category:<10} ₹{amount / 100:>12,.2f} ({share:.1f}%)")
months = {e.date.isoformat()[:7] for e in expenses}
print(f"\nAcross {len(months)} month(s) — average ₹{total / 100 / len(months):,.2f}")
return 0
def cmd_delete(args: argparse.Namespace) -> int:
path = data_file()
expenses, next_id = load(path)
match = next((e for e in expenses if e.id == args.id), None)
if match is None:
raise ValueError(f"no expense with id {args.id}")
expenses.remove(match)
save(path, expenses, next_id)
print(f"Deleted #{match.id}: {match.category} ₹{match.rupees:,.2f}")
return 0
The empty check comes first, because the average divides by the month count and
sorted on an empty dictionary would print nothing useful anyway. Module 6's
ZeroDivisionError, prevented by a guard clause.
{...[:7] for e in expenses} is a set comprehension from module 4 — unique
months, counted.
next((...), None) finds the first match or None, replacing a loop with a
flag.
next_id is not decremented on delete. Deliberate, from the planning
lesson: ids are never reused.
Step 8: check the failure cases
Go through your failure table and try every row:
$ expenses add abc food
$ expenses add 0 food
$ expenses add 100 groceries
$ expenses delete 999
$ echo "not json" > ~/.expenses.json && expenses list
Each should print a clear message and exit non-zero — not a traceback. A traceback reaching a user is a bug.
argparse handles the first one for you, which is the reason to use it.
Check your work
The milestone is step 3. Once data survives between runs, the program is real and everything after is addition rather than construction. If you got that far, the hard part is done.
Atomic writes.
temporary = path.with_suffix(".tmp")
temporary.write_text(...)
temporary.replace(path)
replace() is atomic on every major platform, so a crash mid-write leaves the
original intact rather than half a file. Three extra lines to make data loss
impossible.
One try in main catches every ValueError raised anywhere below and
turns it into a message. The commands raise; the CLI translates. That separation
is why no traceback reaches a user.
Exit codes. 0 for success, 1 for failure. It is what makes the tool
usable in a script — expenses add 250 food && echo ok only works if the
failure is signalled.
Validation before counting. Everything that rejects input uses continue
and sits above attempts += 1, so a typo does not cost the user a turn. Order
is doing real work there.
50.5 is rejected because isdigit() is False for a string containing a
dot. Acceptable for this program; try/except from module 6 is the general
answer.
The corrupt file. Your program must refuse and leave the file alone. Check it afterwards — if the file is now empty or valid-but-empty, the load path returned empty instead of raising, and the next save destroyed somebody's data.
The edit command. Because Expense is frozen, you cannot modify one — you
build a replacement with dataclasses.replace(expense, amount_paise=...) or a
fresh constructor call. Notice that the design pushed you towards the safer
option, since a replacement goes through validation and a mutation would not.
Practice
- Build all eight steps. Run after each. Commit after each.
- After step 3, confirm data survives by closing the terminal and reopening.
- Break the JSON file by hand and confirm the program refuses without overwriting it.
- Add
--month 2026-09tolist. - Run
ruff format .andruff check --fix .and fix what remains. - Add an
editcommand. SinceExpenseis frozen, you will have to replace rather than modify — notice that the design pushed you towards the safer option.
Next: testing what you have built.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship