Crash Courses · PhD
Capstone: Multi-Module CLI Expense Tracker
Build a real multi-file Python project with proper structure, imports, argparse, and file persistence.
18 min read+195 XP on completionCert: Python Crash Course
Tap any word in the text below to start reading from there.
Capstone: Multi-Module CLI Expense Tracker
Project Structure
expense-tracker/
├── main.py ← CLI entry point
├── expenses/
│ ├── __init__.py ← makes 'expenses' a package
│ ├── models.py ← Expense class + data validation
│ ├── storage.py ← JSON file read/write
│ └── reports.py ← summary/stats functions
└── data/
└── expenses.json ← persisted data (created on first run)This structure mirrors real Python projects: one package (expenses/) containing logically grouped modules, and a top-level entry point.
---
expenses/models.py — Data Model
# expenses/models.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
VALID_CATEGORIES = {"food", "transport", "housing", "entertainment", "health", "other"}
@dataclass
class Expense:
amount: float
category: str
note: str = ""
date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
def __post_init__(self):
if self.amount <= 0:
raise ValueError(f"Amount must be positive, got {self.amount}")
self.category = self.category.lower()
if self.category not in VALID_CATEGORIES:
raise ValueError(
f"Invalid category '{self.category}'. "
f"Valid options: {', '.join(sorted(VALID_CATEGORIES))}"
)
def to_dict(self):
return asdict(self)
@classmethod
def from_dict(cls, data):
return cls(**data)
def __str__(self):
note_str = f" — {self.note}" if self.note else ""
return f"[{self.id}] {self.date} ${self.amount:.2f} {self.category:<14}{note_str}"---
expenses/storage.py — Persistence
# expenses/storage.py
import json
from pathlib import Path
from .models import Expense # relative import — . means "this package"
DATA_FILE = Path("data/expenses.json")
def load_expenses():
"""Load all expenses from JSON file. Returns empty list if file missing."""
DATA_FILE.parent.mkdir(exist_ok=True)
if not DATA_FILE.exists():
return []
try:
data = json.loads(DATA_FILE.read_text(encoding="utf-8"))
return [Expense.from_dict(item) for item in data]
except (json.JSONDecodeError, KeyError) as e:
print(f"Warning: Could not read data file ({e}). Starting fresh.")
return []
def save_expenses(expenses):
"""Persist expenses list to JSON file."""
DATA_FILE.parent.mkdir(exist_ok=True)
data = [e.to_dict() for e in expenses]
DATA_FILE.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8"
)---
expenses/reports.py — Analytics
# expenses/reports.py
from collections import defaultdict
from .models import Expense
def summary(expenses):
"""Print a per-category summary and grand total."""
if not expenses:
print("No expenses recorded yet.")
return
by_category = defaultdict(float)
for e in expenses:
by_category[e.category] += e.amount
total = sum(by_category.values())
print("\n=== Expense Summary ===")
for cat, amount in sorted(by_category.items(), key=lambda x: -x[1]):
bar = "█" * int(amount / total * 30)
print(f" {cat:<14} ${amount:>8.2f} {bar}")
print(f" {'TOTAL':<14} ${total:>8.2f}")
print(f" {len(expenses)} transactions")---
main.py — CLI Entry Point with argparse
# main.py
import argparse
import sys
from expenses.models import Expense, VALID_CATEGORIES
from expenses.storage import load_expenses, save_expenses
from expenses.reports import summary
def cmd_add(args):
expenses = load_expenses()
try:
expense = Expense(
amount=args.amount,
category=args.category,
note=args.note or "",
)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
expenses.append(expense)
save_expenses(expenses)
print(f"Added: {expense}")
def cmd_list(args):
expenses = load_expenses()
if not expenses:
print("No expenses yet. Add one with: python main.py add --amount 50 --category food")
return
for e in expenses:
print(e)
def cmd_summary(args):
summary(load_expenses())
def cmd_delete(args):
expenses = load_expenses()
original_count = len(expenses)
expenses = [e for e in expenses if e.id != args.id]
if len(expenses) == original_count:
print(f"No expense found with ID: {args.id}")
else:
save_expenses(expenses)
print(f"Deleted expense {args.id}")
def main():
parser = argparse.ArgumentParser(
prog="expense-tracker",
description="Track your expenses from the command line.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# add subcommand
add_p = subparsers.add_parser("add", help="Add a new expense")
add_p.add_argument("--amount", type=float, required=True, help="Expense amount")
add_p.add_argument("--category", required=True, choices=sorted(VALID_CATEGORIES))
add_p.add_argument("--note", default="", help="Optional description")
add_p.set_defaults(func=cmd_add)
# list subcommand
list_p = subparsers.add_parser("list", help="List all expenses")
list_p.set_defaults(func=cmd_list)
# summary subcommand
sum_p = subparsers.add_parser("summary", help="Show category summary")
sum_p.set_defaults(func=cmd_summary)
# delete subcommand
del_p = subparsers.add_parser("delete", help="Delete an expense by ID")
del_p.add_argument("id", help="The 8-character expense ID")
del_p.set_defaults(func=cmd_delete)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()Run the CLI:
python main.py add --amount 45.50 --category food --note "Lunch at JST"
python main.py add --amount 120 --category transport
python main.py list
python main.py summary
python main.py delete abc12345
python main.py --help---
Key Concepts Demonstrated
- Package structure — expenses/ is importable because of __init__.py
- Relative imports — from .models import Expense keeps modules decoupled
- Dataclasses — __post_init__ for validation, asdict() for serialization
- argparse subparsers — each command maps to a function
- if __name__ == "__main__" — prevents main() from running on import
- Pathlib — clean file I/O without os.path gymnastics
- Error handling — validation errors exit cleanly with sys.exit(1)
Reading progress
0% read
In this module
__init__.py
Relative Import
argparse
Package Structure
0%