Skip to content

CLI

Installing tempest-fastapi-sdk[cli] exposes a tempest console script. It does two jobs: bootstrap a new layered service from the SDK's preferred skeleton, and run the four quality gates (ruff check, ruff format, mypy, pytest) without copy-pasting the same commands into every project.

tempest --help                                  # list every command
tempest --version                               # show the SDK version

The CLI is the [cli] extra (since 0.308.0)

tempest is a development tool, so it lives in an extra rather than in the base package. Install it the way you use it:

uv tool install "tempest-fastapi-sdk[cli]"   # global command
uv add --dev "tempest-fastapi-sdk[cli]"      # in the project's dev group

The reason is ruff: the gate (tempest check) comes from tempest-cli, which requires ruff>=0.8.0 at runtime. In the base package, every service importing the SDK shipped a formatter to production, and a project pinning ruff<0.8 in its dev group could not even resolve its lock.

Without the extra the script is still installed and explains what is missing, exiting with code 2 and no traceback:

$ tempest --help
error: missing tempest_cli, typer, click. The tempest CLI needs the optional [cli] extra. Install it with:
  uv add --dev "tempest-fastapi-sdk[cli]"   (in a project)
  uv tool install "tempest-fastapi-sdk[cli]"   (as a global command)

import tempest_fastapi_sdk does not need the extra; reading tempest_fastapi_sdk.cli.app (or importing tempest_fastapi_sdk.cli.main, .config, .lint, .pr_prompt) without it raises ImportError with the same instruction.

Usage error? The full help shows up with it

When you type an unknown command, an invalid option, or forget a required argument, tempest prints that command's complete --help (every parameter, default and description) before the error line — instead of Click's terse Try '... --help'. You fix it on the spot, without re-running with --help.

tempest user create            # forgot --email
# ... full `user create` help (every option) ...
# Error: Missing option '--email' / '-e'.

Scaffold a new service

tempest new my_service                          # scaffold under ./my_service
tempest new my_service --path ~/projects        # custom parent dir
# default HOST/PORT in .env.example and SDK extras pinned in pyproject.toml
tempest new my_service --bind-host 0.0.0.0 --bind-port 9090 --extras upload
tempest new my_service --force                  # overwrite existing dir

The skeleton matches the layered architecture documented in Architecture »:

my_service/
├── main.py                  # one-liner → src.server.run()
├── pyproject.toml           # pins tempest-fastapi-sdk + ruff/mypy/pytest
├── .env.example             # TITLE/DESCRIPTION, SERVER_*, LOG_*, DATABASE_URL, JWT_* (with [auth]), TOKEN_SECRET, CORS_*
├── alembic.ini              # paths anchored on %(here)s
├── alembic/                 # env.py + versions/ with the baseline revision
├── docker-compose.yaml      # services keyed to the chosen extras
├── Dockerfile               # multi-stage with uv, non-root user
├── .dockerignore
├── .gitignore
├── README.md
├── CLAUDE.md                # project rules for AI agents and humans
├── src/
│   ├── __init__.py          # re-exports run from src.server
│   ├── server.py            # module-level app + run() via run_server
│   ├── api/
│   │   ├── app.py           # create_app() wires SDK middleware + handlers
│   │   ├── routers/         # __init__.py with the placeholder GET /api/
│   │   └── dependencies/    # auth.py (require_token) + resources.py (db, get_session, get_mailer/get_storage per extra) + controllers.py (factories)
│   ├── controllers/         # orchestration between services
│   ├── services/            # business logic
│   ├── schemas/             # Pydantic DTOs
│   ├── core/                # settings.py (settings + get_settings) + exceptions.py
│   ├── db/
│   │   ├── configs/names.py # table names behind one symbol
│   │   ├── models/user.py   # UserModel — only with [auth] (or [admin])
│   │   └── repositories/
│   ├── queue/               # only with the [queue] extra — broker + handlers
│   ├── tasks/               # only with [tasks] or [tasks-redis] — TaskQueue + jobs
│   ├── ui/                  # only with the [ssr] extra — pages/layout/components/styles
│   └── utils/
└── tests/
    └── test_smoke.py        # asserts /api/ and /health/liveness boot

The generated CLAUDE.md is the project's contract

Every new project ships with a CLAUDE.md pinning the rules that keep services alike: the dependency direction between layers, the exact order of the seven steps a new domain follows (schema → model → repository → service → controller → provider → router), the table of what not to reimplement because the SDK already ships it, and a definition of done that ends in tempest check.

It exists for an AI agent to read before writing the first line — so its examples are verified in the SDK's own CI: tests/cli/test_scaffold_runtime.py writes the example domain into a scaffolded project and runs it (POST 201, duplicate 409 with the right code, paginated listing in the SDK envelope). A renamed symbol breaks that test, not someone's project.

The generated pyproject.toml pins the current SDK version with the extras you asked for, and only those (tempest-fastapi-sdk>=<version> without --extras). admin and auth are opt-in: --extras auth gives the project the UserModel, JWTSettings on Settings and the JWT_* block in .env.example; --extras admin also makes src/api/app.py mount the admin panel — and brings auth along, because the panel's login checks the password through PasswordUtils. With neither, the service starts with no user table, no panel and no bcrypt/PyJWT in the lock: --extras postgres pins just [postgres], --extras admin,cache pins [admin,auth,cache], and duplicates are dropped with the list sorted alphabetically. Up to 0.312.x admin and auth were always pinned. The dev group gets tempest-fastapi-sdk[cli]>=<version>, so tempest runs in the project without entering the image: the generated Dockerfile installs with uv sync --no-dev. The scaffolded .env.example uses the v0.8.0 settings naming (SERVER_HOST/SERVER_PORT/SERVER_DEBUG/SERVER_RELOAD/LOG_LEVEL/…), and src/server.py delegates to tempest_fastapi_sdk.run_server so uvicorn is imported lazily and tests can import the app without it. Validation rules: the project name must match ^[a-z][a-z0-9_]*$ and cannot collide with a Python keyword, so tempest new Bad-Name and tempest new class exit with code 2 before any file is written.

API title, description and version

Since v0.48.0 the scaffolded Settings carries TITLE, VERSION and DESCRIPTION, and src/api/app.py consumes them (FastAPI(title=settings.TITLE, version=settings.VERSION, description=settings.DESCRIPTION), make_health_router(version= settings.VERSION) and, with [admin], AdminSite(title=f"{settings.TITLE} admin")).

The version has a single source: Settings.VERSION comes from [project].version in pyproject.toml, read by read_project_version. The path is anchored on settings.py itself (not the cwd), so the value is the project's even when it runs from another directory; bumping the release is editing pyproject.toml, and /health/readiness and the OpenAPI info.version follow. Title and description stay in .env, and the VERSION env var still overrides the file when you need to publish a version other than the committed one:

TITLE=My API
DESCRIPTION=Payments API for product X.
# VERSION=1.2.0  # optional: overrides [project].version

Extras-driven docker-compose.yaml

Since v0.25.0 the scaffold generates a docker-compose.yaml carrying only the supporting services the chosen extras actually need — no ZooKeeper, no Kafka, nothing you won't use.

Extra Container Port(s) published on 127.0.0.1
(always) postgres:18-alpine 5432
[cache] / [tasks-redis] redis:8-alpine 6379
[queue] / [tasks] rabbitmq:4-management-alpine 5672 (AMQP) + 15672 (UI)
[minio] quay.io/minio/minio + bootstrap mc 9000 (API) + 9001 (Console)
[email] mailhog/mailhog 1025 (SMTP) + 8025 (UI)

Every infra port is published on loopback only ("127.0.0.1:5432:5432"): the process you run on the host still reaches Postgres at localhost:5432, and nothing outside the machine does. The API does not need those ports — inside the compose it talks to the infra by service name (see the prod profile).

Example — service using cache + S3 uploads + emails:

tempest new my_service --extras auth,cache,minio,email

Generates:

  • postgres, redis, minio (+ minio-bootstrap creating the uploads bucket), mailhog
  • .env.example with REDIS_URL, MINIO_ROOT_* (the container), STORAGE_* (the app), SMTP_HOST=localhost, SMTP_PORT=1025, SMTP_USE_TLS=false (MailHog is plain — no STARTTLS)

Credentials come from .env, not hardcoded in the compose

As of v0.37.0, no credential is written straight into docker-compose.yaml. Each environment: block uses the ${VAR:-default} form, and Docker Compose resolves VAR from the .env next to the compose file. The :-default keeps the stack bootable before you copy .env.example to .env — but set real secrets in .env for any non-throwaway deploy. Variables read by compose: POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB, RABBITMQ_DEFAULT_USER / RABBITMQ_DEFAULT_PASS / RABBITMQ_DEFAULT_VHOST, MINIO_ROOT_USER / MINIO_ROOT_PASSWORD — all with their defaults already in .env.example.

Boot only the infra — the dev loop, with the app running on the host with reload:

docker compose up -d
uv run python main.py

Tear down keeping volumes:

docker compose --profile prod down

Tear down wiping volumes:

docker compose --profile prod down -v

down without --profile prod leaves the API up

A bare docker compose down does not see the api service: it stops the infra, leaves the my_service-api container running, and the network ends in Resource is still in use. Passing --profile prod to down works in both cases — with or without the API running.

The prod profile: the API inside the compose

The compose also carries the API itself, as an api service behind profiles: ["prod"]. Without the profile it does not exist — docker compose up -d keeps starting just the infra, as above. With the profile, the project's Dockerfile is built and the app runs on the compose network, next to the infra:

cp .env.example .env
docker compose --profile prod up -d --build --wait
curl http://127.0.0.1:8000/health/readiness
{"status":"ready","checks":{"database":true},"version":"0.1.0"}

For tempest new my_service --extras cache,tasks, the generated block is:

