Bridge To Data And ML
Capstone Project: Expense Tracker
A small but complete Expense Tracker: add expenses, save/load them from a JSON file, and analyze spending with pandas. Deliberately, this project touches almost
Jr Codex Python Notes
Level: Advanced Prerequisites: All of Modules 1–5, Chapter 1, Chapter 2 Time to complete: ~45 minutes
Table of Contents
- What We're Building
- Project Structure
- Step 1 — Custom Exceptions
- Step 2 — The
ExpenseDataclass - Step 3 — The
ExpenseTrackerClass - Step 4 — Persistence with JSON
- Step 5 — Analysis with Pandas
- Step 6 — Tying It Together
- Step 7 — A Quick Test
- Where to Go From Here
1. What We're Building
A small but complete Expense Tracker: add expenses, save/load them from a JSON file, and analyze spending with pandas. Deliberately, this project touches almost everything from this entire Python curriculum:
Concepts Used From
─────────────────────────────────────────────────
Classes, @dataclass Module 4, Module 5 Ch.4
Custom exceptions Module 3 Ch.5
File I/O, JSON Module 3 Ch.2-3
Context managers (via `with`) Module 5 Ch.3
Decorators (a simple logging one) Module 5 Ch.2
Type hints Module 5 Ch.3
List comprehensions Module 2 Ch.5
Pandas DataFrame & groupby This module, Ch.2
─────────────────────────────────────────────────
2. Project Structure
expense_tracker/
├── expenses.py # Expense dataclass + custom exceptions
├── tracker.py # ExpenseTracker class
├── main.py # ties everything together, runs the program
└── expenses.json # created automatically — the saved data
3. Step 1 — Custom Exceptions
Following Module 3, Chapter 5's pattern — a small hierarchy of domain-specific exceptions:
# expenses.py
class ExpenseError(Exception):
"""Base class for all expense-tracker-related errors."""
pass
class InvalidAmountError(ExpenseError):
"""Raised when an expense amount is zero or negative."""
pass
class InvalidCategoryError(ExpenseError):
"""Raised when a category isn't in the allowed list."""
pass4. Step 2 — The Expense Dataclass
Using @dataclass (Module 5, Chapter 4) instead of hand-writing __init__/__repr__:
# expenses.py (continued)
from dataclasses import dataclass, field
from datetime import date
ALLOWED_CATEGORIES = {"food", "transport", "rent", "entertainment", "other"}
@dataclass
class Expense:
amount: float
category: str
description: str = ""
expense_date: date = field(default_factory=date.today) # safe mutable-ish default (Module 5 Ch.4)
def __post_init__(self):
"""Runs automatically right after __init__ — the natural place for validation."""
if self.amount <= 0:
raise InvalidAmountError(f"Amount must be positive, got {self.amount}")
if self.category not in ALLOWED_CATEGORIES:
raise InvalidCategoryError(
f"'{self.category}' is not a valid category. "
f"Choose from: {', '.join(sorted(ALLOWED_CATEGORIES))}"
)
def to_dict(self) -> dict:
"""Convert to a JSON-serializable dict (dates aren't natively JSON-friendly)."""
return {
"amount": self.amount,
"category": self.category,
"description": self.description,
"expense_date": self.expense_date.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> "Expense":
"""Alternative constructor (Module 4 Ch.1) — rebuilds an Expense from saved JSON."""
return cls(
amount=data["amount"],
category=data["category"],
description=data.get("description", ""),
expense_date=date.fromisoformat(data["expense_date"]),
)__post_init__ is a @dataclass-specific hook — it runs immediately after the generated __init__ finishes, making it the natural home for validation logic that a plain dataclass doesn't do automatically.
5. Step 3 — The ExpenseTracker Class
This is where composition (Module 4, Chapter 4) comes in — ExpenseTracker has-a list of Expense objects, plus a small logging decorator (Module 5, Chapter 2) for visibility:
# tracker.py
from functools import wraps
from expenses import Expense
def log_action(func):
"""A simple decorator (Module 5, Ch.2) logging every tracker action."""
@wraps(func)
def wrapper(self, *args, **kwargs):
result = func(self, *args, **kwargs)
print(f"[LOG] {func.__name__} called")
return result
return wrapper
class ExpenseTracker:
def __init__(self):
self.expenses: list[Expense] = [] # type-hinted instance attribute (Module 5 Ch.3)
@log_action
def add_expense(self, expense: Expense) -> None:
self.expenses.append(expense)
def total_spent(self) -> float:
return sum(e.amount for e in self.expenses) # generator expression, Module 5 Ch.1
def by_category(self, category: str) -> list[Expense]:
return [e for e in self.expenses if e.category == category] # comprehension, Module 2 Ch.5
def __len__(self) -> int:
"""Dunder method (Module 4 Ch.7) — enables len(tracker)."""
return len(self.expenses)
def __iter__(self):
"""Dunder method — enables `for expense in tracker:` (Module 5 Ch.1)."""
return iter(self.expenses)Notice __len__ and __iter__ — straight from Module 4/5's dunder method chapters — make ExpenseTracker behave naturally with Python's built-ins:
tracker = ExpenseTracker()
print(len(tracker)) # 0 — works because of __len__
for expense in tracker: # works because of __iter__
print(expense)6. Step 4 — Persistence with JSON
Straight from Module 3's file handling and JSON chapters, using with for guaranteed cleanup:
# tracker.py (continued)
import json
from expenses import Expense
class ExpenseTracker:
# ... (previous methods) ...
def save_to_file(self, filepath: str) -> None:
data = [e.to_dict() for e in self.expenses]
with open(filepath, "w") as f:
json.dump(data, f, indent=2)
def load_from_file(self, filepath: str) -> None:
try:
with open(filepath, "r") as f:
data = json.load(f)
except FileNotFoundError:
print(f"No saved data found at {filepath}, starting fresh.")
return
self.expenses = [Expense.from_dict(item) for item in data]7. Step 5 — Analysis with Pandas
This is where Chapter 2's pandas primer plugs directly into the classes we've already built — converting our own objects into a DataFrame for analysis:
# tracker.py (continued)
import pandas as pd
class ExpenseTracker:
# ... (previous methods) ...
def to_dataframe(self) -> pd.DataFrame:
"""Bridge from our custom objects into a pandas DataFrame."""
records = [e.to_dict() for e in self.expenses]
return pd.DataFrame(records)
def summary_by_category(self) -> pd.DataFrame:
df = self.to_dataframe()
if df.empty:
return df
return (
df.groupby("category")["amount"]
.agg(total="sum", average="mean", count="count")
.sort_values("total", ascending=False)
)8. Step 6 — Tying It Together
# main.py
from datetime import date
from expenses import Expense, InvalidAmountError, InvalidCategoryError
from tracker import ExpenseTracker
def seed_sample_data(tracker: ExpenseTracker) -> None:
sample_expenses = [
(45.50, "food", "Groceries"),
(12.00, "transport", "Bus pass"),
(1200.00, "rent", "Monthly rent"),
(30.00, "entertainment", "Movie night"),
(60.25, "food", "Restaurant dinner"),
]
for amount, category, description in sample_expenses:
try:
expense = Expense(amount=amount, category=category, description=description)
tracker.add_expense(expense)
except (InvalidAmountError, InvalidCategoryError) as e:
print(f"Skipped invalid expense: {e}")
def main():
tracker = ExpenseTracker()
tracker.load_from_file("expenses.json")
if len(tracker) == 0:
seed_sample_data(tracker)
print(f"\nTotal expenses tracked: {len(tracker)}")
print(f"Total spent: ${tracker.total_spent():.2f}\n")
print("Spending by category:")
print(tracker.summary_by_category())
tracker.save_to_file("expenses.json")
print("\nSaved to expenses.json")
if __name__ == "__main__": # Module 3 Ch.1 — script vs import behavior
main()$ python main.py
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
Total expenses tracked: 5
Total spent: $1347.75
Spending by category:
total average count
category
rent 1200.00 1200.000000 1
food 105.75 52.875000 2
entertainment 30.00 30.000000 1
transport 12.00 12.000000 1
Saved to expenses.json9. Step 7 — A Quick Test
Following Module 5, Chapter 5's pytest patterns:
# test_expenses.py
import pytest
from expenses import Expense, InvalidAmountError, InvalidCategoryError
def test_valid_expense():
e = Expense(amount=50.0, category="food")
assert e.amount == 50.0
assert e.category == "food"
def test_negative_amount_raises():
with pytest.raises(InvalidAmountError):
Expense(amount=-10, category="food")
def test_invalid_category_raises():
with pytest.raises(InvalidCategoryError):
Expense(amount=50, category="not_a_real_category")
@pytest.mark.parametrize("amount,category", [
(10.0, "food"),
(500.0, "rent"),
(1.0, "other"),
])
def test_various_valid_expenses(amount, category):
e = Expense(amount=amount, category=category)
assert e.amount == amount$ pytest test_expenses.py -v
test_expenses.py::test_valid_expense PASSED
test_expenses.py::test_negative_amount_raises PASSED
test_expenses.py::test_invalid_category_raises PASSED
test_expenses.py::test_various_valid_expenses[10.0-food] PASSED
test_expenses.py::test_various_valid_expenses[500.0-rent] PASSED
test_expenses.py::test_various_valid_expenses[1.0-other] PASSED10. Where to Go From Here
This capstone deliberately stayed small enough to read in one sitting, but every piece is extensible using tools already covered in this curriculum:
| Extension Idea | Concepts to Apply |
|---|---|
| Add a CLI with monthly filtering | argparse (standard library), datetime comparisons |
| Add a budget-limit warning | @property with validation (Module 4, Ch.5) |
| Support multiple currencies | Dunder methods on a Money class (Module 4, Ch.7) |
| Visualize spending | matplotlib, introduced in the Machine Learning notes |
| Load expenses from a bank CSV export | csv.DictReader (Module 3, Ch.3) |
You've now completed the entire Python curriculum — from print("Hello") in Module 1 to a working, tested, data-analyzed application here. The natural next step is the Machine Learning Notes, which assume exactly this level of Python, NumPy, and pandas comfort as their starting point.
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Python Index