Expert · Lesson 90 of 94

Capstone: a production-ready package

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

The Advanced project built the library: models, parsing, ledger, and report, typed and tested, with a public API in expenses/__init__.py and no knowledge of terminals, files, or deployment. The capstone wraps it in every production layer this level taught, one at a time, each in its own module or file, without touching the core. That separation is the point of the whole design: the library is reusable from a web service or a notebook, and the production shell is replaceable.

Capstone: a production-ready package: expenses/ library (unchanged): models · parse · ledger · report → cli.py: argparse, exit codes → settings.py: env + TOML, fail fast → main: logging setup, Settings, run
Capstone: a production-ready package · Figure 1 · Open for full size

Around that core the production layers stack outside-in. The project file declares the command, the dependencies and the tools; the tools run under pre-commit locally and in CI on every push; CI builds the image; and the image ships with the documents that let someone else run and trust it. Each layer is small on its own. Together they are the difference between code that works on your machine and a package a team can deploy.

Capstone: a production-ready package: pyproject.toml: scripts, deps, dev group, tool config → ruff · mypy --strict · pytest · pre-commit → ci.yml · release.yml → Dockerfile · compose.yaml · /health → README · CHANGELOG · LICENSE · v1.0.0 tag
Capstone: a production-ready package · Figure 2 · Open for full size

The command is the user's interface. expenses report FILE --format json --month 2026-09 and expenses summary FILE are subcommands built with argparse sub-parsers; each maps to a small function that takes plain values and a Settings object, calls the library, prints the result, and returns an exit code. Configuration comes from Settings.from_env() layered with expenses.toml and flags, exactly as the configuration lesson set out. Logging is configured once in main, at a level from settings, with the library's logger emitting a WARNING for each skipped line.

Layer File(s) Lesson it came from
command src/expenses/cli.py argparse; file organizer project
configuration src/expenses/settings.py, expenses.toml configuration and secrets
logging cli.main → basicConfig; library emits logging
packaging pyproject.toml, uv.lock, [project.scripts] project structure; packaging
quality gates [tool.ruff], [tool.mypy], .pre-commit-config.yaml code quality and CI
pipeline .github/workflows/ci.yml, release.yml code quality and CI; packaging
container Dockerfile, .dockerignore, compose.yaml, /health deploying with Docker
documentation README.md, docstrings, CHANGELOG.md documenting code
safety path checks, no shell, parameters, audit security essentials

Done is a checklist, not a feeling. uv run ruff check . and ruff format --check . pass. uv run mypy src is clean under strict. uv run pytest is green with tests for the CLI's exit codes and the settings' failure modes as well as the library. pre-commit run --all-files passes. The CI workflow is green on the pull request. uv build produces a wheel that installs into a fresh environment and runs expenses --help. The container builds, reports healthy, and runs the command. The README lets a stranger install and use it in two minutes. The version is 1.0.0, tagged, with a changelog entry.

Capstone: a production-ready package: ruff + format → mypy --strict → pytest → pre-commit → CI green → wheel installs, --help works → container healthy → README + CHANGELOG + tag v1.0.0
Capstone: a production-ready package · Figure 3 · Open for full size

What you have at the end is a template. Every project after this one starts by copying the shape: src layout, pyproject.toml with the tool tables, a Settings class, a cli.py with sub-parsers, the quality hooks, the workflow files, the Dockerfile. The domain code changes; the production shell stays the same, and it takes an afternoon rather than a month to get a new idea to a state that can be handed to someone else. That is what production-ready means in practice.

Code

The command layer: sub-parsers mapped to small functions, a Settings object threaded through, logging configured once, and meaningful exit codes. The library modules from the Advanced project are assumed installed as expenses; a minimal stand-in is written here so the file runs on its own.

# expenses/__init__.py
"""Stand-in for the Advanced-project library so this lesson runs standalone."""

import csv
import json
import logging
from collections import defaultdict
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from datetime import date

log = logging.getLogger(__name__)


class ParseError(ValueError):
    pass


@dataclass(frozen=True, order=True)
class Expense:
    day: date
    category: str
    amount: float

    @property
    def month(self) -> str:
        return self.day.strftime("%Y-%m")


def parse_lines(lines: Iterable[str], skip_bad: bool = False) -> Iterator[Expense]:
    for line_no, row in enumerate(csv.DictReader(lines), start=2):
        try:
            yield Expense(date.fromisoformat(row["day"]), row["category"], float(row["amount"]))
        except (KeyError, ValueError) as err:
            if skip_bad:
                log.warning("skipping line %d: %s", line_no, err)
                continue
            raise ParseError(f"line {line_no}: {err}") from err


class Ledger:
    def __init__(self, expenses: Iterable[Expense]) -> None:
        self._items: list[Expense] = sorted(expenses)

    def __len__(self) -> int:
        return len(self._items)

    def for_month(self, month: str) -> Ledger:
        return Ledger(e for e in self._items if e.month == month)

    def total(self) -> float:
        return round(sum(e.amount for e in self._items), 2)

    def by_category(self) -> dict[str, float]:
        totals: dict[str, float] = defaultdict(float)
        for e in self._items:
            totals[e.category] += e.amount
        return {c: round(v, 2) for c, v in sorted(totals.items())}