services:
  api:
    # Only under `docker compose --profile prod up -d --build`;
    # plain `docker compose up -d` keeps running just the infra.
    profiles: ["prod"]
    build: .
    container_name: my_service-api
    restart: unless-stopped
    env_file: .env
    environment:
      # environment: wins over env_file. .env points every backend
      # at localhost (right for the host, wrong in a container), so
      # the hosts are re-pointed at the compose service names here.
      # SMTP_* is left to .env on purpose: MailHog is dev-only and
      # production mail goes through the real SMTP server.
      SERVER_HOST: 0.0.0.0
      SERVER_PORT: "8000"
      DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-app}@postgres:5432/${POSTGRES_DB:-my_service}
      REDIS_URL: redis://redis:6379/0
      RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-guest}:${RABBITMQ_DEFAULT_PASS:-guest}@rabbitmq:5672/
      TASKIQ_BROKER_URL: amqp://${RABBITMQ_DEFAULT_USER:-guest}:${RABBITMQ_DEFAULT_PASS:-guest}@rabbitmq:5672/
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    healthcheck:
      # python:3.13-slim has no curl; readiness is 503 until the DB answers.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/readiness', timeout=3)"]
      interval: 10s
      timeout: 5s
      retries: 6
      start_period: 20s

Piece by piece:

  • environment: rewrites the hosts. The .env the scaffold writes points everything at localhost — right for the host process, wrong inside a container, where localhost is the API container itself. Compose gives environment: precedence over env_file:, so every host .env.example points at localhost is swapped for the service name, only for the extras you chose:

    Variable Value inside the compose When
    DATABASE_URL postgresql+asyncpg://…@postgres:5432/<db> always
    REDIS_URL redis://redis:6379/0 [cache]
    RABBITMQ_URL / TASKIQ_BROKER_URL amqp://…@rabbitmq:5672/ [queue] / [tasks]
    TASKIQ_BROKER_URL redis://redis:6379/0 [tasks-redis] (wins over the amqp:// of [tasks])
    STORAGE_ENDPOINT minio:9000 [minio]

    Without that rewrite the API does not crash — it boots against the wrong thing. The same image run with only --env-file .env answered {"status":"ready","checks":{"database":true}} with 200 and created /app/app.db inside the container: the .env default DATABASE_URL is SQLite, and Postgres was never touched.

  • SERVER_HOST: 0.0.0.0 and SERVER_PORT. The Dockerfile already sets SERVER_HOST=0.0.0.0, but the .env carries SERVER_HOST=127.0.0.1 and beats the image ENV — uvicorn comes up on http://127.0.0.1:8000 inside the container and the published port does not answer. Pinning both in environment: fixes it. This does not contradict the templates' 127.0.0.1 bind: that one is for the process on the host; inside the container the bind must accept the compose network.

  • depends_on with condition: service_healthy, one per extra service (all of them have a healthcheck). With [minio], the API also waits for minio-bootstrap to finish (service_completed_successfully), so the uploads bucket exists before the first request.
  • healthcheck on /health/readiness, via urllib. python:3.13-slim has no curl. The scaffold mounts make_health_router, which serves /health/liveness and /health/readiness — /health alone answers 404. Readiness answers 503 while the database is unreachable, and urlopen raises on it, so healthy means the app reached Postgres. It checks only the database: Redis, RabbitMQ and MinIO are not in the scaffold's payload.
  • env_file: .env is required under the profile. Without .env, docker compose --profile prod config fails with env file .../.env not found; the dev docker compose up -d keeps working without it.

prod profile decisions

  • Compose default network, no networks: block. Service names already resolve on it; a named network would only matter if another compose had to join this one.
  • Infra on 127.0.0.1 only, API on every interface. The dev loop on the host still reaches the database and broker; in production they are not exposed outside the machine. The API port is published normally — it is the one serving traffic.
  • MailHog without a profile, and SMTP stays with .env. MailHog starts in both modes (the dev loop does not change), but the API's environment: does not rewrite SMTP_*: inside the container, the .env.example's SMTP_HOST=localhost does not reach MailHog. In production set the real SMTP server in .env; to test email against MailHog inside the compose, use SMTP_HOST=mailhog in .env.
  • asyncpg is a dependency of the generated project. The scaffold's pyproject.toml declares asyncpg>=0.30.0 next to aiosqlite, because the image installs only what pyproject.toml declares and the prod profile always uses postgresql+asyncpg://.
Migrations under the prod profile

The scaffold ships alembic.ini, alembic/ and a baseline revision (the tables of the models the extras brought — users with [auth], an empty upgrade() without it), and the lifespan of the generated src/api/app.py calls AlembicHelper(...).sync_schema_async() before db.connect(). So the compose needs no migration service: the API migrates Postgres as it starts.

cp .env.example .env
docker compose --profile prod up -d --build --wait
curl http://127.0.0.1:8000/health/readiness

Measured with tempest new my_service --extras admin,postgres: the API answered /health/readiness 200, Postgres held users and alembic_version at the baseline revision, and restarting the API ran no upgrade again. sync_schema_async refuses a destructive migration (dropping a table, column or constraint) and handles an empty database, one created before Alembic and one already migrated. With several replicas starting together, turn it off with DATABASE_MIGRATE_ON_STARTUP=false and run docker compose --profile prod run --rm api tempest db upgrade as a job.

A new model gets its migration with tempest db revision -m "..."; the next start applies it.

Image tags are pinned by the SDK — bump them through pyproject.toml of the SDK, not on a per-project basis. Current versions (v0.26.0+): postgres:18-alpine, redis:8-alpine, rabbitmq:4-management-alpine.

Regenerating docker-compose.yaml in an existing project

When you change installed extras (uv add "tempest-fastapi-sdk[minio]") or the SDK bumps image versions, regenerate with:

tempest generate --docker                        # read extras from local pyproject.toml
tempest generate --docker --extras cache,minio   # force explicit extras
tempest generate --docker --name my-svc          # override container-name prefix
tempest generate --docker --force                # overwrite an existing compose file

The command reads [project] name + extras from the current directory's pyproject.toml (pass --path for another). It refuses to overwrite without --force so hand edits don't get clobbered. The .env.example addendum is idempotent — re-running does not duplicate service blocks.

Dockerfile to containerize the app

Fullstack: the SPA is detected and built in a Node stage

If the project holds a frontend — a package.json under web/, frontend/, client/ or ui/ — the generated Dockerfile gains a Node stage that installs and builds the SPA, and only the resulting dist/ is copied into the final image. Neither node_modules nor the Node toolchain reaches the runtime.

Regenerated Dockerfile
Regenerated .dockerignore
  SPA stage: builds web/ and copies web/dist into the image.
Option Effect
(none) Detect by package.json. An empty directory does not count
--spa-dir apps-web An unconventional layout
--no-spa Backend-only image even with a frontend present

Serve the result with make_spa_router("web/dist"), included after every API router. A project with no frontend gets no spa stage and no COPY --from=spa: the Dockerfile carries only the builder and final stages shown below.

Since v0.71.0, tempest new also generates a ready-to-build Dockerfile + .dockerignore. The Dockerfile is multi-stage and uses uv:

  • builder stage — installs dependencies into /app/.venv (a cached layer that only re-runs when pyproject.toml / uv.lock change), then installs the project.
  • final stage — copies only the venv + the source, creates /app/logs and hands /app to the non-root user (app, uid 1000), which can then write logs/ and the SQLite app.db, and exposes the configured port.

The file generated for my_service, pasted from the generator's output:

# Dockerfile — generated by `tempest new` for my_service.
#
# Multi-stage build using uv (https://docs.astral.sh/uv/):
#   * builder stage installs dependencies into /app/.venv
#   * final stage copies only the venv + source, runs as a non-root user
#
# Build:  docker build -t my_service .
# Run:    docker run --rm -p 8000:8000 --env-file .env my_service
# Or, with the infra:  docker compose --profile prod up -d --build
#
# The image binds SERVER_HOST=0.0.0.0 (see the final stage) so the app is
# reachable from outside the container even without a .env file. The `api`
# service in docker-compose.yaml (prod profile) builds this image and
# re-points DATABASE_URL & co. from the .env's localhost to the compose
# service names; a bare `docker run --env-file .env` does not, so it only
# reaches the infra if you edit those hosts yourself.

# ---- builder ----------------------------------------------------------------
FROM python:3.13-slim AS builder

# uv ships as a static binary; pin the tag in production for reproducibility.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Copy bytecode-compiled packages instead of symlinking — the venv must be
# self-contained when copied across stages.
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy

WORKDIR /app

# Install dependencies first (without the project) so this layer is cached
# and only re-runs when pyproject.toml / uv.lock change. uv.lock is optional
# in a fresh scaffold — the glob keeps the COPY from failing when it is absent.
COPY pyproject.toml uv.lock* ./
RUN uv sync --no-dev --no-install-project

# Now copy the source and install the project itself.
COPY . .
RUN uv sync --no-dev

# ---- final ------------------------------------------------------------------
FROM python:3.13-slim

# Run as an unprivileged user. /app is owned by it so the app can write
# logs/ and the default SQLite app.db without root.
RUN useradd --create-home --uid 1000 app

WORKDIR /app
COPY --from=builder --chown=app:app /app /app

# WORKDIR created /app as root before the COPY, and `COPY --chown` only sets
# ownership on the copied *contents* — not on the pre-existing /app directory
# node itself. So the app user cannot create new entries (the runtime logs/
# dir, the default SQLite app.db, etc.) inside /app and the app crashes at
# startup with `PermissionError: [Errno 13] ... 'logs'`. Chown the directory
# and pre-create logs/ so the SDK's file logging (LOG_DIR=logs) works.
RUN mkdir -p /app/logs && chown -R app:app /app

# Put the venv on PATH so `python` resolves to the project interpreter.
# SERVER_HOST=0.0.0.0 binds all interfaces inside the container so the app
# is reachable from the host (overridable via .env / `environment:`).
ENV PATH="/app/.venv/bin:$PATH" \
    SERVER_HOST=0.0.0.0 \
    SERVER_PORT=8000

USER app
EXPOSE 8000

CMD ["python", "main.py"]

Build and run:

docker build -t my_service .
docker run --rm -p 8000:8000 --env-file .env my_service

The image binds to 0.0.0.0 by default

