PythonIntermediate 20 min Lesson 29 of 30

Day 29 — Mini Project

Put the last four weeks together: a command-line expense tracker with SQLite storage, validation, tests and a clean structure.

Python · Lesson 29 of 30
0/30 done(0%)

What is it? #

Reading about Python and writing Python are different skills. This lesson is a complete small project that uses most of what you have learned: functions, exceptions, files, classes, SQLite, type hints and tests.

The project is a command-line expense tracker. You add expenses, list them, and get a summary by category. It stores data in SQLite so it survives restarts.

The point is not the feature list. It is practising the shape of a real project: a thin interface layer, a service layer with the rules, storage separated from both, and tests on the part that matters.

Type it out rather than copying. The bugs you hit while typing are the lesson.

Think of it like this #

Learning to cook by reading recipes gets you only so far. At some point you have to burn something. A mini project is your first full meal — small enough to finish, real enough to teach you what the recipe left out.

Simple example #

Three commands: add records an expense, list shows recent ones, summary totals by category. Everything goes through a service layer so the storage could be swapped without touching the command-line code.

Code #

PYTHON
# tracker/storage.py
import sqlite3
from pathlib import Path

DB_PATH = Path("expenses.db")


def connect() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    with conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS expenses (
                id       INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT NOT NULL,
                amount   REAL NOT NULL,
                note     TEXT NOT NULL DEFAULT '',
                spent_on TEXT NOT NULL
            )
        """)
    return conn
PYTHON
# tracker/service.py
from datetime import date
from typing import Iterable

VALID_CATEGORIES = {"food", "travel", "bills", "other"}


class ValidationError(ValueError):
    """Raised when user input breaks a rule."""


def add_expense(conn, category: str, amount: float, note: str = "") -> int:
    category = category.strip().lower()
    if category not in VALID_CATEGORIES:
        raise ValidationError(f"category must be one of {sorted(VALID_CATEGORIES)}")
    if amount <= 0:
        raise ValidationError("amount must be greater than zero")

    with conn:
        cur = conn.execute(
            "INSERT INTO expenses (category, amount, note, spent_on) VALUES (?, ?, ?, ?)",
            (category, round(float(amount), 2), note.strip(), date.today().isoformat()),
        )
    return cur.lastrowid


def recent(conn, limit: int = 10) -> Iterable:
    return conn.execute(
        "SELECT * FROM expenses ORDER BY id DESC LIMIT ?", (limit,)
    ).fetchall()


def summary(conn) -> list[tuple[str, float]]:
    rows = conn.execute(
        "SELECT category, SUM(amount) AS total FROM expenses "
        "GROUP BY category ORDER BY total DESC"
    ).fetchall()
    return [(r["category"], round(r["total"], 2)) for r in rows]
PYTHON
# tracker/cli.py
import argparse
import sys
from . import service, storage


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(prog="tracker", description="Track expenses")
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="Record an expense")
    add.add_argument("category")
    add.add_argument("amount", type=float)
    add.add_argument("--note", default="")

    listing = sub.add_parser("list", help="Show recent expenses")
    listing.add_argument("--limit", type=int, default=10)

    sub.add_parser("summary", help="Totals by category")

    args = parser.parse_args(argv)
    conn = storage.connect()

    try:
        if args.command == "add":
            new_id = service.add_expense(conn, args.category, args.amount, args.note)
            print(f"Saved expense #{new_id}")
        elif args.command == "list":
            for row in service.recent(conn, args.limit):
                print(f"{row['id']:>4}  {row['spent_on']}  {row['category']:<8} {row['amount']:>9.2f}  {row['note']}")
        else:
            for category, total in service.summary(conn):
                print(f"{category:<8} {total:>10.2f}")
    except service.ValidationError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1
    finally:
        conn.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
BASH
python -m tracker.cli add food 450 --note "team lunch"
python -m tracker.cli list --limit 5
python -m tracker.cli summary

How it works #

storage.py owns the database. It creates the table if needed, so the first run works on a clean machine. Nothing else in the project writes SQL.

service.py holds the rules: which categories are allowed, that the amount must be positive, that text gets trimmed. It raises ValidationError instead of printing, because a service should not assume it is being used by a command line.

cli.py is the interface layer. argparse defines subcommands and their arguments, converts amount to a float automatically, and generates --help output for free.

The try/except/finally in main turns a ValidationError into a friendly message and a non-zero exit code. Exit codes matter: shell scripts and CI check them to decide whether a command succeeded.

Returning an integer from main and passing it to SystemExit is the clean pattern — it also makes main callable from a test with a list of arguments.

Notice what is not here. No global state, no SQL in the CLI, no printing inside the service. Each layer does one thing, which is what lets you test the service directly.

Real-world use #

This layering is the same one used by production systems, just smaller. Swap cli.py for routes.py and you have a web API with the same service layer underneath. Add a scheduled job that calls service.summary and it reuses the identical code.

Command-line tools like this are genuinely useful at work: data migrations, one-off reports, cleanup scripts, admin operations. Being able to write a solid one in twenty minutes is a practical skill.

The natural next steps are the ones a real project takes: add tests for the service, move the database path into configuration, add a date filter, and export the summary as CSV.

Common mistakes #

  • Putting SQL in the CLI layer, which makes the storage impossible to change later.
  • Printing from the service layer instead of raising errors the caller can handle.
  • Forgetting to return a non-zero exit code on failure, so scripts think it succeeded.
  • Skipping input validation because "I am the only user". You will not be.
  • Never running it with bad input. Test the error paths yourself before shipping.

Practice #

Build the project as written, then extend it: add a delete subcommand that takes an ID and returns an error if it does not exist, and add a --since YYYY-MM-DD filter to the list command. Then write two pytest tests for add_expense — one success, one ValidationError.

Quick quiz

  1. 1. Why does the service layer raise an exception instead of printing?

  2. 2. What does `argparse` give you for free?

  3. 3. Why return a non-zero exit code on failure?

  4. 4. Why keep all SQL inside `storage.py`?

  5. 5. Which part of this project is easiest to unit test?

Summary

  • A real project separates interface, business rules and storage.
  • Services raise errors; the interface layer decides how to report them.
  • argparse gives you subcommands, type conversion and help text cheaply.
  • Exit codes matter for scripts and CI.
  • Type the project out yourself — the bugs along the way are the lesson.