CLI¶
Instalar o tempest-fastapi-sdk[cli] expõe um console script tempest. Ele faz dois trabalhos: criar um novo serviço em camadas a partir do esqueleto preferido do SDK e rodar os quatro gates de qualidade (ruff check, ruff format, mypy, pytest) sem copiar e colar os mesmos comandos em cada projeto.
A CLI é o extra [cli] (desde a 0.308.0)
O tempest é ferramenta de desenvolvimento, então mora num extra e não
no pacote base. Instale do jeito que você usa:
uv tool install "tempest-fastapi-sdk[cli]" # comando global
uv add --dev "tempest-fastapi-sdk[cli]" # no grupo dev do projeto
O motivo é o ruff: o gate (tempest check) vem do tempest-cli, que
exige ruff>=0.8.0 em runtime. No pacote base, todo serviço que importa
o SDK levava um formatador para produção, e um projeto que fixa
ruff<0.8 no grupo dev nem conseguia resolver o lock.
Sem o extra, o script continua instalado e explica o que falta, com saída 2 e sem 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 não precisa do extra; ler
tempest_fastapi_sdk.cli.app (ou importar tempest_fastapi_sdk.cli.main,
.config, .lint, .pr_prompt) sem ele levanta ImportError com a
mesma instrução.
Erro de uso? O help completo aparece junto
Quando você digita um comando inexistente, uma opção inválida ou
esquece um argumento obrigatório, o tempest imprime o --help
completo daquele comando (todos os parâmetros, defaults e
descrições) antes da linha de erro — em vez do Try '... --help'
enxuto do Click. Você corrige na hora, sem reexecutar com --help.
Gerar um novo serviço¶
tempest new my_service # gera em ./my_service
tempest new my_service --path ~/projects # diretório-pai customizado
# HOST/PORT padrão no .env.example e extras do SDK fixados no pyproject.toml
tempest new my_service --bind-host 0.0.0.0 --bind-port 9090 --extras upload
tempest new my_service --force # sobrescreve diretório existente
O esqueleto casa com a arquitetura em camadas documentada em Arquitetura »:
my_service/
├── main.py # one-liner → src.server.run()
├── pyproject.toml # fixa tempest-fastapi-sdk + ruff/mypy/pytest
├── .env.example # TITLE/DESCRIPTION, SERVER_*, LOG_*, DATABASE_URL, JWT_* (com [auth]), TOKEN_SECRET, CORS_*
├── alembic.ini # caminhos ancorados em %(here)s
├── alembic/ # env.py + versions/ com a revision baseline
├── docker-compose.yaml # serviços baseados nos extras escolhidos
├── Dockerfile # multi-stage com uv, usuário não-root
├── .dockerignore
├── .gitignore
├── README.md
├── CLAUDE.md # regras do projeto para agentes de IA e humanos
├── src/
│ ├── __init__.py # re-exporta run de src.server
│ ├── server.py # app no nível do módulo + run() via run_server
│ ├── api/
│ │ ├── app.py # create_app() conecta middleware + handlers do SDK
│ │ ├── routers/ # __init__.py com o GET /api/ placeholder
│ │ └── dependencies/ # auth.py (require_token) + resources.py (db, get_session, get_mailer/get_storage por extra) + controllers.py (fábricas)
│ ├── controllers/ # orquestração entre services
│ ├── services/ # lógica de negócio
│ ├── schemas/ # DTOs Pydantic
│ ├── core/ # settings.py (settings + get_settings) + exceptions.py
│ ├── db/
│ │ ├── configs/names.py # nomes de tabela num símbolo só
│ │ ├── models/user.py # UserModel — só com [auth] (ou [admin])
│ │ └── repositories/
│ ├── queue/ # só com o extra [queue] — broker + handlers
│ ├── tasks/ # só com [tasks] ou [tasks-redis] — TaskQueue + jobs
│ ├── ui/ # só com o extra [ssr] — pages/layout/components/styles
│ └── utils/
└── tests/
└── test_smoke.py # garante que /api/ e /health/liveness sobem
O CLAUDE.md gerado é o contrato do projeto
Todo projeto novo nasce com um CLAUDE.md que fixa as regras que
mantêm os serviços parecidos entre si: a direção das dependências
entre camadas, a ordem exata dos sete passos de uma feature nova
(schema → model → repository → service → controller → provider →
router), a tabela do que não reimplementar porque o SDK já
entrega, e o "definition of done" que termina em tempest check.
Ele existe para um agente de IA ler antes de escrever a primeira
linha — e por isso os exemplos dele são verificados na CI do SDK:
tests/cli/test_scaffold_runtime.py escreve o domínio de exemplo
num projeto scaffoldado e o executa (POST 201, duplicado 409 com o
code certo, listagem paginada no envelope do SDK). Um símbolo
renomeado no SDK quebra o teste, não o projeto de quem usa.
O pyproject.toml gerado fixa a versão atual do SDK com os extras que você pediu, e só eles (tempest-fastapi-sdk>=<versão> sem --extras). admin e auth são opt-in: com --extras auth o projeto ganha o UserModel, o JWTSettings no Settings e o bloco JWT_* no .env.example; com --extras admin o src/api/app.py também monta o painel admin — e o auth entra junto, porque o login do painel verifica a senha pelo PasswordUtils. Sem nenhum dos dois, o serviço nasce sem tabela de usuário, sem painel e sem bcrypt/PyJWT no lock: --extras postgres fixa só [postgres], --extras admin,cache fixa [admin,auth,cache], e duplicatas somem com a lista saindo em ordem alfabética. Até a 0.312.x admin e auth eram fixados sempre. O grupo dev ganha tempest-fastapi-sdk[cli]>=<versão>, para o tempest rodar no projeto sem entrar na imagem: o Dockerfile gerado instala com uv sync --no-dev. O .env.example criado usa a nomenclatura de settings da v0.8.0 (SERVER_HOST/SERVER_PORT/SERVER_DEBUG/SERVER_RELOAD/LOG_LEVEL/…), e src/server.py delega a tempest_fastapi_sdk.run_server para que o uvicorn seja importado de forma preguiçosa e os testes possam importar o app sem ele. Regras de validação: o nome do projeto deve casar com ^[a-z][a-z0-9_]*$ e não pode colidir com uma palavra-chave do Python, então tempest new Bad-Name e tempest new class saem com código 2 antes de qualquer arquivo ser escrito.
Título, descrição e versão da API
A partir da v0.48.0 o Settings scaffoldado carrega TITLE,
VERSION e DESCRIPTION, e src/api/app.py os consome
(FastAPI(title=settings.TITLE, version=settings.VERSION,
description=settings.DESCRIPTION), make_health_router(version=
settings.VERSION) e, com [admin], AdminSite(title=f"{settings.TITLE} admin")).
A versão tem uma fonte só: o VERSION do Settings sai de
[project].version no pyproject.toml, lido por
read_project_version.
O caminho é ancorado no próprio settings.py (não no cwd), então a
versão é a do projeto mesmo rodando de outro diretório; bumpar a
release é editar o pyproject.toml, e o /health/readiness e o
info.version do OpenAPI acompanham. Título e descrição continuam no
.env, e a env VERSION ainda sobrepõe o arquivo quando você precisa
publicar uma versão diferente do que está commitado:
docker-compose.yaml baseado nos extras¶
Desde a v0.25.0 o scaffold gera um docker-compose.yaml com apenas os serviços que os extras escolhidos precisam — sem ZooKeeper, Kafka ou qualquer outra coisa que você não vai usar.
| Extra | Container subido | Porta(s) publicada(s) em 127.0.0.1 |
|---|---|---|
| (sempre) | 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) |
Toda porta da infra é publicada só no loopback ("127.0.0.1:5432:5432"):
o processo que você roda no host continua alcançando o Postgres em
localhost:5432, e nada de fora da máquina alcança. A API não precisa dessas
portas — dentro do compose ela fala com a infra pelo nome do serviço (veja o
profile prod).
Exemplo — projeto que usa cache + uploads em S3 + emails:
Gera:
postgres,redis,minio(+minio-bootstrapque cria o bucketuploads),mailhog.env.examplecomREDIS_URL,MINIO_ROOT_*(o container),STORAGE_*(o app),SMTP_HOST=localhost,SMTP_PORT=1025,SMTP_USE_TLS=false(MailHog é plain — sem STARTTLS)
Credenciais vêm do .env, não estão hardcoded no compose
A partir da v0.37.0, nenhuma credencial é gravada direto no
docker-compose.yaml. Cada bloco environment: usa a forma
${VAR:-default}, e o Docker Compose resolve VAR a partir do
.env ao lado do compose. O :-default mantém o stack subindo
antes de você copiar .env.example para .env — mas defina
segredos reais no .env para qualquer deploy não-descartável.
Variáveis lidas pelo compose: POSTGRES_USER / POSTGRES_PASSWORD
/ POSTGRES_DB, RABBITMQ_DEFAULT_USER / RABBITMQ_DEFAULT_PASS
/ RABBITMQ_DEFAULT_VHOST, MINIO_ROOT_USER /
MINIO_ROOT_PASSWORD — todas com seus padrões já no .env.example.
Subir só a infra — o fluxo de dev, com a app rodando no host e reload:
Derrubar mantendo volumes:
Derrubar limpando volumes:
down sem --profile prod deixa a API de pé
Um docker compose down puro não enxerga o serviço api: ele derruba a
infra, deixa o container my_service-api rodando e a rede termina em
Resource is still in use. Passar --profile prod no down funciona
nos dois casos — com ou sem a API no ar.
Profile prod: a API dentro do compose¶
O compose também traz a própria API, como um serviço api atrás de
profiles: ["prod"]. Sem o profile ele não existe — docker compose up -d
continua subindo só a infra, como acima. Com o profile, o Dockerfile do
projeto é construído e a app sobe na rede do compose, ao lado da infra:
cp .env.example .env
docker compose --profile prod up -d --build --wait
curl http://127.0.0.1:8000/health/readiness
Para tempest new my_service --extras cache,tasks, o bloco gerado é:
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
Pedaço por pedaço:
-
environment:reescreve os hosts. O.envque o scaffold gera aponta tudo paralocalhost— certo para o processo no host, errado dentro de um container, ondelocalhosté o próprio container da API. O Compose dá precedência aoenvironment:sobre oenv_file:, então cada host que o.env.exampleaponta paralocalhosté trocado pelo nome do serviço, só para os extras escolhidos:Variável Valor dentro do compose Quando DATABASE_URLpostgresql+asyncpg://…@postgres:5432/<db>sempre REDIS_URLredis://redis:6379/0[cache]RABBITMQ_URL/TASKIQ_BROKER_URLamqp://…@rabbitmq:5672/[queue]/[tasks]TASKIQ_BROKER_URLredis://redis:6379/0[tasks-redis](vence oamqp://do[tasks])STORAGE_ENDPOINTminio:9000[minio]Sem essa reescrita a API não quebra — ela sobe no lugar errado. A mesma imagem rodada só com
--env-file .envrespondeu{"status":"ready","checks":{"database":true}}com200e criou/app/app.dbdentro do container: oDATABASE_URLdefault do.envé SQLite, e o Postgres nunca foi tocado. -
SERVER_HOST: 0.0.0.0eSERVER_PORT. ODockerfilejá defineSERVER_HOST=0.0.0.0, mas o.envtrazSERVER_HOST=127.0.0.1e vence oENVda imagem — o uvicorn sobe emhttp://127.0.0.1:8000dentro do container e a porta publicada não responde. Fixar os dois noenvironment:resolve. Isso não contradiz o bind127.0.0.1dos templates: aquele vale para o processo no host; dentro do container o bind precisa aceitar a rede do compose. depends_oncomcondition: service_healthy, um por serviço de extra (todos têmhealthcheck). Com[minio], a API também espera ominio-bootstrapterminar (service_completed_successfully), para o bucketuploadsexistir antes do primeiro request.healthcheckem/health/readiness, viaurllib. Apython:3.13-slimnão temcurl. O scaffold monta omake_health_router, que serve/health/livenesse/health/readiness—/healthsozinho responde404. A readiness responde503enquanto o banco não atende, e ourlopenlevanta nesse caso, entãohealthysignifica que a app alcançou o Postgres. Ela checa só o banco: Redis, RabbitMQ e MinIO não entram no payload do scaffold.env_file: .envé obrigatório no profile. Sem.env,docker compose --profile prod configfalha comenv file .../.env not found; odocker compose up -dde dev continua funcionando sem ele.
Decisões do profile prod
- Rede default do Compose, sem bloco
networks:. Os nomes de serviço já resolvem nela; rede nomeada só faria sentido se outro compose precisasse se juntar a esta. - Infra só em
127.0.0.1, API em todas as interfaces. O dev no host continua alcançando banco e broker; em produção eles não ficam expostos para fora da máquina. A porta da API é publicada normalmente — é ela que serve tráfego. - MailHog sem profile, e o SMTP fica com o
.env. O MailHog sobe nos dois modos (o fluxo de dev não muda), mas oenvironment:da API não reescreveSMTP_*: dentro do container oSMTP_HOST=localhostdo.env.examplenão alcança o MailHog. Em produção configure no.envo servidor SMTP real; para testar e-mail com o MailHog dentro do compose, useSMTP_HOST=mailhogno.env. asyncpgé dependência do projeto gerado. Opyproject.tomldo scaffold declaraasyncpg>=0.30.0ao lado doaiosqlite, porque a imagem instala só o que opyproject.tomldeclara e o profileprodsempre usapostgresql+asyncpg://.
Migrations no profile prod¶
O scaffold já vem com alembic.ini, alembic/ e uma revision baseline
(as tabelas dos models que os extras trouxeram — users com [auth], um
upgrade() vazio sem ele), e o lifespan do src/api/app.py gerado chama
AlembicHelper(...).sync_schema_async() antes do db.connect(). Então o
compose não precisa de serviço de migration: a API migra o Postgres ao subir.
cp .env.example .env
docker compose --profile prod up -d --build --wait
curl http://127.0.0.1:8000/health/readiness
Medido com tempest new my_service --extras admin,postgres: a API respondeu
/health/readiness 200, o Postgres ficou com users e alembic_version na
revision baseline, e reiniciar a API não rodou nenhum upgrade de novo.
O sync_schema_async recusa migration destrutiva (drop de tabela, coluna ou
constraint) e trata banco vazio, banco criado antes do Alembic e banco já
migrado. Com várias réplicas subindo juntas, desligue com
DATABASE_MIGRATE_ON_STARTUP=false e rode
docker compose --profile prod run --rm api tempest db upgrade num job.
Model novo ganha migration com tempest db revision -m "..."; a próxima
subida aplica.
As tags das imagens são fixadas — atualize por pyproject.toml da SDK, não no projeto gerado. Versões atuais (v0.26.0+): postgres:18-alpine, redis:8-alpine, rabbitmq:4-management-alpine.
Regerar o docker-compose.yaml num projeto existente¶
Quando você muda os extras instalados (uv add "tempest-fastapi-sdk[minio]") ou o SDK bumpa as imagens, regenere com:
tempest generate --docker # lê extras do pyproject.toml local
tempest generate --docker --extras cache,minio # força extras explicitos
tempest generate --docker --name my-svc # sobrescreve prefixo do container
tempest generate --docker --force # sobrescreve compose existente
O comando lê o [project] name + extras do pyproject.toml do diretório atual (use --path pra outro). Recusa overwrite sem --force pra não pisar em edits manuais. O .env.example é atualizado de forma idempotente — re-rodar não duplica blocos.
Dockerfile para containerizar a app¶
Fullstack: a SPA é detectada e compilada num stage Node
Se o projeto tem um frontend — um package.json em web/, frontend/,
client/ ou ui/ — o Dockerfile gerado ganha um stage Node que instala e
compila a SPA, e só o dist/ resultante é copiado para a imagem final.
Nem node_modules nem o toolchain do Node chegam ao runtime.
Regenerated Dockerfile
Regenerated .dockerignore
SPA stage: builds web/ and copies web/dist into the image.
| Opção | Efeito |
|---|---|
| (nenhuma) | Detecta pelo package.json. Diretório vazio não conta |
--spa-dir apps-web |
Layout fora da convenção |
--no-spa |
Imagem backend-only mesmo com frontend presente |
Sirva o resultado com make_spa_router("web/dist"),
incluído depois de todos os routers de API. Projeto sem frontend não
ganha stage spa nem COPY --from=spa: o Dockerfile sai só com os
stages builder e final mostrados abaixo.
Desde a v0.71.0, tempest new também gera um Dockerfile + .dockerignore prontos pra empacotar o serviço como imagem. O Dockerfile é multi-stage e usa uv:
- stage
builder— instala as dependências num/app/.venv(camada cacheada que só re-roda quandopyproject.toml/uv.lockmudam), depois instala o projeto. - stage final — copia só o venv + o código, cria
/app/logse passa/apppara o usuário não-root (app, uid 1000), que então consegue escrever ologs/e oapp.dbdo SQLite, e expõe a porta configurada.
O arquivo gerado para my_service, colado da saída do gerador:
# 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"]
Construir e rodar:
A imagem faz bind em 0.0.0.0 por padrão
O stage final define ENV SERVER_HOST=0.0.0.0 para que a app
seja acessível de fora do container mesmo sem .env. Localmente
o scaffold mantém SERVER_HOST=127.0.0.1 (serviço interno) — o
container sobrescreve pra 0.0.0.0 porque ali o bind precisa
aceitar conexões externas. Um --env-file .env sozinho não liga a
imagem à infra do docker-compose.yaml: o .env aponta para
localhost. Para isso use o
profile prod.
Só que variável de --env-file vence o ENV da imagem: um .env
copiado do .env.example traz SERVER_HOST=127.0.0.1 e devolve o bind
para dentro do container. Tire o SERVER_HOST do .env que vai para o
container, ou ponha SERVER_HOST=0.0.0.0 nele.
Subir app + infra num comando só
O docker-compose.yaml gerado já constrói esta imagem no serviço api,
atrás do profile prod:
docker compose --profile prod up -d --build. Veja o
profile prod.
Regerar o Dockerfile — tempest generate --dockerfile¶
tempest generate --dockerfile # Dockerfile + .dockerignore
tempest generate --dockerfile --name my-svc # sobrescreve nome nos comentários
tempest generate --dockerfile --force # sobrescreve arquivos existentes
tempest generate --docker --dockerfile --src # tudo numa tacada
A porta do EXPOSE / SERVER_PORT é lida do SERVER_PORT no .env (ou .env.example), caindo em 8000 se não achar. Como os outros generators, recusa overwrite sem --force.
Gerar as camadas src dos extras — tempest generate --src¶
As camadas sempre presentes (api, controllers, services, schemas, db, core, utils) já vêm no scaffold. As camadas que só fazem sentido com um extra específico — [queue] (FastStream) e [tasks] (TaskIQ) — não entram no esqueleto base: deixar pacotes placeholder vazios em todo serviço contraria as regras de layout. Quando você adiciona um desses extras a um projeto existente (uv add "tempest-fastapi-sdk[queue]"), gere a camada correspondente com:
tempest generate --src # lê extras do pyproject.toml local
tempest generate --src --extras tasks # força extras explícitos
tempest generate --src --force # sobrescreve arquivos existentes
tempest generate --docker --src # compose + camadas numa tacada
Mapeamento extra → camada gerada:
| Extra | Arquivos criados (sob src/ ou app/) |
|---|---|
[queue] |
queue/__init__.py (RabbitBroker + AsyncQueueManager + get_broker), queue/handlers.py (subscriber de exemplo) |
[tasks] / [tasks-redis] |
tasks/__init__.py (tq = TaskQueue.from_settings(settings) + broker/scheduler para a CLI do TaskIQ + get_task_queue), tasks/jobs.py (task de exemplo com @tq.task) |
Os dois extras geram a mesma camada: o transporte sai do TASKIQ_BROKER_URL em runtime. Com [tasks-redis], o tempest new também declara TASKIQ_BROKER_URL no Settings com default redis://localhost:6379/0 e escreve a mesma URL no .env.example, porque o default do mixin é amqp://, que escolhe o RabbitMQ e exige [tasks]. Se mesmo assim a URL apontar para um transporte cujo extra não está instalado, o ImportError do TaskQueue.from_settings nomeia o TASKIQ_BROKER_URL, o valor lido (senha mascarada) e a URL do outro transporte.
A raiz (src ou app) é detectada automaticamente, e os imports gerados (from src.queue import broker) já apontam pra ela. A operação é idempotente: arquivos existentes são mantidos a menos que você passe --force, então um handler editado à mão nunca é sobrescrito silenciosamente — o arquivo irmão que ainda não existe é criado normalmente. Extras sem camada associada (ex.: só [cache]) não geram nada e o comando avisa.
tempest new já gera as camadas dos extras escolhidos
Um tempest new my_service --extras auth,queue já entrega
src/queue/ pronto — o generate --src é pra quando você adiciona
o extra depois de criar o projeto.
Depois de gerar:
cd my_service
uv sync # instala SDK + ferramentas de dev
cp .env.example .env
uv run python main.py # serve no HOST:PORT configurado
uv run pytest # o smoke test embutido
Subir o serviço — tempest serve¶
tempest serve # host/port/reload vindos das settings
tempest serve --reload # liga o reload nesta execução
tempest serve --host 0.0.0.0 --port 9000 # sobrescreve
tempest serve --workers 4 # múltiplos processos (sem reload)
tempest serve --app src.api.app:create_app # app fora dos lugares convencionais
O main.py scaffoldado já sobe o uvicorn programaticamente; este comando
serve para o projeto que ainda não tem esse arquivo, ou para quando você
quer sobrescrever host/porta sem editar .env. A resolução é a mesma do
run_server: flag > settings do projeto > default do SDK
(127.0.0.1, 8000, sem reload).
O uvicorn recebe a string, nunca o objeto
O reloader reimporta o alvo no processo filho. Entregar a instância já
importada funciona — e transforma --reload em nada, em silêncio. Por
isso o comando importa o candidato uma vez só para validar, e passa
adiante "src.server:app".
--reload e --workers juntos saem com código 2: o uvicorn não honra os
dois. E --no-reload desliga de verdade, mesmo com SERVER_RELOAD=true
no .env — a flag tem três estados (ligado, desligado, "decida você").
Console interativo — tempest shell¶
Um REPL com o projeto já carregado, para não escrever script descartável toda vez que a pergunta é "quantos pedidos esse usuário tem?".
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=...>]
O que está no prompt: settings (a instância do projeto), cada model
mapeado que o projeto define (a BaseModel do SDK fica de fora — query
na classe base nunca é o que você quis dizer), select, text, o
AsyncDatabaseManager como db e uma session já aberta.
await no nível de cima, de propósito
O console compila com ast.PyCF_ALLOW_TOP_LEVEL_AWAIT e roda a corrotina
no mesmo loop em que a sessão foi aberta. Expor um helper
run(coro) pareceria equivalente e não é: sessão dirigida de um segundo
loop levanta MissingGreenlet no primeiro lazy load.
--no-db começa sem conectar — útil quando o banco está fora do ar e você
só quer inspecionar settings e models.
Banco de dados — tempest db¶
Wrapper Alembic. Usa o AlembicHelper por trás, então a configuração (alembic.ini + env.py) continua sendo a fonte da verdade.
Resolução do DATABASE_URL na seguinte ordem:
- Flag
--database-url. - Env var
DATABASE_URL. - Settings no
core/settings.pydo projeto, sob qualquer raiz de código (srcouapp). Vale qualquer instância depydantic_settings.BaseSettings— os nomessettingseconfigsão tentados primeiro, e depois o módulo é varrido por tipo —, ou o resultado de um factory zero-arg que devolva uma (get_settings(), ou qualquer callable anotado com retornoBaseSettings). DATABASE_URLno.envdo projeto.sqlalchemy.urldoalembic.ini.
O nome da instância não importa mais, e o factory vale
Até a 0.280.0 só o nome settings era aceito, e a falha era um
ImportError engolido por um except Exception: return None. Um serviço
que chamasse a instância de config recebia "no database URL. Pass
--database-url, set DATABASE_URL, or run inside a project with
src/core/settings.py" — apontando para duas condições que o projeto já
satisfazia. Hoje o que decide é o tipo da instância, o factory
documentado (get_settings()) é aceito, o .env é lido, e quando nada
resolve cada tentativa reporta o motivo no 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).
A mesma descoberta é compartilhada por tempest db, tempest doctor,
tempest user e o env.py gerado — um projeto cujo settings só existe
como get_settings() passa a migrar fora do CLI também.
tempest db init # cria alembic.ini + alembic/env.py
tempest db revision -m "init users table" # autogenerate por padrão
tempest db revision -m "manual change" --manual # cria arquivo vazio pra editar
tempest db upgrade # alembic upgrade head
tempest db upgrade <rev> # upgrade até rev específico
tempest db downgrade # rollback de 1 step
tempest db downgrade <rev> # rollback até rev específico
tempest db current # imprime revision aplicado
tempest db history # histórico de revisions
tempest db history -v # com message body completo
tempest db stamp head # marca o DB sem rodar migrations
tempest db squash -m "init" --yes # colapsa o histórico em 1 migration
tempest db backup # dump em backups/<db>_<ts>.<ext>
tempest db backup -o dump.sql # plain SQL (Postgres) por extensão
tempest db restore dump.dump --yes # restaura (clean + recreate)
tempest db seed # roda src.db.seeds:seed
tempest db seed --seed src.db.fixtures:demo # callable customizado
stamp head não adota um banco existente
stamp grava o ponteiro sem rodar nada. Contra um schema que o Alembic
não construiu, carimbar head registra como aplicadas revisions que
nunca rodaram: o banco fica com as colunas velhas enquanto current,
upgrade e history respondem que está em dia, e a falha aparece semanas
depois em produção. A revision certa para adoção é a base — veja
Migrations », que traz AlembicHelper.adopt() e
sync_schema() para não escolher isso à mão.
Quando o Alembic recusa o comando¶
Erro do Alembic que descreve o estado do banco — e não um bug — sai como uma linha, com o que fazer:
$ tempest db revision -m "novo campo" --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
Quatro condições ganham conselho: banco atrás da head, histórico com mais de
uma head, revisão que não existe em alembic/versions/, e revisões que não
estão na mesma linhagem. Qualquer outra mensagem sai como o Alembic
escreveu, sem tradução — dica errada seria pior que a mensagem crua.
Quer o traceback de volta?
TEMPEST_DEBUG=1 tempest db revision ... re-levanta a exceção intocada.
Use quando o erro não for uma das condições acima e você precisar ver
de onde ele veio.
Só a CLI traduz
Chamar AlembicHelper.revision(...) direto, de um script ou do lifespan,
continua levantando alembic.util.exc.CommandError. Código quer a
exceção tipada; quem está no terminal quer a frase.
Colapsar o histórico — tempest db squash¶
Com o tempo o diretório alembic/versions/ cresce sem limite — cada ajuste de schema vira mais um arquivo que o Alembic precisa percorrer em todo upgrade. O squash zera esse histórico para uma única migration raiz que descreve o schema atual, sem perder os bancos já existentes.
Operação destrutiva — rode contra um banco de desenvolvimento
O squash faz downgrade base no banco configurado (DROPa todas as tabelas) para conseguir autogerar o schema completo num arquivo só. Por isso exige --yes. Confirme que o DATABASE_URL aponta para um banco de dev antes de rodar.
O fluxo é:
- Captura o
headatual (vira o nome do diretório de backup). downgrade base— limpa o banco para o autogenerate enxergar o schema vazio.- Move as revisions antigas para
alembic/versions/_squashed_<oldhead>/(subdiretório que o Alembic ignora). Use--no-backuppara apagar de vez. - Autogera uma migration raiz a partir de
BaseModel.metadata. upgrade headrecria o schema e marca a nova revision.
tempest db squash -m "init" --yes # backup recuperável (padrão)
tempest db squash -m "init" --yes --no-backup # apaga os arquivos antigos
Bancos de produção não são tocados
O squash só mexe no banco configurado. Depois de fazer deploy da árvore colapsada, marque os bancos de produção como migrados sem recriar tabelas:
Squash manual (sem perder dados) → stamp --purge
Se você unificar as migrations à mão (apaga versions/, escreve 1 migration baseline, mantém o banco com os dados), o alembic_version ainda aponta para a revision antiga — que não existe mais no tree. Um stamp normal falha com Can't locate revision. Use --purge para limpar o ponteiro órfão e gravar o novo baseline:
Recap: squash troca um histórico que cresce sem fim por uma migration inicial limpa; stamp reconcilia os bancos que já estão no schema final (use --purge quando a revision gravada não existir mais). Backup recuperável por padrão — o Git é a sua segunda rede.
Backup e restore — tempest db backup / tempest db restore¶
Snapshot do banco para um arquivo e volta. A estratégia muda por dialeto, mas a CLI é a mesma:
- PostgreSQL —
pg_dump/pg_restore. O formato vem da extensão do arquivo:.dump→ custom (pg_dump -Fc, comprimido, restaura compg_restore),.sql→ plain (psql). Force com--plain/--custom. Exige os client tools do Postgres noPATH. - SQLite — cópia do arquivo do banco.
Pré-requisito: client tools do Postgres
backup / restore num banco PostgreSQL dependem dos binários pg_dump, pg_restore e psql disponíveis no PATH. Eles não vêm com o pacote Python — são instalados pelo sistema operacional. Sem eles, a CLI falha com uma mensagem clara ('pg_dump' not found on PATH).
SQLite não precisa de nada — o backup é uma cópia de arquivo feita pela stdlib.
tempest db backup # backups/<db>_<YYYYMMDD-HHMMSS>.dump
tempest db backup -o snapshot.sql # plain SQL (Postgres) pela extensão
tempest db backup -o snap.dump --custom # força formato custom
tempest db restore snapshot.sql --yes # restaura (psql)
tempest db restore snap.dump --yes # restaura (pg_restore --clean --if-exists)
Restore sobrescreve o banco de destino
Por padrão o restore é clean + recreate: dropa os objetos existentes antes de recriar, então o resultado é uma cópia fiel do backup (pg_restore --clean --if-exists; no plain dropa/recria o schema public; no SQLite sobrescreve o arquivo). Por isso exige --yes. Use --no-clean para aplicar o dump por cima do schema atual.
A senha do Postgres não vaza no ps
A URL é parseada em -h/-p/-U/-d e a senha vai via PGPASSWORD no environment do subprocesso — nunca na linha de comando.
Recap: backup tira um snapshot (formato pela extensão no Postgres, cópia no SQLite); restore --yes traz de volta, limpando o destino por padrão.
Detectar drift de model — tempest db check¶
O tempest db revision --autogenerate grava um diff quando você pede. Nada
percebe quando alguém edita um model e esquece de pedir — até o deploy
que precisava da coluna.
Sem drift:
Com drift, ele nomeia as operações que uma revision conteria e sai com código 1:
error: New upgrade operations detected: [('add_column', ...'colour'...)]
Run `tempest db revision -m "<message>"` to record them.
É um passo de CI
Rode depois do tempest db upgrade no pipeline. A comparação precisa do
banco em head — quando ele está atrás, o Alembic diz isso e a mensagem
passa direto, sem virar "drift".
Popular o banco — tempest db seed¶
Roda um callable de seed do projeto dentro de uma sessão gerenciada (commit no sucesso, rollback no erro). O callable recebe uma AsyncSession posicional e pode ser sync ou async; o que ele insere é decisão sua — o SDK só cuida do ciclo de vida da sessão. Por padrão importa src.db.seeds:seed.
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import CategoryModel
async def seed(session: AsyncSession) -> int:
"""Popula categorias iniciais. Retorna a contagem (opcional)."""
session.add_all([CategoryModel(name="Livros"), CategoryModel(name="Games")])
await session.flush()
return 2
Quando o callable devolve um int, a CLI mostra a contagem: Seeded via src.db.seeds:seed (2 rows).
Usuários — tempest user¶
Insere/lista usuários direto no banco usando o UserModel concreto do projeto (default src.db.models:UserModel). Útil pra bootstrapear o primeiro admin sem rodar SQL manual.
# Cria usuário comum
tempest user create --email ana@example.com --password senha-forte-12 --no-admin
# Cria admin (pode logar no /admin)
tempest user create --email admin@local --password admin-pass-12 --admin
# Pede senha interativamente (não fica no shell history)
tempest user create --email admin@local --admin
# Modelo customizado fora do layout scaffoldado — DEVE ser subclasse de BaseUserModel
tempest user create --email x@y --password pass-12-chars --model myapp.models.user:UserModel
# Colunas que o SEU UserModel adiciona — repetível, uma por coluna
tempest user create --email ana@example.com --password senha-forte-12 \
--set display_name=Ana --set locale=pt-BR
# Promove/rebaixa um usuário existente (liga/desliga is_admin)
tempest user promote --email ana@example.com # vira admin
tempest user revoke --email ana@example.com # volta a ser comum
# Troca a senha de um usuário existente (pede a senha duas vezes)
# e derruba as sessões dele junto
tempest user set-password --email ana@example.com
# Tabela de refresh token fora de src.db.models
tempest user set-password --email ana@example.com \
--refresh-token-model myapp.models:UserRefreshTokenModel
# Troca a senha e deixa as sessões vivas (opt-out explícito)
tempest user set-password --email ana@example.com --keep-sessions
# Primeira conta privilegiada já com MFA + evento de auditoria na mesma transação
tempest user create --email staff@example.com --admin --require-mfa \
--audit-model src.db.models:AuditLogModel --event staff.bootstrapped
# Conta que perdeu o celular e os recovery codes: remove o segundo fator,
# derruba as sessões e audita na mesma transação
tempest user mfa-reset --email staff@example.com \
--audit-model src.db.models:AuditLogModel
# Lista
tempest user list # todos
tempest user list --admin # só admins
Conta com MFA: --require-mfa, set-password e mfa-reset
create --require-mfa inscreve o segundo fator antes do commit:
mostra a URI otpauth:// e o segredo, pede o código do app
Authenticator (até 3 tentativas) e só grava com código válido — sem ele,
sai com código 1 e nenhuma linha é escrita. Exige MFAMixin no
UserModel e a tabela de recovery codes (--recovery-code-model,
default src.db.models:UserRecoveryCodeModel); os recovery codes saem
uma vez, depois do commit. --audit-model (um BaseAuditLogModel com
AuditRequestMixin) grava record_event na mesma transação, com o nome
de --event (default user.created); sem ele, o comando avisa no stderr
que nenhum evento foi gravado.
set-password numa conta com MFA ativo (e AUTH_MFA_ENABLED ligado,
como no login) pede o código — TOTP ou recovery code — e troca a senha
por UserAuthService.change_password_with_mfa. Passo a passo na
receita de MFA.
Quem perdeu o celular e os recovery codes não tem código para dar:
mfa-reset é a saída — veja
Recuperando quem perdeu o segundo fator abaixo.
UserModel com coluna própria obrigatória: use --set
O create preenche email, hashed_password, is_admin e
is_active. Toda outra coluna do seu UserModel entra por
--set <coluna>=<valor>, validada contra as colunas mapeadas e
convertida pro tipo da coluna (bool, int, float, Decimal, UUID,
ISO-8601 de data/hora, enum pelo valor e JSON).
tempest user create -e novato@example.com -p 'senha-forte-12' --no-admin
# error: UserModel requires a value for: display_name.
# Pass each one as --set <column>=<value>.
Coluna NOT NULL sem default que você não passou é perguntada num
terminal interativo (uma pergunta por coluna) e vira erro com código 2
numa execução sem TTY — em vez do IntegrityError cru que o banco
levantava. Chave desconhecida também sai com 2, listando as colunas
aceitas; --set email= / --set hashed_password= / --set is_admin=
são recusadas apontando a flag dona (--email, --password,
--admin/--no-admin).
Insert recusado pelo banco (email duplicado, quase sempre) sai com
código 1 e a mensagem do banco — error: could not insert user: … —
em vez de traceback.
Sem --admin/--no-admin, o create pergunta
Quando você não passa nem --admin nem --no-admin num
terminal interativo, o tempest user create pergunta
Should this user be an administrator? [y/N]. Em execuções
não-interativas (CI, pipes, scripts) o prompt é pulado e o usuário
nasce comum (is_admin=False) — passe --admin explicitamente pra
criar admin sem TTY.
tempest user promote / tempest user revoke localizam o usuário por email (case-insensitive) e só alternam is_admin. Quando nenhum usuário casa com o email, saem com código 1 e a mensagem no user found.
Trocando a senha — tempest user set-password¶
É o lado do operador do fluxo de reset: a conta pra quem não dá pra mandar link, o primeiro admin trancado fora de um ambiente novo, a credencial rotacionada depois de um incidente. Localiza por email (case-insensitive), hasheia com o set_password do próprio model — o mesmo hash que o login verifica — e sai com código 1 e no user found quando ninguém casa.
tempest user set-password --email ana@example.com
# Password:
# Confirm:
# Password updated for ana@example.com (id=6f0c...)
# Revoked 2 active refresh token(s).
Trocar a senha derruba as sessões — por padrão
Trocar o hash, sozinho, não encerra sessão nenhuma: um access token
já emitido continua válido até expirar, e um refresh token guardado
no banco continua trocável por um novo — exatamente a janela que o
reset existe pra fechar. Por isso a revogação não é uma flag que
você precisa lembrar: o comando resolve
src.db.models:UserRefreshTokenModel (o nome convencional da tabela
opt-in de refresh token — o tempest new não a scaffolda; quem
liga a tabela escreve o model com esse nome em src/db/models/) e revoga os refresh tokens do usuário na mesma
transação que grava o hash novo.
- Tabela em outro lugar?
--refresh-token-model myapp.models:UserRefreshTokenModel. Spec que você digitou e não resolve é erro com código 2 — nunca degrada em silêncio pra "não revoguei nada". - Não quer revogar?
--keep-sessions, que é recusado com código 2 se vier junto de--refresh-token-model(as duas flags se contradizem). - Projeto que nunca ligou a tabela opt-in de refresh token não tem o
que revogar, e o comando diz isso em
stderr:note: no refresh-token model at 'src.db.models:UserRefreshTokenModel', so no session was revoked. Pass --refresh-token-model if yours lives elsewhere.
A contagem é de sessões que esta execução matou — linha já
revogada não é recontada, então rodar de novo imprime
Revoked 0 active refresh token(s).
O mesmo, de dentro do código
await service.revoke_user_sessions(session, user_id=user.id) é o
par administrativo do revoke_refresh_token: logout-everywhere
endereçado por id, pra quando você não tem o token em mãos (um
endpoint de "desconectar tudo", um agente de suporte trancando conta
comprometida). Devolve quantas linhas revogou, e 0 quando o
projeto não usa refresh_token_model — JWT stateless não tem linha
pra derrubar.
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:
"""Revoga todas as sessões de um usuário. Retorna quantas caíram."""
killed = await service.revoke_user_sessions(session, user_id=user_id)
await session.commit()
return killed
A política de senha é a do seu projeto, não a da CLI
create e set-password validam o plaintext com
check_password_policy contra os três campos AUTH_PASSWORD_* do
src.core.settings do projeto — e contra os defaults do
PasswordPolicy (mínimo 12 caracteres, máximo 72 bytes) quando o
projeto não compõe AuthSettings. É a mesma régua do signup e do
painel admin, então a senha que a CLI aceita é sempre uma que o dono
da conta consegue usar pra logar.
O teto é medido em bytes UTF-8, a unidade que o bcrypt conta: 40
caracteres acentuados são 80 bytes e saem com código 2 e
error: password must be at most 72 bytes. — antes de qualquer
escrita no banco.
Resolução do DATABASE_URL igual ao tempest db (env var > instância ou factory de settings > .env > alembic.ini).
Recuperando quem perdeu o segundo fator — tempest user mfa-reset¶
Conta com MFA ativo cujo dono perdeu o celular e os recovery codes: o set-password pede o código, e não há código para dar. O mfa-reset remove o fator sem prova — por isso pede confirmação e grava quem fez.
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).
# Fora de um terminal (CI, script): --yes é obrigatório
tempest user mfa-reset --email staff@example.com --yes \
--audit-model src.db.models:AuditLogModel --event support.mfa_recovered
# Remove o fator e deixa as sessões vivas (opt-out explícito)
tempest user mfa-reset --email staff@example.com --yes --keep-sessions
- Roda
UserAuthService.mfa_reset: limpatotp_secret,totp_enabled_atetotp_last_stepe apaga os recovery codes (--recovery-code-model, defaultsrc.db.models:UserRecoveryCodeModel; sem tabela, sai com código 2 nomeando a flag). - Revoga as sessões por padrão, na mesma transação, com as mesmas regras do
set-password:--refresh-token-model(defaultsrc.db.models:UserRefreshTokenModel),--keep-sessionscomo saída explícita, e as duas juntas recusadas com código 2. --audit-model(comAuditRequestMixin) gravarecord_eventna mesma transação, com o nome de--event(defaultuser.mfa_reset); se a escrita da auditoria falha, o reset volta junto. Sem--audit-model, o comando avisa no stderr que nenhum evento foi gravado.--eventsem--audit-modelsai com código 2.- Sem confirmação nada muda: fora de terminal, sem
--yes, sai com código 1; no terminal, responderntambém sai com 1. - Conta sem MFA ativo sai com código 2 (
has no MFA active, nothing to reset.);UserModelsemMFAMixintambém, antes de abrir o banco. Email desconhecido sai com 1 eno user found.
Depois do reset a conta entra só com a senha até se inscrever de novo, e o set-password deixa de pedir código. A receita de MFA detalha o caminho, inclusive a chamada direta no código: Conta que perdeu o segundo fator.
Inspecionar, desativar e apagar¶
O list responde "quem existe". As quatro abaixo respondem o resto.
# A linha inteira desse usuário, incluindo as colunas que o SEU model adiciona
tempest user show --email ana@example.com
tempest user show --email ana@example.com --json
# Desliga o acesso (reversível) e derruba as sessões junto
tempest user deactivate --email ana@example.com
tempest user activate --email ana@example.com
# Sessões (refresh tokens) desse usuário
tempest user sessions --email ana@example.com
tempest user sessions --email ana@example.com --revoke
# Apaga a linha (irreversível)
tempest user delete --email ana@example.com --yes
show redige o hashed_password: nenhuma pergunta que esse comando
responde precisa do hash, e imprimi-lo coloca uma credencial no scrollback
do terminal e no log do CI.
deactivate revoga sessão, e não é opcional
Virar is_active=False não encerra nada sozinho: o refresh token que já
foi emitido continua trocável, e a conta que você acabou de desligar
continua rendendo access token novo. Por isso a revogação roda na mesma
transação que o flag — a mesma regra que o set-password segue.
sessions sem --revoke lista uma linha por token (id, família, criação,
expiração, estado). Usuário sem sessão imprime tabela vazia e sai com 0 —
"não está logado em lugar nenhum" é resposta, não erro. A contagem do
--revoke é de sessões que aquela execução matou, então rodar duas
vezes imprime Revoked 0 na segunda.
delete é irreversível, e leva os tokens junto
A tabela de refresh token scaffoldada tem ForeignKey sem
ON DELETE CASCADE, então apagar o usuário com token vivo falharia com
erro de integridade citando uma constraint em vez da causa. O comando
apaga os tokens na mesma transação. Fora de um terminal interativo,
--yes é obrigatório. Quando a intenção é "essa pessoa não deve mais
entrar", o comando certo é deactivate.
Segredos — tempest secrets¶
Quatro comandos, ordenados por quanto mexem no seu .env:
| Comando | Escreve no .env? |
Quando usar |
|---|---|---|
generate |
nunca | pegar um valor pra colar num secret manager |
init |
só o que está vazio ou placeholder | logo depois do tempest new |
rotate |
sempre, com backup | o segredo vazou ou envelheceu |
vapid |
par de chaves Web Push | ligar notificação no browser |
Gerar um valor avulso — tempest secrets generate¶
Não toca em arquivo nenhum: imprime e pronto.
# Um segredo, valor cru
tempest secrets generate
# Três de uma vez
tempest secrets generate --count 3
# Rotulado, pronto pra colar num .env ou num painel de deploy
tempest secrets generate --keys JWT_SECRET,TOKEN_SECRET
# JSON, pra pipar num secret manager
tempest secrets generate --keys JWT_SECRET --json
Saída do terceiro:
JWT_SECRET=_Ptx1Cq4lUAK4EJX7GAnXMp115d6i92I_rrTDJ3sS59noFLm3N1wTWL-F0OhCkbr
TOKEN_SECRET=qNsIyp8hp4BZll_BzcDS7omceBNB4a4EI9JVWvIQEg_R5VveTY0IuLBiogp0uRBa
Tip
--count e --keys dizem a mesma coisa (quantos segredos sair), então
passar os dois é erro de uso, não um deles vencendo em silêncio.
Preencher o .env recém-criado — tempest secrets init¶
O tempest new escreve JWT_SECRET=change-me-change-me-change-me-32 e
TOKEN_SECRET= vazio — exatamente os dois casos que o
tempest check-config reporta como security.W001 / security.W004.
O init troca só esses:
Chave que já tem valor de verdade é mantida, então rodar de novo não faz nada:
Isso é o que separa o init do rotate: o init é seguro de rodar em
qualquer momento — inclusive num script de setup — porque nunca destrói
segredo em uso. Pra substituir valor real, é opt-in explícito, e aí ele faz
backup antes:
Rotacionar — tempest secrets rotate¶
Gera e reescreve as linhas correspondentes no .env no lugar — fazendo
backup do arquivo antigo antes — e deixa as outras linhas intactas.
# Rotaciona JWT_SECRET e TOKEN_SECRET no .env (gera .env.bak)
tempest secrets rotate
# Só imprime os novos valores (não escreve nada)
tempest secrets rotate --print
# Chaves e arquivo customizados
tempest secrets rotate --keys JWT_SECRET,SESSION_SECRET --env .env.prod
# Mais entropia, sem backup
tempest secrets rotate --length 64 --no-backup
Warning
Rotacionar JWT_SECRET invalida todo token assinado com o valor antigo: usuários são deslogados e links de reset/ativação pendentes param de funcionar. Rotacione numa janela de manutenção e reinicie o serviço pra carregar os novos valores.
Chaves Web Push — tempest secrets vapid¶
Web Push não usa string aleatória: usa um par de chaves P-256. O browser
assina a subscrição com a pública e o servidor assina cada push com a
privada, então os dois valores nascem juntos e não saem do generate.
# Escreve VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY no .env
tempest secrets vapid
# Com o contato que vai no claim `sub` do JWT VAPID
tempest secrets vapid --subject mailto:ops@example.com
# Só imprime
tempest secrets vapid --print
São exatamente os campos que o WebPushSettings lê, no formato que o
pywebpush lê de volta: privada é o escalar de 32 bytes e pública é o
ponto não-comprimido de 65 bytes, ambos em base64url sem padding.
Danger
Trocar um par que já existe invalida toda subscrição ativa: o browser
que subscreveu com a chave pública antiga não aceita push assinado com a
nova, e cada cliente precisa subscrever de novo. Por isso o comando se
recusa a sobrescrever sem --force.
Recap¶
generateimprime,initpreenche o que falta,rotatetroca tudo,vapidgera o par do Web Push.inité idempotente e nunca perde segredo em uso — é o comando do primeiro dia do projeto.rotateevapid --forcesão destrutivos de formas diferentes: um desloga usuário, o outro derruba subscrição de push.- Todo arquivo que esses comandos escrevem sai
0600.
Fila e tasks — tempest queue / tempest tasks¶
# Publica uma mensagem pelo broker do projeto
tempest queue publish orders.paid '{"order_id": 7}' --json
tempest queue handlers # canais que este serviço consome
# Tasks de fundo
tempest tasks list # nomes registrados
tempest tasks run src.tasks:send_welcome --arg ana@example.com --kwarg retries=2
O publish conecta, publica e fecha — nessa ordem, num processo só.
Fechar é o passo que o script descartável esquece, e é ele que garante o
flush da publicação antes do comando retornar.
O nome da task não é o nome da função
O TaskIQ registra sob <módulo>:<função> (src.tasks:send_welcome), então
o nome que o run recebe não é o que está no arquivo. O list é o que diz
qual é qual — e ele importa <root>.tasks.jobs antes de listar, porque
task só existe no broker depois que o módulo que a declara foi importado.
tasks run enfileira, não executa: quem roda é o worker. Comando que
retorna com sucesso significa "a mensagem foi aceita", não "o job deu certo".
Sem --broker / --queue, os dois procuram <root>.queue:broker e
<root>.tasks:tq — os nomes que o tempest generate --src escreve. Quando
nada resolve, cada tentativa é listada com a causa antes do código 2.
E-mail — tempest email test¶
O par que mais custa tempo em configuração de SMTP é STARTTLS (porta 587,
SMTP_USE_TLS) contra TLS implícito (porta 465, SMTP_USE_SSL), e o único
jeito honesto de saber qual o servidor quer é conectar e mandar.
tempest email test --to voce@example.com
tempest email test --to voce@example.com --subject "deploy ok" --body "..."
tempest email test --to voce@example.com --html # exercita o caminho multipart
Monta o cliente com EmailUtils(**settings.email_kwargs()) — a mesma
construção que o serviço faz —, então mensagem que chega aqui chega pela
app. Recusa do servidor sai com código 1 carregando a resposta dele, mais a
linha que lembra o par TLS.
Projeto que não compõe EmailSettings sai com código 2 dizendo isso: compor
só os mixins que se usa é o normal, não defeito.
Object store — tempest storage¶
tempest storage check # endpoint responde? bucket existe?
tempest storage ls # chaves do bucket default
tempest storage ls relatorios/ --flat # só o nível imediato
tempest storage put ./nota.pdf --key notas/2026-09.pdf
tempest storage get notas/2026-09.pdf --out ./baixado.pdf
tempest storage presign notas/2026-09.pdf --expires 900
tempest storage rm notas/2026-09.pdf
Tudo passa pelo AsyncMinIOClient(**settings.storage_kwargs()), então
endpoint, credencial, região e — o que mais importa — o endpoint público
usado para assinar URL são os do serviço.
URL assinada com o endpoint interno é válida e inútil
Assinar contra minio:9000 produz uma URL correta que o browser do
usuário não resolve. Configure STORAGE_PUBLIC_ENDPOINT; o presign
imprime, no stderr, para qual host ele assinou, justamente para esse
erro aparecer antes de ir para o cliente.
Bucket vazio lista nada e sai com 0 — "nenhum objeto" é resultado, não falha, a mesma convenção dos repositories do SDK.
Feature flags — tempest flags¶
Flag existe para ser movida sem deploy. Com o backend Redis, essa mudança
morava num redis-cli HSET feature_flags <nome> 1 — o tipo de comando que
sai com um dígito errado na hora errada.
tempest flags list # tudo que o backend guarda
tempest flags enable new-checkout # liga para todo processo que lê esse backend
tempest flags disable new-checkout
tempest flags get new-checkout # estado guardado
tempest flags list --key ff:staging # outro hash, outro ambiente
Os quatro falam pelo RedisFeatureFlagBackend, então o valor que o serviço
lê e o que a CLI escreve passam pelo mesmo código de codificação.
off e unset são respostas diferentes
get sai com 1 quando a flag não existe no backend (unset), e com
0 quando ela existe valendo off. A diferença importa: flag não
definida resolve para o default que o chamador passou no código; flag
em off sobrescreve esse default.
A URL sai de --redis-url > REDIS_URL > settings do projeto. Redis fora do
ar sai com código 2 carregando a mensagem do driver — "connection refused"
diz o que consertar, "ocorreu um erro" não.
Cache — tempest cache¶
tempest cache ping # PONG, ou código 1
tempest cache stats # versão, chaves, memória, hit ratio
tempest cache flush --namespace orders # invalida o que o @cached registrou
tempest cache flush --tag user:42
tempest cache flush --key "cache:orders:list:abc"
tempest cache flush --all --yes # FLUSHDB
As opções direcionadas passam pelo CacheInvalidator — o mesmo código que o
serviço chama —, então as chaves apagadas aqui são as que o @cached
escreveu lá. Passe o mesmo --key-prefix que os decorators usam, senão os
registries não batem.
--all é FLUSHDB, não 'limpar o cache'
Apaga toda chave do banco selecionado: sessão, contador de rate limit
e feature flag junto, quando dividem o mesmo Redis. Por isso exige
--yes. Na dúvida, invalide por namespace.
O hit ratio é do servidor, não da sua aplicação
keyspace_hits / keyspace_misses são contadores do Redis desde o último
restart, somando todos os clientes. O Redis não atribui hit a chamador —
o número responde "esse servidor está sendo útil", não "meu endpoint X
está".
Modelos — tempest model¶
Análise, benchmark, conversão e quantização de modelos ONNX. Precisa do
extra [modelops-onnx], exceto hardware, que só reporta o que esta
máquina consegue medir:
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
| Comando | O que faz |
|---|---|
analyze |
Parâmetros, tamanho, opset e shapes, sem executar o modelo. |
bench |
Latência (mediana/IQR/p95/p99), RAM, GPU e energia sobre N repetições. |
optimize |
Persiste as otimizações de grafo do ONNX Runtime num novo .onnx. |
quantize |
Quantização dinâmica int8. |
export-ort |
Converte para .ort + .required_operators.config. |
hardware |
O que a máquina roda e qual sampler de energia está disponível. |
analyze, bench e hardware aceitam --json, o que os torna
utilizáveis como passo de CI. Extra ausente sai com código 2 e a linha de
instalação, nunca com traceback. Detalhes em
Modelops.
Diagnóstico — tempest doctor¶
O check-config lê as settings e raciocina sobre elas; não abre socket
nenhum. Isso é a troca certa para um gate rápido e a errada para "por que o
serviço não sobe", cuja resposta quase sempre é uma dependência fora do ar,
inalcançável deste host, ou com credencial que ninguém atualizou.
tempest doctor
tempest doctor --json # pra script / healthcheck de deploy
tempest doctor --timeout 2 # aperta a espera de cada sonda de rede
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))
Cada linha é uma conexão de verdade, não uma dedução. Sai com código 1 quando qualquer checagem falha, então serve de smoke test de deploy.
skip não é ok, de propósito
Capacidade que o projeto não configurou aparece como skip. Linha verde
para um Redis que nunca foi montado é o tipo de tranquilidade que custa um
incidente.
O default do mixin conta como "não configurado": o EmailSettings nasce
com SMTP_HOST=localhost e o StorageSettings com
STORAGE_ENDPOINT=localhost:9000, então o serviço que compõe o mixin sem
usar a capacidade pareceria configurado. A comparação é contra
model_fields[campo].default — a mesma régua do check_secrets.
A última linha roda o registry de checagens estáticas (o mesmo do
check-config), então um comando responde as duas metades de "esse deploy
está sadio".
Rotas — tempest routes¶
Lista as URLs que a aplicação de fato serve.
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 -
Quatro coisas nessa tabela que uma leitura de código não te dá de graça:
- Router montado aparece. Desde o FastAPI 0.141.1, o
include_routerguarda uma entrada_IncludedRouterem vez de achatar as rotas na aplicação — então{r.path for r in app.routes}não contém nenhum path do router incluído. O comando lê a mesma expansão que o gerador de OpenAPI percorre. - Prefixo já resolvido. O path é o do router mais o de cada
include_routeracima dele. - Rota fora do schema também aparece, com
SCHEMA=no— página HTML e endpoint interno servem tráfego, e é justamente o que uma resposta tirada doopenapi.jsonesconde. GUARDSmostra a dependência herdada do include, não só a declarada na rota. É como se confere que oDepends(require_admin)doinclude_routeralcançou tudo que devia.
Filtros e formato:
tempest routes --match /api/orders # só paths com esse trecho
tempest routes --method post,delete # só esses métodos
tempest routes --all # inclui /docs, /redoc, /openapi.json
tempest routes --json # array JSON, pra script
tempest routes --app src.server:app # app fora dos lugares convencionais
Sem --app, o comando procura o app em src.server:app,
src.api.app:create_app, main:app (e os mesmos sob app/). Quando nada
resolve, ele lista o que tentou e por quê antes de sair com código 2.
--match que não casa com nada sai com código 1, pra typo em script falhar
em vez de parecer aplicação sem rota.
Erros documentados no OpenAPI — tempest openapi-errors¶
Compara, por rota, as AppException que o fluxo pode levantar com o que a rota
declarou em error_responses(...) / @raises(...):
tempest openapi-errors # relatório informativo (exit 0)
tempest openapi-errors --check # exit 1 quando há drift (gate de CI)
tempest openapi-errors --path src --path libs # diretórios explícitos, repetível
tempest openapi-errors --check --allow-unreachable # só falha em undocumented
tempest openapi-errors --fix --dry-run # diff das declarações faltantes
tempest openapi-errors --fix # grava (exige árvore git limpa)
Sem --path, varre ./src ou ./app — o que existir. A análise é estática
(ast, sem importar a aplicação) e percorre router -> controller -> service ->
repository, lendo os raise e as seções Raises: das docstrings.
src/api/routers/jobs.py:15 POST /{service_id}/candidates
undocumented: CandidateAlreadyExistsException, ServiceFullException
1 route(s) with drift, 2 undocumented exception(s).
--fix fecha o buraco escrevendo responses=error_responses(...) na rota (mais
os imports que faltarem), estendendo a declaração que já existir em vez de
substituí-la. Ele só acrescenta: findings unreachable nunca são removidos, já
que alcançabilidade não enxerga raise dinâmico.
Detalhes e limitações na receita Erros no OpenAPI ».
Guards de permissão — tempest permissions¶
Lê o contrato dos guards do @requires direto do código-fonte (ast, sem
importar a aplicação) e reporta o que o decorator não consegue ver em tempo de
import:
tempest permissions # relatório informativo (exit 0)
tempest permissions --check # exit 1 se houver erro (gate de CI)
tempest permissions --check --strict # falha também nos warnings
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).
Erros: guard que levanta fora da hierarquia AppException, guard-predicado
(-> bool), aridade errada, guard async em função sync, rota sem parâmetro de
usuário. Warnings: guard que nunca nega, anotação faltando, guard que o checker
não conseguiu resolver (lambda, nome duplicado, definição fora do escopo).
Detalhes na receita Guards de permissão (@requires) ».
Integrações — tempest integrations¶
tempest integrations list # o que o SDK ships + estado da credencial aqui
tempest integrations verify openpix # uma chamada autenticada de verdade
tempest integrations verify mercado-pago
tempest integrations verify zap --base-url http://localhost:3000
Num projeto recém-saído do tempest new, sem credencial nenhuma no .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)
O verify monta o cliente com HTTPClient(**settings.<provedor>_kwargs()) e
faz a leitura autenticada mais barata que cada API oferece: GET /company
na OpenPix e GET /users/me no Mercado Pago — nenhuma das duas move dinheiro
nem cria registro. Credencial errada sai com código 1 carregando a recusa do
provedor.
Esse endpoint é tabela mantida à mão — com guard
Escolher "a chamada mais barata" de cada provedor é decisão humana, e nome de endpoint gerado muda. Um teste assere que cada método nomeado ainda existe no cliente gerado e que não exige argumento, então renomeação upstream quebra um teste em vez do terminal de quem está de plantão.
Os dois provedores sem mixin de settings no SDK se comportam diferente:
-
zap precisa de
--base-url, porque o SDK não sabe onde a sua instância mora. Overifychamahealth, que não pede credencial, então ele mede alcance, não autorização. Sem--base-urlsai com código 2; instância fora do ar sai com código 1: -
stripe não tem
verify: a API não oferece leitura autenticada que não gaste dinheiro nem crie registro. O comando sai com código 2, com ou sem--base-url:
Agentes — tempest agents¶
tempest agents tools --agent src.agents:agent # que ferramentas ele tem
tempest agents run "resuma o relatório" --agent src.agents:agent --trace
tools não chama modelo nenhum — nenhum token é gasto, e funciona com
backend sem credencial configurada. É a resposta rápida para "essa
configuração de agente enxerga mesmo a ferramenta que acabei de registrar?".
O código de saída segue succeeded, não 'não levantou exceção'
Run cortado pelo orçamento (passos ou tempo) ainda devolve texto.
Tratar esse texto como resposta é o erro que o comando se recusa a
cometer por você: run sai com 1 e imprime o motivo da parada
(max_steps, max_seconds) no stderr, com a saída no stdout do
mesmo jeito.
Não existe layer agents scaffoldada, então --agent é praticamente sempre
necessário — o comando diz isso em vez de fingir uma convenção.
Cliente de integração — tempest openapi-client¶
Gera schemas Pydantic + um cliente HTTP tipado a partir da especificação OpenAPI de um sistema de terceiros:
tempest openapi-client https://api.terceiro.com/openapi.json --name terceiro
tempest openapi-client ./vendor/spec.yaml --name terceiro --force
tempest openapi-client <spec> -H "Authorization: Bearer $TOKEN" --schemas-only
Escreve <src|app>/integrations/<name>/ com schemas.py (uma classe por
componente, com title/description/examples da spec preenchidos) e
client.py (um método async por operação, sobre um HTTPClient injetado).
+ src/integrations/terceiro/__init__.py
+ src/integrations/terceiro/client.py
+ src/integrations/terceiro/schemas.py
4 schema(s), 12 operation(s).
Detalhes, cobertura de OpenAPI e limitações na receita Cliente de integração (OpenAPI) ».
Exportar o contrato — tempest openapi-export¶
O openapi-client consome a spec de outra pessoa; este escreve a sua.
# Documento no stdout
tempest openapi-export
# Arquivo (JSON por padrão)
tempest openapi-export --out contracts/openapi.json
# YAML (precisa do extra [openapi])
tempest openapi-export --out contracts/openapi.yaml --yaml
Duas coisas saem disso. A primeira é alimentar um gerador — o próprio
tempest openapi-client, um gerador de cliente TypeScript, um mock server —
sem subir o serviço. A segunda é commitar o documento e transformar
mudança de contrato em algo que o revisor vê:
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.
Código 1 quando difere, 0 quando está em dia. Operação que entrou e que saiu
vêm primeiro porque são as que quebram consumidor; mudança dentro de uma
operação vira ~ <path> changed. "A spec mudou" não é mensagem revisável —
por isso o comando nomeia o que se moveu.
No CI
Rode --check no mesmo job que roda os testes. O dia em que alguém
muda um status code, um campo obrigatório ou um path, o PR fica
vermelho com a linha exata, em vez de o cliente descobrir em produção.
Descrição de PR com IA — tempest pr-prompt¶
A branch ficou verde e falta a parte que todo mundo pula: escrever a descrição do Pull Request. Qualquer assistente escreve uma boa — o que falta pra ele são as duas coisas que moram no repositório: o template que o time combinou e o diff que a branch realmente produziu.
tempest pr-prompt monta os dois num prompt só e joga em stdout, então ele
pipa direto pra IA que você usa:
tempest pr-prompt # compara com main, prompt no stdout
tempest pr-prompt develop # outra base
tempest pr-prompt | claude -p # pipa direto pro assistente
tempest pr-prompt --out pr_prompt.txt # grava num arquivo pra colar
O prompt carrega três blocos:
- O template do PR. O do próprio repositório vence — é o contrato que os
revisores daquele projeto leem. São procurados, nessa ordem:
.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.mdepull_request_template.md. Sem nenhum deles, entra o template embutido do SDK. - As regras que impedem a IA de devolver o template com os placeholders
dentro: nada de
Sim/Nãosem escolher, nada de_texto em itálico_de instrução, nenhuma seção apagada, e nada de inventar migration, env var ou dependência que não aparece no diff. - O contexto da branch: assuntos dos commits, a lista
--name-statusdos arquivos alterados e um trecho do patch de cada arquivo.
O diff é o mesmo que a forge mostra
Os patches são lidos como base...head (três pontos) — o diff em relação
ao merge-base, que é o que o GitHub/GitLab exibe no PR. Com dois pontos,
todo commit que entrou na base depois que sua branch nasceu seria
atribuído a você.
O que é completo e o que é amostrado
A lista de commits e a lista de arquivos alterados vão inteiras, sempre — a IA sempre sabe o que mudou. O que é limitado são os trechos de diff: por padrão os 10 arquivos com mais linhas alteradas, cada um cortado em 1500 caracteres. Ou seja, o que é amostrado é como mudou.
Os trechos são limitados porque o diff inteiro de uma branch grande não cabe
no contexto e é quase todo ruído (lock file, changelog, migration gerada). Os
arquivos entram ordenados por linhas alteradas, não em ordem alfabética —
senão o orçamento vai embora em .github/ e CHANGELOG.md antes de chegar no
arquivo sobre o qual o PR é:
tempest pr-prompt --full # todos os arquivos, patch inteiro
tempest pr-prompt --max-files 20 # 20 arquivos com patch (default: 10)
tempest pr-prompt --max-files 0 # só a lista de arquivos, sem patch
tempest pr-prompt --max-chars 4000 # mais patch por arquivo (default: 1500)
--full levanta os dois limites de uma vez — use numa branch pequena o
bastante pra mandar inteira. Ele recusa vir junto com --max-files /
--max-chars (saída 2): sobrescrever em silêncio um número que você digitou
seria pior que reclamar.
Nada é cortado em silêncio
Um patch truncado leva a marca trecho cortado e os arquivos que ficaram
sem trecho viram uma linha explícita no prompt (Mais N arquivo(s)
alterado(s)…). A IA lê um contexto parcial como parcial, em vez de tratar
o pedaço como a mudança inteira. O corte também respeita a quebra de linha,
pra não sobrar meia linha de diff — que a IA leria como código que não
existe.
O resumo (template escolhido, contagem de commits/arquivos) sai em stderr,
então o pipe continua limpo:
template: .github/pull_request_template.md
7 commit(s), 12 changed file(s), 10 excerpt(s), 2 file(s) without a patch.
Outras opções:
tempest pr-prompt --head feat/outra # descreve outra branch, sem checkout
tempest pr-prompt --lang en # regras e template embutido em inglês
tempest pr-prompt -t docs/meu_template.md # template explícito, vence todos
tempest pr-prompt -p ../outro-repo # roda contra outro repositório
Base inexistente vira origin/<base>
Num clone recém-feito costuma não existir uma main local, só
origin/main. Quando a base não resolve, o comando tenta
origin/<base> antes de falhar. Se nem isso existir, ele sai com código
2 e diz qual ref não encontrou. Quando a comparação não tem nem commit
nem arquivo, a saída é código 1 — quase sempre a base está errada.
Gates de qualidade¶
Esses comandos vêm do tempest-cli (v0.226.0)
O gate de qualidade não tem nada de FastAPI — é ruff, mypy e
pytest. Desde a v0.226.0 ele vive num pacote próprio,
tempest-cli, que o extra
[cli] do SDK declara e que o SDK monta na CLI dele.
Nada muda para você: tempest check é o mesmo comando, com as
mesmas flags e o mesmo [tool.tempest] typing_strictness. O que
muda é que quem não usa FastAPI agora consegue instalar só o
gate:
Por que separar: para chegar nesses quatro comandos era preciso instalar FastAPI, SQLAlchemy, Alembic e Pydantic — 38,7 MB de dependências e cerca de 0,5 s de import por invocação, medidos, para comandos que não tocam em nada disso.
Os dois nunca divergem porque há uma implementação só: o SDK chama
register_commands(app) do tempest_cli. Importar
tempest_fastapi_sdk.cli.lint ou .pr_prompt continua funcionando
e devolve as mesmas funções.
Os comandos de lint chamam a ferramenta do projeto, procurando o executável nesta ordem:
- o
$VIRTUAL_ENVativado; - o
.venvmais próximo, subindo a partir do diretório atual — o ambiente do projeto, mesmo sem ativação; - o diretório do interpretador que roda o próprio
tempest; - o
PATH, pulando shim de gerenciador de versão (pyenv/asdf) que não despacha para lugar nenhum; uv run --with <tool> <tool>, quando ouvestá noPATH.
O projeto vem antes do ambiente da CLI de propósito: com uv tool install, a CLI tem o próprio ruff, e um projeto que fixa outra versão continua rodando a que fixou.
tempest lint # ruff check .
tempest fix # ruff check --fix-only . + ruff format . + ruff check . (escreve)
tempest fix --unsafe # também aplica os --unsafe-fixes do ruff
tempest format # ruff format . (escreve)
tempest fmt-check # ruff format --check . (somente leitura)
tempest type # mypy .
tempest test # pytest
tempest test tests/api/ # pytest com filtro de caminho
tempest test --fast # pytest -n auto -p no:cacheprovider (pytest-xdist)
tempest test --fast -w 4 # quatro workers
tempest check # lint + fmt-check + type + lint-layers + test, para no primeiro erro
tempest check --fast # o mesmo gate, com o passo de teste em paralelo
--fast (a partir do tempest-cli 0.4.0) roda a suíte em paralelo com o
pytest-xdist do extra [tests]; sem ele instalado no ambiente do pytest, a
saída é 127 com a instrução de instalação. Quando usar, e o que fazer com
teste que só falha em paralelo, está na
receita de testes.
tempest fix é a passada única de "organize o projeto" — ordena e remove imports duplicados, descarta imports não usados, normaliza aspas de strings, remove espaços em branco no fim das linhas e então roda ruff format para alinhar indentação, comprimento de linha, linhas em branco e a quebra de linha final. Rode-o antes do push quando o CI fica pegando detalhes de estilo.
O relatório sai do arquivo já formatado (tempest-cli 0.5.1)
O tempest fix roda três passadas: ruff check --fix-only,
ruff format e, por fim, ruff check, que é de onde saem o relatório
e o exit code. Uma violação que o formatter resolveu não aparece mais
nem derruba o comando. O que sobra depois da formatação continua saindo
com código ≠ 0, então o CI segue falhando nas pendências reais.
Comentários e strings longos são encurtados, com prova de AST (tempest-cli 0.5.1)
O ruff format não quebra comentário nem string literal — igual ao
Black. Depois dele, o tempest fix quebra cada comentário E501 no
limite (um comentário no fim da linha sobe para cima dela) e divide cada
string literal simples nos espaços, em partes concatenadas dentro de
parênteses. Cada edição só fica quando a AST do arquivo é idêntica antes
e depois, então o programa não muda. Com line-length = 88, isto:
"""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
vira isto:
"""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
Pragma de ferramenta (noqa, type: ignore, fmt:, …), f-string,
string com aspas triplas e palavra maior que uma linha ficam como estão.
Para essas linhas — e para o que o formatter não tem onde quebrar, como
um nome pontilhado — o tempest fix nomeia o token que estoura, o tipo
e a ação que resolve, e separa as pendências entre as que não têm
autofix e as que o tempest fix --unsafe corrigiria. Uma f-string
longa, por exemplo, sai assim (saída 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).
Todo comando retorna o exit code da ferramenta subjacente, então tempest check é seguro para conectar ao CI (tempest check || exit 1) ou a hooks de pre-commit. Quando nenhum dos cinco passos acha a ferramenta, o wrapper sai com 127 em vez de falhar silenciosamente, dizendo o que instalar:
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.
Regras de camada — tempest lint-layers¶
O CLAUDE.md que o tempest new escreve diz como um service e um
repository têm forma. Prosa não é gate: um serviço com dez services sem
BaseService, repositories embrulhando o list do SDK e funções soltas
num services/feeds.py passou ruff, mypy e pytest limpos (#520).
O tempest lint-layers é o gate dessas regras (e da convenção de nome de enum) — e o tempest check o roda
como passo próprio, depois do mypy e antes do pytest:
tempest lint-layers # o projeto do diretório atual (lê src/ ou app/)
tempest lint-layers src # o diretório de código direto
Ele lê o código com ast e nunca importa o projeto, então roda mesmo
com import quebrado. Cada violação é uma linha arquivo:linha: CÓDIGO
mensagem, e a mensagem diz o que mudar:
| Código | Onde | O que acusa |
|---|---|---|
SVC001 |
services/ |
classe pública que não herda BaseService (direto, via outro service do projeto ou via um *Service importado do tempest_fastapi_sdk) |
SVC002 |
services/ |
segunda classe pública no módulo, ou qualquer função de módulo — helper vai para utils/ |
SVC003 |
services/ |
__init__ que só chama super().__init__ com os próprios parâmetros e/ou guarda self.repository |
REPO001 |
repositories/ |
método cujo corpo é só return [await] self.<método do BaseRepository>(...) |
ENUM001 |
todo o código | classe pública que herda BaseStrEnum (direto ou via outra classe do projeto) e cujo nome não termina em Enum — a mensagem diz o nome esperado; renomear não pede migration, porque o enum_type_name tira um Enum final (#518) |
RTR001 |
routers/ |
chamada a uma classe *Controller ou *Service (nome com inicial maiúscula) em qualquer ponto do módulo — corpo de rota, função auxiliar, nível de módulo. A construção vai para um provider em api/dependencies/controllers.py e chega à rota por Depends |
RTR002 |
routers/ |
rota (@<router>.get / post / put / patch / delete / head / options / trace / api_route) cuja anotação de retorno ou response_model= cita um model ORM, em qualquer profundidade (list[ProductModel], Optional[ProductModel], união com None) |
Os três casos do #520, num projeto gerado pelo tempest new, saem assim
no tempest check (trecho da saída real; 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).
O projeto como o tempest new gera passa limpo, e continua limpo com os
exemplos de ProductService / ProductRepository / router de produto do
CLAUDE.md copiados para dentro dele — o teste da suíte faz exatamente
isso.
Service sem repository: opt-out explícito
Um service que não tem repository para entregar ao BaseService (um
catálogo estático lido de arquivo, uma fachada sobre API externa)
marca a linha do class com # tempest: no-base-service. O marcador
tira a classe do SVC001 e do SVC003; o SVC002 continua valendo.
# src/services/catalogue.py
import json
from pathlib import Path
class CatalogueService: # tempest: no-base-service
"""Catálogo estático lido de um JSON — não há repository."""
def __init__(self, path: Path) -> None:
self.items: list[dict[str, str]] = json.loads(path.read_text())
É um comentário, não um atributo de classe, porque é uma instrução para
o checker e nada em runtime lê — o mesmo motivo de # noqa ser
comentário.
O que o REPO001 deixa passar de propósito
Um método que sobrescreve um nome do BaseRepository (delete
encaminhando para soft_delete) é política, não wrapper: quem chama
service.delete passa a apagar logicamente. E um método que chama
outro método definido na própria classe não é wrapper do SDK.
Router: controller por Depends, resposta em schema¶
As duas regras de routers/ cobrem os desvios que mais aparecem num
router: montar o controller na mão dentro da rota e devolver a linha ORM.
Este router, escrito num projeto do tempest new, faz os dois:
# 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)
O tempest lint-layers sai com exit code 1 e três linhas (saída real):
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).
A correção é a forma que o CLAUDE.md do projeto ensina: o provider
get_product_controller em src/api/dependencies/controllers.py monta a
cadeia, a rota recebe controller: ProductController =
Depends(get_product_controller) e anota o retorno com
ProductResponseSchema.
O que conta como model ORM. Um nome importado de um módulo cujo
caminho tem o segmento models (src.db.models, src.db.models.user),
ou uma classe pública definida num diretório models/ do projeto — desde
que a rota não a importe de um módulo schemas. Um schema que por acaso
tem o mesmo nome de um model continua limpo.
Construção que precisa ficar no router: # tempest: router-builds
O marcador é a exceção que você assume por escrito, para a review ler o motivo. Ele vale para as chamadas daquela linha, não para o módulo inteiro:
# 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")
O RTR002 não tem opt-out: a correção tem uma forma só — devolver o
schema que o map_to_response do repository produz.
O que as regras de router não veem
Elas leem sintaxe e nunca importam o projeto, então ficam de fora:
rota sem anotação de retorno e sem response_model=; model guardado
fora de qualquer models/ e importado de módulo sem esse segmento; as
bases ORM do próprio SDK (BaseUserModel) usadas direto na rota;
controller montado por alias (factory = ProductController; factory())
ou classe cujo nome não termina em Controller / Service.
Onde o @requires fica não é regra. A receita de
guards de permissão aplica o @requires na
rota como primeira forma, e o próprio requires documenta handler de
rota, método de controller e de service como lugares válidos. Acusar o
@requires na rota acusaria a documentação do SDK; a escolha entre os
dois lugares é revisão — o critério está em
Arquitetura › Guards.
Requer tempest-cli>=0.5.0
O passo entra no tempest check pelo extra_steps do
register_commands, que chegou no tempest-cli 0.5.0; o extra [cli]
declara tempest-cli>=0.5.1, então ele já vem. O tempest-cli check sozinho, sem o SDK, não
conhece estas regras: elas são do SDK.
Recap¶
O tempest cobre o ciclo inteiro — do scaffold (new),
passando pela infra (generate --docker / --dockerfile / --src),
migrações (db), usuários (user) e segredos (secrets), até os gates
de qualidade (lint / fix / type / test / check), com as regras de camada do SDK (lint-layers) dentro do check.
Próximos passos¶
Depois de gerar o serviço, siga para as receitas relacionadas:
- Banco de dados » —
BaseRepository, sessões async e o fluxo de migrações por trás dotempest db. - Filas e tarefas » — as camadas que
tempest generate --srcgera para os extras[queue](FastStream) e[tasks](TaskIQ). - Deploy seguro » — migrações destrutivas e shutdown
gracioso ao containerizar com o
Dockerfilegerado.