What you'll learn
- Assemble every Expert lesson into one deliverable: the expense library as a typed, tested, packaged, documented, containerized command
- Add the production layers around the library without changing it: CLI, configuration, logging setup, packaging, quality gates, CI, container
- Write the README and release checklist that let someone else run and maintain it
- Recognize the shape of a finished project, so you can bring any future project to it
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.

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.

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.

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)
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
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
- Letting production concerns leak into the library.
argparse,os.environ, andbasicConfigstay in the shell; the library takes values and returns results. - A README written last, from memory. Write it as you add each layer and test it on someone who has not seen the project.
- Calling it done without the checklist. Green tests are one item; the wheel installing cleanly and the container reporting healthy are the ones people skip.
What's next
- Production readiness is a set of layers around an unchanged core: command, configuration, logging, packaging, gates, pipeline, container, docs.
- Done is a checklist you can run: ruff, mypy, pytest, pre-commit, CI, wheel install, container health, README, changelog, tag.
- The shell is a template; every future project starts by copying it.
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.