def render(ledger: Ledger, fmt: str = "text") -> str:
    if fmt == "json":
        payload = {"total": ledger.total(), "by_category": ledger.by_category(), "count": len(ledger)}
        return json.dumps(payload)
    lines = [f"{c:<8}{v:>10.2f}" for c, v in ledger.by_category().items()]
    return "\n".join(lines + [f"{'total':<8}{ledger.total():>10.2f}"])
# expenses/settings.py
import os
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any


class ConfigError(Exception):
    pass


@dataclass(frozen=True)
class Settings:
    data_dir: Path
    default_format: str = "text"
    log_level: str = "INFO"
    skip_bad_lines: bool = False

    @classmethod
    def load(cls, env: Mapping[str, str] | None = None, config_file: str = "expenses.toml") -> Settings:
        source: Mapping[str, str] = os.environ if env is None else env
        values: dict[str, Any] = {}
        if Path(config_file).exists():
            with open(config_file, "rb") as f:
                values.update(tomllib.load(f).get("expenses", {}))
        if "EXPENSES_DATA_DIR" in source:
            values["data_dir"] = source["EXPENSES_DATA_DIR"]
        if "EXPENSES_FORMAT" in source:
            values["default_format"] = source["EXPENSES_FORMAT"]
        if "LOG_LEVEL" in source:
            values["log_level"] = source["LOG_LEVEL"]
        if "data_dir" not in values:
            raise ConfigError("data_dir is required: set EXPENSES_DATA_DIR or [expenses] data_dir")
        fmt = str(values.get("default_format", "text"))
        if fmt not in ("text", "json"):
            raise ConfigError(f"default_format must be text or json, got {fmt!r}")
        return cls(
            data_dir=Path(str(values["data_dir"])),
            default_format=fmt,
            log_level=str(values.get("log_level", "INFO")).upper(),
            skip_bad_lines=bool(values.get("skip_bad_lines", False)),
        )
# expenses/cli.py
import argparse
import logging
import sys
from collections.abc import Mapping
from pathlib import Path

from expenses import Ledger, ParseError, parse_lines, render
from expenses.settings import ConfigError, Settings

log = logging.getLogger(__name__)


def safe_path(settings: Settings, name: str) -> Path:
    target = (settings.data_dir / name).resolve()
    if not target.is_relative_to(settings.data_dir.resolve()):
        raise PermissionError(f"{name!r} is outside the data directory")
    return target


def load_ledger(settings: Settings, name: str) -> Ledger:
    with open(safe_path(settings, name), encoding="utf-8") as f:
        return Ledger(parse_lines(f, skip_bad=settings.skip_bad_lines))


def cmd_report(settings: Settings, args: argparse.Namespace) -> int:
    ledger = load_ledger(settings, args.file)
    if args.month:
        ledger = ledger.for_month(args.month)
    print(render(ledger, args.format or settings.default_format))
    return 0


