Sign In Warming up the neural circuits...
By the end of this project you will:
- Design a multi-command CLI with argparse subparsers
- Persist data as JSON with a versioned, corruption-safe storage layer
- Produce grouped reports with comprehensions and Counter
- Structure the code as an importable package with a main entry
- Add a professional baseline: pytest tests, logging, and release checklist
Goal
Build a command-line expense tracker that lets users:
- Add expenses with amount, category, and description
- List all expenses or filter by category/date
- Generate reports (total by category, monthly summaries)
- Export data to CSV
Demo:
$ python -m expenses add 45.50 --category food --desc "Groceries"
$ python -m expenses add 12.00 --category transport --desc "Bus pass"
$ python -m expenses list
$ python -m expenses report --by-category
$ python -m expenses report --month 2024-01
Architecture
expenses/
├── __init__.py
├── __main__.py # Entry point (python -m expenses)
├── cli.py # argparse setup and commands
├── storage.py # JSON file persistence
├── models.py # Expense dataclass
├── reports.py # Report generation
└── data/
└── expenses.json # Data file
Data flow:
User → CLI (argparse) → Storage (JSON) → Reports → Output
Implementation
Step 1: Data Model (models.py)
from dataclasses import dataclass, asdict
from datetime import date
@dataclass
class Expense:
amount: float
category: str
description: str
date: str # ISO format: "2024-01-15"
def to_dict(self
Step 2: Storage Layer (storage.py)
import json
from pathlib import Path
from typing import List
from .models import Expense
DATA_DIR = Path(__file__).parent / "data"
EXPENSES_FILE = DATA_DIR / "expenses.json"
def ensure_data_dir():
DATA_DIR.mkdir(exist_ok
Step 3: Reports (reports.py)
from collections import Counter
from typing import List
from .models import Expense
def total_by_category(expenses: List[Expense]) -> dict:
totals = {}
for e in expenses:
totals[e.category] = totals
Step 4: CLI (cli.py)
import argparse
from datetime import date
from .models import Expense
from .storage import add_expense, load_expenses
from .reports import total_by_category, monthly_summary, format_report
def cmd_add(args):
expense = Expense(
amount=args
Step 5: Entry Point (main.py)
from .cli import build_parser
def main():
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
Runbook
# Setup
cd expenses
python -m venv venv
source venv/bin/activate
# Run
python -m expenses add 45.50 --category food --desc "Groceries"
python -m expenses add 12.00 --category transport
python -m expenses list
python -m
Extension Ideas
- CSV export:
python -m expenses export --format csv
- Budget tracking: Set monthly budgets per category
- TUI: Interactive terminal UI with
curses or rich
- Database: Switch from JSON to SQLite
- Multi-user: User authentication and separate data files
Architecture Decision Record
| Decision | Choice | Why |
|---|
| JSON over SQLite | JSON | Simple, human-readable, no dependencies |
| argparse over click | argparse | Built-in, no external dependencies |
| dataclass over dict | dataclass | Type safety, IDE support, clean |
| Module structure | Single package | Clean imports, testable, scalable |
Packaging Baseline (pyproject.toml)
Use pyproject.toml so the project can be installed and run as a real package.
[project]
name = "expenses"
version = "0.1.0"
description = "Expense Tracker CLI"
requires-python = ">=3.11"
[project.scripts]
expenses = "expenses.__main__:main"
This gives you both:
python -m expenses ...
expenses ... (after installation)
Logging Baseline
Replace ad-hoc print debugging with structured logging for key actions.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("expenses")
def cmd_add(args):
logger
Minimum logging points:
- command start
- failure
- write success/failure
Testing Baseline (tests/)
Add focused tests for core logic and storage behavior.
tests/
├── test_models.py
├── test_reports.py
└── test_storage.py
Example test:
from expenses.reports import total_by_category
def test_total_by_category():
class Expense:
def __init__(self, amount, category):
self.amount = amount
self.category = category
expenses = [Expense
Release Checklist
Before calling P1 complete:
python -m pytest -q passes
- Expense data write path handles malformed JSON safely
- Core commands validated:
add, list, report --by-category, report --month
- README has setup, run, and test steps
- Logging exists for command start + storage failures
Interview Defense Notes
Be ready to explain:
- Why JSON was chosen over SQLite at this stage.
- How the package layout supports testability.
- How you would migrate to SQLite without breaking CLI UX.
- What failure scenarios you handled (invalid , malformed data, missing files).
- What your next production upgrades would be (auth, multi-user, cloud sync).
Summary
This project combines every L1 concept:
- Lists/tuples — storing expenses
- Dictionaries — grouping by category
- Comprehensions — filtering and transforming
- String methods — formatting output
- Functions — organizing code
- Modules — package structure
- File I/O — JSON persistence
- Classes — dataclass for Expense
- Error handling — try/except for file operations
- Standard library — argparse, json, collections
Self-Check Quiz
What module is used for command-line argument parsing?
):
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> 'Expense':
return cls(**data)
=
True
)
def load_expenses() -> List[Expense]:
ensure_data_dir()
if not EXPENSES_FILE.exists():
return []
with open(EXPENSES_FILE) as f:
data = json.load(f)
return [Expense.from_dict(item) for item in data]
def save_expenses(expenses: List[Expense]):
ensure_data_dir()
with open(EXPENSES_FILE, "w") as f:
json.dump([e.to_dict() for e in expenses], f, indent=2)
def add_expense(expense: Expense):
expenses = load_expenses()
expenses.append(expense)
save_expenses(expenses)
.
get
(
e
.
category
,
0
)
+
e
.
amount
return totals
def monthly_summary(expenses: List[Expense], month: str) -> dict:
"""month format: '2024-01'"""
monthly = [e for e in expenses if e.date.startswith(month)]
return {
"month": month,
"total": sum(e.amount for e in monthly),
"count": len(monthly),
"by_category": total_by_category(monthly),
}
def format_report(data: dict) -> str:
lines = []
for category, total in sorted(data.items()):
lines.append(f" {category}: ${total:.2f}")
return "\n".join(lines)
.
amount
,
category=args.category,
description=args.desc or "",
date=date.today().isoformat(),
)
add_expense(expense)
print(f"Added: ${expense.amount:.2f} ({expense.category})")
def cmd_list(args):
expenses = load_expenses()
if not expenses:
print("No expenses recorded.")
return
for e in expenses:
print(f" {e.date} ${e.amount:>8.2f} {e.category:12} {e.description}")
def cmd_report(args):
expenses = load_expenses()
if args.by_category:
data = total_by_category(expenses)
print("Total by category:")
print(format_report(data))
elif args.month:
data = monthly_summary(expenses, args.month)
print(f"Summary for {args.month}:")
print(f" Total: ${data['total']:.2f} ({data['count']} expenses)")
print(format_report(data['by_category']))
def build_parser():
parser = argparse.ArgumentParser(description="Expense Tracker CLI")
sub = parser.add_subparsers(dest="command", required=True)
# add
p_add = sub.add_parser("add", help="Add an expense")
p_add.add_argument("amount", type=float, help="Amount in dollars")
p_add.add_argument("--category", "-c", default="general", help="Category")
p_add.add_argument("--desc", "-d", help="Description")
p_add.set_defaults(func=cmd_add)
# list
p_list = sub.add_parser("list", help="List all expenses")
p_list.set_defaults(func=cmd_list)
# report
p_report = sub.add_parser("report", help="Generate reports")
p_report.add_argument("--by-category", action="store_true")
p_report.add_argument("--month", help="Month (YYYY-MM)")
p_report.set_defaults(func=cmd_report)
return parser
expenses
report
--by-category
python -m expenses report --month 2024-01
# Test
python -m pytest tests/
.
info
(
"
add expense requested
"
,
extra
=
{
"
category
"
:
args
.
category
,
"
amount
"
:
args
.
amount
})
# ... existing logic ...
(
100
,
"
food
"
),
Expense
(
50
,
"
food
"
),
Expense
(
20
,
"
travel
"
)]
totals = total_by_category(expenses)
assert totals["food"] == 150
assert totals["travel"] == 20