The final stage sets ENV SERVER_HOST=0.0.0.0 so the app is reachable from outside the container even without a .env. Locally the scaffold keeps SERVER_HOST=127.0.0.1 (internal service) — the container overrides it to 0.0.0.0 because the bind there must accept external connections. A bare --env-file .env does not wire the image to the infra in docker-compose.yaml: the .env points at localhost. Use the prod profile for that.

But a variable from --env-file beats the image's ENV: a .env copied from .env.example carries SERVER_HOST=127.0.0.1 and puts the bind back inside the container. Drop SERVER_HOST from the .env that goes to the container, or set SERVER_HOST=0.0.0.0 in it.

App + infra in one command

The generated docker-compose.yaml already builds this image in the api service, behind the prod profile: docker compose --profile prod up -d --build. See the prod profile.

Regenerating the Dockerfile — tempest generate --dockerfile

tempest generate --dockerfile                    # Dockerfile + .dockerignore
tempest generate --dockerfile --name my-svc      # override the name in the comments
tempest generate --dockerfile --force            # overwrite existing files
tempest generate --docker --dockerfile --src     # everything in one shot

The EXPOSE / SERVER_PORT port is read from SERVER_PORT in .env (or .env.example), falling back to 8000 when absent. Like the other generators, it refuses to overwrite without --force.

Generating the src layers from extras — tempest generate --src

The always-present layers (api, controllers, services, schemas, db, core, utils) ship in the scaffold. The layers that only make sense with a specific extra — [queue] (FastStream) and [tasks] (TaskIQ) — are not part of the base skeleton: dropping empty placeholder packages in every service contradicts the layout rules. When you add one of those extras to an existing project (uv add "tempest-fastapi-sdk[queue]"), generate the matching layer with:

tempest generate --src                           # read extras from local pyproject.toml
tempest generate --src --extras tasks            # force explicit extras
tempest generate --src --force                   # overwrite existing files
tempest generate --docker --src                  # compose + layers in one shot

Extra → generated layer mapping:

Extra Files created (under src/ or app/)
[queue] queue/__init__.py (RabbitBroker + AsyncQueueManager + get_broker), queue/handlers.py (example subscriber)
[tasks] / [tasks-redis] tasks/__init__.py (tq = TaskQueue.from_settings(settings) + broker/scheduler for the TaskIQ CLI + get_task_queue), tasks/jobs.py (example task with @tq.task)

Both extras generate the same layer: the transport comes from TASKIQ_BROKER_URL at runtime. With [tasks-redis], tempest new also declares TASKIQ_BROKER_URL on Settings with a redis://localhost:6379/0 default and writes the same URL to .env.example, because the mixin's default is amqp://, which selects RabbitMQ and needs [tasks]. If the URL still points at a transport whose extra is not installed, the ImportError from TaskQueue.from_settings names TASKIQ_BROKER_URL, the value it read (password masked) and the URL shape of the other transport.

The source root (src or app) is auto-detected, and generated imports (from src.queue import broker) already point at it. The operation is idempotent: existing files are kept unless you pass --force, so a hand-edited handler is never clobbered silently — a sibling file that doesn't exist yet is still written. Extras with no associated layer (e.g. just [cache]) generate nothing and the command says so.

tempest new already generates the chosen extras' layers

A tempest new my_service --extras auth,queue already ships src/queue/ — generate --src is for when you add the extra after creating the project.

After scaffolding:

cd my_service
uv sync                                         # installs SDK + dev tools
cp .env.example .env
uv run python main.py                           # serves on the configured HOST:PORT
uv run pytest                                   # the bundled smoke test

Run the service — tempest serve

tempest serve                              # host/port/reload from the settings
tempest serve --reload                     # turn reload on for this run
tempest serve --host 0.0.0.0 --port 9000   # override
tempest serve --workers 4                  # several processes (no reload)
tempest serve --app src.api.app:create_app # app outside the conventional spots

The scaffolded main.py already boots uvicorn programmatically; this command is for the project that does not have that file yet, or for overriding host and port without editing .env. Resolution is run_server's: flag > the project's settings > the SDK default (127.0.0.1, 8000, no reload).

uvicorn gets the string, never the object

The reloader re-imports the target in the child process. Handing it the already-imported instance works — and silently turns --reload into nothing. So the command imports the candidate once to validate it, and passes "src.server:app" along.

--reload together with --workers exits 2: uvicorn cannot honour both. And --no-reload really does turn it off, even with SERVER_RELOAD=true in .env — the flag has three states (on, off, "you decide").

Interactive console — tempest shell

A REPL with the project already loaded, so "how many orders does this user have?" does not cost a throwaway script.

tempest shell
tempest shell — Python 3.11.12, top-level await enabled
available: WidgetModel, db, select, session, settings, text
session: open on sqlite+aiosqlite:///./app.db
>>> rows = await session.execute(select(WidgetModel))
>>> rows.scalars().all()
[<WidgetModel id=...>]

What the prompt holds: settings (the project's instance), every mapped model the project defines (the SDK's BaseModel stays out — a query against the base class is never what you meant), select, text, the AsyncDatabaseManager as db, and an already-open session.

Top-level await, on purpose

The console compiles with ast.PyCF_ALLOW_TOP_LEVEL_AWAIT and runs the coroutine on the same loop the session was opened on. Exposing a run(coro) helper would look equivalent and is not: a session driven from a second loop raises MissingGreenlet at the first lazy load.

--no-db starts without connecting — useful when the database is down and you only want to inspect settings and models.

Database — tempest db

Alembic wrapper backed by AlembicHelper — your project's alembic.ini + env.py stay the source of truth.

DATABASE_URL resolution order:

  1. --database-url flag.
  2. DATABASE_URL env var.
  3. Settings on the project's core/settings.py, under either code root (src or app). Any pydantic_settings.BaseSettings instance qualifies — the names settings and config are tried first, then the module is scanned by type — or the result of a zero-arg factory that returns one (get_settings(), or any callable annotated to return BaseSettings).
  4. DATABASE_URL in the project's .env.
  5. sqlalchemy.url from alembic.ini.

The instance name no longer matters, and the factory counts

Up to 0.280.0 only the name settings was accepted, and the failure was an ImportError swallowed by except Exception: return None. A service naming its instance config got "no database URL. Pass --database-url, set DATABASE_URL, or run inside a project with src/core/settings.py" — pointing at two conditions the project already satisfied. Today the instance's type decides, the documented factory (get_settings()) is accepted, .env is read, and when nothing resolves each attempt reports why on stderr:

note: importing src.core.settings failed: ImportError("cannot import name ...")
error: no database URL. Pass --database-url, set DATABASE_URL, put it in
       .env, or expose settings with a DATABASE_URL on
       src/core/settings.py (or app/core/settings.py).

The same discovery is shared by tempest db, tempest doctor, tempest user and the generated env.py — a project whose settings exist only as get_settings() now migrates outside the CLI too.

tempest db init                                  # create alembic.ini + alembic/env.py
tempest db revision -m "init users table"        # autogenerate by default
tempest db revision -m "manual change" --manual  # empty file you'll edit
tempest db upgrade                               # alembic upgrade head
tempest db upgrade <rev>                         # upgrade to a specific revision
tempest db downgrade                             # roll back one step
tempest db downgrade <rev>                       # roll back to a specific revision
tempest db current                               # print the applied revision
tempest db history                               # revisions newest → oldest
tempest db history -v                            # with full message body
tempest db stamp head                            # mark the DB without running migrations
tempest db squash -m "init" --yes                # collapse history into 1 migration
tempest db backup                                # dump to backups/<db>_<ts>.<ext>
tempest db backup -o dump.sql                    # plain SQL (Postgres) by extension
tempest db restore dump.dump --yes               # restore (clean + recreate)
tempest db seed                                  # runs src.db.seeds:seed
tempest db seed --seed src.db.fixtures:demo      # custom callable

stamp head does not adopt an existing database

stamp writes the pointer without running anything. Against a schema Alembic did not build, stamping head records revisions that never ran as applied: the database keeps its old columns while current, upgrade and history all answer that it is up to date, and the failure surfaces weeks later in production. The right revision for adoption is the base — see Migrations », which ships AlembicHelper.adopt() and sync_schema() so the choice is not made by hand.

When Alembic refuses the command

An Alembic error describing the state of the database — not a bug — comes out as one line, with what to do about it:

$ tempest db revision -m "new field" --autogenerate
error: the database is behind head — a migration is pending. Run `tempest db upgrade`, then try again.
       (TEMPEST_DEBUG=1 for the full traceback)
$ echo $?
1

Four conditions get advice: the database is behind head, the history has more than one head, the revision is not in alembic/versions/, and two revisions are not on the same branch. Any other message is printed exactly as Alembic wrote it — a wrong hint would be worse than the raw words.

Want the traceback back?

TEMPEST_DEBUG=1 tempest db revision ... re-raises untouched. Use it when the error is not one of the conditions above and you need to see where it came from.

Only the CLI translates

Calling AlembicHelper.revision(...) directly, from a script or a lifespan, still raises alembic.util.exc.CommandError. Code wants the typed exception; a person at a terminal wants the sentence.

Collapse the history — tempest db squash

Over time the alembic/versions/ directory grows without bound — every schema tweak adds another file Alembic must walk on every upgrade. squash resets that history to a single root migration describing the current schema, while keeping existing databases usable.

Destructive — run it against a development database

squash runs downgrade base on the configured database (DROPs every table) so it can autogenerate the full schema into a single file. That's why it requires --yes. Make sure DATABASE_URL points at a dev database before running.

The flow is:

  1. Capture the current head (used to name the backup directory).
  2. downgrade base — empty the database so autogenerate sees an empty schema.
  3. Move the old revisions into alembic/versions/_squashed_<oldhead>/ (a subdirectory Alembic ignores). Pass --no-backup to delete them instead.
  4. Autogenerate one root migration from BaseModel.metadata.
  5. upgrade head recreates the schema and stamps the new revision.
