Cookbook · Tooling · 2026
The scaffolding shelf — the files you create once at the start of every project and never think about again: the package manager, the linter config, the Dockerfile, the CI pipeline. Copy-paste-ready, 2026-current. You came from PHP where the project was the folder; here the project is a handful of dotfiles that make it reproducible.
git clone to a running app without asking questions, the setup isn't done.
When: the first thing you do in an empty folder. uv is the standard Python package/project manager in 2026 — it replaces pip + venv + pip-tools + virtualenv with one fast Rust tool that also writes a lockfile.
terminal
# scaffold a project (creates pyproject.toml, .python-version, .venv on first run) uv init my-api && cd my-api # add runtime deps — uv resolves, installs, and pins to uv.lock uv add fastapi "uvicorn[standard]" pydantic-settings # add dev-only deps to a separate group uv add --dev pytest ruff # run anything inside the project env — no "activate" needed uv run uvicorn app.main:app --reload # reproduce someone else's env exactly from the lockfile uv sync
one tool does resolve, install, lock, and run — and it's an order of magnitude faster than pip because it's written in Rust and caches aggressively. uv.lock means every machine and CI run gets byte-identical dependencies, which pip alone never gave you. uv run auto-creates and updates the venv, so you stop forgetting to activate it.
plain pip install still works and isn't wrong for a quick script — but for a project, installing into the global interpreter or hand-rolling requirements.txt skips the lockfile, so "works on my machine" comes back. Commit uv.lock; never .gitignore it.
When: always. Modern Python keeps project metadata, dependencies, and tool config in a single pyproject.toml instead of setup.py + requirements.txt + a dozen rc-files. uv generates it; you'll hand-edit it constantly.
pyproject.toml
# project metadata + the deps uv manages [project] name = "my-api" version = "0.1.0" description = "A small FastAPI service" requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.32", "pydantic-settings>=2.6", ] # dev tools live here, installed by `uv sync` but never shipped [dependency-groups] dev = ["pytest>=8.3", "ruff>=0.8"] # pytest reads this too — one file, many tools [tool.pytest.ini_options] testpaths = ["tests"]
it's the PEP 621 standard, so every tool (uv, ruff, pytest, the build backend) reads its config from this one place. New devs open one file and see the whole project: what it is, what it needs, how it's tested. No more hunting for which of five config files owns a setting.
runtime deps go under [project].dependencies; tools you only need locally go under [dependency-groups].dev. Mixing them ships pytest and ruff into production images — bigger, slower, more attack surface. Keep the split clean.
When: every project, from commit one. ruff is the 2026 default: a single Rust tool that does what black + flake8 + isort + a pile of plugins used to do, hundreds of times faster.
pyproject.toml (append)
[tool.ruff] line-length = 100 target-version = "py313" [tool.ruff.lint] # E/F = pyflakes+pycodestyle, I = import sorting (isort), UP = pyupgrade, B = bugbear select = ["E", "F", "I", "UP", "B"] [tool.ruff.format] # black-compatible formatter, built in — no separate black needed quote-style = "double"
terminal
uv run ruff check --fix # lint + auto-fix imports, unused vars, modern syntax uv run ruff format # reformat (replaces black)
one dependency, one config block, one cache — instead of four tools that fight over line length and import order. It runs in milliseconds, so you can put it on save in your editor and in pre-commit without anyone noticing the delay. --fix turns most lint warnings into automatic edits.
ruff check (lint) and ruff format (formatter) are two commands — running only the linter leaves code unformatted, and vice-versa. Run both, and run format last so it has the final say on whitespace.
When: as soon as there's logic worth protecting. A tests/ folder beside your package, with shared setup in conftest.py that pytest discovers automatically.
tests/conftest.py
import pytest from fastapi.testclient import TestClient from app.main import app # a fixture: shared, reusable test setup pytest injects by name @pytest.fixture def client() -> TestClient: with TestClient(app) as c: yield c # teardown (closing) happens after the yield
tests/test_health.py
# the `client` argument is the fixture above, wired in automatically def test_health(client): r = client.get("/health") assert r.status_code == 200 assert r.json() == {"status": "ok"}
terminal
uv run pytest # discovers tests/, runs everything uv run pytest -q -x # quiet, stop at first failure
conftest.py fixtures are shared across every test file in that folder without imports — pytest matches them by argument name. That's where you build a TestClient, a throwaway DB session, or a seeded user once, instead of repeating setup in every test. yield gives you clean teardown for free.
don't import conftest — pytest loads it as a plugin, importing it directly breaks discovery. And keep test files named test_*.py with functions named test_*, or pytest silently skips them.
When: the moment you have a secret (DB URL, API key). Commit a template of the keys, never the values; load them through pydantic-settings so config is typed.
.env.example (committed — keys only, no real values)
# copy to .env and fill in
DATABASE_URL=postgresql://user:pass@localhost:5432/app
ANTHROPIC_API_KEY=
DEBUG=false
app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") database_url: str anthropic_api_key: str debug: bool = False settings = Settings() # reads .env + real env vars, validates types
the committed .env.example documents which variables the app needs, so a new dev copies it to .env and fills the blanks — no Slack archaeology. pydantic-settings validates and types them at startup, so a missing or malformed value fails loudly on boot, not deep in a request.
the real .env must be in .gitignore (next recipe). Leaking it once means rotating every key in it — and git history keeps the leak forever even after you delete the file. Treat a committed secret as already compromised.
When: first commit, before you stage anything. The lines you always need for a Python + Node + Docker project so junk and secrets never enter history.
.gitignore
# --- Python --- __pycache__/ *.py[cod] .venv/ .pytest_cache/ .ruff_cache/ *.egg-info/ # --- Node / frontend --- node_modules/ dist/ .next/ # --- env & secrets (NEVER commit the real one) --- .env .env.* !.env.example # but DO keep the template # --- IDE / OS noise --- .vscode/ .idea/ .DS_Store
the !.env.example negation is the trick — it re-includes the template after the broad .env.* ignore, so you commit the shape without the secrets. Ignoring .venv/ and node_modules/ keeps the repo small and clones fast; they're rebuilt from lockfiles anyway.
.gitignore only stops untracked files. If you already committed .env, adding it here does nothing — you must git rm --cached .env and rotate the secrets, because the old values stay in history.
When: you're shipping the service anywhere — a server, a PaaS, Kubernetes. A multi-stage build installs deps in a fat builder, then copies only what's needed into a slim, non-root final image.
Dockerfile
# ---- stage 1: builder — install deps with uv ---- FROM python:3.13-slim AS builder COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv WORKDIR /app ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy # copy only lock + manifest first so this layer caches until deps change COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev --no-install-project COPY . . RUN uv sync --frozen --no-dev # ---- stage 2: runtime — slim, no build tools, non-root ---- FROM python:3.13-slim WORKDIR /app RUN useradd --create-home appuser COPY --from=builder --chown=appuser:appuser /app /app ENV PATH="/app/.venv/bin:$PATH" USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
copying pyproject.toml + uv.lock before the source means Docker caches the dependency layer — rebuilds after a code change skip reinstalling everything. The final stage drops uv and build tools, so the shipped image is small. Running as appuser means a container breakout doesn't get root.
use --frozen so the build fails if uv.lock is stale instead of silently re-resolving. And add a .dockerignore (at least .venv, .git, __pycache__) or you'll copy your local venv into the image and bloat it.
When: local dev needs the app and a database together. Docker Compose brings up both with one command; pgvector gives you Postgres with vector search for RAG.
compose.yaml
# the modern file is compose.yaml — no "version:" key needed services: api: build: . ports: ["8000:8000"] env_file: .env depends_on: db: # wait until the DB passes its healthcheck, not just "started" condition: service_healthy db: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: app ports: ["5432:5432"] volumes: - pgdata:/var/lib/postgresql/data # data survives restarts healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d app"] interval: 5s timeout: 3s retries: 5 volumes: pgdata:
terminal
docker compose up --build # bring the whole stack up docker compose down # stop (keeps the volume)
the named pgdata volume keeps your database between up/down cycles. condition: service_healthy + the pg_isready healthcheck means the API only starts once Postgres actually accepts connections — no more first-request crashes from a DB that's "up" but still initialising.
plain depends_on: [db] only waits for the container to start, not to be ready — Postgres takes a few seconds to accept queries. Always pair it with a healthcheck and condition: service_healthy, or your app races the database on boot.
When: the project has more than two or three commands. A Makefile is the one place every command lives, so nobody has to remember the exact flags — make dev just works.
Makefile
# .PHONY = these are command names, not files to build
.PHONY: dev test lint fmt up down
dev:
uv run uvicorn app.main:app --reload
test:
uv run pytest -q
lint:
uv run ruff check .
uv run ruff format --check .
fmt:
uv run ruff check --fix . && uv run ruff format .
up:
docker compose up --build
down:
docker compose down
it's living documentation — a new dev runs make and sees every entry point, instead of grepping the README for the right incantation. CI and humans run the same commands (make lint, make test), so "passes locally" and "passes in CI" finally mean the same thing.
Make requires tab indentation, not spaces — a space-indented recipe fails with the infamous "missing separator" error. If your editor auto-converts tabs to spaces, fix it for Makefile. (Prefer YAML over tabs? just is a popular task-runner alternative.)
When: you want issues caught on commit, not in CI ten minutes later. pre-commit runs ruff (and friends) against staged files every time you git commit.
.pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff # lint, with auto-fix
args: [--fix]
- id: ruff-format # then format
terminal
uv run pre-commit install # wire it into .git/hooks (do once) uv run pre-commit run --all-files # run on the whole repo manually
it shifts the fast checks left — a formatting slip or unused import gets fixed before it ever leaves your machine, so your PRs stay about logic, not whitespace. Pinning rev: means everyone runs the exact same hook version, no drift.
the hooks only run after pre-commit install — cloning the repo isn't enough, each dev must install once. And pre-commit is a convenience, not a gate: it's skippable with --no-verify, so keep the same checks in CI (next recipe) as the real enforcement.
When: the repo is on GitHub and more than one person touches it. Run lint + tests against a real Postgres on every push and PR, so broken code never reaches main.
.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres: # a real DB for the test job
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U user"
--health-interval 5s --health-retries 5
env:
DATABASE_URL: postgresql://user:pass@localhost:5432/app
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4 # installs uv + enables caching
with:
enable-cache: true # cache the uv download dir
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest -q
the services: postgres block spins up a throwaway DB the tests connect to — same engine as production, so DB-specific bugs surface in CI, not staging. setup-uv with caching means installs take seconds after the first run. The --frozen sync proves the lockfile is honest.
the service container's healthcheck must pass before tests run, or they'll race the DB. And mirror your local commands exactly (ruff check, ruff format --check, pytest) — note format --check (verify only) here vs format (rewrite) locally, so CI fails on unformatted code instead of silently editing it.
When: always — it's free discipline. Prefix each commit with a type(scope): message so history reads like a changelog and tools can generate releases from it.
git log (the convention)
# type(scope): short imperative summary feat(auth): add password reset endpoint fix(api): return 404 instead of 500 on missing user chore(deps): bump fastapi to 0.115 docs(readme): add local-run section refactor(db): extract session dependency test(search): cover empty-query case # a breaking change is flagged with ! (or a BREAKING CHANGE: footer) feat(api)!: drop the deprecated /v1 routes
the type prefix makes a year of history skimmable — you can see at a glance what's a feature vs a fix vs noise. Because it's machine-parseable, tools auto-generate changelogs and bump semantic versions (fix → patch, feat → minor, ! → major). Recruiters reading your repo notice the discipline immediately.
keep the summary imperative and short — "add X", not "added X" or "this commit adds X" — and under ~50 chars. Don't stuff three unrelated changes into one feat:; the convention only pays off if one commit is one logical change.
When: every repo, day one — even a private one. The README is the front door: it answers what this is, why it exists, and how to run it before anyone reads a line of code.
README.md
# My API One-sentence description of what it does. ## What A FastAPI service that <does the thing>. Postgres + pgvector for search. ## Why The problem it solves / who it's for. Two or three lines, no more. ## Run it locally ```bash cp .env.example .env # then fill in the blanks uv sync # install deps from the lockfile docker compose up -d db # start Postgres uv run uvicorn app.main:app --reload ``` Open http://localhost:8000/docs ## Stack Python 3.13 · FastAPI · SQLAlchemy · Postgres/pgvector · uv · ruff · pytest
What / Why / Run-it-locally / Stack is the minimum that makes a repo recruiter-readable and onboarding-friendly. The runnable copy-paste block is the most valuable part — it's the proof that your "5-minute setup" bar from the top of this shelf actually holds. If those commands work, the whole shelf worked.
a README rots the moment the run commands drift from reality. Tie it to the Makefile (tell people to run make dev) so there's one source of truth, and re-test the local-run steps in a clean clone before you call the project done.
add/sync, lockfile, Docker integration) and ruff documentation (astral.sh — check/format, rule selection, pre-commit hook). Versions pinned in the snippets are 2026-current; bump them as the tools release.