JSTAcademy
0 XP
Dashboard
Crash Courses
Capstone: Multi-Module CLI Expense Tracker
18 min
PhD+195 XP
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

  1. Package structure — expenses/ is importable because of __init__.py
  2. Relative imports — from .models import Expense keeps modules decoupled
  3. Dataclasses — __post_init__ for validation, asdict() for serialization
  4. argparse subparsers — each command maps to a function
  5. if __name__ == "__main__" — prevents main() from running on import
  6. Pathlib — clean file I/O without os.path gymnastics
  7. Error handling — validation errors exit cleanly with sys.exit(1)
0%