tempest db squash -m "init" --yes               # recoverable backup (default)
tempest db squash -m "init" --yes --no-backup   # delete the old files

Production databases are not touched

squash only touches the configured database. After deploying the collapsed tree, mark production databases as migrated without recreating tables:

tempest db stamp head

Manual squash (keep your data) → stamp --purge

If you unify migrations by hand (delete versions/, write one baseline migration, keep the database with its data), alembic_version still points at the old revision — which no longer exists in the tree. A plain stamp fails with Can't locate revision. Use --purge to clear the stale pointer and record the new baseline:

tempest db stamp init_schema --purge

Recap: squash swaps an ever-growing history for one clean initial migration; stamp reconciles databases already at the final schema (use --purge when the recorded revision no longer exists). Recoverable backup by default — Git is your second net.

Backup and restore — tempest db backup / tempest db restore

Snapshot the database to a file and back. The strategy differs per dialect, but the CLI is the same:

  • PostgreSQL — pg_dump / pg_restore. The format comes from the file extension: .dump → custom (pg_dump -Fc, compressed, restored with pg_restore), .sql → plain (psql). Force it with --plain / --custom. Requires the PostgreSQL client tools on PATH.
  • SQLite — copies the database file.

Prerequisite: PostgreSQL client tools

backup / restore against a PostgreSQL database depend on the pg_dump, pg_restore and psql binaries being on your PATH. They do not ship with the Python package — they are installed by your operating system. Without them the CLI fails with a clear message ('pg_dump' not found on PATH).

SQLite needs nothing — the backup is a stdlib file copy.

sudo apt-get update && sudo apt-get install -y postgresql-client
sudo dnf install -y postgresql
sudo pacman -S postgresql
brew install libpq && brew link --force libpq
choco install postgresql   # Chocolatey
scoop install postgresql   # or Scoop

Verify the install

pg_dump --version && pg_restore --version && psql --version
Match the client major version to the server (a newer pg_dump reads older servers, but not the other way around).

tempest db backup                       # backups/<db>_<YYYYMMDD-HHMMSS>.dump
tempest db backup -o snapshot.sql       # plain SQL (Postgres) by extension
tempest db backup -o snap.dump --custom # force the custom format
tempest db restore snapshot.sql --yes   # restore (psql)
tempest db restore snap.dump --yes      # restore (pg_restore --clean --if-exists)

Restore overwrites the target database

By default the restore is clean + recreate: existing objects are dropped before being recreated, so the result is a faithful copy of the backup (pg_restore --clean --if-exists; plain drops/recreates the public schema; SQLite overwrites the file). That's why it requires --yes. Pass --no-clean to apply the dump on top of the current schema.

The Postgres password never leaks into ps

The URL is parsed into -h/-p/-U/-d and the password is passed via PGPASSWORD in the subprocess environment — never on the command line.

Recap: backup takes a snapshot (format by extension on Postgres, file copy on SQLite); restore --yes brings it back, cleaning the target by default.

Catch model drift — tempest db check

tempest db revision --autogenerate records a diff when you ask for one. Nothing notices when somebody edits a model and forgets to ask — until the deploy that needed the column.

tempest db check

No drift:

No drift: the models match the migration tree.

With drift, it names the operations a revision would contain and exits 1:

error: New upgrade operations detected: [('add_column', ...'colour'...)]
       Run `tempest db revision -m "<message>"` to record them.

This is a CI step

Run it after tempest db upgrade in the pipeline. The comparison needs the database at head — when it is behind, Alembic says so and the message is passed through instead of being reported as drift.

Seed the database — tempest db seed

Runs a project seed callable inside a managed session (commit on success, rollback on error). The callable takes a positional AsyncSession and may be sync or async; what it inserts is up to you — the SDK only wires the session lifecycle. Defaults to importing src.db.seeds:seed.

from sqlalchemy.ext.asyncio import AsyncSession

from src.db.models import CategoryModel


async def seed(session: AsyncSession) -> int:
    """Seed initial categories. Return the count (optional)."""
    session.add_all([CategoryModel(name="Books"), CategoryModel(name="Games")])
    await session.flush()
    return 2

When the callable returns an int, the CLI prints the count: Seeded via src.db.seeds:seed (2 rows).

Users — tempest user

Seed and list users using the project's concrete UserModel (default src.db.models:UserModel). Bootstraps the first admin without manual SQL.

# Create a regular user
tempest user create --email ana@example.com --password strong-pass-12 --no-admin

# Create an admin (can log into /admin)
tempest user create --email admin@local --password admin-pass-12 --admin

# Read the password interactively (never lands in shell history)
tempest user create --email admin@local --admin

# Custom model outside the scaffolded layout — MUST be a BaseUserModel subclass
tempest user create --email x@y --password pass-12-chars --model myapp.models.user:UserModel

# Columns YOUR UserModel adds — repeatable, one per column
tempest user create --email ana@example.com --password strong-pass-12 \
    --set display_name=Ana --set locale=pt-BR

# Promote / demote an existing user (toggles is_admin)
tempest user promote --email ana@example.com    # becomes admin
tempest user revoke  --email ana@example.com    # back to a regular account

# Replace an existing user's password (prompts twice) and revoke
# their sessions along with it
tempest user set-password --email ana@example.com

# Refresh-token table outside src.db.models
tempest user set-password --email ana@example.com \
    --refresh-token-model myapp.models:UserRefreshTokenModel

# Replace the password and leave the sessions alive (explicit opt-out)
tempest user set-password --email ana@example.com --keep-sessions

# First privileged account born with MFA + audit event in the same transaction
tempest user create --email staff@example.com --admin --require-mfa \
    --audit-model src.db.models:AuditLogModel --event staff.bootstrapped

# Account that lost the phone and the recovery codes: remove the second
# factor, end the sessions and audit it in the same transaction
tempest user mfa-reset --email staff@example.com \
    --audit-model src.db.models:AuditLogModel

# List
tempest user list                                # everyone
tempest user list --admin                        # admins only

Accounts with MFA: --require-mfa, set-password and mfa-reset

create --require-mfa enrolls the second factor before the commit: it prints the otpauth:// URI and the secret, asks for a code from the Authenticator app (up to 3 tries) and writes only with a valid one — without it, it exits with code 1 and no row is written. It needs MFAMixin on the UserModel and the recovery-code table (--recovery-code-model, default src.db.models:UserRecoveryCodeModel); the recovery codes are printed once, after the commit. --audit-model (a BaseAuditLogModel with AuditRequestMixin) writes record_event in the same transaction, named by --event (default user.created); without it, the command says on stderr that no event was recorded.

set-password on an account with MFA active (and AUTH_MFA_ENABLED on, as at login) asks for the code — TOTP or recovery code — and changes the password through UserAuthService.change_password_with_mfa. Step by step in the MFA recipe.

Whoever lost the phone and the recovery codes has no code to give: mfa-reset is the way back — see Recovering an account that lost its second factor below.

A UserModel with a required column of its own: use --set

create fills email, hashed_password, is_admin and is_active. Every other column of your UserModel goes in via --set <column>=<value>, validated against the mapped columns and converted to the column type (bool, int, float, Decimal, UUID, ISO-8601 date/time, enum by value, and JSON).

tempest user create -e newbie@example.com -p 'strong-pass-12' --no-admin
# error: UserModel requires a value for: display_name.
#        Pass each one as --set <column>=<value>.

A NOT NULL column with no default that you did not pass is prompted for in an interactive terminal (one prompt per column) and becomes an exit-code-2 error without a TTY — instead of the raw IntegrityError the database used to raise. An unknown key also exits 2, listing the accepted columns; --set email= / --set hashed_password= / --set is_admin= are refused, pointing at the flag that owns them (--email, --password, --admin/--no-admin).

An insert the database refuses (a duplicate email, most often) exits with code 1 and the database's own message — error: could not insert user: … — instead of a traceback.

Without --admin/--no-admin, create asks

When you pass neither --admin nor --no-admin in an interactive terminal, tempest user create prompts Should this user be an administrator? [y/N]. Non-interactive runs (CI, pipes, scripts) skip the prompt and create a regular user (is_admin=False) — pass --admin explicitly to create an admin without a TTY.

tempest user promote / tempest user revoke find the user by email (case-insensitive) and only flip is_admin. When no user matches the email they exit with code 1 and a no user found message.

Changing a password — tempest user set-password

The operator's half of the reset flow: the account nobody can mail a link to, the first admin locked out of a fresh environment, the credential rotated after a report. It finds the user by email (case-insensitive), hashes the new plaintext with the model's own set_password — the very hash the login endpoint verifies — and exits with code 1 and no user found when nobody matches.

tempest user set-password --email ana@example.com
# Password:
# Confirm:
# Password updated for ana@example.com (id=6f0c...)
# Revoked 2 active refresh token(s).

Changing the password revokes the sessions — by default

Changing the hash does not, on its own, end any session: an access token already issued stays valid until it expires, and a DB-backed refresh token can still be exchanged for a fresh one — exactly the window the reset exists to close. So the revocation is not a flag you have to remember: the command resolves src.db.models:UserRefreshTokenModel (the conventional name of the opt-in refresh-token table — tempest new does not scaffold it; whoever turns the table on writes the model under that name in src/db/models/) and revokes the user's refresh tokens in the same transaction that writes the new hash.

  • Table somewhere else? --refresh-token-model myapp.models:UserRefreshTokenModel. A spec you typed that does not resolve is an exit-code-2 error — it never degrades silently into "revoked nothing".
  • Do not want the revocation? --keep-sessions, which is refused with code 2 when it comes together with --refresh-token-model (the two flags contradict each other).
  • A project that never wired the opt-in refresh-token table has nothing to revoke, and the command says so on stderr: note: no refresh-token model at 'src.db.models:UserRefreshTokenModel', so no session was revoked. Pass --refresh-token-model if yours lives elsewhere.

The count is of the sessions this run killed — an already revoked row is not counted again, so running it twice prints Revoked 0 active refresh token(s).

The same thing, from code

