diff --git a/skills/fastapi-uv-docker/SKILL.md b/skills/fastapi-uv-docker/SKILL.md new file mode 100644 index 0000000..4c37db9 --- /dev/null +++ b/skills/fastapi-uv-docker/SKILL.md @@ -0,0 +1,393 @@ +--- +name: fastapi-uv-docker +description: 'Audit and migrate an existing Python project to best practices for a cloud-native ASGI FastAPI app managed with uv and run with uvicorn in Docker. Use when: conforming a project to production standards, setting up src layout, configuring pyproject.toml, writing multi-stage Dockerfiles, wiring lifespan and settings, adding health endpoints, enforcing non-root container user, migrating from requirements.txt to uv.' +argument-hint: 'What is the current state of the project (bare Python, requirements.txt, pip, etc.)?' +--- + +# FastAPI Project Best Practices + +Bring an existing Python project into full conformance with cloud-native best practices: **FastAPI + uv + uvicorn + Docker**. This skill audits the current state, produces a gap list, and walks through each conformance area in priority order. + +## When to Use + +- Migrating an existing app from `pip`/`requirements.txt` to `uv` +- Conforming a FastAPI app to src-layout and app-factory structure +- Writing a production-grade Dockerfile with multi-stage builds +- Wiring Pydantic settings from environment variables +- Adding health endpoints and graceful lifespan shutdown +- Enforcing non-root container user and proper signal handling +- Setting up `docker-compose.yml` for local dev and CI + +## Progressive Loading References + +Load these references only when needed: + +- FastAPI patterns and app structure: [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md) +- uv project layout and dependency management: [./references/uv-project-layout.md](./references/uv-project-layout.md) +- Docker and cloud-native patterns: [./references/docker-cloud-native.md](./references/docker-cloud-native.md) + +--- + +## Procedure + +### Step 0: Audit the Project + +Before making changes, map the current state across six areas. Produce a short gap list for each. + +| Area | Check | +|------|-------| +| **Project manager** | Is `uv` used? Is `pyproject.toml` present? Is `uv.lock` committed? | +| **Package layout** | Is a `src/` layout used? Is the package installable? | +| **App structure** | Is `create_app()` factory used? Is lifespan wired? Are routers registered via `APIRouter`? | +| **Configuration** | Are settings loaded from env via Pydantic `BaseSettings`? Are secrets out of code? | +| **Container** | Is there a `Dockerfile`? Multi-stage? Non-root user? `.dockerignore` present? | +| **Cloud-native** | Is there a `/healthz` endpoint? Graceful shutdown? Structured logs? | + +Load [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md) for structure rules. +Load [./references/uv-project-layout.md](./references/uv-project-layout.md) for uv migration rules. + +Completion check: You can name every gap before touching any file. + +--- + +### Step 1: Migrate to uv and Establish pyproject.toml + +**If the project uses `requirements.txt` / `setup.py` / `setup.cfg` / `pip`:** + +1. Initialize uv if not present: `uv init` (or `uv init --lib` for importable package). +2. Import existing requirements: `uv add -r requirements.txt`. +3. Remove `requirements.txt`, `setup.py`, `setup.cfg`, and any `Pipfile`. +4. Ensure `.python-version` is committed with the target Python version. +5. Commit `uv.lock` — it is the source of truth for reproducible installs. +6. Add `.venv` to `.gitignore` and `.dockerignore`. + +**Canonical `pyproject.toml` shape:** + +```toml +[project] +name = "my-app" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi[standard]>=0.115", + "uvicorn[standard]>=0.34", + "pydantic-settings>=2.0", +] + +[project.scripts] +my-app = "my_app.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +dev-dependencies = [ + "pytest>=8", + "httpx>=0.27", + "pytest-asyncio>=0.24", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/my_app"] +``` + +**Commands:** + +```bash +uv add fastapi[standard] uvicorn[standard] pydantic-settings +uv add --dev pytest httpx pytest-asyncio +uv sync # creates .venv and installs all deps +uv run pytest # run tests via uv +``` + +Completion check: `uv run python -c "import my_app"` succeeds. + +--- + +### Step 2: Establish src Layout + +Move the package under `src/` to prevent import confusion between installed and local code. + +``` +. +├── pyproject.toml +├── uv.lock +├── .python-version +├── .env.example +├── README.md +├── src/ +│ └── my_app/ +│ ├── __init__.py +│ ├── main.py # create_app() + entry point +│ ├── config.py # Pydantic BaseSettings +│ ├── lifespan.py # @asynccontextmanager lifespan +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── health.py # GET /healthz +│ │ └── v1/ +│ │ └── __init__.py +│ └── services/ +│ └── __init__.py +├── tests/ +│ ├── conftest.py +│ └── test_health.py +├── Dockerfile +├── .dockerignore +└── docker-compose.yml +``` + +Completion check: `uv run python -m my_app` starts the server. + +--- + +### Step 3: Wire the FastAPI App Factory + +Load [./references/fastapi-best-practices.md](./references/fastapi-best-practices.md) for the full patterns. Key rules: + +**`src/my_app/main.py`:** + +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI +from my_app.api.health import router as health_router +from my_app.config import Settings + +def create_app(settings: Settings | None = None) -> FastAPI: + if settings is None: + settings = Settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + # startup: open DB pools, load models, etc. + app.state.settings = settings + yield + # shutdown: close connections + + app = FastAPI( + title="My App", + lifespan=lifespan, + docs_url="/docs" if settings.debug else None, + redoc_url=None, + ) + app.include_router(health_router) + return app + +app = create_app() +``` + +**`src/my_app/api/health.py`:** + +```python +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +router = APIRouter(tags=["ops"]) + +@router.get("/healthz", include_in_schema=False) +async def health() -> JSONResponse: + return JSONResponse({"status": "ok"}) +``` + +**`src/my_app/config.py`:** + +```python +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + debug: bool = False + host: str = "0.0.0.0" + port: int = 8000 + log_level: str = "info" +``` + +Completion check: `uv run uvicorn my_app.main:app --reload` starts with no import errors. + +--- + +### Step 4: uvicorn Production Configuration + +**Never** configure uvicorn inside application code. Pass all settings via CLI or environment. + +```bash +# Development +uv run uvicorn my_app.main:app --reload --host 127.0.0.1 --port 8000 + +# Production (single process — let orchestrator handle replication) +uv run uvicorn my_app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --workers 1 \ + --loop uvloop \ + --http h11 \ + --log-level info \ + --access-log \ + --proxy-headers \ + --forwarded-allow-ips '*' +``` + +**When to use `--workers > 1`:** Only for single-server Docker Compose deployments where you cannot replicate at the orchestrator level. For Kubernetes / cloud run: always `--workers 1` and scale via replicas. + +**`--proxy-headers`** is required whenever the container sits behind a reverse proxy (nginx, traefik, cloud load balancer) — it makes FastAPI trust `X-Forwarded-For` and `X-Forwarded-Proto`. + +Completion check: `curl http://localhost:8000/healthz` returns `{"status":"ok"}`. + +--- + +### Step 5: Write the Dockerfile + +Load [./references/docker-cloud-native.md](./references/docker-cloud-native.md) for the full template and cloud-native rules. Key requirements: + +- Multi-stage build: `builder` stage installs deps; `runtime` stage is slim. +- Pin uv version (copy from official image, not `latest`). +- Use `uv sync --locked --no-editable` to install into `.venv`. +- Set `ENV PATH="/app/.venv/bin:$PATH"` — do **not** use `uv run` in production `CMD`. +- Run as non-root user. +- Use `CMD` exec form, never shell form. +- Add `HEALTHCHECK`. + +**Canonical Dockerfile:** + +```dockerfile +# syntax=docker/dockerfile:1 + +# ── builder ────────────────────────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS builder + +# Pin uv version for reproducibility +COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/ + +WORKDIR /app + +# Install dependencies first (cache layer) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-editable + +# Copy source and install project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable + +# ── runtime ─────────────────────────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS runtime + +# Non-root user +RUN groupadd --system --gid 1001 appgroup && \ + useradd --system --uid 1001 --gid appgroup --no-log-init appuser + +WORKDIR /app + +# Copy only the virtual environment (not source code) +COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv + +# Copy application source +COPY --chown=appuser:appgroup src/ /app/src/ + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" + +# Exec form — required for graceful shutdown / lifespan events +CMD ["uvicorn", "my_app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--proxy-headers"] +``` + +Completion check: `docker build -t my-app . && docker run --rm -p 8000:8000 my-app` serves `/healthz`. + +--- + +### Step 6: Write .dockerignore and docker-compose.yml + +**`.dockerignore`:** + +``` +.venv/ +.git/ +.gitignore +.env +*.pyc +__pycache__/ +.pytest_cache/ +dist/ +*.egg-info/ +README.md +``` + +**`docker-compose.yml` (local dev):** + +```yaml +services: + app: + build: . + ports: + - "8000:8000" + environment: + - DEBUG=true + - LOG_LEVEL=debug + env_file: + - .env + develop: + watch: + - action: sync + path: ./src + target: /app/src + - action: rebuild + path: ./pyproject.toml + - action: rebuild + path: ./uv.lock +``` + +Completion check: `docker compose up` starts the app; `docker compose watch` enables hot reload. + +--- + +### Step 7: Cloud-Native Checklist + +Run this final checklist before shipping: + +- [ ] `GET /healthz` returns 200 with no auth required +- [ ] App reads all config from environment (`Settings` with no hardcoded values) +- [ ] `.env` is in `.gitignore` and `.dockerignore`; `.env.example` is committed +- [ ] `uv.lock` is committed +- [ ] `.python-version` is committed +- [ ] Dockerfile uses non-root user (`USER appuser`) +- [ ] `CMD` uses exec form (list, not string) +- [ ] `--proxy-headers` is set in the uvicorn `CMD` if behind a proxy +- [ ] `PYTHONUNBUFFERED=1` is set (logs flush immediately) +- [ ] `EXPOSE` declares the correct port +- [ ] `HEALTHCHECK` is defined +- [ ] Multi-stage build — final image contains no build tools or uv binary +- [ ] `.venv` is in `.dockerignore` +- [ ] No secrets hardcoded in `Dockerfile`, `pyproject.toml`, or source +- [ ] `uv sync --locked` in CI (fail if lock is stale) +- [ ] Tests pass via `uv run pytest` + +--- + +## Anti-patterns to Fix + +| Anti-pattern | Correct approach | +|---|---| +| `requirements.txt` | Use `uv` with `pyproject.toml` and `uv.lock` | +| `pip install` in Dockerfile | `uv sync --locked` | +| `ENV SECRET_KEY=abc123` in Dockerfile | Inject at runtime via env; never bake secrets | +| Shell form `CMD uvicorn ...` | Exec form `CMD ["uvicorn", ...]` | +| `FROM tiangolo/uvicorn-gunicorn-fastapi` | Build from scratch with `python:3.x-slim` | +| Multiple workers inside K8s container | `--workers 1`; scale via replicas | +| Running as root in container | `USER appuser` with explicit UID 1001 | +| Startup/shutdown in `@app.on_event` | Use `@asynccontextmanager` lifespan | +| Config loaded from `.env` directly in code | Pydantic `BaseSettings` with `env_file` | diff --git a/skills/fastapi-uv-docker/references/docker-cloud-native.md b/skills/fastapi-uv-docker/references/docker-cloud-native.md new file mode 100644 index 0000000..1d0c052 --- /dev/null +++ b/skills/fastapi-uv-docker/references/docker-cloud-native.md @@ -0,0 +1,345 @@ +# Docker and Cloud-Native Patterns + +Source: https://docs.docker.com/build/building/best-practices/ | https://docs.astral.sh/uv/guides/integration/docker/ | https://fastapi.tiangolo.com/deployment/docker/ + +--- + +## Canonical Multi-Stage Dockerfile (uv + FastAPI + uvicorn) + +```dockerfile +# syntax=docker/dockerfile:1 + +# ── builder stage ───────────────────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS builder + +# Pin uv version — never use :latest in production +COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/ + +WORKDIR /app + +# Layer: install dependencies only (cached until pyproject.toml or uv.lock changes) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev --no-editable + +# Layer: copy source and install project +COPY src/ /app/src/ +COPY pyproject.toml uv.lock /app/ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-editable + +# ── runtime stage ───────────────────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS runtime + +# Non-root user with explicit UID/GID +RUN groupadd --system --gid 1001 appgroup && \ + useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser + +WORKDIR /app + +# Copy only the venv (uv binary stays in builder) +COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv + +# Copy app source +COPY --from=builder --chown=appuser:appgroup /app/src /app/src + +# Activate the venv via PATH — do not rely on `uv run` at runtime +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_PYTHON_DOWNLOADS=0 + +USER appuser + +EXPOSE 8000 + +# Liveness probe — no extra tools needed; uses stdlib urllib +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD python -c \ + "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \ + || exit 1 + +# Exec form (NOT shell form) — required for SIGTERM → graceful shutdown +CMD ["uvicorn", "my_app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--proxy-headers", \ + "--forwarded-allow-ips", "*"] +``` + +--- + +## .dockerignore + +``` +# Python artifacts +.venv/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +*.egg-info/ + +# Environment and secrets +.env +.env.* +!.env.example + +# Git +.git/ +.gitignore + +# Docs and local tooling +README.md +docs/ +*.md +``` + +**Always add `.venv/` to `.dockerignore`** — it is platform-specific and will cause subtle failures if copied into the image. + +--- + +## docker-compose.yml (Local Development) + +```yaml +services: + app: + build: + context: . + target: runtime # build only up to the runtime stage + ports: + - "8000:8000" + environment: + DEBUG: "true" + LOG_LEVEL: "debug" + env_file: + - .env + restart: unless-stopped + + # Docker Compose Watch — hot reload without rebuilding image + develop: + watch: + - action: sync + path: ./src + target: /app/src + ignore: + - .venv/ + - __pycache__/ + - action: rebuild + path: ./pyproject.toml + - action: rebuild + path: ./uv.lock +``` + +Run with: + +```bash +docker compose up # start normally +docker compose watch # start with live sync +docker compose up --build # force rebuild +``` + +--- + +## Key Dockerfile Rules + +### Multi-Stage Builds + +- **Stage 1 (`builder`)**: Has uv, build tools, compiles `.pyc` files if needed. +- **Stage 2 (`runtime`)**: Minimal; contains only the venv and source. No uv, no build tools. +- Final image size: typically 150–200 MB for a FastAPI app (vs 500+ MB with a single stage). + +### Layer Caching Strategy + +Copy files in order of change frequency (least → most): + +``` +1. uv.lock + pyproject.toml → install deps (cached for days) +2. Source code → install project (invalidated on every code change) +``` + +This means dependency installation is only re-run when `uv.lock` or `pyproject.toml` changes. + +### CMD Exec Form (Critical) + +```dockerfile +# ✅ Exec form — process receives SIGTERM directly → graceful shutdown +CMD ["uvicorn", "my_app.main:app", "--host", "0.0.0.0", "--port", "8000"] + +# ❌ Shell form — process is a child of /bin/sh → SIGTERM goes to shell, not uvicorn +CMD uvicorn my_app.main:app --host 0.0.0.0 --port 8000 +``` + +Shell form breaks graceful shutdown and FastAPI lifespan shutdown events. + +### Non-Root User + +```dockerfile +RUN groupadd --system --gid 1001 appgroup && \ + useradd --system --uid 1001 --gid appgroup --no-log-init --home /app appuser +USER appuser +``` + +- Use `--system` for service accounts (no shell, no home by default). +- Use `--no-log-init` to avoid `/var/log/faillog` disk exhaustion (Go runtime bug in older kernels). +- Use explicit UID/GID (1001) — deterministic, scanners can reason about it. +- Never run as UID 0 (root) in production. + +### HEALTHCHECK + +```dockerfile +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD python -c \ + "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" \ + || exit 1 +``` + +- `--start-period`: grace period on startup before health checks begin. +- `--retries`: mark as unhealthy only after N consecutive failures. +- Uses stdlib `urllib` — no extra tools needed, not even `curl`. + +--- + +## Cloud-Native Twelve-Factor Principles Applied + +### Factor III — Config (Environment Variables) + +- All config comes from environment variables, never from code. +- `pydantic-settings` reads from env automatically. +- Provide `.env.example` with documentation; never commit `.env`. + +```bash +# Runtime injection +docker run -e DATABASE_URL="postgresql://..." -e SECRET_KEY="..." my-app +# or via env_file in compose / Kubernetes Secret +``` + +### Factor XI — Logs (Treat as Event Streams) + +- Set `PYTHONUNBUFFERED=1` — logs are flushed immediately to stdout/stderr. +- Never write logs to files inside the container. +- Configure uvicorn to log JSON in production: + +```python +# In create_app() lifespan or a logging setup module +import logging +import json + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + return json.dumps({ + "level": record.levelname, + "message": record.getMessage(), + "logger": record.name, + }) +``` + +Or use `structlog` with the JSON renderer for production. + +### Factor IX — Disposability (Fast Startup, Graceful Shutdown) + +- FastAPI lifespan handles startup/shutdown. +- Uvicorn forwards `SIGTERM` to the Python process when using exec form `CMD`. +- The process should be fully ready to serve requests within 10 seconds. +- Kubernetes `terminationGracePeriodSeconds` should be >= your timeout. + +--- + +## Kubernetes Readiness / Liveness Probes + +Expose two endpoints: + +```python +@router.get("/healthz", include_in_schema=False) # liveness +async def health(): + return {"status": "ok"} + +@router.get("/readyz", include_in_schema=False) # readiness +async def readiness(request: Request): + # Check DB, cache, etc. + return {"status": "ready"} +``` + +Kubernetes manifest snippet: + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + +readinessProbe: + httpGet: + path: /readyz + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +--- + +## Scaling: Workers vs. Replicas + +| Deployment | Workers setting | Reasoning | +|---|---|---| +| Kubernetes / Cloud Run | `--workers 1` | Orchestrator handles replication; 1 process per container for predictable memory | +| Docker Compose (single host) | `--workers 4` | No external replication; use CPU cores | +| Local dev | `--reload` (single process) | Hot reload only works with single process | + +**Never use `--reload` in production.** + +--- + +## Base Image Selection + +| Use case | Recommended base | +|---|---| +| Standard production | `python:3.12-slim-bookworm` | +| Smallest possible image | `python:3.12-alpine3.20` (musl; watch for C extension compat) | +| uv-managed Python | `ghcr.io/astral-sh/uv:python3.12-bookworm-slim` | +| Security-hardened | `cgr.dev/chainguard/python:latest` (distroless) | + +**Do not use:** +- `python:latest` — unpinned, breaks reproducibility +- `tiangolo/uvicorn-gunicorn-fastapi` — deprecated by FastAPI team +- Full `python:3.x` (non-slim) — 300+ MB unnecessary overhead + +--- + +## Build Optimizations + +```bash +# Enable BuildKit (default in Docker >= 23) +export DOCKER_BUILDKIT=1 + +# Build with cache mount (fastest for repeated local builds) +docker build -t my-app . + +# CI: force fresh base image + no layer cache +docker build --pull --no-cache -t my-app . + +# Multi-platform build (for ARM deployment from x86 CI) +docker buildx build --platform linux/amd64,linux/arm64 -t my-app . +``` + +--- + +## Security Hardening Checklist + +- [ ] Non-root user with explicit UID/GID +- [ ] Read-only filesystem where possible (`--read-only` docker run flag or `securityContext.readOnlyRootFilesystem: true` in K8s) +- [ ] No secrets in `ENV` Dockerfile instructions +- [ ] Minimal base image (slim/alpine) +- [ ] Multi-stage build (no build tools in runtime image) +- [ ] Pin base image and uv version (not `:latest`) +- [ ] `.dockerignore` excludes `.env`, `.git`, `.venv` +- [ ] `EXPOSE` only the port actually used +- [ ] Regular base image updates in CI (`docker build --pull`) diff --git a/skills/fastapi-uv-docker/references/fastapi-best-practices.md b/skills/fastapi-uv-docker/references/fastapi-best-practices.md new file mode 100644 index 0000000..85990cb --- /dev/null +++ b/skills/fastapi-uv-docker/references/fastapi-best-practices.md @@ -0,0 +1,238 @@ +# FastAPI Best Practices + +Source: https://fastapi.tiangolo.com/deployment/ | https://fastapi.tiangolo.com/advanced/events/ + +--- + +## App Factory Pattern + +Always use `create_app()` — it makes the app testable (inject a test `Settings`) and avoids module-level side effects. + +```python +# src/my_app/main.py +from contextlib import asynccontextmanager +from fastapi import FastAPI +from my_app.config import Settings +from my_app.api import health, v1 + +def create_app(settings: Settings | None = None) -> FastAPI: + if settings is None: + settings = Settings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + # --- startup --- + app.state.settings = settings + # Open DB pool, warm caches, etc. + yield + # --- shutdown --- + # Close DB pool, flush buffers, etc. + + app = FastAPI( + title=settings.app_name, + version="1.0.0", + lifespan=lifespan, + docs_url="/docs" if settings.debug else None, + redoc_url=None, + ) + + app.include_router(health.router) + app.include_router(v1.router, prefix="/api/v1") + return app + +# Module-level instance for uvicorn +app = create_app() +``` + +**Never use `@app.on_event("startup")` / `@app.on_event("shutdown")`** — these are deprecated. The `asynccontextmanager` lifespan is the canonical approach since FastAPI 0.95. + +--- + +## Router Organization + +``` +src/my_app/api/ +├── __init__.py +├── health.py # GET /healthz — no auth, no versioning +├── deps.py # Shared Depends() factories +└── v1/ + ├── __init__.py # APIRouter with prefix="/v1" + ├── items.py + └── users.py +``` + +Each router file: + +```python +from fastapi import APIRouter + +router = APIRouter(prefix="/items", tags=["items"]) + +@router.get("/") +async def list_items() -> list[Item]: + ... +``` + +Root registration: + +```python +from fastapi import APIRouter +from my_app.api.v1 import items, users + +router = APIRouter() +router.include_router(items.router) +router.include_router(users.router) +``` + +--- + +## Pydantic Settings (Configuration) + +```python +# src/my_app/config.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "My App" + debug: bool = False + host: str = "0.0.0.0" + port: int = 8000 + log_level: str = "info" + # Add DB URL, secret keys, etc. here — never hardcode + # database_url: str # required — will raise if missing + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +Use `lru_cache` so the `.env` file is read once. In tests, override with: + +```python +from my_app.config import get_settings +from my_app.main import create_app + +app = create_app(settings=Settings(debug=True, database_url="sqlite://")) +``` + +**Never** import `settings` as a module-level singleton — it prevents test overrides. + +--- + +## Health Endpoint + +```python +# src/my_app/api/health.py +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +router = APIRouter(tags=["ops"]) + +@router.get("/healthz", include_in_schema=False) +async def health() -> JSONResponse: + """Kubernetes/Docker liveness probe. No auth. No versioning.""" + return JSONResponse({"status": "ok"}) + +@router.get("/readyz", include_in_schema=False) +async def readiness(request: Request) -> JSONResponse: + """Readiness probe — check DB connectivity, etc.""" + # Example: await request.app.state.db.execute("SELECT 1") + return JSONResponse({"status": "ready"}) +``` + +Rules: +- No authentication required on `/healthz` and `/readyz`. +- `/healthz` — liveness: can the process respond? +- `/readyz` — readiness: are dependencies available? +- Keep them on the root path (not `/api/v1/healthz`). + +--- + +## Dependency Injection + +Use `Depends()` to share resources from app state: + +```python +# src/my_app/api/deps.py +from fastapi import Depends, Request +from my_app.config import Settings + +def get_settings(request: Request) -> Settings: + return request.app.state.settings + +SettingsDep = Annotated[Settings, Depends(get_settings)] +``` + +In route handlers: + +```python +@router.get("/config") +async def show_config(settings: SettingsDep) -> dict: + return {"debug": settings.debug} +``` + +--- + +## Error Handling + +Register a global exception handler for unhandled errors: + +```python +from fastapi import Request +from fastapi.responses import JSONResponse + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + # Log the exception — never expose internal details to clients + logger.exception("Unhandled error", exc_info=exc) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) +``` + +--- + +## CORS + +Add CORS middleware only when needed (e.g., browser clients from a different origin): + +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, # list from config, never ["*"] in prod + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +--- + +## Response Models + +Always declare `response_model` or return type annotations — FastAPI uses them for OpenAPI docs and response validation: + +```python +@router.post("/items/", response_model=ItemOut, status_code=201) +async def create_item(item: ItemIn) -> ItemOut: + ... +``` + +Use separate `In` / `Out` models when the write shape differs from the read shape (e.g., password hashing, computed fields). + +--- + +## Security Checklist + +- Never expose `docs_url` in production (set `docs_url=None` when `not settings.debug`). +- Validate all user input with Pydantic models — never pass raw request data to DB queries. +- Use `SecretStr` for passwords and API keys in `Settings`. +- Apply authentication globally via middleware or `app.include_router(..., dependencies=[Depends(verify_token)])`. +- Add `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` headers in production middleware. diff --git a/skills/fastapi-uv-docker/references/uv-project-layout.md b/skills/fastapi-uv-docker/references/uv-project-layout.md new file mode 100644 index 0000000..765cf64 --- /dev/null +++ b/skills/fastapi-uv-docker/references/uv-project-layout.md @@ -0,0 +1,251 @@ +# uv Project Layout and Dependency Management + +Source: https://docs.astral.sh/uv/guides/projects/ | https://docs.astral.sh/uv/concepts/projects/layout/ | https://docs.astral.sh/uv/guides/integration/docker/ + +--- + +## Core Files + +| File | Purpose | Commit? | +|------|---------|---------| +| `pyproject.toml` | Project metadata, deps, tool config | Yes | +| `uv.lock` | Exact resolved versions, cross-platform | **Yes** | +| `.python-version` | Default Python version for the project | Yes | +| `.venv/` | Local virtual environment | No (`.gitignore`) | + +**`uv.lock` must be committed.** It is the source of truth for reproducible installs in CI and Docker. Never edit it by hand. + +--- + +## Essential Commands + +```bash +# Initialize a new project (app, not library) +uv init --app + +# Initialize a library (installable package with src layout) +uv init --lib + +# Add a runtime dependency +uv add fastapi[standard] + +# Add multiple dependencies at once +uv add uvicorn[standard] pydantic-settings + +# Add dev-only dependency +uv add --dev pytest httpx pytest-asyncio ruff mypy + +# Remove a dependency +uv remove requests + +# Upgrade a specific package (keeps rest of lockfile intact) +uv lock --upgrade-package fastapi + +# Upgrade all packages +uv lock --upgrade + +# Sync env to lockfile (install/remove as needed) +uv sync + +# Sync without dev deps (e.g., in CI or Docker) +uv sync --no-dev + +# Sync and assert lockfile is up-to-date (for CI / Docker) +uv sync --locked + +# Run a command in the project environment +uv run pytest +uv run uvicorn my_app.main:app --reload + +# Run a one-off command without installing anything permanently +uv run --with httpx python -c "import httpx; print(httpx.__version__)" +``` + +--- + +## pyproject.toml Reference + +```toml +[project] +name = "my-app" +version = "0.1.0" +description = "Production FastAPI service" +requires-python = ">=3.12" +license = { text = "MIT" } +readme = "README.md" + +dependencies = [ + "fastapi[standard]>=0.115", + "uvicorn[standard]>=0.34", + "pydantic-settings>=2.0", +] + +[project.scripts] +# Creates `my-app` CLI entry point when installed +my-app = "my_app.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/my_app"] + +[tool.uv] +dev-dependencies = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "httpx>=0.27", # needed for FastAPI TestClient + "ruff>=0.6", + "mypy>=1.11", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +strict-markers = true + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "S"] + +[tool.mypy] +strict = true +``` + +--- + +## src Layout with uv + +Use `uv init --lib` or set up manually: + +``` +my-app/ +├── pyproject.toml +├── uv.lock +├── .python-version # e.g., "3.12" +├── .env.example +├── README.md +├── src/ +│ └── my_app/ +│ └── __init__.py +└── tests/ + └── conftest.py +``` + +The `src/` layout prevents the local directory from shadowing the installed package, which would otherwise cause silent test failures when testing the installed version. + +**hatchling config for src layout:** + +```toml +[tool.hatch.build.targets.wheel] +packages = ["src/my_app"] +``` + +--- + +## Migration from pip / requirements.txt + +```bash +# 1. Initialize uv in an existing project +uv init --no-workspace # if already has pyproject.toml, skip this + +# 2. Import from requirements.txt +uv add -r requirements.txt + +# 3. Import dev requirements +uv add --dev -r requirements-dev.txt + +# 4. Verify lockfile was created +cat uv.lock | head -20 + +# 5. Clean up old files +rm requirements.txt requirements-dev.txt setup.py setup.cfg Pipfile Pipfile.lock + +# 6. Add .venv to .gitignore +echo ".venv/" >> .gitignore +``` + +--- + +## uv in Docker + +The canonical pattern uses `--mount=type=cache` for fast rebuilds and `--no-install-project` for layer separation: + +```dockerfile +# Copy uv binary (pin the version) +COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /uvx /bin/ + +WORKDIR /app + +# Layer 1: install dependencies (changes rarely → cached aggressively) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-editable + +# Layer 2: copy source and install project (changes frequently) +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable +``` + +**Key flags:** + +| Flag | Effect | +|------|--------| +| `--locked` | Fail if `uv.lock` is out of date with `pyproject.toml` | +| `--no-install-project` | Install deps but not the project itself (layer separation) | +| `--no-editable` | Install in non-editable mode (copy code into `.venv`, not symlink) | +| `--no-dev` | Skip dev dependencies (use in production images) | +| `--compile-bytecode` | Pre-compile `.pyc` files (faster startup, larger image) | + +**After syncing, activate the venv via PATH (not `uv run`) in production:** + +```dockerfile +ENV PATH="/app/.venv/bin:$PATH" +CMD ["uvicorn", "my_app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +Using `CMD ["uv", "run", "uvicorn", ...]` in production is fine but adds a small overhead and requires uv to be present in the final image. + +--- + +## CI/CD with uv + +```yaml +# GitHub Actions example +- name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "0.5.27" # pin for reproducibility + +- name: Sync dependencies + run: uv sync --locked + +- name: Run tests + run: uv run pytest --tb=short + +- name: Lint + run: uv run ruff check . + +- name: Type check + run: uv run mypy src/ +``` + +--- + +## .gitignore Entries for uv Projects + +``` +.venv/ +__pycache__/ +*.pyc +.env +dist/ +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +``` + +**Do NOT ignore `uv.lock`** — it must be committed.