Practice Project: CLI Expense Tracker
Build a real app from scratch that combines everything you've learned. This is not a toy exercise ā this is how real projects work.
What You'll Build
- A command-line tool that manages expenses in a JSON file
- Support for adding, listing, filtering, deleting, and summarizing expenses
- Proper error handling for missing files, bad inputs, and edge cases
- Clean project structure with multiple modules
- A requirements.txt and .env file for configuration
- Git-tracked with proper .gitignore
Project Requirements
Your expense tracker should support these commands:
python tracker.py add "Morning coffee" 4.50 --category Food
python tracker.py add "Uber ride" 12.00 --category Transport
python tracker.py add "Netflix subscription" 15.99
python tracker.py list # show all expenses
python tracker.py list --category Food # filter by category
python tracker.py total # total of all expenses
python tracker.py total --category Transport # total for a category
python tracker.py delete 3 # delete expense #3
python tracker.py export # export to CSV
Step 1 ā Set Up the Project
Create the project structure
# Create project
mkdir expense-tracker && cd expense-tracker
# Set up virtual environment
python -m venv venv
source venv/bin/activate
# Create project files
touch tracker.py models.py storage.py utils.py
touch requirements.txt .env .gitignore
# Initialize git
git init
Create the .gitignore file:
venv/
__pycache__/
*.pyc
.env
expenses.json
*.csv
Create the .env file:
# Configuration
DATA_FILE=expenses.json
DEFAULT_CATEGORY=General
CURRENCY=USD
Step 2 ā The Expense Model
Define the Expense class in models.py
This is where you use classes, type hints, and dunder methods from the OOP lesson.
"""Data models for the expense tracker."""
from datetime import datetime
class Expense:
"""Represents a single expense entry."""
def __init__(
self,
expense_id: int,
description: str,
amount: float,
category: str = "General",
date: str = None
):
self.id = expense_id
self.description = description
self.amount = amount
self.category = category
self.date = date or datetime.now().strftime("%Y-%m-%d %H:%M")
def to_dict(self) -> dict:
"""Convert to dict for JSON serialization."""
return {
"id": self.id,
"description": self.description,
"amount": self.amount,
"category": self.category,
"date": self.date,
}
@classmethod
def from_dict(cls, data: dict) -> Expense:
"""Create an Expense from a dict (loaded from JSON)."""
return cls(
expense_id=data["id"],
description=data["description"],
amount=data["amount"],
category=data.get("category", "General"),
date=data.get("date"),
)
def __str__(self):
"""Pretty-print for terminal display."""
return f"#{self.id:3} | ${self.amount:8.2f | {self.category:12s} | {self.description}"
@classmethod for from_dict
@classmethod lets you create an alternative constructor. Instead of Expense(id, desc, amt), you can call Expense.from_dict(data). The cls parameter refers to the class itself (like self but for the class). This pattern is extremely common when loading data from JSON or APIs.
Step 3 ā Storage Layer
Read and write expenses from JSON in storage.py
This uses file I/O, JSON, and error handling from the earlier lessons.
"""Functions for loading and saving expense data."""
import json
import os
from dotenv import load_dotenv
from models import Expense
# Load config from .env
load_dotenv()
DATA_FILE = os.environ.get("DATA_FILE", "expenses.json")
def load_expenses() -> list[Expense]:
"""Load all expenses from the JSON file."""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
return [Expense.from_dict(item) for item in data]
except FileNotFoundError:
# First run ā no file yet, return empty list
return []
except json.JSONDecodeError:
# File exists but is corrupted
print(f"ā ļø Warning: {DATA_FILE} is corrupted. Starting fresh.")
return []
def save_expenses(expenses: list[Expense]) -> None:
"""Save all expenses to the JSON file."""
data = [exp.to_dict() for exp in expenses]
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
def get_next_id(expenses: list[Expense]) -> int:
"""Get the next available ID (max ID + 1)."""
if not expenses:
return 1
return max(exp.id for exp in expenses) + 1
Step 4 ā Utility Functions
Helper functions in utils.py
"""Utility functions for the expense tracker."""
import csv
from models import Expense
def format_currency(amount: float, symbol="$") -> str:
"""Format a number as currency."""
return f"{symbol}{amount:.2f}"
def filter_by_category(expenses: list[Expense], category: str) -> list[Expense]:
"""Return only expenses matching a category (case-insensitive)."""
category_lower = category.lower()
return [
exp for exp in expenses
if exp.category.lower() == category_lower
]
def calculate_total(expenses: list[Expense]) -> float:
"""Sum all expense amounts."""
return round(sum(exp.amount for exp in expenses), 2)
def export_to_csv(expenses: list[Expense], filename="expenses.csv") -> None:
"""Export expenses to a CSV file."""
with open(filename, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "date", "category", "description", "amount"])
writer.writeheader()
for exp in expenses:
writer.writerow(exp.to_dict())
print(f"ā
Exported {len(expenses) expenses to {filename")
Step 5 ā The Main Program
Command dispatch and user interaction in tracker.py
This ties everything together ā parsing arguments, calling the right functions, and handling errors gracefully.
"""CLI Expense Tracker ā a practice project for Python fundamentals."""
import sys
import argparse
from storage import load_expenses, save_expenses, get_next_id
from models import Expense
from utils import filter_by_category, calculate_total, export_to_csv, format_currency
def cmd_add(args):
"""Add a new expense."""
expenses = load_expenses()
# Validate amount is a positive number
try:
amount = float(args.amount)
if amount <= 0:
print("ā Amount must be a positive number.")
return
except ValueError:
print(f"ā Invalid amount: {args.amount")
return
new_id = get_next_id(expenses)
expense = Expense(
expense_id=new_id,
description=args.description,
amount=amount,
category=args.category,
)
expenses.append(expense)
save_expenses(expenses)
print(f"ā
Added: {expense")
def cmd_list(args):
"""List all expenses, optionally filtered by category."""
expenses = load_expenses()
if not expenses:
print("No expenses found. Add one with: python tracker.py add \"Description\" Amount")
return
if args.category:
expenses = filter_by_category(expenses, args.category)
if not expenses:
print(f"No expenses in category '{args.category'")
return
print("\nš Your Expenses:")
print("ā" * 70)
for exp in expenses:
print(exp)
print("ā" * 70)
print(f"Total: {format_currency(calculate_total(expenses)) | {len(expenses) items")
def cmd_total(args):
"""Show total spending, optionally filtered by category."""
expenses = load_expenses()
if not expenses:
print("No expenses recorded yet.")
return
if args.category:
expenses = filter_by_category(expenses, args.category)
print(f"Total in '{args.category': ")
else:
print("Total across all categories:")
print(f" š° {format_currency(calculate_total(expenses)) | {len(expenses) expenses")
def cmd_delete(args):
"""Delete an expense by ID."""
expenses = load_expenses()
for i, exp in enumerate(expenses):
if exp.id == args.id:
deleted = expenses.pop(i)
save_expenses(expenses)
print(f"šļø Deleted: {deleted")
return
print(f"ā Expense #{args.id not found.")
def cmd_export(args):
"""Export expenses to CSV."""
expenses = load_expenses()
if not expenses:
print("No expenses to export.")
return
export_to_csv(expenses)
# āāā Argument Parser āāā
def main():
parser = argparse.ArgumentParser(description="š° CLI Expense Tracker")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# add command
add_parser = subparsers.add_parser("add", help="Add a new expense")
add_parser.add_argument("description", help="Description of the expense")
add_parser.add_argument("amount", help="Amount in dollars")
add_parser.add_argument("--category", default="General", help="Category (default: General)")
# list command
list_parser = subparsers.add_parser("list", help="List all expenses")
list_parser.add_argument("--category", help="Filter by category")
# total command
total_parser = subparsers.add_parser("total", help="Show total spending")
total_parser.add_argument("--category", help="Filter by category")
# delete command
del_parser = subparsers.add_parser("delete", help="Delete an expense")
del_parser.add_argument("id", type=int, help="ID of the expense to delete")
# export command
subparsers.add_parser("export", help="Export to CSV")
# Parse and dispatch
args = parser.parse_args()
commands = {
"add": cmd_add,
"list": cmd_list,
"total": cmd_total,
"delete": cmd_delete,
"export": cmd_export,
}
if args.command in commands:
commands[args.command](args)
else:
parser.print_help()
if __name__ == "__main__":
main()
Step 6 ā Test It!
Run your tracker
# Make sure venv is active
source venv/bin/activate
# Install dependencies
pip install python-dotenv
pip freeze > requirements.txt
# Add some expenses
python tracker.py add "Morning coffee" 4.50 --category Food
python tracker.py add "Uber to office" 12.00 --category Transport
python tracker.py add "Netflix" 15.99 --category Entertainment
python tracker.py add "Lunch sandwich" 8.75 --category Food
python tracker.py add "Gym membership" 49.99 --category Health
# List all
python tracker.py list
# List just Food expenses
python tracker.py list --category Food
# Show total
python tracker.py total
# Show total for Food only
python tracker.py total --category Food
# Delete an expense
python tracker.py delete 2
# Export to CSV
python tracker.py export
Step 7 ā Bonus Challenges
If you finished the base project and want to go further, try these extensions:
Bonus 1 ā Monthly Summary
Add a summary command that shows spending broken down by month and category. Use a dict of dicts: {month: {category: total}}.
Bonus 2 ā Budget Alerts
Add a budget command that lets you set a monthly budget per category. When you add an expense, warn if the category exceeds its budget.
Bonus 3 ā Undo
Add an undo command that reverses the last action. Store a log of actions in a separate history.json file.
Bonus 4 ā Date Filtering
Add --from and --to date filters to the list and total commands. Parse dates with datetime.strptime().
What This Project Covers
Lesson used Where in this project
āāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Variables & Types amounts, strings, booleans throughout
Control Flow argument dispatch, filtering, validation
Data Structures lists of expenses, dicts for serialization
Functions every file uses functions for organization
File I/O & JSON storage.py reads/writes expenses.json
Error Handling try/except for missing/corrupted files, bad input
Classes & OOP Expense class with methods & dunder methods
Packages & venv python-dotenv, argparse, csv, project structure