await service.revoke_user_sessions(session, user_id=user.id) is the administrative counterpart of revoke_refresh_token: logout-everywhere addressed by id, for when you do not hold the token (a "sign out everywhere" endpoint, a support agent locking a compromised account). It returns how many rows it revoked, and 0 when the project does not use refresh_token_model — stateless JWTs carry no row to flip.

from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import UserAuthService


async def force_logout(
    user_id: UUID,
    session: AsyncSession,
    service: UserAuthService,
) -> int:
    """Revoke every session of a user. Returns how many fell."""
    killed = await service.revoke_user_sessions(session, user_id=user_id)
    await session.commit()
    return killed

The password policy is your project's, not the CLI's

create and set-password both check the plaintext with check_password_policy against the three AUTH_PASSWORD_* fields of the project's src.core.settings — and against the PasswordPolicy defaults (minimum 12 characters, maximum 72 bytes) when the project does not compose AuthSettings. It is the same ruler signup and the admin panel use, so a password the CLI accepts is always one the account owner can log in with.

The ceiling is measured in UTF-8 bytes, the unit bcrypt counts: 40 accented characters are 80 bytes and exit with code 2 and error: password must be at most 72 bytes. — before anything is written to the database.

DATABASE_URL resolves the same way as tempest db (env var > settings instance or factory > .env > alembic.ini).

Recovering an account that lost its second factor — tempest user mfa-reset

An account with MFA active whose owner lost the phone and the recovery codes: set-password asks for the code, and there is no code to give. mfa-reset removes the factor without proof — so it asks for confirmation and records who did it.

tempest user mfa-reset --email staff@example.com \
    --audit-model src.db.models:AuditLogModel
# Remove the second factor of staff@example.com? It will log in with the password alone until it enrolls again. [y/N]: y
# MFA reset for staff@example.com (id=2ced...)
# Revoked 2 active refresh token(s).

# Outside a terminal (CI, a script): --yes is required
tempest user mfa-reset --email staff@example.com --yes \
    --audit-model src.db.models:AuditLogModel --event support.mfa_recovered

# Remove the factor and leave the sessions alive (explicit opt-out)
tempest user mfa-reset --email staff@example.com --yes --keep-sessions
  • It runs UserAuthService.mfa_reset: clears totp_secret, totp_enabled_at and totp_last_step and deletes the recovery codes (--recovery-code-model, default src.db.models:UserRecoveryCodeModel; without a table, it exits with code 2 naming the flag).
  • It revokes the sessions by default, in the same transaction, with the same rules as set-password: --refresh-token-model (default src.db.models:UserRefreshTokenModel), --keep-sessions as the explicit opt-out, and both together refused with code 2.
  • --audit-model (with AuditRequestMixin) writes record_event in the same transaction, named by --event (default user.mfa_reset); if the audit write fails, the reset rolls back with it. Without --audit-model, the command says on stderr that no event was recorded. --event without --audit-model exits with code 2.
  • Without confirmation nothing changes: outside a terminal, without --yes, it exits with code 1; in a terminal, answering n exits with 1 too.
  • An account without MFA active exits with code 2 (has no MFA active, nothing to reset.); a UserModel without MFAMixin does too, before the database is opened. An unknown email exits with 1 and no user found.

After the reset the account logs in with the password alone until it enrolls again, and set-password stops asking for a code. The MFA recipe walks the path, including the direct call from code: An account that lost its second factor.

Inspect, deactivate and delete

list answers "who exists". The four below answer the rest.

# The whole row, including the columns YOUR model adds
tempest user show --email ana@example.com
tempest user show --email ana@example.com --json

# Turn access off (reversible) and drop the sessions with it
tempest user deactivate --email ana@example.com
tempest user activate --email ana@example.com

# That user's sessions (refresh tokens)
tempest user sessions --email ana@example.com
tempest user sessions --email ana@example.com --revoke

# Delete the row (irreversible)
tempest user delete --email ana@example.com --yes

show redacts hashed_password: no question this command answers needs the hash, and printing it puts a credential in terminal scrollback and in CI logs.

deactivate revokes sessions, and that is not optional

Flipping is_active=False ends nothing on its own: a refresh token already issued stays exchangeable, and the account you just turned off keeps minting fresh access tokens. So the revocation runs in the same transaction as the flag — the rule set-password already follows.

sessions without --revoke prints one line per token (id, family, created, expires, state). A user with no session prints an empty table and exits 0 — "not logged in anywhere" is an answer, not a failure. The --revoke count is the number of sessions that run killed, so a second run prints Revoked 0.

delete is irreversible, and takes the tokens with it

The scaffolded refresh-token table carries a ForeignKey with no ON DELETE CASCADE, so deleting a user with a live token would fail with an integrity error naming a constraint rather than the cause. The command removes the tokens in the same transaction. Outside an interactive terminal, --yes is required. When what you mean is "this person should not be able to log in any more", the command is deactivate.

Secrets — tempest secrets

Four commands, ordered by how much they touch your .env:

Command Writes to .env? When to use it
generate never grab a value to paste into a secret manager
init only what is empty or a placeholder right after tempest new
rotate always, with a backup the secret leaked or went stale
vapid the Web Push key pair turn on browser notifications

Generate a loose value — tempest secrets generate

Touches no file at all: it prints and stops.

# One secret, bare value
tempest secrets generate

# Three at once
tempest secrets generate --count 3

# Labelled, ready to paste into a .env or a deploy form
tempest secrets generate --keys JWT_SECRET,TOKEN_SECRET

# JSON, to pipe into a secret manager
tempest secrets generate --keys JWT_SECRET --json

Output of the third one:

JWT_SECRET=_Ptx1Cq4lUAK4EJX7GAnXMp115d6i92I_rrTDJ3sS59noFLm3N1wTWL-F0OhCkbr
TOKEN_SECRET=qNsIyp8hp4BZll_BzcDS7omceBNB4a4EI9JVWvIQEg_R5VveTY0IuLBiogp0uRBa

Tip

--count and --keys say the same thing (how many secrets come out), so passing both is a usage error rather than one of them silently winning.

Fill a freshly scaffolded .env — tempest secrets init

tempest new writes JWT_SECRET=change-me-change-me-change-me-32 and an empty TOKEN_SECRET= — exactly the two cases tempest check-config reports as security.W001 / security.W004. init replaces only those:

tempest secrets init
set JWT_SECRET (was placeholder)
set TOKEN_SECRET (was empty)

A key that already holds a real value is kept, so running it again does nothing:

kept JWT_SECRET (already set)
kept TOKEN_SECRET (already set)
Nothing to do.

That is what separates init from rotate: init is safe to run at any moment — including from a setup script — because it never destroys a secret in use. Replacing a real value is explicit opt-in, and then it backs the file up first:

tempest secrets init --force

Rotate — tempest secrets rotate

Generates and rewrites the matching .env lines in place — backing up the old file first — and leaves every other line untouched.

# Rotate JWT_SECRET and TOKEN_SECRET in .env (writes .env.bak)
tempest secrets rotate

# Just print the new values (writes nothing)
tempest secrets rotate --print

# Custom keys and file
tempest secrets rotate --keys JWT_SECRET,SESSION_SECRET --env .env.prod

# More entropy, no backup
tempest secrets rotate --length 64 --no-backup

Warning

Rotating JWT_SECRET invalidates every token signed with the old value: users are logged out and pending reset/activation links stop working. Rotate during a maintenance window and restart the service to load the new values.

Web Push keys — tempest secrets vapid

Web Push does not use a random string: it uses a P-256 key pair. The browser subscribes with the public key and the server signs every push with the private one, so the two values are born together and cannot come out of generate.

# Writes VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY into .env
tempest secrets vapid

# With the contact carried in the VAPID JWT's `sub` claim
tempest secrets vapid --subject mailto:ops@example.com

# Print only
tempest secrets vapid --print

These are exactly the fields WebPushSettings reads, in the shape pywebpush reads back: the private key is the 32-byte scalar and the public key is the 65-byte uncompressed point, both base64url without padding.

Danger

Replacing a pair that already exists invalidates every active subscription: a browser that subscribed with the old public key will not accept a push signed with the new private one, and each client has to subscribe again. That is why the command refuses to overwrite without --force.

Recap

  • generate prints, init fills what is missing, rotate replaces everything, vapid mints the Web Push pair.
  • init is idempotent and never loses a secret in use — it is the command for a project's first day.
  • rotate and vapid --force are destructive in different ways: one logs users out, the other drops push subscriptions.
  • Every file these commands write ends up 0600.

Queue and tasks — tempest queue / tempest tasks

# Publish a message through the project's broker
tempest queue publish orders.paid '{"order_id": 7}' --json
tempest queue handlers                     # channels this service consumes

# Background tasks
tempest tasks list                         # registered names
tempest tasks run src.tasks:send_welcome --arg ana@example.com --kwarg retries=2

publish connects, publishes and closes — in that order, in one process. Closing is the step a throwaway script forgets, and it is the one that flushes the publish before the command returns.

A task's name is not its function's name

TaskIQ registers under <module>:<function> (src.tasks:send_welcome), so the name run takes is not the one in the source file. list is what tells you which is which — and it imports <root>.tasks.jobs first, because a task exists on the broker only after the module declaring it has been imported.

tasks run enqueues; it does not execute. The worker runs the job, so a command that returns successfully means the message was accepted, not that the job succeeded.

Without --broker / --queue, both probe <root>.queue:broker and <root>.tasks:tq — the names tempest generate --src writes. When nothing resolves, every attempt is listed with its cause before exit 2.

Email — tempest email test

The pair that costs the most time in SMTP setup is STARTTLS (port 587, SMTP_USE_TLS) against implicit TLS (port 465, SMTP_USE_SSL), and the only honest way to learn which one a server wants is to connect and send.

tempest email test --to you@example.com
tempest email test --to you@example.com --subject "deploy ok" --body "..."
tempest email test --to you@example.com --html      # exercises the multipart path

It builds the client with EmailUtils(**settings.email_kwargs()) — the same construction the service performs — so a message that arrives here arrives from the app. A refusal exits 1 carrying the server's own reply, plus the line that recalls the TLS pair.

