← Roadmap šŸ Month 1: Python
0/10

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

Project Requirements

Your expense tracker should support these commands:

text
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

1

Create the project structure

bash
# 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:

text .gitignore
venv/
__pycache__/
*.pyc
.env
expenses.json
*.csv

Create the .env file:

text .env
# Configuration
DATA_FILE=expenses.json
DEFAULT_CATEGORY=General
CURRENCY=USD

Step 2 — The Expense Model

2

Define the Expense class in models.py

This is where you use classes, type hints, and dunder methods from the OOP lesson.

python models.py
"""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

3

Read and write expenses from JSON in storage.py

This uses file I/O, JSON, and error handling from the earlier lessons.

python storage.py
"""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

4

Helper functions in utils.py

python 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

5

Command dispatch and user interaction in tracker.py

This ties everything together — parsing arguments, calling the right functions, and handling errors gracefully.

python tracker.py
"""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!

6

Run your tracker

bash
# 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

text
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

āœ… You've completed the Python Fundamentals when you can confirm: