Skip to content

CLI

Installing tempest-fastapi-sdk 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

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
tempest new my_service \
    --bind-host 0.0.0.0 \                       # default HOST in .env.example
    --bind-port 9090 \                          # default PORT in .env.example
    --extras auth,upload                        # pinned SDK extras
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/VERSION/SERVER_HOST/SERVER_PORT/DATABASE_URL/JWT_SECRET/CORS_ORIGINS
├── docker-compose.yaml      # services keyed to the chosen extras
├── .gitignore
├── README.md
├── CLAUDE.md                # project rules for AI agents and humans
├── src/
│   ├── server.py            # uvicorn.run() + module-level FastAPI app
│   ├── api/
│   │   ├── app.py           # create_app() wires SDK middleware + handlers
│   │   ├── routers/         # placeholder business router
│   │   └── dependencies/    # auth.py (require_token) + factories
│   ├── controllers/         # orchestration between services
│   ├── services/            # business logic
│   ├── schemas/             # Pydantic DTOs
│   ├── core/                # settings.py + exceptions.py
│   ├── db/
│   │   ├── models/
│   │   └── repositories/
│   ├── 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 (tempest-fastapi-sdk[auth,admin]>=<version> by default — change with --extras). 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 / version come from .env

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 AdminSite(title=f"{settings.TITLE} admin")). Tune the title shown in Swagger/ReDoc and the /admin header from .env, no code edits:

TITLE=My API
VERSION=1.2.0
DESCRIPTION=Payments API for product X.

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 Exposed port(s)
(always) postgres:18-alpine 5432
[cache] redis:8-alpine 6379
[queue] / [tasks] rabbitmq:4-management-alpine 5672 (AMQP) + 15672 (UI)
[minio] minio/minio + bootstrap mc 9000 (API) + 9001 (Console)
[email] mailhog/mailhog 1025 (SMTP) + 8025 (UI)

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_*, 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 it all:

docker compose up -d

Tear down keeping volumes:

docker compose down

Tear down wiping volumes:

docker compose down -v

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 renders a byte-identical Dockerfile to before.

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, runs as a non-root user (app, uid 1000), and exposes the configured port.
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock* ./
RUN uv sync --no-dev --no-install-project
COPY . .
RUN uv sync --no-dev

FROM python:3.13-slim
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
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. Pass --env-file .env to point DATABASE_URL at the infra in docker-compose.yaml.

docker-compose.yaml stays infra-only

The generated compose brings up only Postgres + the services your extras need (Redis, RabbitMQ, MinIO, MailHog) — it does not embed an app service. The Dockerfile is standalone: use docker build / docker run, or add an app: service with build: . to the compose by hand if you want a one-command stack.

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 (broker + AsyncBrokerManager + get_broker), queue/handlers.py (example subscriber)
[tasks] tasks/__init__.py (broker + AsyncTaskBrokerManager + get_task_manager), tasks/jobs.py (example task)

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

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. src.core.settings.settings.DATABASE_URL (when run from a scaffolded project root).
  4. sqlalchemy.url from alembic.ini.
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

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:

  • PostgreSQLpg_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.

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

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

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.

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

Secrets — tempest secrets

Generate and rotate application secrets (JWT_SECRET / TOKEN_SECRET by default), rewriting the matching .env lines in place — backing up the old file first — and leaving 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) — pipe into a secret manager
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.

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.

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.


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.


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 declares as a dependency and 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. They look for the executable on PATH first, and otherwise fall back to uv run <tool> so a project-local virtualenv works without manual activation.

tempest lint                                    # ruff check .
tempest fix                                     # ruff check --fix . + ruff format .   (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 check                                   # lint + fmt-check + type + test, stops at first failure

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.

ruff format always runs — even with leftover errors

ruff check --fix exits non-zero whenever any violation it cannot autofix is left (an over-length string/comment, an undefined name, …). tempest fix runs ruff format anyway, so a single unfixable line never blocks formatting the whole file — long code lines still get wrapped and extra blank lines removed. The lint exit code is still surfaced afterwards, so CI keeps failing on the real leftovers.

Long strings and comments are never wrapped

Neither ruff format nor tempest fix wraps long string literals or comments — same behavior as Black. Those E501 lines stay and must be shortened by hand or silenced with # noqa: E501.

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 neither the executable nor uv is on PATH, the wrapper prints error: '<tool>' is not on PATH and 'uv' is unavailable and exits with 127 instead of failing silently.


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

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.