A project that composes no EmailSettings exits 2 saying so: composing only the mixins you use is ordinary, not broken.

Object store — tempest storage

tempest storage check                       # does the endpoint answer? bucket there?
tempest storage ls                          # keys in the default bucket
tempest storage ls reports/ --flat          # only the immediate level
tempest storage put ./note.pdf --key notes/2026-09.pdf
tempest storage get notes/2026-09.pdf --out ./downloaded.pdf
tempest storage presign notes/2026-09.pdf --expires 900
tempest storage rm notes/2026-09.pdf

Everything goes through AsyncMinIOClient(**settings.storage_kwargs()), so the endpoint, credentials, region and — the one that matters most — the public endpoint used to sign URLs are the service's own.

A URL signed with the internal endpoint is valid and useless

Signing against minio:9000 produces a correct URL your user's browser cannot resolve. Set STORAGE_PUBLIC_ENDPOINT; presign prints on stderr which host it signed for, precisely so that mistake surfaces before the URL reaches a client.

An empty bucket lists nothing and exits 0 — "no object" is a result, not a failure, the same convention the SDK's repositories follow.

Feature flags — tempest flags

A flag exists to be moved without a deploy. With the Redis backend that move lived in redis-cli HSET feature_flags <name> 1 — the kind of command that gets a digit wrong at the wrong hour.

tempest flags list                       # everything the backend holds
tempest flags enable new-checkout        # on for every process reading it
tempest flags disable new-checkout
tempest flags get new-checkout           # the stored value
tempest flags list --key ff:staging      # another hash, another environment
new-checkout  on
legacy-upload off

All four speak through RedisFeatureFlagBackend, so the value the service reads and the value the CLI writes go through the same encoding code.

off and unset are different answers

get exits 1 when the backend holds no value (unset) and 0 when it holds off. The difference matters: an unset flag resolves to the default the caller passed in code, while off overrides that default.

The URL comes from --redis-url > REDIS_URL > the project's settings. A Redis that is down exits 2 carrying the driver's own message — "connection refused" names the fix, "an error occurred" does not.

Cache — tempest cache

tempest cache ping                       # PONG, or exit 1
tempest cache stats                      # version, keys, memory, hit ratio
tempest cache flush --namespace orders   # invalidate what @cached registered
tempest cache flush --tag user:42
tempest cache flush --key "cache:orders:list:abc"
tempest cache flush --all --yes          # FLUSHDB

The targeted options go through CacheInvalidator — the same code the service calls — so the keys deleted here are the keys @cached wrote there. Pass the same --key-prefix those decorators use, or the registries will not line up.

--all is FLUSHDB, not 'clear the cache'

It deletes every key in the selected database: sessions, rate-limit counters and feature flags included, when they share the Redis. That is why it demands --yes. When in doubt, invalidate by namespace.

The hit ratio is the server's, not your application's

keyspace_hits / keyspace_misses are Redis counters since the last restart, summed across every client. Redis does not attribute a hit to a caller — the number answers "is this server earning its keep", not "is my endpoint X".

Models — tempest model

Analyze, benchmark, convert and quantize ONNX models. Needs the [modelops-onnx] extra, except hardware, which only reports what this host can measure:

tempest model analyze models/classify.onnx
tempest model bench models/classify.onnx --dim height=224 --dim width=224
tempest model quantize models/classify.onnx models/classify.int8.onnx
tempest model export-ort models/classify.int8.onnx -o dist/mobile -t arm
tempest model hardware
Command What it does
analyze Parameters, size, opset and shapes, without running the model.
bench Latency (median/IQR/p95/p99), RAM, GPU and energy over N repetitions.
optimize Persists ONNX Runtime's graph optimizations into a new .onnx.
quantize Dynamic int8 quantization.
export-ort Converts to .ort plus .required_operators.config.
hardware What the host runs and which energy sampler is available.

analyze, bench and hardware accept --json, which makes them usable as a CI step. A missing extra exits 2 with the install line, never a traceback. Details in Modelops.

Diagnostics — tempest doctor

check-config reads the settings and reasons about them; it never opens a socket. That is the right trade for a fast gate and the wrong one for "why does the service not come up", where the answer is almost always a dependency that is down, unreachable from this host, or holding credentials nobody updated.

tempest doctor
tempest doctor --json            # for scripts / deployment health checks
tempest doctor --timeout 2       # tighten the wait on each network probe
OK    python         3.11.12 (/srv/app/.venv/bin/python)
OK    sdk            tempest-fastapi-sdk 0.293.0
OK    settings       Settings
OK    database       postgresql+asyncpg://app:***@db:5432/app
FAIL  redis          ConnectionError: Error 111 connecting to cache:6379
SKIP  rabbitmq       no RABBITMQ_URL
SKIP  smtp           SMTP_HOST is still the mixin default
SKIP  minio          the [minio] extra is not installed
OK    config checks  no error (2 warning(s))

Every line is a real connection, not a deduction. It exits 1 when any check fails, so it works as a deployment smoke test.

skip is not ok, on purpose

A capability the project never configured reads skip. A green line for a Redis nobody set up is the kind of reassurance that costs an outage.

The mixin default counts as unconfigured: EmailSettings ships SMTP_HOST=localhost and StorageSettings ships STORAGE_ENDPOINT=localhost:9000, so a service composing the mixin without using the capability would look configured. The comparison is against model_fields[field].default — the same ruler check_secrets uses.

The last line runs the static check registry (the one behind check-config), so one command answers both halves of "is this deployment sane".

Routes — tempest routes

Lists the URLs the application actually serves.

tempest routes
METHOD  PATH                    NAME          SCHEMA  GUARDS
GET     /api/items/{item_id}    read_item     yes     require_admin
POST    /api/items/             create_item   yes     require_admin
GET     /health                 health        yes     -
GET     /painel                 painel        no      -

Four things in that table reading the code does not hand you:

  • A mounted router shows up. Since FastAPI 0.141.1, include_router keeps an _IncludedRouter entry instead of flattening the routes onto the application — so {r.path for r in app.routes} contains none of the included router's paths. The command reads the same expansion the OpenAPI generator walks.
  • Prefixes already resolved. The path is the router's plus every include_router above it.
  • Routes outside the schema are listed too, with SCHEMA=no — an HTML page and an internal endpoint serve traffic, and they are exactly what an answer taken from openapi.json hides.
  • GUARDS shows the dependency inherited from the include, not only the one declared on the route. That is how you confirm the Depends(require_admin) on include_router reached everything it should.

Filters and format:

tempest routes --match /api/orders        # only paths containing this
tempest routes --method post,delete       # only these methods
tempest routes --all                      # include /docs, /redoc, /openapi.json
tempest routes --json                     # JSON array, for scripting
tempest routes --app src.server:app       # app outside the conventional spots

Without --app, the command probes src.server:app, src.api.app:create_app, main:app (and the same under app/). When nothing resolves it lists what it tried and why before exiting 2. A --match that matches nothing exits 1, so a typo in a script fails instead of reading as an application with no routes.

Errors documented in OpenAPI — tempest openapi-errors

Compares, per route, the AppExceptions the flow can raise against what the route declared in error_responses(...) / @raises(...):

tempest openapi-errors                          # advisory report (exit 0)
tempest openapi-errors --check                  # exit 1 on drift (CI gate)
tempest openapi-errors --path src --path libs   # explicit directories, repeatable
tempest openapi-errors --check --allow-unreachable   # fail only on undocumented
tempest openapi-errors --fix --dry-run          # diff of the missing declarations
tempest openapi-errors --fix                    # write (needs a clean git tree)

Without --path it scans ./src or ./app — whichever exists. The analysis is static (ast, without importing the application) and walks router -> controller -> service -> repository, reading both raise statements and the docstrings' Raises: sections.

src/api/routers/jobs.py:15  POST /{service_id}/candidates
  undocumented: CandidateAlreadyExistsException, ServiceFullException
1 route(s) with drift, 2 undocumented exception(s).

--fix closes the gap by writing responses=error_responses(...) into the route (plus whatever imports are missing), extending an existing declaration instead of replacing it. It only ever adds: unreachable findings are never removed, since reachability cannot see a dynamic raise.

Details and limitations in the Errors in OpenAPI » recipe.


Permission guards — tempest permissions

Reads the @requires guard contract straight off the source (ast, without importing the application) and reports what the decorator cannot see at import time:

tempest permissions                    # informative report (exit 0)
tempest permissions --check            # exit 1 on any error (CI gate)
tempest permissions --check --strict   # fail on warnings too
tempest permissions --path src --path libs
src/api/routers/orders.py:41  delete_order
  error: guard-foreign-exception: guard 'order_owner' raises ValueError, which is
    not an AppException subclass; the API layer answers it as HTTP 500 without an
    error code
2 finding(s), 1 error(s).

Errors: a guard raising outside the AppException hierarchy, a predicate-style guard (-> bool), wrong arity, an async guard on a sync function, a route with no user parameter. Warnings: a guard that never denies, a missing annotation, a guard the checker could not resolve (lambda, duplicated name, definition outside the scanned paths).

Details in the Permission guards (@requires) » recipe.


Integrations — tempest integrations

tempest integrations list                    # what ships + the credential state here
tempest integrations verify openpix          # one real authenticated call
tempest integrations verify mercado-pago
tempest integrations verify zap --base-url http://localhost:3000

In a project fresh out of tempest new, with no credential in the .env:

openpix       tempest_fastapi_sdk.integrations.payment.openpix:OpenPixClient
              OPENPIX_APP_ID not set
mercado-pago  tempest_fastapi_sdk.integrations.payment.mercado_pago:MercadoPagoClient
              MERCADOPAGO_ACCESS_TOKEN not set
stripe        tempest_fastapi_sdk.integrations.payment.stripe:StripeClient
              no settings mixin (build the client yourself)
zap           tempest_fastapi_sdk.integrations.messaging.zap:ZapClient
              no settings mixin (build the client yourself)

verify builds the client with HTTPClient(**settings.<provider>_kwargs()) and makes the cheapest authenticated read each API offers: OpenPix's GET /company and Mercado Pago's GET /users/me — neither moves money nor creates a record. A bad credential exits 1 carrying the provider's refusal.

That endpoint is a hand-kept table — with a guard

Choosing "the cheapest call" per provider is a human decision, and generated endpoint names change. A test asserts each named method still exists on the generated client and takes no required argument, so an upstream rename breaks a test instead of the terminal of whoever is on call.

The two providers the SDK ships no settings mixin for behave differently:

  • zap needs --base-url, because the SDK does not know where your instance lives. verify calls health, which takes no credential, so it measures reachability, not authorization. Without --base-url it exits 2; an instance that is down exits 1:

    error: zap has no settings mixin, so the CLI does not know where it lives. Pass --base-url.
    error: zap refused the call — ConnectTimeout:
    
  • stripe has no verify: the API offers no authenticated read that neither spends money nor creates a record. The command exits 2, with or without --base-url:

    error: stripe exposes no cheap authenticated read, so there is nothing the CLI can call without spending money or creating a record. Construct the client in your service.
    

Agents — tempest agents

tempest agents tools --agent src.agents:agent     # which tools it holds
tempest agents run "summarise the report" --agent src.agents:agent --trace

tools calls no model — no token is spent, and it works with a backend that has no credentials configured. It is the quick answer to "does this agent configuration actually see the tool I just registered?".

The exit code follows succeeded, not 'raised nothing'

A run cut short by its budget (steps or seconds) still returns text. Treating that text as an answer is the mistake this command refuses to make for you: run exits 1 and prints the stop reason (max_steps, max_seconds) on stderr, with the output on stdout either way.

There is no scaffolded agents layer, so --agent is nearly always needed — and the command says so rather than pretending a convention exists.

Integration client — tempest openapi-client

Generates Pydantic schemas + a typed HTTP client from a third party's OpenAPI specification:

tempest openapi-client https://api.vendor.com/openapi.json --name vendor
tempest openapi-client ./vendor/spec.yaml --name vendor --force
tempest openapi-client <spec> -H "Authorization: Bearer $TOKEN" --schemas-only

