Cookbook · Tooling · 2026

Tooling & Project Setup

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.

The bar to clear A new project should be runnable in 5 minutes. Clone, run one command, and it's up — deps installed, linter wired, tests green, container builds. Everything on this shelf exists to hit that bar. If a teammate (or recruiter, or future you) can't go from git clone to a running app without asking questions, the setup isn't done.
On this shelf
  1. Start a project with uv
  2. pyproject.toml — the one config file
  3. ruff — lint + format
  4. pytest layout + a fixture
  5. .env & .env.example
  6. .gitignore essentials
  7. Dockerfile (multi-stage)
  8. compose.yaml (api + pgvector)
  9. Makefile / task shortcuts
  10. pre-commit config
  11. GitHub Actions CI
  12. Conventional commits
  13. README skeleton

Python project 4 recipes

Start a project with uv

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.

pyproject.toml — the one config file

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.

ruff — lint + format in one config

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.

pytest layout + a fixture

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.

Config & secrets 2 recipes

.env & .env.example

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.

.gitignore essentials

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.

Containers 2 recipes

Dockerfile (multi-stage, Python)

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.

compose.yaml (api + pgvector)

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.

CI & git hygiene 5 recipes

Makefile / task shortcuts

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.)

pre-commit config

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.

GitHub Actions CI

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.

Conventional commits

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.

README skeleton

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.

Sources Commands and config here track the current docs: uv documentation (astral.sh — project init, 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.