def cmd_summary(settings: Settings, args: argparse.Namespace) -> int:
    ledger = load_ledger(settings, args.file)
    print(f"{len(ledger)} expenses, total {ledger.total():.2f}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="expenses", description="Expense reports from CSV files.")
    parser.add_argument("--verbose", "-v", action="store_true")
    sub = parser.add_subparsers(dest="command", required=True)
    report = sub.add_parser("report", help="per-category report")
    report.add_argument("file")
    report.add_argument("--format", choices=["text", "json"])
    report.add_argument("--month", help="YYYY-MM")
    report.set_defaults(func=cmd_report)
    summary = sub.add_parser("summary", help="one-line total")
    summary.add_argument("file")
    summary.set_defaults(func=cmd_summary)
    return parser


def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        settings = Settings.load(env)
    except ConfigError as err:
        print(f"config error: {err}", file=sys.stderr)
        return 2
    logging.basicConfig(
        level="DEBUG" if args.verbose else settings.log_level,
        format="%(levelname)s %(name)s: %(message)s",
        stream=sys.stderr,
    )
    try:
        return int(args.func(settings, args))
    except FileNotFoundError as err:
        log.error("no such file: %s", Path(err.filename).name)
        return 1
    except (ParseError, PermissionError) as err:
        log.error("%s", err)
        return 1


if __name__ == "__main__":
    sys.exit(main())

The command in use, driven from Python with environment dictionaries so every path is exercised: a report, a JSON report for one month, a summary, and each failure mode with its message on stderr and its exit code.

# demo.py
from pathlib import Path

from expenses.cli import main

data = Path("data")
data.mkdir(exist_ok=True)
(data / "sept.csv").write_text("day,category,amount\n2026-09-01,rent,950\n2026-09-02,food,12.5\n2026-08-30,food,9\n2026-09-03,food,oops\n", encoding="utf-8")
Path("expenses.toml").write_text('[expenses]\ndata_dir = "data"\nskip_bad_lines = true\n', encoding="utf-8")
env = {"LOG_LEVEL": "ERROR"}

print("exit", main(["report", "sept.csv"], env))
print("exit", main(["report", "sept.csv", "--format", "json", "--month", "2026-09"], env))
print("exit", main(["summary", "sept.csv"], env))

codes = [
    main(["summary", "missing.csv"], env),
    main(["summary", "../demo.py"], env),
    main(["summary", "sept.csv"], {"EXPENSES_FORMAT": "xml"}),
]
print("failure exit codes:", codes)
Output
food         21.50
rent        950.00
total       971.50
exit 0
{"total": 962.5, "by_category": {"food": 12.5, "rent": 950.0}, "count": 2}
exit 0
3 expenses, total 971.50
exit 0
ERROR expenses.cli: no such file: missing.csv
ERROR expenses.cli: '../demo.py' is outside the data directory
config error: default_format must be text or json, got 'xml'
failure exit codes: [1, 1, 2]

The production files, all from earlier lessons, gathered in one place: pyproject.toml with scripts and tool tables, the pre-commit hooks, the CI and release workflows, and the Dockerfile.

# pyproject.toml
[project]
name = "expenses-cli"
version = "1.0.0"
description = "Expense reports from CSV files"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
dependencies = []

[project.scripts]
expenses = "expenses.cli:main"

[dependency-groups]
dev = ["pytest==9.1.1", "mypy==2.3.1", "ruff==0.16.6", "pre-commit==4.6.2"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/expenses"]

[tool.ruff]
line-length = 100
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

[tool.mypy]
strict = true

[tool.pytest.ini_options]
testpaths = ["tests"]
# .github/workflows/ci.yml
name: ci
on: { push: { branches: [main] }, pull_request: {} }
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv python install 3.14.7 && uv sync
      - run: uv run ruff check . && uv run ruff format --check .
      - run: uv run mypy src
      - run: uv run pytest -q
      - run: uv build && uv run --isolated --with dist/*.whl expenses --help
# Dockerfile
FROM python:3.14.7-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
RUN pip install --no-cache-dir . && useradd --create-home app
USER app
ENV EXPENSES_DATA_DIR=/data
VOLUME ["/data"]
ENTRYPOINT ["expenses"]
CMD ["--help"]

The CLI tests, the definition of done as a script, and the README that goes with a 1.0.0. The script formats first (a formatter's job is to make that step boring), then checks; it runs the tools this course pins against the stand-in package.

# tests/test_cli.py
from expenses.cli import main


def test_missing_config_exits_2(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    assert main(["summary", "x.csv"], {}) == 2


def test_report_json(tmp_path):
    (tmp_path / "e.csv").write_text("day,category,amount\n2026-09-01,rent,950\n")
    env = {"EXPENSES_DATA_DIR": str(tmp_path), "LOG_LEVEL": "ERROR"}
    assert main(["report", "e.csv", "--format", "json"], env) == 0


def test_path_escape_refused(tmp_path):
    env = {"EXPENSES_DATA_DIR": str(tmp_path), "LOG_LEVEL": "CRITICAL"}
    assert main(["summary", "../etc/passwd"], env) == 1
# done.sh
ruff format --line-length 100 expenses tests | tail -1
ruff check --select E,F,I,UP,B --line-length 100 expenses tests && echo "ruff check: ok"
ruff format --check --line-length 100 expenses tests && echo "ruff format: ok"
mypy expenses --ignore-missing-imports 2>&1 | tail -1
PYTHONPATH=. pytest -q tests 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/' | tail -1
Output
2 files reformatted, 2 files left unchanged
All checks passed!
ruff check: ok
4 files already formatted
ruff format: ok
Success: no issues found in 3 source files
3 passed in Ns
# README.md
# expenses-cli

Per-category expense reports from CSV files, from the command line or a container.

## Install

    pip install expenses-cli            # or: docker run --rm -v $PWD/data:/data ghcr.io/you/expenses report sept.csv

## Use

    export EXPENSES_DATA_DIR=./data
    expenses report sept.csv --format json --month 2026-09
    expenses summary sept.csv

Configuration: `expenses.toml` (`[expenses] data_dir, default_format, skip_bad_lines`), overridden by
`EXPENSES_DATA_DIR`, `EXPENSES_FORMAT`, `LOG_LEVEL`. Exit codes: 0 ok, 1 input error, 2 configuration error.

## Develop

    uv sync && pre-commit install && uv run pytest

See CHANGELOG.md for releases. MIT licensed.

Try it yourself

Take your own expense library from the Advanced project and add every layer in the table, in order, committing after each: cli.py with the two sub-commands, settings.py, the pyproject.toml tables, ruff and mypy clean, the pre-commit config, the CI workflow, the Dockerfile with a /health route, the README and changelog. Run the done checklist, tag v1.0.0, and publish to TestPyPI. Then delete the library's domain code and note how much of the shell you can reuse for the next project.

Common mistakes

What's next

You have shipped a production-ready Python package from an empty folder. The technical journey is complete. Next, the closing lessons: where to go from here, how to keep practicing, and how to turn this into work.

Course overview