Writes <src|app>/integrations/<name>/ with schemas.py (one class per component, with the spec's title/description/examples filled in) and client.py (one async method per operation, over an injected HTTPClient).

  + src/integrations/vendor/__init__.py
  + src/integrations/vendor/client.py
  + src/integrations/vendor/schemas.py
4 schema(s), 12 operation(s).

Details, OpenAPI coverage and limitations in the Integration client (OpenAPI) » recipe.


Export the contract — tempest openapi-export

openapi-client consumes somebody else's spec; this one writes yours.

# Document on stdout
tempest openapi-export

# To a file (JSON by default)
tempest openapi-export --out contracts/openapi.json

# YAML (needs the [openapi] extra)
tempest openapi-export --out contracts/openapi.yaml --yaml

Two things come out of that. The first is feeding a generator — tempest openapi-client itself, a TypeScript client generator, a mock server — without booting the service. The second is committing the document, which turns a contract change into something the reviewer sees:

tempest openapi-export --out contracts/openapi.json --check
error: contracts/openapi.json is out of date:
  + DELETE /api/items/{item_id}
  - GET /api/legacy
  ~ /api/items/{item_id} changed
Run 'tempest openapi-export --out <file>' to refresh it.

Exit 1 when it differs, 0 when it is current. Operations added and removed come first because those are the ones that break consumers; a change inside an operation reads as ~ <path> changed. "The spec changed" is not a reviewable message — which is why the command names what moved.

In CI

Run --check in the same job as the tests. The day somebody changes a status code, a required field or a path, the PR goes red with the exact line instead of the client finding out in production.

PR description with AI — tempest pr-prompt

The branch is finally green and the step everyone skips is left: writing the Pull Request description. Any assistant writes a good one — what it lacks are the two things that live in the repository: the template the team agreed on and the diff the branch actually produced.

tempest pr-prompt assembles both into a single prompt and writes it to stdout, so it pipes straight into whichever assistant you run:

tempest pr-prompt                               # compares against main, prompt on stdout
tempest pr-prompt develop                       # another base
tempest pr-prompt | claude -p                   # pipe it into the assistant
tempest pr-prompt --out pr_prompt.txt           # write a file to paste from

The prompt carries three blocks:

  1. The PR template. The repository's own wins — it is the contract that project's reviewers read. These are looked up, in order: .github/pull_request_template.md, .github/PULL_REQUEST_TEMPLATE.md, .github/PULL_REQUEST_TEMPLATE/pull_request_template.md, .gitlab/merge_request_templates/default.md, docs/pull_request_template.md, .pull_request_template.md and pull_request_template.md. With none of them, the SDK's bundled template is used.
  2. The rules that stop the model from returning the template with its placeholders still in it: no undecided Yes/No, no _italic instruction_, no section dropped, and no invented migration, env var or dependency that is absent from the diff.
  3. The branch context: commit subjects, the --name-status list of changed files, and an excerpt of each file's patch.

The diff is the one the forge shows

Patches are read as base...head (three dots) — the merge-base diff, which is what GitHub/GitLab display on the pull request. With two dots, every commit that landed on base after your branch started would be attributed to you.

What is complete and what is sampled

The commit list and the changed-file list always go in whole — the model always knows what changed. What is bounded are the diff excerpts: by default the 10 files with the most changed lines, each cut at 1500 characters. So what gets sampled is how it changed.

Excerpts are bounded because the whole diff of a large branch does not fit in the context and is mostly noise (lock file, changelog, generated migration). Files enter ordered by changed lines, not alphabetically — otherwise the budget is spent on .github/ and CHANGELOG.md before it reaches the file the PR is actually about:

tempest pr-prompt --full                        # every file, whole patch
tempest pr-prompt --max-files 20                # 20 files with a patch (default: 10)
tempest pr-prompt --max-files 0                 # file list only, no patch
tempest pr-prompt --max-chars 4000              # more patch per file (default: 1500)

--full lifts both bounds at once — use it on a branch small enough to send whole. It refuses to run alongside --max-files / --max-chars (exit 2): silently overriding a number you typed would be worse than complaining.

Nothing is dropped silently

A truncated patch carries the excerpt cut mark, and the files left without one become an explicit line in the prompt (N more changed file(s)…). The model reads a partial context as partial instead of taking the fragment for the whole change. The cut also respects line boundaries, so half a diff line never survives — the model would read it as code that does not exist.

The summary (which template was picked, commit/file counts) goes to stderr, so the pipe stays clean:

template: .github/pull_request_template.md
7 commit(s), 12 changed file(s), 10 excerpt(s), 2 file(s) without a patch.

Other options:

tempest pr-prompt --head feat/other             # describe another branch, no checkout
tempest pr-prompt --lang en                     # rules and bundled template in English
tempest pr-prompt -t docs/my_template.md        # explicit template, wins over all
tempest pr-prompt -p ../other-repo              # run against another repository

A missing base falls back to origin/<base>

A fresh clone usually has no local main, only origin/main. When the base does not resolve, the command tries origin/<base> before failing. When neither exists it exits with code 2 naming the ref it could not find. When the comparison holds no commit and no file, it exits 1 — almost always a wrong base.


Quality gates

These commands come from tempest-cli (v0.226.0)

The quality gate has nothing to do with FastAPI — it is ruff, mypy and pytest. Since v0.226.0 it lives in its own package, tempest-cli, which the SDK's [cli] extra declares and the SDK mounts on its CLI.

Nothing changes for you: tempest check is the same command, with the same flags and the same [tool.tempest] typing_strictness. What changes is that people not using FastAPI can now install the gate alone:

uv add --dev tempest-cli
tempest-cli check

Why split it: reaching those four commands meant installing FastAPI, SQLAlchemy, Alembic and Pydantic — 38.7 MB of dependencies and about 0.5 s of import time per invocation, measured, for commands that touch none of it.

The two never diverge because there is one implementation: the SDK calls register_commands(app) from tempest_cli. Importing tempest_fastapi_sdk.cli.lint or .pr_prompt keeps working and returns the same functions.

The lint commands shell out to the project's tooling, looking for the executable in this order:

  1. the activated $VIRTUAL_ENV;
  2. the nearest .venv, walking up from the working directory — the project's environment, even when it was never activated;
  3. the directory of the interpreter running tempest itself;
  4. PATH, skipping a version-manager shim (pyenv/asdf) that dispatches nowhere;
  5. uv run --with <tool> <tool>, when uv is on PATH.

The project comes before the CLI's own environment on purpose: under uv tool install the CLI carries its own ruff, and a project that pins another version still runs the one it pinned.

tempest lint                                    # ruff check .
tempest fix                                     # ruff check --fix-only . + ruff format . + ruff check .   (writes)
tempest fix --unsafe                            # also apply ruff's --unsafe-fixes
tempest format                                  # ruff format .          (writes)
tempest fmt-check                               # ruff format --check .   (read-only)
tempest type                                    # mypy .
tempest test                                    # pytest
tempest test tests/api/                         # pytest with a path filter
tempest test --fast                             # pytest -n auto -p no:cacheprovider  (pytest-xdist)
tempest test --fast -w 4                        # four workers
tempest check                                   # lint + fmt-check + type + lint-layers + test, stops at first failure
tempest check --fast                            # the same gate, with the test step in parallel

--fast (from tempest-cli 0.4.0 on) runs the suite in parallel with the pytest-xdist of the [tests] extra; without it installed in pytest's environment, the exit code is 127 with the install instruction. When to use it, and what to do with a test that fails only in parallel, is in the testing recipe.

tempest fix is the one-shot "organize the project" pass — sorts and dedupes imports, drops unused imports, normalizes string quotes, removes trailing whitespace, then runs ruff format to align indentation, line length, blank lines and trailing newlines. Run it before pushing when CI keeps catching style nits.

The report comes from the formatted file (tempest-cli 0.5.1)

tempest fix runs three passes: ruff check --fix-only, ruff format and finally ruff check, which is where the report and the exit code come from. A violation the formatter resolved no longer shows up or fails the command. Whatever is left after formatting still exits non-zero, so CI keeps failing on the real leftovers.

Long comments and strings are shortened, with an AST proof (tempest-cli 0.5.1)

ruff format never breaks a comment or a string literal — same as Black. After it, tempest fix wraps each E501 comment at the limit (a trailing comment moves above its line) and splits each plain string literal at its spaces into implicitly concatenated parts inside parentheses. Every edit is kept only when the file's AST is identical before and after, so the program cannot change. With line-length = 88, this:

"""Long lines."""

MESSAGE: str = "this is a deliberately long plain string literal that goes well beyond the configured line length limit"


def f() -> int:
    """Return one."""
    return 1  # this trailing comment is deliberately long so that it overflows the configured line length

becomes this:

"""Long lines."""

MESSAGE: str = (
    "this is a deliberately long plain string literal that goes well beyond the "
    "configured line length limit"
)


def f() -> int:
    """Return one."""
    # this trailing comment is deliberately long so that it overflows the configured
    # line length
    return 1

Tool pragmas (noqa, type: ignore, fmt:, …), f-strings, triple-quoted strings and words longer than a line are left alone. For those lines — and for what the formatter has nowhere to break, such as a dotted name — tempest fix names the overflowing token, its kind and the action that resolves it, and splits the leftovers into those with no autofix and those tempest fix --unsafe would fix. A long f-string, for instance, comes out like this (exit 1):

1 line(s) still too long after ruff format — the line-length setting was applied; the formatter has nowhere to break these:
  src/longs.py:4 [string] ` this is a deliberately long f-string literal that goes well beyond the limit`
    ruff format does not split string literals, and fix could not split this one safely (an f-string, a triple-quoted string, or a word longer than a line). Break it into implicitly concatenated parts inside parentheses.
1 residual violation(s): 1 with no autofix (edit by hand).

Every command returns the underlying tool's exit code, so tempest check is safe to wire into CI (tempest check || exit 1) or pre-commit hooks. When none of the five steps finds the tool, the wrapper exits with 127 instead of failing silently, naming what to install:

error: 'ruff' was not found in this project's environment, on PATH, and 'uv' is unavailable to run it. Install it with 'uv add --dev ruff' — or the whole set with 'uv add --dev "tempest-cli[tools]"' — and retry.

Layer rules — tempest lint-layers

The CLAUDE.md that tempest new writes says how a service and a repository are shaped. Prose is not a gate: a service with ten services without BaseService, repositories wrapping the SDK's list, and loose functions in a services/feeds.py passed ruff, mypy and pytest clean (#520). tempest lint-layers is the gate for those rules (and for the enum naming convention) — and tempest check runs it as a step of its own, after mypy and before pytest:

tempest lint-layers            # the project in the current directory (reads src/ or app/)
tempest lint-layers src        # the source directory itself

It reads the code with ast and never imports the project, so it runs even with a broken import. Each violation is one path:line: CODE message line, and the message says what to change:

Code Where What it flags
SVC001 services/ a public class that does not inherit BaseService (directly, through another project service, or through a *Service imported from tempest_fastapi_sdk)
SVC002 services/ a second public class in the module, or any module-level function — a helper goes to utils/
SVC003 services/ an __init__ that only calls super().__init__ with its own parameters and/or stores self.repository
REPO001 repositories/ a method whose body is only return [await] self.<BaseRepository method>(...)
ENUM001 all source a public class inheriting BaseStrEnum (directly or through another project class) whose name does not end in Enum — the message names the expected name; renaming needs no migration, because enum_type_name drops one trailing Enum (#518)
RTR001 routers/ a call to a *Controller or *Service class (a name starting upper case) anywhere in the module — route body, helper function, module level. The construction goes to a provider in api/dependencies/controllers.py and reaches the route through Depends
RTR002 routers/ a route (@<router>.get / post / put / patch / delete / head / options / trace / api_route) whose return annotation or response_model= names an ORM model, at any depth (list[ProductModel], Optional[ProductModel], a union with None)

The three cases of #520, in a project generated by tempest new, come out of tempest check like this (an excerpt of the real output; exit code 1):

$ mypy --disallow-untyped-defs --disallow-incomplete-defs .
Success: no issues found in 29 source files
$ lint-layers
src/db/repositories/news.py:18: REPO001 `NewsRepository.list_all` only forwards to BaseRepository.list; delete it and call `repository.list(...)` with those arguments at the call site.
src/services/feeds.py:9: SVC002 module-level function `page_envelope` in services/; move it to utils/, or make it a method of the service that uses it.
src/services/feeds.py:24: SVC001 `NewsService` does not inherit BaseService; subclass `BaseService[Repository, ResponseSchema]` and drop what it reimplements. A service with no repository opts out with `# tempest: no-base-service` on the class line; a type that is not a service belongs in schemas/ or core/.
src/services/feeds.py:27: SVC003 `NewsService.__init__` only stores the repository, which BaseService already does; delete it.
src/services/feeds.py:37: SVC002 `TrendService` shares this module with `NewsService`; one public class per module in services/ — move it to its own file.
14 layer finding(s).

The project as tempest new generates it passes clean, and stays clean with the CLAUDE.md ProductService / ProductRepository / product router examples copied into it — the suite's test does exactly that.

A service without a repository: explicit opt-out

A service that has no repository to hand to BaseService (a static catalogue read from a file, a facade over an external API) marks its class line with # tempest: no-base-service. The marker takes the class out of SVC001 and SVC003; SVC002 still applies.

# src/services/catalogue.py
import json
from pathlib import Path


class CatalogueService:  # tempest: no-base-service
    """Static catalogue read from a JSON file — there is no repository."""

    def __init__(self, path: Path) -> None:
        self.items: list[dict[str, str]] = json.loads(path.read_text())

It is a comment, not a class attribute, because it is an instruction to the checker and nothing at runtime reads it — the same reason # noqa is a comment.

What REPO001 lets through on purpose

A method that overrides a BaseRepository name (delete forwarding to soft_delete) is a policy, not a wrapper: whoever calls service.delete now deletes logically. And a method that calls another method defined on the same class is not a wrapper of the SDK.

Router: controller through Depends, response as a schema

The two routers/ rules cover the deviations a router shows most: building the controller by hand inside the route, and returning the ORM row. This router, written into a tempest new project, does both:

# src/api/routers/product.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from src.api.dependencies.resources import get_session
from src.controllers import ProductController
from src.db.models import ProductModel
from src.db.repositories import ProductRepository
from src.services import ProductService

router: APIRouter = APIRouter(prefix="/products", tags=["products"])


@router.get("/{product_id}")
async def get_product(
    product_id: int,
    session: AsyncSession = Depends(get_session),
) -> ProductModel:
    """Read a product."""
    controller = ProductController(ProductService(ProductRepository(session)))
    return await controller.get_by_id(product_id)

tempest lint-layers exits with code 1 and three lines (real output):

src/api/routers/product.py:15: RTR002 route `get_product` returns the ORM model `ProductModel` (return annotation); return the response schema the repository maps it to (`map_to_response`) and annotate the route with that schema.
src/api/routers/product.py:20: RTR001 `ProductController(...)` is built inside a router; move the construction to a provider in api/dependencies/controllers.py and receive it in the route as `Depends(get_...)`. A call that has to stay opts out with `# tempest: router-builds` on its line.
src/api/routers/product.py:20: RTR001 `ProductService(...)` is built inside a router; move the construction to a provider in api/dependencies/services.py and receive it in the route as `Depends(get_...)`. A call that has to stay opts out with `# tempest: router-builds` on its line.
3 layer finding(s).

The fix is the shape the project's CLAUDE.md teaches: the get_product_controller provider in src/api/dependencies/controllers.py builds the chain, the route takes controller: ProductController = Depends(get_product_controller) and annotates its return with ProductResponseSchema.

What counts as an ORM model. A name imported from a module whose path has a models segment (src.db.models, src.db.models.user), or a public class defined in a models/ directory of the project — as long as the route does not import it from a schemas module. A schema that happens to share a model's name stays clean.

A construction that has to stay in the router: # tempest: router-builds

The marker is the exception you own in writing, so review reads the reason. It covers the calls on that line, not the whole module:

# src/api/routers/reports.py
from fastapi import APIRouter

from src.services import ReportService

report_service: ReportService = ReportService()  # tempest: router-builds
router: APIRouter = APIRouter(prefix="/reports")

RTR002 has no opt-out: the fix has one shape — return the schema the repository's map_to_response produces.

What the router rules do not see

They read syntax and never import the project, so these stay out: a route with no return annotation and no response_model=; a model kept outside any models/ directory and imported from a module without that segment; the SDK's own ORM bases (BaseUserModel) used directly in a route; a controller built through an alias (factory = ProductController; factory()) or a class whose name does not end in Controller / Service.

Where @requires sits is not a rule. The permission guards recipe applies @requires to the route as its first form, and requires itself documents route handlers, controller methods and service methods as valid places. Flagging @requires on a route would flag the SDK's own documentation; choosing between the two places is review — the criterion is in Architecture › Guards.

Requires tempest-cli>=0.5.0

The step reaches tempest check through register_commands' extra_steps, which arrived in tempest-cli 0.5.0; the [cli] extra declares tempest-cli>=0.5.1, so it comes with it. tempest-cli check alone, without the SDK, does not know these rules: they belong to the SDK.


Recap

tempest covers the whole cycle — from scaffolding (new), through infra (generate --docker / --dockerfile / --src), migrations (db), users (user) and secrets (secrets), to the quality gates (lint / fix / type / test / check), with the SDK's layer rules (lint-layers) inside check.

Next steps

Once the service is generated, head to the related recipes:

  • Database » — BaseRepository, async sessions and the migration flow behind tempest db.
  • Queue and tasks » — the layers tempest generate --src writes for the [queue] (FastStream) and [tasks] (TaskIQ) extras.
  • Safe deploys » — destructive migrations and graceful shutdown when containerizing with the generated Dockerfile.