Changelog¶
All notable changes to tempest-fastapi-sdk are listed below.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.258.0] — 2026-08-27¶
Added¶
EmailUtils.send_many(),BulkEmailReporteFailedRecipient. Avisar a base inteira comsend()num laço custa uma conexão SMTP por mensagem, e o primeiro endereço ruim aborta o resto.send_manyabre uma conexão por lote e reporta o que não entregou em vez de levantar:
from tempest_fastapi_sdk import BulkEmailReport, EmailUtils
mailer = EmailUtils(**settings.email_kwargs())
report: BulkEmailReport = await mailer.send_many(
destinatarios,
subject="Manutenção programada",
body="Vamos ficar fora das 02h às 03h.",
)
report.delivered # aceitos pelo servidor
report.permanent # 5xx — a caixa não existe; pode podar da base
report.transient # 4xx — cheia ou greylisted; reenfileire
Quatro coisas que o laço não faz, e por que a resposta não é gather sobre
send():
- Uma conexão por lote, não por mensagem.
send()conecta, autentica e dáQUITtoda vez; o número de conexões cai delen(recipients)paralen(recipients) / batch_size(default 500). - Fan-out com teto.
gathersobre a lista inteira abre uma conexão por destinatário, e todo provedor de SMTP hospedado limita quantas um remetente segura ao mesmo tempo — passando do teto, as excedentes são estranguladas ou derrubadas, e o teto é do provedor, não nosso.max_concurrency(default 32) limita as conexões abertas — os dois defaults são os mesmos do broadcast de Web Push (_DEFAULT_BROADCAST_PAGE_SIZE,_DEFAULT_BROADCAST_CONCURRENCY), pelo mesmo motivo. - Falha parcial vira relatório. Recusa de destinatário nunca levanta. Só falha da operação levanta — host que não resolve, conexão recusada, autenticação negada.
5xxe4xxchegam separados. Colapsar os dois numa lista "falhou" obriga o chamador a reparsear o código SMTP que ele já tinha. Recusa sem código entra como transitória: o erro barato é tentar de novo, não apagar endereço bom.
Cada destinatário recebe a própria mensagem — ninguém vê o endereço de ninguém. Concorrência é por conexão, não por mensagem: SMTP é serial num socket só, então o lote é enviado sequencialmente pela conexão dele e o paralelismo vem de rodar vários lotes ao mesmo tempo.
DatabaseBackup(docker_container=...)roda o tooling do Postgres dentro do container do banco. Sem isso, a imagem da aplicação precisa carregarpostgresql-clientnuma versão compatível com o servidor só para o job noturno. A imagem do banco já tem opg_dumpexato da versão dela:
backup = DatabaseBackup(settings.DATABASE_URL, docker_container="app-db")
written = backup.backup(Path("backups/app.dump"))
backup.restore(written)
O dump é produzido dentro do container e atravessa pelo stdout para o arquivo
local; o restore faz o inverso, com o arquivo entrando pelo stdin do
pg_restore/psql — nada é copiado para dentro, então não sobra arquivo
temporário nem janela em que ele está pela metade. Três detalhes que o teste
fixa:
-h/-psão descartados. Host e porta da URL descrevem como a aplicação alcança o banco de fora; dentro do container esse caminho não existe. Usuário e database continuam vindo da URL.- A senha atravessa por nome. O comando carrega
-e PGPASSWORDsem valor, e o Docker copia do ambiente do processo chamador — escrever-e PGPASSWORD=…colocaria a senha na linha de comando do container, que qualquerpsno host lê. - Dump que falhou não fica no disco. O arquivo de destino é removido quando o processo sai não-zero, para um dump truncado não passar por um backup bom.
BackupToolMissingError passa a checar o docker no lugar do pg_dump
neste modo. SQLite ignora o parâmetro — é cópia de arquivo dos dois jeitos.
A propriedade que importa (o dump gerado lá dentro volta e restaura lá
dentro) é testada atravessando de verdade: tests/db/test_backup.py sobe um
postgres:16-alpine sob a marca docker, opt-in por make test-docker.
- Os métodos de
LogUtilsaceitam posicionais%-style estacklevel. Adotar o SDK num serviço que já loga em%-style não exige reescrever call site, e a interpolação continua lazy — o template fica a mesma string entre chamadas, que é no que o agregador de log agrupa:
log.info("Email enviado com sucesso para %s", "ana@example.com")
log.error("Falha ao enviar para %s: %s", email, motivo, op="send")
Os dois estilos convivem: os posicionais montam a mensagem, **fields
continua virando chave de topo no JSON. stacklevel tem default 2, então
funcName/lineno do record apontam para o seu call site em vez de para
dentro da fachada; quem embrulha o LogUtils numa camada própria passa
stacklevel=3.
-
MetricsUtils.disk_async(), estrict=emdisks/disks_async.disks_async([path])era a única forma async de ler um caminho só, e ela loga e pula o erro — porque a lista é plural e um caminho ruim não deve derrubar os outros quatro. Para um caminho só isso vira ausência silenciosa: o endpoint responde200com o bloco de disco simplesmente ausente, e o dashboard não distingue mount que sumiu de disco que ninguém pediu.disk_asyncpropaga, como odisksíncrono.strict=Trueleva o mesmo comportamento para a variante plural. -
token_type_allowed(strict=..., legacy_claims=...). O default — token semtypé aceito — é certo para token que o SDK mintou e furado para token que o consumidor mintou. Um serviço que já separava access de refresh com um claim próprio (type,token_type) não temtypem nenhum token legado nem os marcadores do SDK, então todos caem no "aceita" e um refresh token autoriza chamada de API pela vida inteira do refresh:
legado = {"sub": "u1", "type": "refresh"}
token_type_allowed(legado, [ACCESS_TOKEN_TYPE])
# True — o SDK não conhece o claim `type`
token_type_allowed(
legado,
[ACCESS_TOKEN_TYPE],
strict=True,
legacy_claims=("type",),
)
# False
legacy_claims é lido em ordem e só quando typ está ausente; strict=True
recusa o que continuar sem classificação e não desliga os marcadores
antigos do SDK — refresh: True continua sendo refresh.
Changed¶
- Os arquivos de log passam a rotacionar por padrão.
configure_loggingganhamax_bytes(default10_000_000) ebackup_count(default5), e os handlers por nível viramRotatingFileHandler.FileHandlerpuro cresce sem teto: num serviço com uma linha por request, rodando em host de longa duração,info.logé o que enche o disco — e disco cheio derruba o serviço e o que mais dividir a partição. O lado leitor deste par já tinha o teto (DEFAULT_MAX_RECORDS_PER_FILE = 20_000no router de/logs, adicionado depois que um serviço com diretório de log em gigabytes respondeu com worker morto); este é o lado de quem escreve.
max_bytes=0 volta ao FileHandler puro, para host onde logrotate ou um
sidecar é o dono da retenção. Detalhe em docs/migration.md, seção 0.258.0.
Fixed¶
- Bound numérico em schema
type: stringvira bound de tamanho no codegen — e os dois camposcommentda OpenPix voltam a se construir. Nada legítimo produzmaximumnumtype: string(string não tem magnitude), e spec no mundo real escreve assim mesmo:ChargeRefundPayload.commenteRefundPayload.commentcarregammaximum: 140sob uma descrição que diz "Maximum length of 140 characters". Passado ao pé da letra, o gerador emitiaField(le=140)numstr— e pydantic não rejeita o valor, ele levanta na construção:
ChargeRefundPayload(comment="obrigado")
# TypeError: Unable to apply constraint 'le' to supplied value obrigado
Ou seja: todo refund com comentário falhava antes de sair do processo.
maximum / minimum num schema de string agora são relidos como
max_length / min_length; bound exclusivo não tem equivalente de tamanho
que valha adivinhar e é descartado. Schema numérico não muda.
[0.257.0] — 2026-08-27¶
Changed¶
- BREAKING (pequeno) —
ScriptedBackendeFailingBackendrenomeiam o parâmetrospecsparatools, e as quatro assinaturas ganham**kwargs. Os dois fakes existem para fingirChatBackend/ToolCallingBackend— e não satisfaziam nenhum dos dois sob mypy: o protocolo chama o parâmetrotoolse aceita**kwargs, e um membro de protocolo só é implementado por assinatura com o mesmo nome de parâmetro.
from tempest_fastapi_sdk.agents import Agent
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies
agent = Agent(ScriptedBackend([replies("ok")]))
# antes: Argument 1 to "Agent" has incompatible type "ScriptedBackend";
# expected "ChatBackend | ToolCallingBackend" [arg-type]
Era a primeira linha de sete blocos da receita
recipes/agents-testing.md: quem seguia a receita num serviço com mypy
ligado colhia o erro no próprio teste.
Migração: a chamada posicional — a que o Agent faz — não muda. Quem
chamava backend.chat_with_tools(messages, specs=[...]) por keyword troca
para tools=. O atributo specs_seen continua com o nome de sempre. Passo a
passo no guia de migração: docs/migration.md, seção 0.257.0.
Fixed¶
on_disconnectaceita callback que devolve alguma coisa. A anotação eraCallable[[], Awaitable[None] | None] | None, mas o corpo fazresult = on_disconnect()e só aguarda quando o retorno é awaitable — qualquer outro valor é descartado. Então o exemplo canônico da receita de SSE não passava no type-checker do leitor:
task = asyncio.create_task(producer())
return stream.response(on_disconnect=task.cancel) # Task.cancel devolve bool
Passa a ser Callable[[], object] | None em EventStream.response,
sse_response e no _guard_stream que os dois usam. Nada muda em runtime —
medido com um callback síncrono que devolve bool contra um endpoint de
verdade: 200, dois frames entregues, callback executado, retorno
descartado. A suíte não fixa essa forma (o teste existente usa
async def cleanup() -> None); quem a exercita hoje é a receita de SSE,
pelo guard de tipo.
- Os stores de Redis aceitam o
redis.asyncio.Redisque a receita manda passar._RedisLike(idempotência e cache de resposta) eRedisLike(WebAuthn) declaravam os membros comoasync def get(self, key: str) -> .... Duas consequências: um membroasync defexige retornoCoroutine, e um parâmetro nomeado exige o mesmo nome na implementação. O redis-py chama o parâmetro denamee devolveAwaitable, então a linha da receita era erro de tipo:
store = RedisIdempotencyStore(Redis.from_url(settings.REDIS_URL))
# antes: Argument 1 to "RedisIdempotencyStore" has incompatible type
# "Redis"; expected "_RedisLike" [arg-type]
Os três protocolos passam a declarar parâmetro posicional e retorno
Awaitable[str | bytes | None] — a forma que o RedisLike do rate limiter
já usava, e que é por isso que aquele nunca teve o problema. Medido nos seis
stores de Redis exportados: três recusavam o cliente real (idempotência,
cache de resposta, WebAuthn) e três aceitavam (rate limit, quota, sessão);
agora nenhum recusa.
Awaitable[Any] também faria o cliente passar, e foi por onde esta correção
passou antes de ser medida — mas apaga o tipo do valor lido: com ele,
raw = await client.get(key) vira Any dentro do próprio SDK, e o
json.loads(raw) logo abaixo deixa de ser checado. Aceitar o cliente e
manter o tipo é a mesma linha; só a forma estreita faz as duas coisas.
require_authenticatedaceita qualquer sujeito, não sóBaseUserModel. A função não lê atributo nenhum — só rejeitaNone— mas oTypeVarestava presa ao modelo de usuário, e a receita do Firebase guarda umFirebaseIdentitycom ela. mypy resolvia oTypeVarparaNonee recusava o argumento. Agora usa umSubjectTsem bound;require_activeerequire_adminmantêm o bound, porque leemis_active/is_admin.
Note¶
- Guard novo:
tests/test_docs_type_guard.py. Os quatro defeitos acima têm a mesma origem — nenhum guard rodava um type-checker sobre os exemplos da doc, e os dois guards de exemplo diziam isso no próprio docstring ("o que ele não pega, de propósito: tipo de argumento"). O guard novo escreve cada bloco parseável do site como um módulo e roda mypy com a config deste repo, lendo quatro códigos de erro:arg-type,call-arg,name-definedeused-before-def.
Ao rodar pela primeira vez — 1999 blocos, 226 arquivos, as duas línguas
mais o README.md: 162 achados. Além dos
quatro defeitos de tipagem do SDK, 89 eram NameError na colagem — uma
passada anterior de "deixar todo exemplo completo" tinha acrescentado o
placeholder depois da linha que o usa (repository = BaseRepository(session, ...)
com session = None três linhas abaixo). O resto era exemplo que o leitor
não conseguia rodar: PutObjectItem(data="thumbs/a.png") subindo o nome do
arquivo como conteúdo, body={...} num cliente que pede o schema gerado,
emb._embed_many (privado, e com outra assinatura) passado ao
BatchScheduler.
A varredura também limpou o que o guard não lê — email = "ana@example.com"
sendo usado como await email.send(...) em duas receitas, order.id sobre
um dict, provider.advance(...) chamado sobre a anotação do protocolo em
vez do fake. Sobraram 25 attr-defined, todos das duas formas legítimas:
excerto de classe sem __init__, e o op.replace_enum que o Alembic
registra em runtime.
O achado mais caro não quebrava nada: a paginação por cursor do README.md
comparava (created_at, id) > (valor, id) como tupla do Python, que
compara só o primeiro elemento e devolve a expressão dele — SQL válido,
desempate silenciosamente perdido, linhas repetidas ou puladas entre páginas.
Agora usa tuple_(), e a página descendente usa < em vez de ~(... > ...),
que incluía a própria linha do cursor.
Custo: ~40s com cache de mypy frio, menos de 1s quente.
[0.256.0] — 2026-08-27¶
Added¶
- Celular BR:
is_valid_mobile_phone_br,normalize_mobile_phone_br,MobilePhoneBRFieldeparse_phone_br.is_valid_phone_brresponde a uma pergunta de formato — isto tem cara de telefone brasileiro? — e aceita fixo. Quando o número é o canal de entrega (WhatsApp, SMS, um código de verificação), aceitar fixo não é validação frouxa: é falha silenciosa. O número entra no cadastro, passa por toda a validação, e só falha lá na frente, quando a notificação não é entregue — sem erro para o usuário e sem log óbvio para quem opera.
from tempest_fastapi_sdk.utils import is_valid_mobile_phone_br, is_valid_phone_br
is_valid_phone_br("(11) 3333-4444") # True — é um telefone
is_valid_mobile_phone_br("(11) 3333-4444") # False — mas não é um celular
Em schema é a troca de um tipo de campo por outro:
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import MobilePhoneBRField
class NotificationTargetSchema(BaseSchema):
phone: MobilePhoneBRField # fixo devolve 422
parse_phone_br entrega o número já quebrado, para quem precisa formatar ou
gravar E.164:
from tempest_fastapi_sdk.utils import parse_phone_br
parsed = parse_phone_br("+55 (11) 98888-7777")
parsed.area_code, parsed.number, parsed.is_mobile, parsed.e164
# ("11", "988887777", True, "+5511988887777")
Três detalhes que o teste fixa, porque nenhum deles é óbvio:
- As duas normalizações divergem de propósito.
normalize_phone_brpreserva o que foi digitado, então a mesma linha vira"5511988887777"ou"11988887777"conforme a grafia, e a coluna guarda duas strings para um número só.normalize_mobile_phone_brsempre devolve os 11 dígitos da forma nacional. O+55continua disponível emPhoneNumberBR.e164. parse_phone_bré mais estrito queis_valid_phone_br. Aplica os prefixos da ANATEL — assinante de 8 dígitos começa em2-5— então"8912345678"passa pelo validador antigo e voltaNoneaqui.- DDD 55 não é o código do país. Santa Maria (RS) colide com o
+55; o prefixo só é descartado quando o total de dígitos (12 ou 13) prova que ele está lá.
O nome PhoneBR já era um alias deprecado de PhoneBRField desde a v0.76,
então o resultado do parse chama-se PhoneNumberBR.
Fecha #208.
AsyncRedisManager.client_proxy: o handle que pode ser construído antes doconnect(). Os dois ciclos de vida não se encaixam. O FastAPI exige middleware registrado no import do módulo —add_middlewaredepois do startup levanta — mas oconnect()só roda no lifespan. Resultado:RedisRateLimitStore(redis_manager.client)levantavaRuntimeErrorno único momento em que o store precisa existir, e não havia ordem em que os dois coubessem.
app.add_middleware(
RateLimitMiddleware,
store=RedisRateLimitStore(cache.client_proxy), # válido desde já
)
O guard não estava protegendo nada: connect() não faz I/O — from_url()
só monta o ConnectionPool, e o redis-py conecta preguiçosamente no
primeiro comando. Medido contra um host não-roteável, from_url() retorna em
0,0001 s; quem falha é o primeiro comando — 1,00 s com
socket_connect_timeout=1, isto é, o timeout configurado, não uma
propriedade do from_url().
Adiantar a construção também não bastava: disconnect() descarta o client e
o connect() seguinte cria um objeto novo, então um store que guardou a
referência antiga fica com client morto. Por isso o proxy resolve o client a
cada acesso de atributo, em vez de capturá-lo uma vez —
test_survives_a_reconnect fixa isso.
Vale para os sete stores Redis do SDK, que recebem client pronto e não
factory. client continua como está, para quem lê dentro de um request; os
docstrings de RedisSessionStore e RedisFeatureFlagBackend, que sugeriam
AsyncRedisManager.client como valor — justamente a chamada que levanta —
agora apontam o client_proxy para construção antecipada.
O tipo declarado é Redis para o proxy encaixar em todo parâmetro de store
sem cast do lado do consumidor. Ele é um handle que encaminha, não um
Redis: isinstance é False e protocolo dunder resolvido no tipo não é
encaminhado.
Fecha #210.
Changed¶
- BREAKING — o 429 do
RateLimitMiddlewarepassa a ser JSON, no envelope de erro do SDK. O SDK define um envelope canônico e o aplica em todo handler registrado porregister_exception_handlers— menos no próprio rate limiter, que respondiatext/plaincom oerror_messagecru. Quem adotava os dois ficava com duas formas de erro na mesma API, e o cliente precisava de um caso especial checandostatus === 429para lertext()em vez dejson().
{"detail": "Too many requests",
"code": "TOO_MANY_REQUESTS",
"details": {"retry_after_seconds": 60, "limit": 15}}
Não era só inconsistência de estilo: error_responses() sempre apontou o 429
para o ErrorResponseSchema, então o corpo real contradizia o schema que a
rota publica e cliente gerado quebrava ao desserializar.
Novo parâmetro error_code, default TooManyRequestsException.code — lido da
própria exceção, para os dois não divergirem. details carrega o que antes só
existia em header: retry_after_seconds e o limit da regra que barrou.
Migração: cliente que ramifica por status === 429 não muda; cliente que
lê o corpo como texto passa a ler JSON e usar detail. Serviço que
reescrevia a resposta com uma subclasse pode apagar o contorno. Passo a passo
no guia de migração: docs/migration.md, seção 0.256.0.
O middleware monta a resposta em vez de levantar a exceção porque
BaseHTTPMiddleware adicionado por add_middleware fica fora do
ExceptionMiddleware do Starlette: exceção levantada no dispatch não
encontra handler e vira 500.
Conferido nos vizinhos que a issue mandou olhar: BodySizeLimitMiddleware já
emitia o envelope, e IdempotencyMiddleware / ResponseCacheMiddleware nunca
emitem corpo de erro próprio — só replayam resposta cacheada. O
rate_limit.py era o único site fora do envelope.
Fecha #211.
[0.255.0] — 2026-08-25¶
Added¶
WebPushSubscriptionService.notify_all(): o aviso global, com a mesma poda do envio por usuário. O segundo caso de uso mais comum do Web Push — manutenção, release, campanha — era o único que obrigava o consumidor a descer para o dispatcher: listar as linhas por fora, montar os schemas na mão, chamarsend_manye chamarprunecom o retorno, reimplementando onotify_userinteiro só para trocar a fonte da lista.
entregues = await service.notify_all(
WebPushPayloadSchema(title="Manutenção programada", body="02h às 03h."),
)
Duas diferenças em relação ao notify_user, as duas sobre tamanho.
Anda em lotes (page_size=500), em vez de carregar a tabela inteira e
abrir N mil corrotinas de uma vez. E limita o paralelismo
(max_concurrency=32), porque cada dispatch é uma requisição TLS a um push
service e milhares ao mesmo tempo rendem rate limit, não envio mais rápido.
O passeio é por cursor, não por offset, porque o método apaga enquanto
anda. Medido numa tabela de 8 inscrições, apagando 4 no caminho, em páginas
de 2: o passeio por offset visitou 6 linhas e nunca chegou em duas — uma
delas viva. O cursor compara (created_at, id) em vez de contar posições.
Linha criada durante o broadcast não é visitada, e é isso que faz o passeio
terminar.
-
WebPushSubscriptionService.list_all(). Todas as inscrições, de todos os usuários, para quem precisa das linhas em si — export, contagem por host, migração. Para entregar, prefiranotify_all, que não segura a tabela em memória. -
WebPushDispatcher.send_many(max_concurrency=...). Semáforo opcional sobre o fan-out.None(default) mantém o comportamento de sempre — todo dispatch começa de imediato, que é o certo para os poucos aparelhos de uma pessoa. -
SSEBroker.broadcast(): um evento para todos os canais.publishresolve um canal para N conexões; faltava o eixo ortogonal — um evento para N canais, que é o "aviso global", "manutenção em 5 minutos", "saiu versão nova". Sem ele, o serviço mantinha por fora um registro paralelo dos canais abertos em cada worker, só para conseguir iterar e chamarpublishem cada um: metade do broker reimplementada no consumidor, e ainda assim só do worker local.
Em processo único, varre os canais locais. Com Redis, publica no canal
reservado __broadcast__ — que o PSUBSCRIBE {prefixo}:* de todo worker já
alcança, sem subscrição nova — e o run() de cada worker refana para todos
os streams locais dele. Um stream inscrito em mais de um canal recebe uma
vez só: o fan-out é sobre o conjunto de streams.
BROADCAST_CHANNEL é reservado de verdade: register e publish recusam
esse nome com ValueError, porque é ele que o lado receptor lê para decidir
entre entregar a um canal e entregar a todos.
-
SSEBroker.local_channels(). Os canais com pelo menos um stream aberto neste worker — a contraparte dolocal_subscribers(canal), que só responde por um. Resolve "quantos conectados agora" sem o consumidor ler_channels, que é privado e mudaria em silêncio. -
EventStream(heartbeat_event=...): o batimento como evento visível. O heartbeat eraServerSentEvent(comment="keepalive")hardcoded no corpo dostream(). Comentário SSE não disparaonmessage: mantém o TCP vivo, que é o propósito declarado, mas é invisível para o JavaScript. Um cliente que usa o batimento como prova de conexão viva — para reconectar, acender indicador, armar watchdog — não tinha o que escutar.
stream = EventStream(
heartbeat_seconds=15.0,
heartbeat_event=ServerSentEvent(data={"type": "PING"}, event="ping"),
)
None mantém o comentário de sempre; um ServerSentEvent troca o frame; um
callable é resolvido por batimento, para quem carimba timestamp no
payload. As duas escolhas eram uma só antes: heartbeat_seconds=None
desligava o batimento e a detecção de ociosidade, e o valor numérico
ligava o batimento só na forma de comentário.
SSEBroker(heartbeat_event=...) repassa para todo stream que abrir. É o
único caminho de composição possível: register constrói o EventStream
por dentro, então nem subclasse do consumidor entra no fluxo.
Fixed¶
- Inscrição morta do Edge agora é podada: o WNS diz
400, não404. O dispatcher tratava404e410como assinatura morta — os dois status que o padrão reserva para isso, e que FCM e Apple respondem. O WNS, push service da Microsoft usado pelo Edge, responde400 Bad Requestcom corpo vazio quando o navegador foi desinstalado, o usuário saiu da conta Microsoft, ou a inscrição foi despejada.
O resultado era que assinatura morta de Edge nunca era podada: ficava no
banco para sempre, falhava em todo envio, e o notify_user a contava como
não entregue indefinidamente.
A regra é escopada ao host: 400 de notify.windows.com vira
WebPushGoneError; 400 de qualquer outro serviço continua WebPushError,
porque ali significa requisição malformada — e apagar a inscrição por causa
disso desinscreveria um aparelho vivo, que é o erro pior dos dois.
- A docstring de
send_manydescrevia comportamento que o código não tinha. Ela afirmava que toda falha era "also returned in the gone list when the endpoint is known"; o código só coletaWebPushGoneError. A lista alimenta oprune, então incluir falha transitória apagaria aparelho vivo — o código estava certo e a prosa, errada.
[0.254.0] — 2026-08-25¶
Added¶
TokenUsage.cache_hit_tokens: o prefixo que o provedor serviu do cache. Quando o começo do prompt bate com uma chamada recente, a DeepSeek e a OpenAI servem essa parte de um cache e cobram bem mais barato por ela — a tabela da DeepSeek põe o token de acerto duas ordens de grandeza abaixo do de erro.from_payloaddescartava o campo, então todo prompt repetido era precificado pelo preço cheio.
O número errado é pior que o número ausente: o total continuava plausível, só que alto. Nada no sistema tinha como notar.
A fatia é parte de input_tokens, não uma parcela a mais — somar os dois
cobra o prefixo duas vezes. O campo é o quarto do dataclass, então
TokenUsage(entrada, saida, total) posicional continua valendo.
_texto, uso = await gen.generate_with_usage("Resuma isto.")
uso.input_tokens # 3000
uso.cache_hit_tokens # 2560 — destes 3000, não além deles
Duas grafias na mesma família OpenAI-compatível, as duas lidas: a DeepSeek
manda prompt_cache_hit_tokens na raiz do usage, a OpenAI manda
prompt_tokens_details.cached_tokens. Ler só uma funciona no provedor em que
se testou e cobra caro demais no outro.
AIUsageStoreprecifica o prefixo em cache.price_cache_hit_per_1kentra no construtor eestimate_costganhacache_hit_tokens=keyword-only;totals()soma a fatia eUsageTotalsa reporta.
O default é None, não 0.0, e a diferença é a linha inteira: 0.0 é um
preço, e um store sem essa configuração passaria a dar o prefixo de graça,
subestimando toda chamada repetida. None significa "não há tarifa separada"
e deixa a fatia no preço de entrada normal — configuração ausente não deveria
mexer em número nenhum.
cache_hit_tokens maior que input_tokens é recusado por clamp, não por
exceção: relatório absurdo do provedor não deve virar um termo negativo de
preço cheio.
extract_json_object(texto), o gêmeo deextract_json_listpara a resposta que é um registro — classificador de intenção, decisão de roteamento, extração de campos fixos. Mesma tolerância a cerca markdown e prosa em volta que oparse_structured, e o mesmoNonede "gere de novo" no lugar da exceção: um laço de retry escrito em volta deexcept ValueErrorengole erro de verdade junto com deslize de formato.
Migration¶
BaseAIUsageModelganhou a colunacache_hit_tokens. Gere a migration (tempest db revision) antes de subir. A coluna é nullable de propósito: linha gravada antes dela não sabe o próprio split entre acerto e erro de cache, e afirmar zero ali cobraria uma chamada com desconto pelo preço cheio. Serviço que não usaAIUsageStorenão é afetado.
[0.253.0] — 2026-08-23¶
Added¶
tempest_fastapi_sdk.testing.fakes: um substituto dirigível para cada costura de terceiro. Oito fakes —FakePixProvider,FakeTextBackend,FakeModerationBackend,FakePushDispatcher,FakeEmailUtils,FakeGeocodingBackend,FakeRoutingBackend,FakeWebSearchBackend— que implementam a costura e não falam com ninguém: sem credencial, sem conta de sandbox, sem rede. Dois usos, um objeto: rodar o serviço local sem se cadastrar em nada, e afirmar sobre um fluxo em teste sem mock escrito à mão.
O SDK já tinha 16 substitutos in-memory, mas todos de infra (Redis, banco, fila). Nenhum de terceiro remoto — cada costura tinha só a implementação real, então validar um checkout, um email de ativação ou um fluxo de agente exigia credencial ou mock.
O que separa isto de mock é o steering. Um mock responde a chamada que você programou; estes guardam estado e deixam o teste movê-lo:
charge = await provider.create_pix_charge(request) # pending
event = provider.advance(charge.provider_charge_id, PaymentStatus.PAID)
event.type # PixEventType.CHARGE_PAID
advance alcança o que o provedor real não dá sob demanda: PAID num sandbox
exige alguém escaneando um QR code, e CHARGED_BACK exige alguém abrindo uma
disputa. Cada fake tem o seu (flag, add_place, add_route, add_results,
queue), e todos aceitam fail_next(erro) — o ramo que falha, com a exceção
que o cliente real levanta, e sem inventar exceção nova.
O que aconteceu fica inspecionável: calls em todos, mais outbox, sent /
sent_to, prompts, charges, queries, routes, checked.
Três decisões que valem registro:
FakeEmailUtilsé subclasse deEmailUtils, não implementação de protocolo, porqueUserAuthServiceé tipado contra a classe concreta — é a herança que faz o fake passar porUserAuthService(email=...)com o type-checker satisfeito. Só osendé substituído;render_templatecontinua sendo o real, então um teste afirma sobre o mesmo HTML que a produção renderiza.FakeRoutingBackenddelega aestimate_travel, o estimador offline que o SDK já ships, em vez de inventar aritmética própria que poderia divergir dele. Medido: mesma distância e mesma duração do estimador, comsourcemarcado como"fake".- Resolução é lazy (PEP 562), como em
integrations: pedir o fake de Pix não importa genai, push nem geo. Há guard para isso — um subprocesso importatesting.fakese conta os módulosgenaicarregados, e o número tem de ser zero.
Guard de conformidade em tests/testing/test_fakes_contract.py: para cada
fake, todo callable da costura existe, os nomes de parâmetro e a anotação de
retorno batem por inspect.signature, e é async exatamente onde a costura é
async — um substituto sync passaria numa checagem por nome e bloquearia o
event loop. Mais um teste que exige que todo fake exportado esteja na tabela,
para fake novo não shippar sem cobertura. Medido: renomeando um parâmetro de
max_results para limit, o guard falha com
['self', 'query', 'limit'] == ['self', 'query', 'max_results'].
Receita nova, bilíngue: docs/recipes/fakes.md. Todo exemplo dela foi
executado e a saída publicada é a medida.
[0.252.0] — 2026-08-23¶
Fixed¶
- O extra
[genai-audio]passa a declarar o runtime que o código importa, e o guard de import para de apagar o diagnóstico (#191).coqui-ttsdeixa torch, torchaudio e torchcodec atrás dos extras dele ([cpu],[cuda],[codec]), então instalar[genai-audio]dava um TTS que não importava. Medido no próprio repo, comuv sync --all-extras(coqui-tts 0.27.5, torch 2.13.0):
Com torchaudio e torchcodec instalados, o resolve trazia transformers
5.14.1 e o import morria mais adiante, porque TTS.api carrega a camada
Tortoise de forma eager:
Medido nas duas pontas: isin_mps_friendly existe na transformers
5.0.0 e não existe da 5.1.0 em diante; com transformers 4.57.6 o
import TTS.api volta a passar. O extra agora declara torch>=2.2.0,
torchaudio>=2.2.0, torchcodec>=0.8.0 e transformers<5 — o teto fica
confinado a este extra, como o httpx<1.0.0 do [genai-image]. Consumidor
que fixava esses quatro pins à mão pode removê-los.
O guard piorava o diagnóstico: except ImportError pega qualquer falha
do import, e as três causas acima chegavam nele com a mensagem que nomeia a
correção. Trocar por "instale [genai-audio]" respondia todas elas com a
única instrução que não resolve — o extra já estava instalado. Agora a
mensagem original é citada:
ImportError: Text-to-speech could not import Coqui TTS: No module named
'torchaudio'. The [genai-audio] extra installs coqui-tts together with
torch, torchaudio, torchcodec and transformers<5; ...
_require_faster_whisper recebeu a mesma forma.
Dois pontos menores no mesmo caminho: resolve_audio_device("auto") tratava
torch ausente como "sem GPU" e caía para CPU em silêncio — máquina com
GPU transcrevia na CPU e a lentidão parecia ser do faster-whisper; agora sai
logger.warning nomeando a causa. E a docstring de TextToSpeech passa a
dizer que o XTTS v2 é licence-gated: lido no coqui-tts 0.27.5,
ModelManager.tos_agreed aceita só tos_agreed.txt ao lado dos pesos ou
COQUI_TOS_AGREED igual à string "1", e sem isso ask_tos chama
input() — dentro de asyncio.to_thread, onde não há tty.
Guards novos em tests/genai/audio/test_audio_runtime_extra.py: um lê
[project.optional-dependencies] e exige os cinco pacotes declarados, um
exige que o teto de transformers continue lá, e dois provam que a mensagem
do upstream sobrevive ao re-raise nos dois guards. Mais um em
tests/genai/audio/test_audio.py para o warning do device.
- A revogação de família no reuso de refresh token deixa de ser descartada
pelo rollback da request (#186).
_lookup_refresh_recorddetectava o replay, chamava_revoke_familye levantavaInvalidTokenException— mas a revogação só passava porflush(). Numa request FastAPI a exceção sai pelo teardown da dependency de sessão, a unit of work é revertida, e a revogação vai com ela.
Medido no repro da issue, antes:
replay rejected: 401: refresh token reuse detected
rows in family: 2 | revoked: 0
BUG: descendant of the replayed family still refreshes
Depois:
replay rejected: 401: refresh token reuse detected
rows in family: 2 | revoked: 2
OK: descendant refused
Detecção sem consequência era o pior dos três resultados possíveis: parece que o roubo foi tratado. Um atacante com token descendente mantinha a sessão viva indefinidamente — exatamente o cenário que a docstring do método diz cobrir.
A ordem da correção é o desenho: lê o family_id antes (o rollback
expira a instância, e ler coluna expirada em contexto async levanta
MissingGreenlet em vez de recarregar), faz rollback() da sessão da
request, aplica o UPDATE e comita. O rollback é deliberado: a escrita que a
request tinha em stage já estava condenada, e comitá-la como efeito colateral
de uma decisão de segurança seria surpresa. Fica na sessão do caller de
propósito — uma sessão nova exigiria uma segunda conexão, e no SQLite a
transação de leitura aberta do caller bloquearia o commit dela, trocando a
revogação por erro de lock justamente na configuração que todo serviço usa
em teste.
Três testes de regressão em tests/auth/test_refresh_db.py, todos
atravessando a fronteira de sessão que a request tem: a família volta
revogada de uma sessão nova, o descendente é recusado, e a escrita alheia em
stage não é comitada. O teste antigo não podia pegar isto — ele comitava
depois do pytest.raises, na mesma sessão, que é a única coisa que uma
request real nunca faz. Provado que os três falham sem a correção.
make_jwt_user_dependencydeixa de entregarNoneao handler quando ouser_loaderrecusa o subject (#187). Comsoft=False, a dependency já levantava 401 para token ausente e para JWT sem o claim de subject — mas se o token decodificava e o loader devolviaNone, esseNoneera retornado para o handler, e a rota respondia 200 com um usuário que o loader tinha recusado.
Medido no repro da issue, antes:
no token -> 401 (expected 401)
owns, declined -> 200 (expected 401)
shared, declined -> 200 (expected 401)
Depois, com os dois ramos — o que abre sessão própria e o que compartilha a
do request — devolvendo 401, e soft=True continuando a entregar None.
O loader é documentado como "the single seam where the service maps
payload[subject_claim] to an actual user", então é o lugar natural para
recusar: conta desativada, subject que não existe mais, id malformado.
Recusar ali não recusava a request. Na prática, desativar uma conta não
tinha efeito até o access token expirar, para quem confiava na dependency
para isso.
A regra agora mora num lugar só (_resolved), aplicada pelos dois ramos: um
subject recusado tem o mesmo desfecho de um subject ausente. soft=True
segue devolvendo None, que é o propósito do flag; levantar
UnauthorizedException/NotFoundException de dentro do loader continua
funcionando para quem quer escolher a mensagem.
Cinco casos de regressão em tests/api/test_jwt_dependency.py, cobrindo os
dois ramos em soft=False e soft=True, mais um que fixa que a recusa é
sobre None e não sobre usuário qualquer. Provado que os dois casos de 401
falham sem a correção.
- A CSP default de
make_spa_routerdeixa de bloquear o próprio bundle da SPA (#188). O router usavaDEFAULT_STATIC_SECURITY_HEADERScomo default desecurity_headers=, e aquele conjunto existe para servir arquivo que não se confia:default-src 'none'; sandbox. Apontado para uma SPA compilada, ele bloqueia o bundle e a folha de estilo da própria página, e osandboxsemallow-scriptsbloqueia execução de script — documento em branco.
Medido em browser real (Playwright), com o default antigo:
Loading the stylesheet '/assets/app.css' violates the following Content
Security Policy directive: "default-src 'none'"
Blocked script execution in '/' because the document's frame is sandboxed
and the 'allow-scripts' permission is not set
O sandbox é tão total que o próprio page.evaluate do Playwright não roda
na página — a demonstração mais direta possível de que nenhum script executa
ali.
Com o default novo: zero mensagem no console, #root com o texto que o
script escreveu, background do CSS externo aplicado, e o atributo style
inline aplicado (que é o caso do React).
Novos DEFAULT_SPA_CONTENT_SECURITY_POLICY e DEFAULT_SPA_SECURITY_HEADERS,
exportados na raiz: default-src 'self', script-src 'self', style-src
'self' 'unsafe-inline', img-src/font-src com data:, connect-src
'self', form-action 'self', base-uri 'self', object-src 'none',
frame-ancestors 'none', mais nosniff, X-Frame-Options: DENY,
Referrer-Policy: strict-origin-when-cross-origin e
Cross-Origin-Resource-Policy: same-origin.
'unsafe-inline' fica em style-src porque React escreve atributo style
inline e política que quebra a UI é política deletada; fica restrita a
estilo, com script-src em 'self'. Quem controla a árvore de
componentes pode apertar para style-src-attr e passar em
security_headers=.
DEFAULT_STATIC_SECURITY_HEADERS não muda — continua sendo o default certo
para HardenedStaticFiles, que é para o que ele foi feito. Um teste fixa que
os dois conjuntos não são iguais, porque igualá-los de novo devolve a
página branca.
tempest newdeixa de escrever placeholder de template noDockerfilee no.dockerignore(#189). Os dois templates são compartilhados comtempest generate dockerfile, que preenche__SPA_HEADER__,__SPA_STAGE__,__SPA_COPY__,__SPA_IGNORE__e__SYSTEM_DEPS__. O contexto do scaffold não tinha nenhuma dessas chaves, então todo projeto novo saía com os marcadores literais e odocker buildmorria na primeira linha que carregava um, antes de qualquer camada:
Medido com docker build --check no projeto scaffoldado, antes e depois: o
erro acima, e depois Check complete, no warnings found.
O mesmo contexto ausente escondia um segundo defeito: __SYSTEM_DEPS__
nunca era renderizado, então um scaffold --extras pdf produzia imagem
sem Pango e sem fontconfig — e o WeasyPrint levanta OSError do cffi no
primeiro render, não no build, então a imagem parecia boa até alguém pedir um
PDF.
Verificado ponta a ponta com Docker de verdade: tempest new →
docker build (imagem de 343 MB) → container de pé, com
/health/liveness, /health/readiness e /docs respondendo 200.
Guard novo em tests/cli/test_scaffold_placeholders.py, sobre a forma e
não sobre os cinco nomes: qualquer __UPPER_CASE__ remanescente em qualquer
arquivo gerado falha, em cinco combinações de extras. Marcador novo em
template novo herda a checagem sem ninguém lembrar de estender. Dunder do
Python (__init__, __all__) não é falso positivo, e há um caso que prova
que o padrão casa exatamente os cinco marcadores que shipparam.
:memory:do SQLite volta a aceitar sessões sobrepostas, sem perder a atomicidade do savepoint (#180). Desde a v0.200.0 o manager emiteBEGINexplícito em todo engine SQLite — necessário para oRELEASE SAVEPOINTparar de comitar — e o SQLAlchemy escolheStaticPoolpara:memory:, que é uma conexão para todas as sessões. Junto, isso derrubava qualquer par de sessões sobrepostas:
A saída mais óbvia — não emitir o BEGIN quando a conexão é compartilhada —
foi medida e troca um defeito pelo outro:
:memory: |
sessão sobreposta | savepoint liberado |
|---|---|---|
com BEGIN (v0.200.0 a v0.251.0) |
cannot start a transaction |
atômico |
sem BEGIN |
ok | vaza (fica durável) |
| cache compartilhado + pool normal | ok | atômico |
Então o manager passa a reescrever a URL in-memory para um banco de cache
compartilhado (file:<nome-único>?mode=memory&cache=shared&uri=true) com
pool normal, mantendo uma conexão viva enquanto existir — banco de cache
compartilhado é destruído quando a última conexão fecha. Nome único por
manager, então dois managers continuam isolados (medido).
Pool informado pelo caller nunca é sobrescrito:
poolclass=StaticPool restaura a topologia antiga, incluindo a falha. Guia de
migração em docs/migration.md, seção 0.252.0.
Novos is_memory_sqlite_url e shared_memory_url, exportados na raiz. Sete
casos de regressão em tests/db/test_connection.py, cobrindo as duas
propriedades, a visibilidade entre sessões, o isolamento entre managers e o
escape hatch.
extract_pdf_textpassa a tratarmax_charscomo teto duro, e a sempre devolver texto do documento (#175). O corte só acontecia em fronteira de página e o aviso entrava por cima do orçamento, então qualquermax_charsmenor que a primeira página devolvia só a anotação. Medido numa página de 239 caracteres: para todomax_charsde 1 a 254 a resposta era exatamente a mesma linha de aviso, e de 1 a 45 ela estourava o teto pedido:
max_chars=40 -> len=46 '\n=== DOCUMENT TRUNCATED AFTER PAGE 0 OF 1 ===\n'
max_chars=100 -> len=46 '\n=== DOCUMENT TRUNCATED AFTER PAGE 0 OF 1 ===\n'
Três defeitos numa linha: nenhum caractere de documento, 46 caracteres para
um teto de 40, e uma "página 0" que não existe. Quem dimensionava o prompt
por max_chars entregava documento vazio ao modelo — e a string não era
vazia, então if not text também não pegava.
Depois, no mesmo documento:
max_chars=40 -> len=40 'Recibo 4021 Recibo 4021 Recibo 4021 Reci'
max_chars=61 -> len=61 'Recibo 4021 Recibo 4\n\n=== PAGE 1 OF 1 TRUNCATED MID-PAGE ===\n'
max_chars=100 -> len=100 'Recibo 4021 [...] Recibo 4021\n\n=== PAGE 1 OF 1 TRUNCATED MID-PAGE ===\n'
A ordem de preferência é páginas inteiras enquanto couberem; depois a
página 1 cortada, anunciada pelo novo DEFAULT_PARTIAL_PAGE_NOTICE; e, se
nenhum aviso couber deixando um terço do orçamento para texto, o
orçamento inteiro vai para o texto — sem aviso e sem marcador de página.
Duas propriedades que a implementação errou antes de acertar, cada uma com guard próprio:
- Orçamento maior nunca devolve menos documento. Renderizar o marcador
sempre que ele cabia criava um segundo degrau:
max_chars=16devolvia 16 caracteres de documento emax_chars=17devolvia um, com o marcador comendo o resto.test_more_budget_never_buys_less_documentfixa o degrau único entre 1 e 300 — e falha comAssertionError: [17, 61]na forma anterior. MID-PAGEsó quando o corte foi no meio. Escolher o aviso pelo ramo do código, e não pelo corte, fazia um documento de 3 páginas emmax_chars=120entregar a página 1 inteira sob aviso de corte no meio. Agora a linha honesta (AFTER PAGE 1 OF 3, 106 caracteres) é preferida, e no orçamento que segura a página mas não esse aviso o corte tira um caractere para oMID-PAGEficar verdadeiro.test_mid_page_is_only_claimed_when_the_page_was_really_cutvarre 1..400.
DEFAULT_PARTIAL_PAGE_NOTICE exportado na raiz e em
tempest_fastapi_sdk.pdf, com o parâmetro partial_page_notice para
sobrescrever. Receita docs/recipes/pdf.md reescrita: o bloco que
documentava o teto furado virou a tabela antes/depois.
BatchSchedulerdeixa de quebrar um lote que já estava inteiro na fila (#176). O loop cronometrava toda retirada, inclusive a de item já enfileirado: cada tentativa passava porasyncio.wait_forcom o que restava da janela demax_wait_ms. Sob carga, a janela de 20 ms queimava entre duas retiradas e o lote saía partido — perda de throughput exatamente sob a carga que batching existe para atacar.
Era isso que fazia test_coalesces_concurrent_submits falhar na suíte
completa e passar isolado. Reproduzido sem tocar no teste, pondo o event
loop sob carga (uma task que bloqueia em fatias de 5 ms), com os cinco
itens já na fila:
antes: batches: [[0, 1, 2], [3, 4]] batch count: 2
depois: batches: [[0, 1, 2, 3, 4]] batch count: 1
A correção é ler primeiro o que já está lá: get_nowait() em laço, e a
espera cronometrada só quando a fila esvazia de fato. Item que já chegou
não depende mais de o agendador ganhar a vez dentro da janela.
A hipótese de "teste frágil" foi descartada por medição: a asserção
quebrada mostrava dois lotes de itens todos já enfileirados, que é o
defeito que o teste existe para pegar. Guard novo
test_queued_items_coalesce_under_loop_load fixa o caso com a carga
embutida, e falha com assert 2 == 1 na forma anterior.
[0.251.0] — 2026-08-23¶
Fixed¶
- A assinatura de webhook do Mercado Pago passa a seguir o algoritmo do
provedor, e um caminho inteiro de notificação deixa de ser rejeitado. A
especificação vendorizada não descreve a assinatura
(
grep -c "x-signature"devolve0), então o algoritmo agora é portado do validador oficial —mercadopago/sdk-nodejs,src/utils/webhook/index.ts, commit99857f33de 2026-08-03, o módulo para o qual a documentação do provedor aponta o integrador.
O defeito principal: o manifesto omite par ausente, e nós rendereizávamos
um template fixo. Entrega sem data.id assina request-id:...;ts:...; do
lado do provedor e assinava id:;request-id:...;ts:...; do nosso — hash
diferente, verificação falhando sempre, para toda notificação sem
data.id. Quem tratava a rejeição como notificação inválida estava
descartando notificação legítima.
Quatro regras do upstream que também faltavam: chave do header é
case-insensitive (TS=, V1=); valor só-espaço é valor ausente, o que muda
quais pares entram no manifesto; ts não-numérico é header malformado; e o
header pode carregar mais de uma versão de hash.
Digests conferidos contra vetores calculados com openssl dgst -sha256
-hmac — outra implementação de HMAC que não a do Python — e os três casos
de manifesto fixados byte a byte. Continua sem medição contra entrega
real: portado da implementação do provedor não é o mesmo que verificado
contra notificação que ele mandou.
uv.lockfora de versão passa a ser pegável. A v0.247.0 shippou com o lock em0.246.0(como v0.236.0, v0.237.0 e v0.238.0 antes dela) e nada reclamou. O Makefile documentava que nenhum guard era possível, porqueuv runreescreve o lock em disco antes de qualquer teste ler. Medido hoje, e é verdade:
sed -i '0,/^version = "0.250.0"$/s//version = "0.1.0"/' uv.lock
uv run python -c "pass"
grep -A2 '^name = "tempest-fastapi-sdk"' uv.lock | grep version # 0.250.0
Guard possível, então, lendo o commit em vez do disco:
tests/test_lock_version_guard.py usa git show HEAD:uv.lock, que é
exatamente o conteúdo que uma tag entrega e que uv run não alcança. O
workflow de release passou a comparar a tag com as três versões
(pyproject.toml, __init__.py, uv.lock), não só com duas.
Changed¶
- Breaking:
DEFAULT_MANIFEST_TEMPLATEe o parâmetromanifest_template=foram removidos — existiam porque o algoritmo era desconhecido, e template não expressa a regra de omissão. No lugar,build_manifest(data_id=..., request_id=..., timestamp=...). - Breaking:
parse_signature_headerdevolveSignatureHeader(.timestamp,.hashes,.digest(versions)) em vez detuple[str, str], porque um header pode carregarv1ev2. verify_signatureganhaversions=(default("v1",), então versão nova falha fechada),tolerance_seconds=para janela anti-replay enow=para teste. Migração parav2deixa de exigir release.
Guia de migração: docs/migration.md, seção 0.251.0.
Added¶
SignatureHeader,build_manifesteDEFAULT_SIGNATURE_VERSIONSexportados no namespace do Mercado Pago.build_manifestexiste para o integrador conferir a string exata que a assinatura cobre.- Documentado que notificação de QR Code não é assinada — o upstream diz
isso explicitamente, e passar essas entregas por
verify_signaturefalha sempre.
[0.250.0] — 2026-08-23¶
Added¶
- O QR do Pix deixa de ser descartado em silêncio —
tempest_fastapi_sdk.integrations.payment.mercado_pagoganhaPixPayment,PixPointOfInteraction,PixTransactionData,create_pix_payment,get_pix_payment,parse_pix_paymentePAYMENTS_PATH.
payment = await create_pix_payment(
http,
body={"transaction_amount": 19.9, "payment_method_id": "pix",
"payer": {"email": "comprador@example.com"}},
idempotency_key=uuid.uuid4(),
)
payment.qr_code # o copia-e-cola
payment.qr_code_base64 # PNG em base64
O defeito que isto corrige, medido: a especificação vendorizada não declara
point_of_interaction no recurso de pagamento — grep -c devolve 2, e as
duas ocorrências são o corpo de requisição do transaction intent de
Payouts, onde o objeto tem só type. Como o BaseSchema é
extra="ignore", o Payment gerado descartava na validação o objeto que a
API devolve: o QR chegava no corpo HTTP e sumia no modelo, sem erro e sem
log. Reproduzido em teste nos dois sentidos — o corpo carrega o QR, o
Payment validado não, e QR_CODE not in payment.model_dump_json().
create_pix_payment faz a mesma requisição que
MercadoPagoClient.create_payment, mesmos headers e mesma serialização; só
o modelo de resposta difere. PixPayment é uma vista: id, status,
valor, expiração e o objeto do QR, sem importar os schemas gerados — ler um
QR não paga os 0,76 s de construir os 324 modelos. Verificado que o
lazy-loading continua intacto após a mudança.
Nomes e tipos de campo são portados do SDK Node oficial do provedor
(mercadopago/sdk-nodejs, src/clients/payment/commonTypes.ts, commit
c2d3c6ae de 2026-07-27), porque a especificação não os descreve. O
conjunto de campos está fixado por teste, então drift upstream falha em vez
de divergir calado.
financial_institution aceita int ou str por divergência medida
entre as duas fontes: o SDK Node tipa number, a especificação tipa
string na API de Orders. Levantar ValidationError ali perderia o
pagamento inteiro por um campo que ninguém reconcilia. e2e_id não
entrou: a especificação o declara na API de Orders e o SDK Node não o tem
em TransactionData, então prometê-lo aqui seria inventar nome de fio.
Fixed¶
openpix-regenemercadopago-regenentram no.PHONYdo Makefile. Inofensivo hoje — não existe arquivo com esses nomes — e inconsistente com todos os outros alvos.
[0.249.0] — 2026-08-23¶
Added¶
- Integração Mercado Pago —
tempest_fastapi_sdk.integrations.payment.mercado_pago: 324 schemas e 143 operações geradas da OpenAPI oficial do provedor (github.com/mercadopago/openapi, Apache-2.0, commit73bc0e49de 2026-08-04), mais as peças que a especificação não descreve.
payment = await client.create_payment(
body={"transaction_amount": 19.9, "payment_method_id": "pix"},
x_idempotency_key=uuid.uuid4(),
)
Caminho de codegen, como o OpenPix — e não o do Stripe, o que foi decidido por medição: a spec inteira do Mercado Pago tem 261 KB contra 847 KB da do OpenPix já vendorizada, e os schemas gerados importam em 0,76 s / 107 MB contra 0,67 s / 107 MB dos do OpenPix. O argumento que fechou o codegen no Stripe (3,3 MB de schemas, 5,8 s, 492 MB) não se aplica.
Diferente do OpenPix, 142 das 143 operações têm operationId, então os
nomes de método são do provedor: create_preference, create_payment,
get_order.
Escrito à mão, porque a spec não diz: DEFAULT_BASE_URL,
MercadoPagoEvent, os helpers de dinheiro e a verificação de webhook.
MercadoPagoSettings—MERCADOPAGO_ACCESS_TOKENeMERCADOPAGO_WEBHOOK_SECRET, commercado_pago_kwargs()devolvendobase_urle o headerAuthorization: Bearer <token>— com prefixo, ao contrário do OpenPix, que manda o AppID cru.
Sem campo de ambiente, de propósito. Medido: a spec declara um único
servers. O que separa uma cobrança de teste de uma real é qual token
você segura, e um enum de ambiente sugeriria uma proteção que não existe.
Fixed¶
-
Docstring gerada de nome longo cabia na régua e voltava fora dela. O emissor de schemas quebrava o resumo em uma linha de conteúdo de 88 colunas, e o
ruff formatpuxava o"""de fecho de volta para ela sem reconferir o orçamento — 91 colunas no arquivo entregue,E501em código gerado que passa pelos mesmos gates do resto. Medido no enumOrderTransactionPaymentPaymentMethodTransactionSecurityStatus, de 61 caracteres. O emissor agora reescreve três colunas mais estreito quando a quebra colapsa em uma linha, forçando a segunda, que o formatter deixa em paz. Guard na classeTestSchemaDocstringdetests/openapi/test_hostile_spec.py, com o formatter de verdade no loop. -
Path longo de operação ficava acima da régua. Mercado Pago tem rota de 113 caracteres (
/instore/qr/seller/collectors/{user_id}/...); oruff formatnunca quebra string, então o gerador passou a dividir opath = ...em fragmentos concatenados na fronteira de/, sem cortar placeholder no meio.
Note¶
Três coisas que a implementação mediu e que valem antes de usar:
- Dinheiro é em reais, não em centavos como no OpenPix — 39 propriedades
monetárias tipadas
number.to_centsrecusa fração de centavo em vez de arredondar, porque arredondar esconderia a divergência. - O QR do Pix não está em
/v1/payments.qr_code,qr_code_base64,digitable_lineee2e_idexistem em um único schema,OrderTransactionPayment, da API de Orders. Como oBaseSchemaéextra="ignore", um Pix criado por/v1/paymentsteria opoint_of_interactionque a API devolve descartado silenciosamente. - A assinatura de webhook ainda não foi medida contra o provedor. A spec
não a descreve (
grep -c "x-signature"devolve0). O HMAC está testado nos dois sentidos e contra adulteração; o que falta é confirmar que o manifesto é o mesmo que o Mercado Pago assina. Por issomanifest_template=é parâmetro: quem medir pode corrigir sem esperar release.
[0.248.0] — 2026-08-23¶
Added¶
- Módulo
app_errors— o lugar onde o frontend deposita o erro que quebrou na mão do usuário.make_app_error_model(tabela abstrata + fábrica),AppErrorService,make_app_error_routere os schemas.
Duas regras vêm de um serviço que rodou isso em produção, e cada uma existe porque a alternativa falha em silêncio:
- Um relato truncado vale mais que um relato perdido. Payload acima do limite da coluna é cortado com marcador, nunca recusado: quem envia é um app que acabou de quebrar e não tem caminho de tratamento para um 422, e o relato mais interessante — o que veio com stack trace grande — é exatamente o que a recusa jogaria fora.
user_idvem do token, nunca do corpo.AppErrorReportSchemanão tem o campo; quem o preenche é o serviço. A separação em dois schemas é o que torna impossível atribuir o próprio erro à conta de outra pessoa.
A tabela: user_id nullable (erro de login acontece antes de existir
usuário — o caso mais difícil de depurar pelo app), FK ON DELETE SET
NULL em vez do CASCADE do resto do schema (o relato descreve defeito da
aplicação, não do usuário), e índice próprio em created_at, porque a
leitura padrão é "mais recente primeiro" numa tabela que cresce sem limite
natural.
A listagem é opt-in: sem admin_dependency a rota GET não é
montada, já que ela devolve stack trace e identificador de aparelho. Quem
usa o AdminSite não precisa dela.
O corte de datas é semiaberto (created_at >= start e < end + 1 dia),
não func.date(created_at): função na coluna descarta o índice.
Receita: docs/recipes/app-errors.md.
FailOpenRateLimitStore— emapi/middlewares/rate_limit.py, envolve uma store de contadores para que uma indisponibilidade deixe a requisição passar, registrando a falha emWARNING.
Nasceu de uma medição feita ao escrever a receita acima: com uma store
cujo hit levanta, a exceção propaga pelo RateLimitMiddleware e o
chamador recebe erro. Para a maioria dos endpoints isso se defende. Para
um endpoint de relato de erro é o contrário: o momento em que a store está
mal é o momento em que os erros disparam, e recusar os relatos ali destrói
a evidência do incidente que está sendo reportado.
O teste fixa as duas metades — que o middleware nu propaga, e que o wrapper serve a requisição, loga o aviso e continua limitando quando a store está saudável.
[0.247.0] — 2026-08-23¶
Changed¶
- O gerador de cliente OpenAPI passa a emitir header declarado como
argumento da chamada, em vez de descartá-lo com a nota "passe via
HTTPClient default_headers". Header que a especificação declara na operação é valor por requisição:
await client.create_payment(
body=PaymentRequest(transaction_amount=19.9),
x_idempotency_key=uuid.uuid4(),
)
Para a maioria dos headers o descarte era inconveniente; para uma chave de
idempotência era defeito. default_headers manda o mesmo valor em toda
requisição, então a segunda cobrança seria deduplicada em cima da primeira e
o cliente veria um pagamento onde fez dois.
O que a mudança produz, medido na especificação oficial do Mercado Pago
(109 paths, 143 operações): as notas de construto não modelado caem de 9
para 5 — as quatro de header somem, sobram as de multipart,
octet-stream e text/csv. A distribuição também justifica a forma
escolhida: 127 operações sem header, 15 com um, 1 com dois, então cada
um vira um argumento keyword-only nomeado, não uma estrutura de
agrupamento.
None não manda header vazio: um X-Idempotency-Key: em branco não é o
mesmo que ausente, e provedor que valida o header responderia 400 em toda
chamada que não optou por ele.
-
cookiecontinua sendo descartado, agora sozinho, e a nota diz por quê: cookie é estado de conexão, não valor de chamada. -
A docstring gerada para um header opcional dizia "Omitted from the query when None". Agora diz "request headers" para header e "query" para query.
Fixed¶
- A docstring de
BaseRepository.paginateanunciava a chavesizeno dicionário de retorno; o método devolvepage_size. Quem seguisse a documentação escrevia umKeyError— aconteceu ao montar oAppErrorService.
Note¶
- O gerado do OpenPix não muda.
make openpix-regenproduz diff vazio: a especificação do OpenPix declara 105 operações e zero parâmetros de header, então a mudança não alcança o único cliente gerado que o SDK ships hoje. O drift test roda igual.
[0.246.0] — 2026-08-23¶
Added¶
- Contrato canônico de Pix —
integrations/payment/base.py. Um serviço que cobra por Pix passa a depender dePixProvidere a receberPixCharge, em vez da forma do provedor.
A razão é divergência medida entre os provedores que o SDK já traz: OpenPix
declara centavos dentro de um float e tem três estados de cobrança;
Mercado Pago declara reais dentro de um float e tem nove estados de
pagamento, cinco de pedido e quatro de transação, em três schemas. Escrever
charge.status == "COMPLETED" não acopla o serviço ao Pix — acopla a um
endpoint de um provedor.
charge = await provider.create_pix_charge(
PixChargeRequest(amount_cents=1990, reference="pedido-1042")
)
A superfície: PixProvider (um Protocol puro, como todo seam de provedor
daqui), PixChargeRequest, PixCharge, PixPayer, PixPaymentEvent,
PaymentStatus e PixEventType.
Três decisões que o contrato fixa:
- Dinheiro é
intde centavos na fronteira.floatnão atravessa o contrato; converter para a unidade de cada provedor é do adapter. - Dois estados, não um.
statusé o canônico;provider_statusguarda a string crua, para log e suporte. -
rawguarda o payload. Sem ele,BaseSchemasendoextra="ignore"descartaria em silêncio tudo que o provedor manda além do contrato. -
OpenPixPixProvider—integrations/payment/adapters/openpix.py, a primeira tradução.STATUS_MAPcobre os três estados do OpenPix e um teste percorre o enum gerado falhando em qualquer valor não mapeado, para que uma regeneração que acrescente estado quebre a CI em vez de reportar cobrança não paga como pendente.
Duas honestidades que o adapter documenta em vez de esconder: o cancelamento
do OpenPix responde só {"status", "id"}, então os demais campos voltam
vazios em vez de disparar uma segunda ida à API que o chamador não pediu; e
paid_at só é preenchido pelo caminho de webhook, porque paidAt aparece
nos exemplos da especificação mas não no schema Charge.
Changed¶
PixChargeePixPaymentEventdesligam ouse_enum_valuesque oBaseSchemaliga. Com o default,charge.statusguardaria a string"paid"echarge.status is PaymentStatus.PAIDseriaFalseem toda cobrança, silenciosamente, enquanto==continuaria funcionando.model_dump(mode="json")segue devolvendo"paid".
[0.245.0] — 2026-08-22¶
Added¶
OpenPixSettings— o mixin que a única integração de pagamento embutida não tinha. O SDK ships 20 classes de settings, cada capacidade com credencial ganhando a sua mais um helper*_kwargs(); OpenPix ficou de fora, então a receita ensinava a reescreverOPENPIX_APP_IDe a resolução de base URL à mão, com umifque aceitava qualquer typo como "sandbox".
Traz OPENPIX_APP_ID e OPENPIX_ENVIRONMENT, e
settings.openpix_kwargs() devolve base_url mais o header de autorização:
Isso também tira do call site do usuário a pegadinha que a receita precisava
de um admonition para avisar: o AppID vai em Authorization cru, sem
prefixo Bearer.
Duas decisões de desenho, ambas com teste:
- Sandbox é o default. Apontar para produção sem querer cobra dinheiro de
verdade; apontar para sandbox sem querer falha barulhento. E o campo é
Literal["production", "sandbox"], entãoprodéValidationErrorna subida do serviço, não uma cobrança no ambiente errado. - O campo é string, não o enum.
settingsnão pode importar o namespaceintegrations, que é lazy paraimport tempest_fastapi_sdknão pagar por 373 nomes gerados.openpix_kwargs()importaOpenPixEnvironmentna chamada, então a base URL continua com fonte única — há teste que percorre o enum e compara, para uma cópia dos hosts não passar. Medido: importar o SDK e a classe carrega zero módulo deintegrations.
[0.244.0] — 2026-08-22¶
Changed¶
- Floor:
tempestweb>=0.67.0(was0.66.0), nos extrasssr,alledev. A v0.243.0 escolheu o piso pela versão em quecreate_app(theme=...)passou a aceitar o argumento. Aceitar não é pintar: o componente resolve a cor na construção e a assa numstyleinline, e o que liga a paleta da sessão ao componente é otempest-core0.12.0, que instala o tema em volta da chamada daview. Atempestweb0.66.0 pinatempest-core>=0.11.0; a 0.67.0 pina>=0.12.0. Com o piso antigo,build_web_app(theme=...)podia resolver para uma combinação válida em que a página pintava baseline em silêncio.
Medido nas duas pontas, com a mesma seed vermelha:
| tempest-core | filled_button numa sessão com tema |
resultado |
|---|---|---|
| 0.11.0 | rgb(88,71,133) |
baseline, tema ignorado |
| 0.12.0 | rgb(191,13,13) |
primary da paleta |
uv.lockatualizado para o que está publicado:tempestweb0.78.0 etempest-core0.14.0 (eram 0.66.0 e 0.11.0). O piso não sobe junto: fica em>=0.67.0, que é a menor versão em que o comportamento documentado acontece. Subir o piso até 0.78.0 obrigaria todo consumidor a atualizar sem o SDK precisar de nada de 0.68–0.78. Piso é o mínimo que funciona; lock é o que a CI exercita.
Salto de 12 minors medido, não deduzido: suíte inteira verde
(6144 passed), mypy e ruff limpos, e a afirmação da doc re-medida nas duas
pontas — tempest-core 0.12.0 (o piso) e 0.14.0 (o atual) resolvem um
filled_button para o primary da paleta, e um theme= explícito no widget
vence nos dois.
Fixed¶
- A doc do
theme=descrevia um workaround que nunca foi necessário. A v0.243.0 documentou que aviewprecisava repassar o tema ao widget (Button(..., theme=app.theme)) e que os helpers detempestweb.componentsnão repassavam. Isso valia para otempest-core0.11.0, que era o que estava instalado quando a afirmação foi medida — e nunca valeu para o piso que a release entregava. Docstring, as quatro receitas e o README passam a descrever o comportamento real: nenhuma mudança de call site, e umtheme=explícito num widget ainda vence.
[0.243.0] — 2026-08-21¶
Added¶
-
build_web_app(..., theme=...)— a Mode B app can ship its own palette. The theme is forwarded to every session'sApp, whereviewreads it back asapp.theme. This is the half a stylesheet cannot cover: a widget bakes its resolved colors into an inlinestyle, so rebranding only the CSS custom properties leaves those at the Material baseline. Needs tempestweb 0.66.0, where the session andcreate_applearned the same argument; the floor moves accordingly.A escopo desta entrada foi corrigido na 0.244.0: o parágrafo original dizia que a
viewprecisava repassar o tema ao widget. Isso descrevia otempest-core0.11.0; a partir do 0.12.0 — piso datempestweb0.67.0 — os componentes que aviewconstrói resolvem contra a paleta sem mudança de call site. Um rebrand completo continua sendo as duas metades: este argumento maistempestweb.html.theme_css(theme)no head do shell, para o que a folha base pinta.
Changed¶
- Floor:
tempestweb>=0.66.0(was0.64.0), across thessr,allanddevextras — the three that carry it.
[0.242.0] — 2026-08-21¶
Added¶
-
BaseJobModel.progress+BaseJobModel.stage, andJobStore.report_progress(job_id, progress=..., stage=...)— the column a progress bar reads and the one conditionalUPDATEthat moves it. A status answers "is it done yet?"; it never answered "how much longer?", which is the question of whoever has been watching a screen for a minute and a half.The statement carries its own guard rails, because the caller cannot:
WHERE status = 'running' AND progress < :value. Two ticks can be in flight at once and the one that arrives second is not the one that measured second, so the bar can only move forward; and a cancelled job is never repainted. No read before the write — the same shape asclaim, for the same SQLite lock-promotion reason.succeed()setsprogress = 1.0.fail()andcancel()leave it where it stopped: a reading that died at 40% shows 40%.Migration: two columns on every table that subclasses
BaseJobModel(ALTER TABLE <jobs> ADD COLUMN stage VARCHAR(64) NOT NULL DEFAULT ''/ADD COLUMN progress FLOAT NOT NULL DEFAULT 0). -
PhasePlan+Phase+ProgressTracker(tempest_fastapi_sdk.tasks) — measured phases turned into the number on the row. The two dishonest bars are well known: one that crawls on a timer tells a story unrelated to the work, and one that jumps 0 → 100 at the end is a spinner wearing a percentage. This is the third way — the caller measures its phases, declares them, and the tracker interpolates inside the phase that is running while pinning the boundaries to real events.PLAN = PhasePlan.from_seconds({"pdf": 1.0, "table": 36.0, "reading": 23.0}) tracker = ProgressTracker(store, job.id, plan=PLAN) table = await tracker.run("table", backend.chat_structured(msgs, Schema))Medians are the weights, so re-measuring is editing numbers rather than recomputing shares. A phase never fills — interpolation stops at
ceiling_margin(0.95) of its span — andtracker.report(phase, done=...)overrides it when the work can actually count. The tick that writes progress is the same tickrun_cancellableuses to ask about cancellation: same row, same interval, one query each way. -
JobStore.list_recent(statuses=...)— "queued or running" is one question. Asking it as two calls makes the two halves disagree the moment a worker claims a job between them. Passing bothstatusandstatusesraisesValueErrorinstead of letting one win silently. -
JobStore.watch(emit_on=...)— watching only the status yields three times for a whole reading (claimed, finished, nothing in between), so a bar driven by it never moves.emit_on=("status", "progress", "stage")yields on every tick the worker wrote. Default is unchanged, and an unknown column name raises instead of never firing. -
TextGenerator.chat_structured(messages, schema)+ theStructuredTextBackendprotocol — the local transformers backend now answers the same structured call asOllamaGenerator, so a service that reads documents into schemas types against the protocol and runs on either without a line changing at the call site. Both classes are verified against the runtime-checkable protocol intests/genai/test_structured_parity.py. -
stop_eventon local generation and onrun_cancellable— a thread cannot be cancelled from outside, so cancelling the coroutine that awaitsasyncio.to_thread(...)left the GPU decoding a reply nobody would read.generate,chat,generate_structuredandchat_structuredaccept athreading.Eventwired to a transformers stopping criterion (asked after every token), andrun_cancellablesets it before cancelling the task. Measured both ways intests/genai/test_structured_parity.py: with the event the thread observes the stop and returns early; without it the thread runs to completion after the await is abandoned. A stopped generation raises the newGenerationStoppedError.
[0.241.0] — 2026-08-20¶
Changed¶
[tool.pydantic-mypy] init_typed = true— in the SDK's ownpyproject.tomland in thepyproject.tomlwritten bytempest new. Declaringplugins = ["pydantic.mypy"]and nothing else type-checks no argument of any model constructor: the plugin runs and synthesizes one keyword-only parameter per field, butinit_typeddefaults toFalse, so each of those parameters is annotatedAny. Measured with mypy 2.3.0 and pydantic 2.13.4 against a two-fieldBaseSchemasubclass, before the setting existed here:
note: Revealed type is "def (__pydantic_self__: Probe, *,
name: Any, age: Any, **kwargs: Any)"
Success: no issues found in 1 source file
The file passing that check contained Probe(name="x", age="doze"). With
the setting on, the same file reports error: Argument "age" to "Probe"
has incompatible type "str"; expected "int" [arg-type], and the
revealed signature carries the real annotations. Pyright and Pylance load
no plugin — they read the annotations directly and always flagged these
call sites — so until now the editor and the gate disagreed and the gate
was the one that was wrong.
Turning it on cost no fixes here: mypy tempest_fastapi_sdk reports
Success: no issues found in 409 source files with init_typed enabled.
The caveat is real but wanted in a service — the setting refuses input
pydantic would coerce, so a Decimal field handed "1.5" is now an
arg-type error even though the value still builds as Decimal('1.5')
at runtime. Documented next to the setting in the typing recipe.
A service scaffolded before this release needs the block pasted into its
own pyproject.toml: mypy reads plugin config only from the config
file, so tempest check cannot layer it the way it layers --strict.
Guard: tests/test_pydantic_mypy_guard.py fails when either config
declares the plugin without the setting. Closes #166.
[0.240.0] — 2026-08-18¶
Added¶
tempest user create --set <column>=<value>— the columns a concreteUserModeladds are now seedable from the CLI.createonly ever wroteemail,hashed_password,is_adminandis_active, so a model that adds one required column of its own — the shape the admin recipe teaches,class UserModel(BaseUserModel)with columns for the domain — could not be seeded at all: the insert went out withNULLin the new column and the database refused it. Measured against a model addingdisplay_name: Mapped[str] = mapped_column(String(64), nullable=False), the pre-fix command exited 1 with an uncaughtConflictExceptionreadingcould not insert user: (sqlite3.IntegrityError) NOT NULL constraint failed: cli_rich_users.display_name.
--set is repeatable and validated before anything opens a connection:
an unknown key exits 2 listing the model's accepted columns, and
email / hashed_password / is_admin are refused by name, pointing at
the flag that owns them (a second spelling of the same value would
otherwise win or lose depending on merge order, and a password in
--set would skip the length check and land in shell history). Values
are converted to the column's Python type — bool, int, float,
Decimal, UUID, ISO-8601 date/time/datetime, enums by value then
by member name (matching how TempestEnum stores them), and JSON for
JSON columns — and a value the type rejects exits 2 naming the column
and the reason.
A required column with no default that --set did not cover is
prompted for when stdin is a terminal, one prompt per column,
following what the --admin/--no-admin prompt already does; without a
TTY the run exits 2 listing every column the insert would have sent as
NULL. Closes #163.
Fixed¶
- A rejected write in the admin re-renders the page instead of answering
500. Every create/edit whose save failed died in the error path — the
one the operator needs — with
MissingGreenlet. The save rolls the session back, and a rollback expires every object in the identity map, including the signed-in principal, loaded before the write and not touched by it; the error page then readprincipal.emailto render the header, which is sync IO from async code.
expire_on_commit=False does not cover this, which is worth writing down
because it is the obvious first guess:
SessionTransaction._restore_snapshot (SQLAlchemy 2.0.51,
orm/session.py:1126) expires all states unconditionally, while the
expire_on_commit test lives in _remove_snapshot, the commit path
(same file, line 1138). The
four write views that render after a failure now reload what the page
reads — the principal, plus the parent row for the inline formset —
awaiting the load so it happens inside the greenlet SQLAlchemy requires.
Reproduced end to end in tests/admin/test_form_error_rollback.py
against a unique constraint: create, edit, CSV import and the inline
formset each answered 500 before and answer 400 with the message in
the form after; neutralize the four reloads and all four tests fail with
MissingGreenlet. The inline and import paths were never reported, and
were broken the same way. The access policy is part of the reproduction
on purpose: a policy that reads a principal column (principal.is_admin)
is the second thing the expired object breaks. Closes #164.
exc.messagereports the message that was raised.AppExceptionassigned the constructor's message todetailonly, somessagefell through to the class attribute:ConflictException(message="Conflict creating Widget").messageanswered"Resource conflict". Every caller reading it off a caught exception reported the generic default — the admin form banner showedResource conflictfor every integrity error instead of naming the model, and the auth flow's activation / password reset pages renderedreasonasInvalid tokenwhere the service had raisedInvalidTokenException(message="token expired")or"token already used". The instance attribute is now set from the same value asdetail; readingmessageoff the class is unchanged, which is whaterror_responses()does to build OpenAPI examples.
[0.239.0] — 2026-08-17¶
Added¶
StageMap/StageStatus(tempest_fastapi_sdk.tasks) — several independent stages tracked on the record itself, rather than in their own job rows.JobStoreis right when the work is the thing; it is the wrong shape when the work decorates a record the interface already fetches, because a second table then means a second query and a join to render one page.
Status columns on the record are the usual answer, and they rot in a
specific way: each stage grows its own copy of "set running" and "mark
failed", a fix has to land N times, and a copy-pasted stage that kept a
neighbour's column name compiles, imports, and reports the neighbour's
state. StageMap is that table written once — declare the stages and the
naming convention, and it resolves the columns and performs the
transitions.
What it deliberately does not do: declare columns (the project writes
its own mapped_column, so migrations and types stay where a reader
expects them), touch a database (every operation is a pure mutation, so
the transitions test without one), or hold preconditions (whether a stage
may start is domain logic).
owns(record, stage, expected)is the check before writing a result, and it is about ownership rather than cancellation: a status that is no longerexpectedcovers both the user cancelling and a newer run having restarted the stage, and writing is wrong in both.cancelis partial and idempotent, returning(cancelled, ignored)— a screen polling for status routinely asks to cancel something that finished a moment ago.- Marking without a
resultkeeps the previous one, so cancelling a regeneration preserves the answer already there instead of making cancellation worse than never asking. - Two stages resolving to the same column is refused at construction,
and an
error=paired with a non-failure status is refused too.
Fixed¶
make releasenow stagesuv.lock. Bumping sedspyproject.toml, which invalidates the lock's record of this package's own version.make checkrefreshes it as a side effect —uv runre-locks before running anything — but nothing staged it, so every tagged commit since the release target was rewritten shipped a lock one version behind its pyproject: v0.236.0, v0.237.0 and v0.238.0 all drifted.
No guard covers it, and the Makefile says why: any test asserting the two
agree runs under uv run, which repairs the lock on disk before the test
can read it, so the guard could never fail. Measured by corrupting the
lock and watching a bare uv run python -c pass put it back.
[0.238.0] — 2026-08-17¶
Added¶
- Per-subject AI usage accounting (
tempest_fastapi_sdk.genai) —BaseAIUsageModel,AIUsageStore, and theUsageTotals/ServiceUsage/DailyUsage/SubjectUsageresult types.GenAIMetricsalready publishes token counters to Prometheus, which answers "how is the fleet doing right now"; it cannot answer "which account burned the budget last month", because the series is per-process, resets on deploy, and carries no user dimension — adding one would make the cardinality unusable. This is the other shape: one row per paid call, queried with ordinary SQL.
record stores a call, record_duration stores local inference (which
costs wall-clock rather than tokens), and totals / by_service /
per_day / top_subjects are the aggregations an admin screen draws.
Each opens its own short session, like JobStore — the recording call
runs inside a worker as often as inside a request.
Three decisions each pinned by a test:
- A call the provider did not price writes no row.
usage=Nonemeans the response carried no usage, which is not the same as a free call; a zeroed row would count toward the call count and the active-user count while contributing nothing.TokenUsage(0, 0, 0)writes nothing either — it is what a short-circuit that never reached the model looks like. - The price is never stored. Cost is computed from the tokens at read time, so correcting a price fixes the whole history instead of leaving old rows priced with a number nobody remembers setting.
- Cost comes back unrounded. Any fixed precision is wrong at some
scale: token prices live around
0.0001per 1000, so rounding to cents reports zero for nearly every single call, while a monthly total wants cents. Formatting stays at the boundary. (Found by a test: the first implementation rounded to four decimals and reported0.0004for a real0.00042.)
Duration rows carry service=NULL and are excluded from token sums and
per-service shares, so local inference never becomes a 0% slice on every
chart.
[0.237.0] — 2026-08-17¶
Added¶
- Cancelling a job that is already running
(
tempest_fastapi_sdk.tasks) —JobStatus.CANCELLED,JobStore.cancel/is_cancelled/cancellation_watch, andrun_cancellable+StageInterruptedError. The queue could start long work and report on it; it could not stop it. Nothing in TaskIQ — or in any broker the SDK speaks — offers "kill the task with this id", so this is cooperative: the request side writesCANCELLEDand answers immediately, and the worker reads that at checkpoints and gives up.
run_cancellable is the checkpoint that runs during the work rather
than between steps. It races the coroutine against the predicate on an
interval and, when the predicate says stop, cancels the coroutine for
real — an in-flight request is aborted and the worker is free within the
poll interval, rather than finishing a call whose result nobody wants.
Verified by asserting the wrapped coroutine received CancelledError,
not merely that the wrapper stopped waiting: remove the cancel and three
tests fail.
Details that each cost somebody a discovery:
cancelis idempotent. Nothing to stop answersNoneinstead of raising — an unknown id, or a job already done, failed or cancelled. A double-click, or a click that races the job finishing on its own, is not an error.- A failing predicate does not discard the work. The predicate usually reads a database; treating an unreachable one as "cancelled" would throw away good work during exactly the incident where redoing it costs the most. The round is skipped and the next one retries.
succeedrefuses to land on top of a cancellation, raising the newJobCancelledError— a subclass ofJobAlreadyFinishedError, so existing handlers keep working, and a distinct type because the two mean opposite things: the parent says your concurrency is wrong, this one says the system did what it was told.CANCELLEDis terminal but is not a failure. It joinsTERMINAL_JOB_STATUSES— the poll stops, the payload is dropped — but an interface highlightingFAILEDshould leave it alone, and an alert that pages on failures should not fire.- It only works on genuinely cancellable awaitables. Work handed to
asyncio.to_threadis not: cancelling the coroutine abandons the wrapper while the thread runs to completion, still holding the CPU. The docstring says so, and says what to do instead.
[0.236.0] — 2026-08-17¶
Added¶
OpenAICompatGenerator(tempest_fastapi_sdk.genai) — text generation against any/chat/completionsendpoint. The wire format is the common denominator — DeepSeek, Groq, Together, OpenRouter, Mistral, vLLM's server, TGI's OpenAI route and Azure all document an OpenAI-compatible one — so a single client reaches them by swappingbase_urlandmodel. That list is what those providers advertise, not a matrix this repo runs: the tests drive anhttpx.MockTransportand pin the request built and the response read, not any live provider. It satisfiesTextBackend— whose own docstring names "a hosted API" as the case it exists to be filled with — so it drops intomake_genai_routerandAIChatPipelinebeside the localTextGeneratorand theOllamaGenerator. Until now the SDK could only talk to a local daemon or local weights.
Built on the SDK's HTTPClient, so every call gets retry with backoff, a
per-host circuit-breaker and X-Request-ID propagation. No vendor SDK:
this is one POST with a bearer token, and a wrapper would bring its own
bounds and buy nothing. An empty api_key raises at construction rather
than deferring to a 401 from inside the first background job.
extra_body carries provider extensions without a branch per vendor, and
is merged under the computed fields so it cannot redirect a call to
another model. The case it was built for, reported by a downstream
service against DeepSeek and not reproduced in this repo: a hybrid
reasoning model with thinking on by default spends max_tokens on the
hidden chain before the real content, so a budget sized for the answer is
exhausted there and the completion comes back empty —
extra_body={"thinking": {"type": "disabled"}} turns it off.
TokenUsage(tempest_fastapi_sdk.genai) — what a call cost, as the provider reported it.OpenAICompatGenerator.generate_with_usage/chat_with_usagereturn it alongside the text; plaingeneratestill returns a barestr, because that is what the protocol declares — exposing usage is an addition, not a change.GenAIMetricsalready observed tokens for Prometheus, but that is per-process and ephemeral; this is the value you can persist for per-user accounting.
Two decisions worth knowing: total_tokens is carried from the response
rather than recomputed, because no provider is obliged to bill
input + output (cached-prefix discounts show up exactly that way); and
a response with no usage yields None, never a zeroed usage — "the
provider did not say" is a different statement from "the call was free".
__add__ sums a job made of several calls.
generate_structured_list+StructuredFormatError(tempest_fastapi_sdk.genai) — generate a list of schema items, retrying when the output holds no usable array. Retrying at the same temperature is close to pointless, since greedy decoding is deterministic and attempt two reproduces attempt one; the first attempt stays greedy and each retry addstemperature_step.
Only a structural failure spends an attempt. An array that parses
with one malformed item is handled by skip_invalid instead, and [] is
a success — the model answered, and the answer is no items — so neither
burns a generation. StructuredFormatError subclasses ValueError and
carries the last raw completion, so the log says what the model wrote
rather than only that it was wrong. Takes any backend exposing
generate(prompt, config=...).
[0.235.0] — 2026-08-17¶
Fixed¶
- A cold
SpeechToTextshared by two callers now builds one model, not two.load()wasif self.is_loaded: returnfollowed by the constructor — idempotent from one thread, and it is called from_transcribe_sync, which runs on a worker thread. The only thing between two callers wasasyncio.Semaphore(max_concurrent), which admitsmax_concurrentof them by construction. Measured with two threads against a counting stub: twoWhisperModelconstructions, not one.self._modelkeeps only the last one written, so the loser's copy stays referenced by nothing the class can reach and its weights sit in the process for as long as the garbage collector has not run.
The guard is a threading.Lock, because the callers to exclude are
threads. The is_loaded test lives inside it rather than being repeated
outside as a fast path — one uncontended acquire measured at ~157 ns
(CPython 3.11, 2M iterations) against a transcription measured in
seconds, and double-checked locking is the shape this bug already hid in
once. unload() takes the same lock and now also clears the batched
pipeline, which holds the model and would otherwise keep it alive.
tests/genai/audio/test_stt_loading.py reproduces it: restore the
pre-fix load() and the constructor count asserts 2 == 1.
- A stray trailing bracket no longer breaks structured extraction.
_extract_jsonsliced from the first{to the last}. One extra}after an otherwise perfect payload madejson.loadsreject a decodable answer, and retrying reproduced it exactly, because the defect was in the cut and not in the generation. (The shape comes from a downstream service that hit it against DeepSeek; this release does not measure how often a given model produces it.) Extraction now scans for a balanced span, skipping brackets inside string values. A non-greedy regex was not the fix: it stops at the first closer and truncates any payload containing a nested object or array.
Added¶
-
extract_json_listandparse_structured_list(tempest_fastapi_sdk.genai) — the array counterparts ofparse_structured, which looks for an object and so does not serve a prompt asking for "a list of items".parse_structured_listvalidates each item, withskip_invalid=Trueto drop one malformed entry instead of losing the other nine.extract_json_listreturns the raw list, orNonewhen there is no decodable array — a signal deliberately distinct from[]:Nonemeans "generate it again",[]means "the model answered, and the answer is no items". A caller that cannot tell them apart either retries a call that succeeded or gives up on a recoverable formatting slip. -
SpeechToText(batch_size=...)— decodes VAD-detected speech spans in parallel through faster-whisper'sBatchedInferencePipelineinstead of one after another. Same model, same weights, same precision; only the scheduling of the decode changes, at the cost of peak memory proportional to the value. Requiresvad_filter=True(the VAD produces the spans a batch is made of) and raisesValueErrorat construction when it is off, rather than at the first transcription. -
SpeechToText(cpu_threads=..., num_workers=...)— CTranslate2'sintra_threadsandinter_threads, previously not reachable through the SDK. Defaults (0,1) are faster-whisper's own, so nothing changes for callers who do not set them. -
SpeechToText(condition_on_previous_text=...)— feeds each window the previous window's text as context. Defaults toTrue, which is faster-whisper's own default, so upgrading does not silently change anybody's transcripts. Turning it off is what you want alongsidebatch_size: it serializes spans that would otherwise decode in parallel, and it is the documented path by which one bad span contaminates the ones after it. -
SpeechToText.transcribe(on_progress=...)— called with(seconds_done, total_seconds)as the decode advances. faster-whisper returns a generator, so without it a long file spends minutes indistinguishable from a hang. It runs on the worker thread, which the docstring says, so a callback touching the event loop needsloop.call_soon_threadsafe.
[0.234.0] — 2026-08-16¶
Fixed¶
- A generated model can be constructed with its Python field names
again — under a type-checker, not just at runtime. Every aliased field
was emitted as
Field(alias="correlationID"), andaliasis the field specifier that renames the parameter in the__init__a checker synthesizes. Measured with basedpyright against the published 0.233.0 wheel:
ChargePayload(correlation_id="order-1", value=1990)
# error: No parameter named "correlation_id"
# error: Argument missing for parameter "correlationID"
It ran fine — populate_by_name=True is set on every generated model —
so nothing failed until a consumer ran pyright, Pylance or basedpyright.
Measured again with validate_by_name, which does not change it either;
mypy accepts both spellings regardless, which is how it shipped.
The wire name is now written twice, as validation_alias (reading) and
serialization_alias (writing). All three directions are pinned by
tests: construction by Python name, validation from the provider's
spelling, and model_dump(by_alias=True) still emitting it. The same fix
applies to the two hand-written schemas that carried an alias
(push, webpush), and tests/test_alias_guard.py fails if a plain
alias= comes back — one keyword, indistinguishable at runtime, that
nothing else in the gate would notice.
[0.233.0] — 2026-08-16¶
Fixed¶
- A component whose top level is
oneOf/anyOfno longer generates an empty model that drops the caller's data. The parser flattenedallOfand ignored the other two combinators, so a named component built fromoneOfbecame a class with no fields — andBaseSchema'sextra="ignore"then discarded everything passed to it. Measured against the vendored OpenPix specification: a charge created with
ChargePayload(
correlation_id="order-1",
value=1990,
customer=CustomerPayload(name="Ana", email="ana@example.com"),
)
went on the wire as {"correlationID": "order-1", "value": 1990.0,
"customer": {}}. No exception, no warning — the customer simply was not
there. Two OpenPix components were affected, CustomerPayload and
PaymentCreatePayload, and both are request payloads.
A component like that now becomes one of two things:
- untitled object variants merge into a single model carrying every
variant's properties, required only where every variant requires it
(OpenPix spells "name plus one of taxID, email or phone" as three
near-identical objects, so
CustomerPayloadis one model withnamerequired); -
titled variants, or a discriminator, stay apart, one class each, named from the specification's
title, with the component name kept as a union alias over them (PaymentCreatePayload = PaymentCreatePayloadPixKey | PaymentCreatePayloadQrCode | ...). -
allOf: [<oneOf component>, {flag}]no longer collapses to the flag. Merging asked the union forproperties, a union has none, and the whole payload vanished: OpenPix'sPOST /api/v1/paymentbody was generated as a model with the single fieldautoApprove, unable to express any payment at all. TheallOfis now distributed into the union, so each variant keeps its own fields and gains the flag. -
Every generated OpenPix name is now listed in the package's
__all__, so a strict type-checker accepts it as a re-export. Measured with basedpyright against the installed wheel:from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayloadreported "ChargePayload" is not exported from module ... Import from "...openpix.schemas" instead. TheTYPE_CHECKINGwildcard made the symbol visible but did not mark it re-exported. mypy accepted the wildcard either way, which is why this shipped. The list is generated byscripts/regen_openpix.pyand pinned by the drift suite. -
A long
# openapi: unsupportednote no longer overruns the line budget. Continuation lines are re-emitted with a four-character"# "prefix that the wrapping did not reserve, so a long enough note produced generated code failingE501in the consumer's own lint run.
Changed¶
- OpenPix: 373 schemas (was 358). The 15 new names are the payment variants, the union aliases over them, and the customer address the old empty models had swallowed.
- Two generated enums were renamed, because the classes that register them
are now generated earlier:
PaymentType->PaymentCreatePayloadPixKeyTypeandPaymentDestinationAliasType->PaymentCreatePayloadPixKeyDestinationAliasType. Same members, same values. See the migration guide. from tempest_fastapi_sdk.integrations.payment.openpix import *now pulls every generated name (and pays the lazy load) instead of the eleven hand-written ones. Importing names explicitly is unaffected.
Documentation¶
- The OpenPix recipe was rewritten as two task-shaped pages.
OpenPix (Pix via Woovi) now teaches the whole
charge flow — suggested layered architecture, opening a charge, the
verified webhook, the API read-back that actually authorizes releasing,
reconciliation, expiry and refunds — and
OpenPix (subscriptions and plans)
covers recurring billing: why the plan lives in your database and the
subscription in theirs,
RECURRENTvsPIX_RECURRING(Pix Automático), the cycle charges, instalments, cancellation, and the access-expires-by-date state machine. Both in PT-BR and EN-US.
[0.232.0] — 2026-08-16¶
Added¶
- Stripe integration (
tempest_fastapi_sdk.integrations.payment.stripe), no extra required.
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
to_minor_units,
)
client = StripeClient(stripe_http_client("sk_test_..."))
intent = await client.payment_intents.create(
{
"amount": to_minor_units("199.90", "brl"),
"currency": "brl",
"metadata": {"order_id": "1042"},
}
)
StripeClientover the SDK'sHTTPClient, with a genericStripeResourcecovering create / retrieve / update / delete / list plusauto_paginatefor nine resources (customers, payment intents, refunds, products, prices, subscriptions, invoices, Checkout sessions, events). Every write carries anIdempotency-Key— a retried charge without one bills the customer twice.stripe_http_clientpinsStripe-Version, so an account upgraded in the dashboard cannot silently change response shapes under a service that never changed.- Money that respects zero-decimal and three-decimal currencies:
to_minor_units/from_minor_units/currency_exponent. ¥1050 is1050, not105000. - Webhook verification over the payload Stripe actually signs
(
f"{t}.{body}", not the body), with a replay window, secret rotation, andsign_payloadso tests do not re-derive it wrongly. An unknown event type never fails the route. StripeEvent— 265 event types, generated from the specification (make stripe-fetch/make stripe-regen) with a drift test.-
StripeErrorsurfacingtype/code/decline_code/param/request_id, instead of a bare status. -
form_encode(tempest_fastapi_sdk.form_encode) — flattens a nested payload into the bracket notation form-encoded APIs read (metadata[user_id]=42,items[0][price]=price_123). Booleans go out lower-case,Noneis dropped rather than sent empty (an empty string clears a field on these APIs),Decimalkeeps its exact text. -
The OpenAPI generator now reads the request body's media type. An operation declaring
application/x-www-form-urlencodedemitsdata=form_encode(payload); JSON operations are unchanged. Before this, a client generated against Stripe had 100% of its writes rejected — all 588 of them declare form encoding and none declares JSON.
Fixed¶
- The OpenAPI parser aborted on re-entrant specifications. Note sinks
were removed from the stack by equality, and two sinks that have
collected nothing are both
[]— so a nested parse (a field's type resolving a$ref, whose component's fields open their own sinks) removed the wrong one and the outer block died withValueError: list.remove(x): x not in list. Removal is now by identity. Parsing Stripe's specification is what surfaced it; the regression test fails on the previous code.
Notes¶
The Stripe client is hand-written, unlike OpenPix, and the reason is
measured on the 2026-07-29.dahlia specification: generating the full
surface produces a schemas.py of 3.3 MB / 81k lines whose import costs
5.8 s and 492 MB of RSS, and slicing by resource does not help because
/v1/prices alone reaches 864 of the 1440 component schemas. What still
comes from the specification — API version, base URL, the event list —
comes through scripts/regen_stripe.py, which also records the numbers.
Closes #156.
[0.231.0] — 2026-08-16¶
Added¶
- Unified push (
tempest_fastapi_sdk.push). The SDK shipped Web Push and nothing for mobile, so a product with a site and an app carried two notification APIs and a caller that had to know which kind of device it was talking to. Now it says "notify this user" once.
from tempest_fastapi_sdk import (
BaseRepository,
DeviceService,
FCMTransport,
PushPayloadSchema,
WebPushTransport,
)
service: DeviceService[DeviceModel] = DeviceService(
BaseRepository(session, model=DeviceModel),
[WebPushTransport(dispatcher), FCMTransport(auth=firebase)],
)
result = await service.notify_user(user_id, PushPayloadSchema(title="Hi"))
PushDispatcher— a one-methodProtocol, in the shapeUploadStorageuses for storage, so the service depends on the contract and a fake transport in tests inherits nothing.WebPushTransportadapts the existingWebPushDispatcher;FCMTransportdelivers to iOS and Android throughfirebase_admin.messaging, reusing the[firebase]extra and the service accountFirebaseAuthalready loaded (FCMTransport(auth=...), via the newFirebaseAuth.appproperty).BaseDeviceTokenModel/make_device_token_model— one table for browsers and phones: web rows keepp256dh/auth, mobile rows carry the FCM registration token,tokenis unique andlast_seen_atis refreshed on every re-registration.DeviceService— idempotent registration (a handset that changes hands moves to the new user), concurrent fan-out where one device failing never aborts the others, and unified pruning: HTTP 404/410 on the web andUnregisteredError/SenderIdMismatchErroron FCM both delete the row.PushFanoutResultreportsdelivered/pruned/failed/skipped.skippedis notpruned: a web-only service keeps its iOS rows instead of deleting devices it merely cannot reach yet.make_push_router—POST /register,POST /unregister, and the existingGET /vapid-public-key.PushSettingsjoinsWebPushSettingsandFirebaseSettingsand resolves a real trap: both declareenabled, so composing them by hand lets the MRO silently pick the Web Push one and a mobile-only service readsenabled is Falsewith FCM configured. It exposesweb_enabled/mobile_enabledand anenabledthat means "can notify anyone".- Device tokens never reach a log line or a response —
mask_push_tokenkeeps a 12-character SHA-256 prefix, the same treatment_mask_endpointgave Web Push endpoints.
Nothing breaks. tempest_fastapi_sdk.webpush is untouched — same
module, same names, same behaviour — and tests/webpush/ passes without a
single edit, which is what proves it.
Measured, not deduced (firebase-admin 7.5.0): Message.token is marked
deprecated in favour of fid, but the two encode different wire fields
({"token": ...} vs {"fid": ...}), so an FCM registration token stays in
token and a test asserts the serialized message still carries it. Also
APNSConfig, not ApnsConfig.
One deliberate divergence from issue #157: FCM's InvalidArgumentError is
not treated as "device gone". FCM raises it both for a bad token and
for a malformed payload, and pruning on it would delete a user's whole
fleet the first time a notification body is wrong.
Recipe: Push (web + mobile). Closes #157.
[0.230.0] — 2026-08-16¶
Added¶
- Firebase ID token verification (
tempest_fastapi_sdk.auth, extra[firebase]). For the shape the SDK did not cover yet: the client signs in with Firebase and the API receives an ID token it has to prove is genuine.
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
firebase = FirebaseAuth(credentials_path="credentials.json")
router = APIRouter()
@router.get("/me")
async def me(
identity: FirebaseIdentity = Depends(firebase.get_identity),
) -> dict[str, str]:
"""Return the verified caller."""
return {"uid": identity.uid, "email": identity.email or ""}
FirebaseAuthowns the idempotent app initialization every service otherwise re-implements:firebase_admin.initialize_app()raisesValueErroron the second call, so two instances with the sameapp_namenow share one app, and distinct names talk to distinct projects. Verification runs inasyncio.to_thread, since Google's verifier is synchronous.- The credential comes from
credentials_json(inline, for deployments without a mounted volume),credentials_path, or the environment's application-default credential — in that order. Configuration failures raiseFirebaseCredentialError(aRuntimeError, not anAppException, because it happens at construction). FirebaseIdentityis a frozen dataclass (uid,email,email_verified,phone_number,provider, plus the fullclaims), so handlers never receive a rawdict[str, Any].- Dependencies:
get_identityandget_uid(strict),get_optional_identity(soft —Noneinstead of raising, pairs withrequire_authenticated). FirebaseUserResolver[UserT]maps a verified identity onto the project's own user object; a resolver answeringNoneis a 401, not an empty response.- Each failure gets its own
code:FIREBASE_TOKEN_MISSING,FIREBASE_TOKEN_INVALID,FIREBASE_TOKEN_EXPIRED,FIREBASE_TOKEN_REVOKED,FIREBASE_UNAVAILABLE, andFIREBASE_USER_DISABLEDat 403 — the caller proved who they are, so it is the one failure the soft variant still raises. FirebaseSettings(FIREBASE_PROJECT_ID,FIREBASE_CREDENTIALS_PATH,FIREBASE_CREDENTIALS_JSON) +firebase_kwargs(), which drops empty values instead of forwarding an empty path.
Measured, not deduced: on firebase-admin 7.5.0, ExpiredIdTokenError
and RevokedIdTokenError are subclasses of InvalidIdTokenError, so
the except ordering is what keeps the three codes distinct — a
parametrized test pins it. A clean venv with the extra installs 33
packages, 52 MB, which is why [firebase] stays out of [all] and the
import is lazy: import tempest_fastapi_sdk and
from tempest_fastapi_sdk.auth import FirebaseAuth both work without it,
and only construction raises ImportError naming the extra.
The suite runs offline — a locally generated RSA key builds a syntactically
valid service account (initialize_app never contacts Google), and
verify_id_token is patched on the real firebase_admin.auth module so
the mapping is exercised against the genuine exception classes. One test
patches nothing and feeds a non-JWT to the real verifier, which rejects it
structurally before any network call.
Recipe: Auth Firebase (ID token). Closes #155.
[0.229.0] — 2026-08-15¶
Everything here came out of building a real document service on the SDK (a public-procurement budget tool) and finding the same four gaps.
Added¶
- Spreadsheet generation (
tempest_fastapi_sdk.spreadsheet, extra[spreadsheet]). The counterpart oftempest_fastapi_sdk.pdf: a PDF is what you send when the numbers are final, an.xlsxis what you send when the recipient has to sort, filter and re-total them.
workbook = new_workbook("Orçamento")
writer = SheetWriter(
workbook["Orçamento"],
columns=[
Column("Item", width=48, wrap=True),
Column("Valor", width=18, number_format=BR_CURRENCY_FORMAT),
],
)
writer.title_block(["PREFEITURA MUNICIPAL", "Pregão 1/2026"])
writer.header_row()
writer.write_row(["Serviço de instalação", Decimal("2930.00")])
writer.total_row(["Total", Decimal("2930.00")])
data = workbook_to_bytes(workbook)
SheetWriter owns the row cursor, so no call site tracks (row, column)
pairs — insert a line at the top and nothing below it has to be renumbered.
Column declares title, width, number format and alignment once, which
is what stops the format from drifting between the first row and the
thousandth.
The BR_* number formats embed the pt-BR language code ([$R$-416]). This
is not cosmetic: Excel resolves a plain #,##0.00 with the locale of
whoever opens the file, so a workbook built in São Paulo renders
1,234.56 on an en-US machine. The mask pins the convention inside the
document.
SheetStyle is plain data — hex colours and integer sizes, no openpyxl
objects — so a project's theme is definable, testable and comparable without
the extra installed. new_workbook also drops the stray Sheet tab that
openpyxl always creates.
- PDF text extraction (
tempest_fastapi_sdk.pdf.extract_pdf_text/extract_pdf_pages, extra[pdf-read]). The inverse of the renderer, and the first step of every "hand a document to a model" pipeline.
Text layer only, no OCR — and a scanned PDF returns "" rather than a blank
document, because handing a model an empty prompt is how a confident answer
gets invented about a page nobody read. Page boundaries survive as markers,
so an extracted figure can cite where it came from, and truncation at
max_chars cuts at the last complete page and says so in the text.
It is a separate extra from [pdf] on purpose: rendering pulls WeasyPrint
plus Pango and fontconfig from the system, and a service that only reads
should carry none of that.
- Brazilian currency helpers (
tempest_fastapi_sdk.utils.currency, no extra).parse_currency_brreads an amount as a document prints it ("R$ 2.930,00") back into an exactDecimal, accepting both separator conventions; it returnsNone— not zero — when no digit is present, which keeps "no price printed" distinguishable from "printed R$ 0,00".
format_currency_br, format_percent_br, format_quantity_br and
quantize_money render for prose without going through locale (which is
process-global, container-dependent and not thread-safe). Money is quantized
ROUND_HALF_UP, matching Brazilian accounting practice rather than
Decimal's banker's rounding, which is what lets a generated document
reproduce a hand-built one cent for cent.
- Decimal ratio fields —
DecimalRatioField(0..1),DecimalPercentField(0..100) andSignedDecimalRatioField(<= 1, may go negative). The SDK shippedRatioField/PercentFieldannotated onfloat, soDecimal("0.28")was silently coerced and the first multiplication by aPriceFieldraisedTypeError— or, once someone "fixed" it with a cast, moved a cent. Use the decimal ones wherever the fraction multiplies money.
Fixed¶
OllamaGenerator.generate_structuredreturned an empty result on reasoning models. It posted to/api/generatewith the schema informat; on a harmony model such asgpt-ossthe daemon answers200 OKwith a non-zeroeval_countand an emptyresponse, because the reply lands in a reasoning channel that endpoint does not surface. Measured againstgpt-oss:20b:/api/generatewithoutformatworks,/api/generatewithformatreturns empty,/api/chatwithformatreturns the JSON inmessage.content. The call now goes to/api/chat, where non-reasoning models behave identically, and raisesValueErrorinstead of returning nothing when the content comes back empty.
It also takes a system= argument now. An instruction concatenated ahead of
a long document is ignored by the model — measured: 0 items extracted from a
24k-character tender with the instruction in the same turn, 20 items with it
in its own system turn.
Changed¶
pdf.formatting.format_centsnow delegates toutils.currency.format_currency_br. Same output (a test fixes the two against each other across the sign boundary); one implementation.
[0.228.0] — 2026-08-15¶
Added¶
BaseJobModel+JobStore— long work with a status the interface can show. A queue hands a call to a worker; it answers none of what the person waiting is asking: has anything picked this up, is it running, what did it produce, why did it stop. TaskIQ's result backend is keyed by task id and holds a return value — what a screen needs is a row. This is the symmetric half of the transactional outbox: that one is a message to publish, this one is work to execute. Closes #151.
BaseJobModel is abstract — subclass it, pick a __tablename__, and
get kind / status / params / payload / result_id / error /
attempts / max_attempts / started_at / finished_at on top of
the usual BaseModel columns. JobStore[JobT] wraps it with
enqueue, claim, succeed, fail, get, list_recent,
reclaim_stale and watch, each in its own short transaction because
its callers are a request handler, a worker that grinds for minutes,
and a screen polling every couple of seconds.
Four decisions worth naming:
claimis a conditionalUPDATE, not a read-then-write. Two workers racing for one id cannot both win; the loser getsNonerather than an error. Measured under a barrier that releases both contenders together: the naive read-then-write shape does not hand the job to both, it raisessqlite3.OperationalError: database is locked— lock promotion, the one contentionbusy_timeoutcannot wait out (v0.227.0). The test asserting that is in the suite, so the race test cannot pass against an implementation with no conditional update.succeed/faildrop the payload, or the table of finished jobs becomes a pile of documents.reclaim_stale()frees the job whose worker died — the failure a queue cannot see, since the task is gone but the row is not. Bounded bymax_attemptsso a job that kills its worker is closed asFAILED(withSTALE_JOB_ERROR) instead of readmitted forever. Two disjointUPDATEs and noSELECTfirst, for the same lock-promotion reason.watch()holds no session between ticks. It is thewhile True: sleep; getevery application writes by hand, minus the part that is easy to get wrong — a watcher holding its transaction open is a watcher blocking the worker it is watching.
JobNotFoundError and JobAlreadyFinishedError are a LookupError
and a RuntimeError, not AppException subclasses: the store runs in
the worker as often as in a request, and a worker has no HTTP status to
answer with. Translate at the boundary with not_found_exception(...).
New recipe: "Jobs (trabalho longo com status)" / "Jobs (long work with
status)".
[0.227.0] — 2026-08-15¶
Three defects an application only meets on the day it grows a worker.
Added¶
-
SQLite runs in WAL, with a 30-second busy timeout. Web process and
taskiq workeron oneapp.dbis where development SQLite stops working: in the default rollback journal a reader and a writer exclude each other. Measured across two processes, one holding a read transaction open while the other inserts —journal_mode=delete: the writer waits out the wholebusy_timeoutand fails withsqlite3.OperationalError: database is locked;journal_mode=wal: it commits immediately.AsyncDatabaseManagernow applies both to every SQLite engine it builds, tunable throughsqlite_wal=/sqlite_busy_timeout=and throughDATABASE_SQLITE_WAL/DATABASE_SQLITE_BUSY_TIMEOUTonDatabaseSettings, and ignored on every other backend.enable_sqlite_walis public for engines built by hand. The docstring names the contention WAL does not fix — a transaction that reads first and writes later, where promoting the lock fails at once and no timeout applies. Closes #152. -
TaskQueue.on_startup/on_shutdown— the worker's lifespan. FastAPI'slifespandoes not run in the worker, so there was nowhere to open the database, the message broker or an HTTP client, and nowhere to close them: the worker worked by accident on lazy connects and never disposed its pool. Hooks take no arguments (TaskIQ's state stays reachable atqueue.broker.state), accept sync or async callables, and register on TaskIQ'sWORKER_*events by default so the web process — which has its own lifespan — is left alone;scope="client"/"both"covers the rest. For resources that already speakconnect/disconnect,TaskQueue.rabbitmq(url, resources=[db, broker])(orqueue.use(db)) does both ends in one line, opening left to right and closing right to left. The newLifecycleResourceprotocol is whatAsyncDatabaseManager,MessageBrokerandAsyncMinIOClientalready satisfy. The in-memory broker runs both sides' events in one process, so worker hooks are testable without a worker. Closes #153. -
not_found_exception(...)/conflict_exception(...). A domain-identifiable 404 was ~30 lines differing from the next aggregate's by three strings — and its signature carried a trap:BaseRepositoryraises the configured class asexception_class(message=...), so the constructor one writes first (taking the record id, because the id is what the caller holds) turns every repository miss into aTypeError— a 500 where the 404 belongs. The factories return classes that accept both call shapes, declarecodein the class body (soerror_responses()documents them andInheritedErrorCodeWarningstays quiet), file the identifier under a nameddetailskey, derive the class name from the code, and take message templates so the wording stays in the project's language. Closes #154.
Changed¶
-
Floors raised:
tempest-cli>=0.3.0,tempestweb>=0.64.0.tempest-cli0.3.0 bundlesruff, sotempest lint/fix/format/fmt-checkrun without a second install, and its tool lookup no longer picks a dead pyenv shim.tempestweb0.64.0 fixes component keys derived from the caller's key — two fields of the same kind on one screen were indistinguishable to the event router, so an edit could apply to the wrong field. -
tempestwebandtempest_coretype-check for real. Both shippy.typedas oftempestweb0.64.0, so theirignore_missing_importsoverrides are gone frompyproject.toml; mypy now checks the SDK'sssranduilayers against the real signatures instead ofAny. Clean on the first run — 380 source files, no issues. -
The repository documents the constructor contract where it bites.
not_found_exception='s own docstring now states that the class is instantiated asexception_class(message=...)and what happens when it is not accepted, instead of leaving the note further up the class docstring.
[0.226.0] — 2026-08-15¶
Changed¶
- The quality gate moved to its own package.
ruff,mypyandpytestnever had anything to do with FastAPI, but reachingtempest checkmeant installing the whole SDK: 38.7 MB of required dependencies (SQLAlchemy alone is 20.6 MB) and ~0.5 s of import time per invocation — measured withpython -X importtime, of which the module doing the work accounts for 16 µs. Closes #150.
lint, fix, format, fmt-check, type, test, check and
pr-prompt now live in
tempest-cli, a package whose
only runtime dependency is typer. The SDK declares it as a
dependency and mounts the same commands through
tempest_cli.main.register_commands(app) — one implementation, two
CLIs, so they cannot drift.
Nothing changes for existing projects. tempest check is the same
command with the same flags; [tool.tempest] typing_strictness is
read the same way; and from tempest_fastapi_sdk.cli.lint import ... /
from tempest_fastapi_sdk.cli.pr_prompt import ... keep resolving —
those modules re-export the shared implementation.
Whoever does not use FastAPI can now install the gate alone:
[tool.tempest]is now read by each key's owner.typing_strictnessbelongs to the gate and moved with it;commands(the project management commands mounted undertempest) is an SDK concept and stays here, read by the newtempest_fastapi_sdk.cli.config.load_project_commands(). The table in yourpyproject.tomlis unchanged — both keys keep working exactly as before.
The one visible consequence: TempestConfig no longer carries a
commands attribute, since the shared config has no business knowing
about FastAPI management commands. Code reading
load_tempest_config().commands should call load_project_commands()
instead.
tempest_fastapi_sdk.openapiformats generated code throughtempest_cli.resolve_tool("ruff")— the same PATH /uv runlookup the gate uses, promoted from a private helper rather than duplicated.
[0.225.0] — 2026-08-15¶
Added¶
OllamaGenerator.chat_structured(messages, schema)— structured output from a message list, so the instruction stays in asystemturn and the content being read stays inuser. Closes #148.
invoice = await generator.chat_structured(
[
{"role": "system", "content": "Extract the invoice fields."},
{"role": "user", "content": document},
],
Invoice,
)
Only generate_structured(prompt, schema) existed, and there was no
path through chat() to reach it: chat() routes every keyword into
options, and Ollama reads format at the top level — so a schema
passed to chat() is ignored silently, returning 200 OK with free
text. chat_structured posts format where the daemon reads it, and
raises TypeError on an explicit format= keyword rather than letting
it be dropped.
build_web_app(..., shell=...)andmake_web_app_router(..., shell=...)— the app shell is the only part of the HTML an application owns, and until now it was whatevertempestweb buildemitted:lang="en", no description meta, no favicon, nowhere for a CSP nonce. Closes #149.
Accepts a str (the document), a Path (read from disk) or a callable
invoked per request — which is what makes a per-response nonce
possible; the callable may declare a Request parameter or none. On
the static router the override also answers the SPA fallback, so a deep
link renders the same document. A str carrying no < is rejected:
that is a path written where a document was expected, and it would
otherwise serve a blank page with no error anywhere.
[0.224.0] — 2026-08-15¶
Added¶
uilayer (tempest_fastapi_sdk.ui, extra[ssr]). An interface layer that sits besidecontrollers/services/schemasin a service, and mirrors it one-to-one in the SDK:ui.pages(one class per screen),ui.layout(structural containers),ui.components(reusable pieces),ui.forms,ui.css. It answers only "what does this look like" — a page receives data a controller already loaded and never performs I/O.
app.include_router(make_css_router(app_stylesheet()))
class HomePage(BasePage):
total: int
def body(self) -> Widget:
return Card(title="Vendas", children=[Text(content=f"{self.total}")])
Bundled components: Card, Alert, DataTable (columns, headers and
cell text derived from the row schema), Pagination +
pagination_for(BasePaginationSchema), EmptyState, NavBar;
layout: Shell (header/main/footer landmarks) and Grid (CSS grid,
auto-fitting without a media query). All of them render class names
rather than inline styles, so the whole look lives in one stylesheet.
- Forms generated from Pydantic schemas (
tempest_fastapi_sdk.ui.forms). The schema that validates the request also describes the form.
result = await parse_form(SignupSchema, request)
if not result.ok:
return html_response(
form_for(SignupSchema, action="/signup",
values=result.values, errors=result.errors),
title="Cadastro", status_code=422,
)
form_for emits an accessible <form> — <label for> bound to the
control, aria-invalid on a failing field, aria-describedby pointing
at hint and message, and native minlength / max / step /
pattern derived from the field metadata. parse_form handles what
HTML expresses differently: an unchecked checkbox means False, a key
the body never carried stays out of the payload (so the schema default
applies and a required field reports Field required against itself),
an empty optional becomes None, and repeated keys or textarea lines
become a list. FormResult carries per-field errors plus the raw
input, so the re-render keeps what the reader typed with no extra
server-side state. exclude= + extra= keep server-owned values out
of the browser's reach. Per-field overrides through
json_schema_extra={"ui": {...}}; form_spec_for + render_form
expose the generated form as plain data to patch before rendering.
Nested models and binary fields raise UnsupportedFieldError rather
than rendering a control that cannot round-trip.
- Typed CSS (
tempest_fastapi_sdk.ui.css).Rule+Media+StyleSheetcover what an inlineStylecannot: selectors, pseudo-classes and media queries.
sheet = StyleSheet(
theme=ThemeTokens(),
rules=[
Rule(".card", declarations={"background": theme.color("surface")}),
Media.min_width(768, [Rule(".card", declarations={"padding": "24px"})]),
],
)
app.include_router(make_css_router(sheet))
ThemeTokens adapts tempest_core's TokenSet — the same one the
client renderer uses — into CSS custom properties: 39 colour roles in
light and dark (prefers-color-scheme and [data-theme]), plus
spacing, shape, typography and motion scales. make_css_router
renders the sheet once, at construction, and serves it with a
content-derived ETag (a matching If-None-Match gets a 304).
StyleSheet.cls("crad") raises instead of silently rendering an
unstyled element, and app_stylesheet() composes tokens + reset +
form rules + component rules in one call.
-
html_responsegainedstylesheets=(rendered as<link rel="stylesheet">) andhead=(raw markup appended to the document head). -
tempest new --extras "ssr"andtempest generate --srcnow scaffold the wholesrc/ui/layer (styles.py,layout/base.py,components/stat.py,pages/home.py) plusapi/routers/web.py. -
Every scaffolded project now ships a
CLAUDE.md— the rules an AI agent (or a new teammate) must follow so services stay alike: the dependency direction between layers, the exact seven-step order for a new domain (schema → model → repository → service → controller → provider → router), how to raise SDK exceptions instead of building responses, the pagination envelope, theuilayer rules, a table of what not to reimplement, the code conventions, the commands, and a definition of done that ends intempest check.
tests/cli/test_scaffold_runtime.py keeps it honest: it writes the
document's own example domain into a scaffolded project and runs it —
POST returns 201, a duplicate returns 409 carrying
code="PRODUCT_NAME_TAKEN", and the paginated listing comes back as
{items, total, page, page_size, pages}. Every SDK symbol the
document imports is resolved against the package, so a rename fails
here rather than in someone's project.
Changed¶
Pagemoved fromtempest_fastapi_sdk.ssr.pagetotempest_fastapi_sdk.ui.pages, where it sits next to the components, layouts and forms a page is built from.from tempest_fastapi_sdk.ssr import Pagekeeps working and returns the very same class.
Notes¶
- Measured, and pinned in
tests/ui/test_core_contract.py: under thetempestwebHTML renderer,tempest_core'sFormrenders as a<div>,Inputrenders without aname(so nothing is submitted) andDropdown/TextArearender as empty<div>s — those widgets belong to the reactive client.ui.formstherefore emits form elements through the documentedtag/attrsescape hatch. Stylevalidates colours as hex literals, so a token reference (var(--t-color-primary)) goes inRule.declarations, never inStyle.
[0.223.0] — 2026-08-14¶
The voice stack was built as four increments (0.219.0-0.222.0) and this release is the first of them to reach PyPI. They are kept as separate entries below because they document distinct pieces of work, but only this version exists as a package: the concurrency defect fixed here was introduced in 0.219.0, so publishing the increments in order would have shipped it four times before the fix.
Added¶
- Face detection and recognition (
tempest_fastapi_sdk.faces, extra[faces]).FaceRecognizerfinds faces, aligns each one and turns it into a comparable vector;compare_facessays whether two vectors are the same person.
recognizer = FaceRecognizer()
faces = await recognizer.recognize("group.jpg") # largest first
same = compare_faces(faces[0].embedding, faces[1].embedding)
Measured separation on a six-person group photo with the default pack: the same face across a re-encoded (0.962), rotated (0.952) and tightly cropped (0.877) version, against a maximum of 0.180 across all fifteen pairs of different people. The default threshold of 0.45 sits in the middle of a gap of nearly 0.7, which makes it a safe default rather than a tuned one — the opposite of the speaker-diarization case, and the docs say so because the intuition does not transfer.
detect() returns boxes, scores and landmarks and no vectors, for the
questions that do not need biometrics: is there a face, how many people,
where to crop.
embed_face() is the enrolment shape and refuses an image with no face
or with only a tiny one. At enrolment a bad vector is not a bad answer, it is
a permanently wrong profile.
- Two model packs, defaulting to the small one. Measured:
buffalo_sis 16 MB and 15 ms againstbuffalo_lat 191 MB and 54 ms, and its separation differs by 0.02 at either bound. Twelve times smaller for that is not a trade worth refusing; the large pack is one keyword away for small, dim or turned faces. Weights are fetched byensure_models(), which belongs in a build step.
Changed¶
insightfacewas measured and rejected. It packages this pipeline, and it installs 558 MB across 24 packages — and theopencv-pythonit requires links against five GL libraries, so a slim container would need system graphics libraries to recognise a face. Running the same ONNX models directly costsonnxruntime+numpy+pillow, which the SDK already carries, and no system libraries at all. The price is the SCRFD decoding and the alignment transform, which are closed-form geometry rather than a long tail of correctness.
Rejected before it: facenet-pytorch (pins torch<2.3.0, capping every
consumer), deepface (TensorFlow) and face-recognition (dlib, compiled).
facesand the audio decode path carry their real types instead ofAny:Image.Imagefor every image parameter and return, andnpt.NDArray[...]for the arrays. Verified load-bearing rather than cosmetic — mypy now rejectsalign_face("not-an-image", ...)withincompatible type "str"; expected "Image", whereAnyaccepted it.
Fixed¶
-
A tight crop is detectable. A 112×112 portrait whose face touches the frame returned zero detections; the detector needs context around a face and an already-tight crop has none to give. Every image now gets a 20% margin before inference, and one below 320 px is upscaled first — measured to take that case from zero faces to one, matching the original at 0.877.
-
A face too small to embed says so rather than returning a vector that describes its own upscaling. Below 40 px on a side the face comes back with an empty
embedding, so a caller can tell "nobody recognisable" from "no face at all". -
Two concurrent transcriptions no longer read each other's speaker count.
ConversationTranscriber.transcribe(num_speakers=...)assigned the count onto the shared diarizer before using it, so two overlapping requests both saw whichever was written last — the caller who asked for two speakers was clustered into five, with no error anywhere. Reproduced, then pinned:tests/genai/audio/test_concurrency.pyasserts[2, 5]and returned[5, 5]against the old code. The count is now a per-call argument,SpeakerDiarizer.diarize(audio, num_speakers=...), and the transcriber passes it through instead of mutating the object.
The same path also called diarizer.unload() to make the new count take
effect, dropping ~46 MB of loaded models out from under any request running
beside it. It is gone; a test asserts it is never called.
The engine carries its cluster count in its own config, so set_config and
process are now held under a threading.Lock: two worker threads doing
that pair on one engine would interleave, which max_concurrent > 1 makes
reachable.
- An enrolment recording is decoded once.
VoiceEmbedder.embed_for_enrollmentdecoded the audio to measure its duration and then decoded it again to embed it. Decoding is the expensive half of that call, so a 30 s enrolment was resampled twice for one vector. The length check moved into the worker that already has the samples.
[0.222.0] — 2026-08-14 (not published; folded into 0.223.0)¶
Changed¶
SpeakerDiarizerestimates the speaker count by default (num_speakers="auto"). Naming the participants is no longer required to get a correct split.
Diarization has to answer two questions and only one is easy: turn boundaries come from the segmentation model, but how many distinct voices comes from no model at all. Measured on a twelve-recording benchmark whose count is correct by construction — turns cut from distinct recordings, rather than from the diarizer's own output, which is the circular benchmark this work started with and discarded:
| method | exact | mean error |
|---|---|---|
| threshold 0.5 | 4/10 | 1.90 |
| threshold 0.7 | 8/10 | 0.40 |
| threshold 0.9 | 8/10 | 0.20 |
| automatic | 12/12 | 0.00 |
num_speakers=<int> remains exact and cheapest — it skips the second pass —
and stays the recommendation when the count is known. num_speakers=None
keeps threshold-only clustering, now documented as the weakest option.
- The clustering threshold is no longer the primary knob, so v0.219.0's "no threshold is right on all recordings" caveat is resolved rather than documented: the estimate does not use one.
Added¶
-
estimate_speaker_count— spectral gap over the turn-embedding affinity matrix. A threshold asks how close is close enough, whose answer moves with the microphone, the language and the room; the gap asks where does this matrix split, which is a property of the recording. -
affinity_report— the eigenvalues, the gaps and the winning margin behind an estimate. "It said four" is not something a person can act on; two near-equal gaps mean the answer could as easily have been three, which is the moment to pass the count explicitly.
Fixed¶
- A monologue is no longer reported as a conversation. The gap search always finds a split, including where there is none: a real six-turn dictation came back as two speakers, which would turn a voice note into a conversation.
A single voice is uniformly similar to itself — even its most distant pair
of turns is close — while two voices produce genuinely distant pairs.
Measured across the twelve recordings, the 10th percentile of pairwise
similarity was 0.490-0.667 for one speaker and -0.080-0.166 for more than
one, and SOLO_COHESION_P10 sits in the middle of that gap with at least
0.14 of margin on each side. It is a property of the bundled model's
similarity scale, not a universal constant, and the docstring says so.
[0.221.0] — 2026-08-14 (not published; folded into 0.223.0)¶
Added¶
make_voice_router— opt-in FastAPI routes for the voice pipeline:POST /voice/transcribe(upload a recording, get it back split by speaker) plusPOST/GET/DELETE/voice/profiles.
The listing and deletion routes exist because the person owns this data, not as a convenience. And the listing never returns the embedding: they need to know a profile exists, not to receive a copy of their own biometric template over HTTP.
tempest voice models|diarize|transcribe— the same pipeline from a shell.diarizenever loads Whisper, which makes it the quick way to check whether the speaker count and threshold are right before paying for transcription.modelsfetches the weights, which belongs in a build step.
Changed¶
-
profiles=withoutcurrent_user_id=raises at wiring time. Enrolling or erasing against a user id taken from the request body would let any caller write biometric data into somebody else's account. Failing when the app is built is the only useful moment to fail — in production it is already shipped. -
Uploads are bounded (25 MiB default) while reading, not after. Audio is held in memory to be decoded, so an unbounded upload exhausts the worker; and measuring after reading the whole body means the oversized upload already occupied the memory it was supposed to be denied.
[0.220.0] — 2026-08-14 (not published; folded into 0.223.0)¶
Added¶
- Voice identification — recognising who a speaker is
(
tempest_fastapi_sdk.genai.audio). Diarization separates speaker 0 from speaker 1; this puts a name on them by matching against enrolled profiles.
VoiceEmbedder turns a recording into a voiceprint, VoiceProfileService
enrols, identifies and deletes, and ConversationTranscriber.transcribe(
identify_with=..., session=..., user_ids=...) labels a whole conversation in
one call.
Measured end to end with real voices: enrolling from one turn and identifying
a different turn by the same person scored 0.687 and 0.734, while an
unenrolled speaker returned None.
Identification runs once per speaker cluster, on that cluster's longest turn — every turn of a cluster is the same voice by construction, so embedding each would pay the model N times for one answer and could label two turns of one person differently.
BaseVoiceProfileModel+make_voice_profile_model— the enrolled voiceprint with its consent record. The raw audio is deliberately absent: nothing here writes a recording, and the vector cannot be played back, so a leak of this table costs far less than a leak of the enrolment clips.
Changed¶
-
Enrolment refuses to run without recorded consent. A voiceprint identifies a person like a fingerprint template; under the LGPD it is sensitive personal data (Art. 5, II) needing specific, highlighted consent (Art. 11, I), which general terms of service do not provide. So
consent_referenceis required, a blank one raisesConsentRequired, and the evidence is stored on the same row as the vector rather than trusted to a flag somewhere else. This is not configurable. -
forget_user()is a method, not an example. Deleting biometric data is an unconditional right (Art. 18, VI) and must not depend on each project writing theWHEREclause correctly. -
A profile written by another embedding model is never compared. Similarity between vectors from different models is a number with no meaning, and a meaningless number that looks like a score is worse than an error.
model_nameis recorded per row andstale_profiles()finds the profiles a model swap invalidated — otherwise those people silently stop being recognised with nothing to indicate why. -
Enrolment refuses audio below 3 seconds. A profile built from one word matches almost anyone, and unlike a bad match, a bad profile keeps being wrong until somebody deletes it.
[0.219.0] — 2026-08-14 (not published; folded into 0.223.0)¶
Added¶
- Speaker diarization — who spoke when (
tempest_fastapi_sdk.genai.audio, extra[genai-diarization]). Transcription already answered what was said;SpeakerDiarizercuts a recording into turns and clusters the voices, andConversationTranscriberjoins the two into a transcript attributed line by line.
transcriber = ConversationTranscriber(
stt=SpeechToText(model_size="small"),
diarizer=SpeakerDiarizer(num_speakers=2),
)
conversation = await transcriber.transcribe("call.wav", language="pt")
print(conversation.transcript()) # "Falante 0: ...\nFalante 1: ..."
print(conversation.by_speaker()) # {0: "...", 1: "..."}
The engine is sherpa-onnx, measured against the alternative.
pyannote.audio 4.0.7 declares 21 runtime dependencies — torch>=2.8,
lightning, matplotlib, three OpenTelemetry packages and a client for its
vendor's paid API — and its pretrained pipeline is gated on HuggingFace, so a
container build needs a token and a manually accepted licence. sherpa-onnx
declares one dependency, runs on ONNX Runtime with no PyTorch, and its models
are open. On a 57-second four-speaker recording it separated all four at RTF
0.125 on CPU.
The recording is transcribed once, not once per turn, then attributed by
timeline overlap. Handing Whisper two-second clips throws away the context it
uses for punctuation and costs an inference per turn. The cost of that choice
is stated rather than hidden: a Whisper span straddling a speaker change
lands wholly on whoever holds more of it, and speech the diarizer dropped as
too short comes back as speaker = -1 instead of vanishing.
Models are not bundled — 46 MB does not belong in a wheel most services
install for other reasons. ensure_models() fetches them once, honoring
TEMPEST_VOICE_MODEL_DIR so a deployment can bake them into an image layer.
DiarizedTranscription/SpeakerTurnwithtranscript()(labelled lines) andby_speaker()(everything one person said).
Changed¶
-
The clustering threshold defaults to 0.9, not sherpa-onnx's 0.5. Swept over three reference recordings, no single value is correct on all of them: 0.5 produced seven clusters for four speakers, while 0.9 is right on two of three and fails by merging rather than by inventing participants. The measurement table is in the docstring and the recipe, and the docs say plainly that passing
num_speakersis the difference between right and wrong, not an optimization. -
sherpa-onnx-coreis declared explicitly in the extra.sherpa-onnxkeeps its compiled libraries there and its wheels declare the dependency, but its sdist metadata does not — and uv locks from the sdist, souv syncinstalled the wrapper alone and the first call died withImportError: libonnxruntime.so: cannot open shared object file. Naming it is what makes the extra installable; it is not redundant.
Fixed¶
- Speaker indices are dense. The clustering returns whatever its internal
bookkeeping produced — a four-speaker recording yielded
0, 1, 2, 4, 7, 8, 9. Passed through, the gaps read as participants who were present and silent, andnum_speakersstopped matching the largest index. Turns are now renumbered0..n-1in order of first appearance.
[0.218.0] — 2026-08-13¶
Added¶
- PDF generation from HTML templates (
tempest_fastapi_sdk.pdf, extra[pdf]=weasyprint+jinja2). Every service eventually issues a document, and what breaks is never the rendering: it is the printed total disagreeing with its own lines, the amount in words being wrong, the table header vanishing on page 2, the logo failing to load without anyone noticing.
PdfRenderer renders an HTML string, a template, or a typed document;
make_pdf_router serves them over HTTP; tempest pdf list|schema|render
drives them from a shell.
pdf: bytes = await PdfRenderer().render_document(
ReceiptDocument(
issue_date=date(2026, 8, 13),
issuer=Party(name="Acme LTDA", document="12345678000195"),
payer=Party(name="Ana Souza"),
amount_cents=125000,
reference="consultoria de julho/2026",
),
)
Five bundled documents, each with a Pydantic schema: ReceiptDocument,
QuoteDocument, ReportDocument, ContractDocument, VoucherDocument. The
schema is the reason bundling templates is worth anything — an HTML file
alone tells you nothing about the keys it needs, so the first missing field
shows up as a blank space in a signed document. Totals are computed from
the items, never accepted, and a discount above the subtotal is refused
rather than printed as a negative price.
The engine is WeasyPrint, for CSS Paged Media: repeating headers,
página X de Y, controlled page breaks. A browser-based renderer costs a
150 MB browser in the image; a pure-Python one cannot paginate a report and
would have pinned reportlab<5 on every consumer. WeasyPrint's own
requirements are lower bounds only.
Brazilian formatting is part of the module, not the templates:
format_cents, format_date/format_date_long, format_document,
format_quantity, and valor_por_extenso — conventional on a recibo, and
exactly the thing that gets written from memory, wrongly. Every connector
case is pinned by test, including um milhão de reais versus dois milhões
e quinhentos mil reais.
Reproducible output needs SOURCE_DATE_EPOCH. WeasyPrint writes no
creation date and no document identifier, but the embedded font subset stamps
a timestamp into its head table, so two renders seconds apart differ —
measured as three hashes across three runs of one container. Pinning
SOURCE_DATE_EPOCH, the convention fontTools honors, makes the output
byte-identical across processes; the test asserts that across a real process
boundary, because two renders inside one process match trivially. Even
pinned, the bytes depend on the font and WeasyPrint versions, so a hash does
not travel between images.
Rendering is CPU-bound, so every call goes through a worker thread behind a
semaphore (max_concurrent, default 4) and the event loop never stalls.
AssetPolicy— templates fetch nothing by default. An HTML renderer resolves URLs on the page's behalf, so a document carrying user data turns<img src>into both a local-file read (file:///etc/passwd) and an SSRF (http://169.254.169.254/).data:URIs always pass because they fetch nothing; a local directory has to be named, and the check is on the resolved path so neither../nor a symlink escapes it. Refusal is loud: the fetcher carries_fail_on_errors, so the render aborts at the first refusal instead of producing an invoice with a hole where the logo was.strict_assets=Falserestores the lenient behavior and logs what it dropped.
Branding.logo_data_uri accepts only data: — a URL would be refused at
render time and silently produce a document with no logo. accent_color,
page_size and margin are shape-constrained because they are written into
the stylesheet, where a value carrying ; or } could close the rule.
tempest generate --dockerfileemits the system packages when the project pins[pdf]. WeasyPrint draws text through Pango and resolves fonts through fontconfig; apython:slimimage has neither, and the failure appears at the first render rather than at build time.fonts-dejavu-coreis in the list because a container with no font lays the document out correctly and draws every glyph as a box.
Recipe: docs/recipes/pdf.md.
Changed¶
- Every public re-export now uses the PEP 484
from x import Y as Yform. 794 import lines across 17__init__.pyfiles were rewritten. Nothing changes at runtime; what changes is that basedpyright and Pylance in strict mode stop reporting "private import usage" when a consumer imports a documented symbol — a diagnostic the SDK was putting in their editor.
The rule had been written in CLAUDE.md for months and was violated 794
times, which is why it now has tests/test_reexport_guard.py. Scope is the
names in __all__: a helper an __init__.py imports for its own use is not
a re-export, and aliasing it would say the opposite.
tests/test_vacuous_guard.pyfails a test whose name or docstring claims to have crossed a boundary — "across processes", "across replicas", "survives a restart" — while the body stays in one process. That is the shape that let this release's own determinism claim ship: the test compared two renders inside a single process, where matching means nothing.
It deliberately does not police the vocabulary of guarantees. The first
draft flagged deterministic / reproducible / idempotent and hit 22
tests, of which roughly twenty were correct as written — idempotence is
f(f(x)) == f(x), an in-process property, and one flagged test asserts
bcrypt is non-deterministic. A guard whose hits are mostly noise teaches
people to add skip markers.
Fixed¶
- The report's grand total no longer repeats on every page. It was a
<tfoot>, which istable-footer-groupand repeats by definition — so the total printed at the foot of page 2 above rows that summed to something else. It is now the last row of the body, still column-aligned, and a test reads the text of each rendered page to hold it there.
[0.217.0] — 2026-08-13¶
Added¶
- WebAuthn / passkeys (
tempest_fastapi_sdk.auth.webauthn, extra[webauthn]). TOTP proves the user holds a shared secret, and a phishing page that forwards the code in real time defeats it. WebAuthn binds the assertion to the origin that asked for it, so a credential registered forapp.example.comproduces nothing a page onapp-example.comcan use. That property, not "passwordless", is why this shipped.
WebAuthnService runs both ceremonies — register begin/complete,
authenticate begin/complete — plus credential listing and removal.
make_auth_router(webauthn=...) mounts six routes when
AUTH_WEBAUTHN_ENABLED is on; enabling it without the service raises at
wiring time rather than 500-ing per request, matching how
recovery_code_model gates MFA.
webauthn = WebAuthnService(
user_model=UserModel,
credential_model=UserWebAuthnCredentialModel,
auth_settings=settings,
)
app.include_router(
make_auth_router(service, session_factory=db.session_dependency, webauthn=webauthn),
)
BaseWebAuthnCredentialModel + make_web_authn_credential_model hold the
table. It stores public keys — unlike a password hash, a full leak of it
authenticates nobody. credential_data is kept as the opaque blob fido2
round-trips, so an upstream format change never needs a migration of parsed
columns.
What the SDK checks beyond the library. The signature counter: one that
did not advance since the last assertion is the spec's cloned-authenticator
signal, and fido2 verifies the signature without tracking it. Authenticators
that always report 0 — most platform passkeys — are exempt, because for
them the counter carries no information. Also: the challenge is popped on use
(a captured response is spent), a credential ID is unique per table rather
than per account, an inactive account cannot log in, and deletion is scoped to
the owner so a foreign ID answers 404 exactly like one that does not exist.
authenticate_begin never reveals whether an account exists. An unknown
email produces a normal ceremony with an empty credential list; answering
differently would make the endpoint an enumeration oracle.
Passkey login does not go through the MFA challenge, deliberately: a passkey with user verification already proves possession and a local factor, which is what the second step exists for.
-
AUTH_WEBAUTHN_*settings —ENABLED,RP_ID,RP_NAME,ALLOWED_ORIGINS,USER_VERIFICATION,RESIDENT_KEY,CHALLENGE_TTL_SECONDS.RP_IDis the security boundary and an empty one is refused at construction.ALLOWED_ORIGINSexists because thefido2default (https://<rp_id>and subdomains) is right in production and wrong on a Vite dev server; when set it replaces that rule entirely, so it is an explicit decision rather than a silent relaxation. -
MemoryWebAuthnChallengeStore/RedisWebAuthnChallengeStorefor the state between the two halves of a ceremony. The Redis one usesGETDEL, so read-and-delete is one operation and two concurrent completions cannot both find the state. -
A software authenticator in the test suite. The tests drive
fido2's real verification with genuine artifacts — attestation object, authenticator data, ES256 signature — because the properties worth testing here (origin binding, single-use challenge, advancing counter) are exactly the ones a mocked verifier would assert away. It supports being wrong on purpose (replaying a counter, signing for a lookalike origin) so the suite can assert the server rejects it.
Recipe: docs/recipes/webauthn.md.
Changed¶
- New optional dependency
fido2>=2.0.0in the[webauthn]extra. It declarescryptography!=35,<52,>=2.6; the upper bound is accepted because it is confined to an optional extra and nothing in the base install resolves it, and because WebAuthn is real engineering — CBOR, COSE keys, attestation formats, signature verification — not a preset table the SDK should own. Same criterion applied todiffusersin v0.177.0.
[0.216.0] — 2026-08-13¶
Added¶
- Token buckets and per-plan quotas (
tempest_fastapi_sdk.api.middlewares.quota). The rate limiter answered one question — did this key exceed N requests in the last W seconds? — and two shapes a paid API needs were not expressible: a burst a well-behaved client is allowed to spend at once, and several limits applied together (60/min under a 1000/day ceiling, per tier).
RateLimitRule describes one limit: a sliding window, or a token bucket
when burst is set, refilling max_requests / window_seconds tokens per
second up to burst. A RateLimitPolicy resolves which rules apply —
StaticRateLimitPolicy for a single tier, PlanRateLimitPolicy for a
mapping of plan to rules, fed by plan_by_jwt_claim / plan_by_header.
app.add_middleware(
RateLimitMiddleware,
policy=PlanRateLimitPolicy(
{
"free": [RateLimitRule(60, 60.0), RateLimitRule(1_000, 86_400.0)],
"pro": [RateLimitRule(600, 60.0, burst=100)],
},
resolve=plan_by_jwt_claim(jwt, "plan"),
default_plan="free",
),
)
A rejected request spends nothing. A caller blocked by the daily ceiling
must not burn a token from the per-minute limit, or the minute drains
without a single request served. MemoryQuotaStore decides every rule under
one lock before writing any; RedisQuotaStore does it inside one Lua
script, which is the only way the whole list is atomic across replicas — a
client-side loop over single-rule calls is not.
PlanRateLimitPolicy validates its configuration at construction (empty
mapping, default_plan outside it, a plan with no rules) because each of
those only surfaces in production as unlimited traffic. An unknown plan
name falls back to the default instead of raising: a request is the wrong
place to discover a typo, and the safe answer is to limit it.
key_by_plan_principal prefixes the resolved plan onto the principal key,
so an upgrade writes to fresh counters rather than inheriting exhausted
ones.
-
RateLimit-Limit/RateLimit-Remaining/RateLimit-Resetheaders on every response, describing the tightest rule — the number a client should pace itself against.RateLimit-Resetis emitted only where it is known (always in policy mode, only on 429 in single-window mode): the sliding-window store reports no reset for an accepted request, and a guessed number is worse than an absent header. Turn the whole set off withlimit_headers=False. -
lupain the dev group, sofakeredisexecutes Lua. The atomicity this feature promises lives inside the Redis script; a fake that re-implements its contract in Python asserts nothing about it. Both stores now run the same behavioral suite, the Redis half against the real script.
Fixed¶
- A slow token bucket no longer refills for free. The bucket key's TTL is
the time to fill the bucket (
capacity / rate), not the rule's window. With a window-derived TTL, a bucket refilling at 10 tokens/minute withburst=1000— over an hour to fill — expired long before that, and the next request found a full bucket. The in-process store has the same fix: it records, per key, the instant its state stops differing from absent, and sweeps against that instead of a fixed age.
[0.215.0] — 2026-08-09¶
Changed¶
- BREAKING —
tempest_fastapi_sdk.openpixmoved totempest_fastapi_sdk.integrations.payment.openpix. Grouping third-party clients by what the provider does keeps the top-level namespace clean and gives the next provider an obvious home. No deprecation shim: the old path existed for a matter of hours, in v0.214.0 alone, and a shim for it would be the exact clutter the move is about. Update the import; nothing else about the surface changed.
Added¶
- The whole OpenPix API now ships in the SDK. v0.214.0 required every
service to run
tempest openapi-clientand keep the output; that meant the same generation and the same hand-written layer maintained in every repository. All 358 schemas and 105 operations are now checked in:
from tempest_fastapi_sdk.integrations.payment.openpix import (
Charge, OpenPixClient, OpenPixEnvironment,
)
The generated half is produced by scripts/regen_openpix.py from the
specification pinned in vendor/openpix-openapi.yaml (make
openpix-regen), and a test fails if the files on disk drift from what
that script produces — checked-in generated code is only trustworthy if
hand edits are loud.
Loaded lazily. Building 358 Pydantic models costs the better part of a
second, and importing the package for to_cents should not pay it, so the
generated modules resolve through PEP 562. Measured: 2 ms to import the
package, ~200 ms on the first generated name, ~0.03 ms after. A test asserts
in a subprocess that importing the package leaves …openpix.schemas out of
sys.modules.
vendor/openpix-openapi.yaml is build-time input and stays out of the
wheel.
Fixed¶
- Generated clients no longer produce a
no-any-returnper operation. The emitted_validatehelper was typed-> Any, and every generated method returns its result — so a strictmypyrun reported one error per operation: 98 on the OpenPix specification, in the consumer's own gate. It is now generic (def _validate(annotation: type[_T], data: Any) -> _T), which is also more honest: the call really does return the annotation it was given. Found by embedding the generated code inside the SDK, wheremake checktype-checks it.
[0.214.0] — 2026-08-09¶
Added¶
tempest_fastapi_sdk.openpix— the thin typed layer over a generated OpenPix integration. Submodule import, no extra, and deliberately not a second copy of the generated package:tempest openapi-clientalready turns the OpenPix specification into 358 schemas and 105 operations, and those stay in the consuming service. Embedding them would double the SDK to freeze a third party's spec into a release.
What it supplies is the four things the specification does not say, and that every OpenPix integration re-derives by hand:
OpenPixEnvironment— production isapi.openpix.com.br, testing isapi.woovi-sandbox.com. Different domains; neither spells the other. Both read from the spec'sserversblock.to_cents/reais_to_cents/cents_to_reais— the specification says "Value in cents of this charge" and types the fieldnumber, so a generated model hands you the float1990.0.to_centsnarrows it exactly and refuses a fraction rather than rounding: the field is already cents, so a fraction means the caller passed reais, and rounding would hide that behind a plausible number.reais_to_centsrounds half-up, which is what money expects and not what the built-inrounddoes (round(0.005 * 100)is0).OpenPixEvent— all 28 webhook events, ported verbatim fromWebhookEventEnum. TheOPENPIX:prefix is not uniform (charge and dispute events carry it, the Pix-automatic family does not); a test pins both so the next reader does not "fix" it.make_openpix_webhook_dependency()— the piece nobody had. The SDK already shippedRSAWebhookSignatureVerifierand the spec already had the events; nothing tied them to the header name and the public key. The dependency verifies, decodes and yields a typedOpenPixWebhookEvent.
Two behaviours chosen to keep a service up. An unrecognized event does not fail the request — OpenPix adds events, and a 500 on one you have never seen turns their release into your outage. A non-JSON body that verified is still delivered: it came from OpenPix, and rejecting it would discard a delivery the provider considers sent.
OpenPixWebhookEvent is a frozen dataclass, not a BaseSchema. That is
load-bearing: BaseSchema sets use_enum_values=True, which stores the
event as a bare str, and the documented
event.event is OpenPixEvent.CHARGE_COMPLETED would then be silently
false on every delivery.
Security, stated plainly. OpenPix's published key is RSA-1024
(verified on load: 1024 bits, exponent 65537), below the 2048-bit floor NIST
has recommended since 2013. A valid signature is evidence the delivery came
from OpenPix — not authorization to move money. The recipe tells you to
re-read the charge from the API before acting on CHARGE_COMPLETED, and to
keep the handler idempotent, since the signature covers the body alone and a
captured delivery replays forever. The key is overridable
(webhook_verifier(public_key_pem=...)) so a rotation does not strand
consumers on an SDK release, and decode_public_key handles the base64 form
the provider actually publishes, checking the result is a PEM so a truncated
paste fails immediately rather than as a signature mismatch in production.
Recipe: docs/recipes/openpix.md.
[0.213.0] — 2026-08-09¶
Fixed¶
Generating against the real OpenPix specification (847 KB, 358 schemas,
105 operations) — the one whose prose motivated v0.211.0 — produced output
with 12 E501 errors, 8 of which survived ruff format. The hostile-spec
suite had not caught them because they need long names, and the names
that overrun are the ones the generator synthesizes itself.
- The
name: Annotation = Field(line was never measured. Inline schemas get a class name built by concatenating the whole path (PostApiV1DecodeEmvResponseEmvMerchantAccountInformationPix), so the annotation alone can overrun before any argument is reached.ruff formatthen wraps the assignment and re-indents the arguments one level deeper — so every string the emitter had pre-split to fit column 88 came out at 92. One defect, two symptoms, and the second one made the first look like a splitting bug.
The emitter now picks the same shape ruff format would, in the same
order it tries them: the plain head while it fits, then the wrapped
assignment with arguments at 12, then a broken annotation with arguments
back at 8. A whole-subscript annotation (list[Item]) breaks inside
its brackets rather than being parenthesized, because that is the form
ruff keeps.
-
ruff formatjoined split strings back together. v0.211.0 forced every split to yield at least two adjacent literals, on the reasoning that a lone parenthesized literal is un-parenthesized. That is true only when the collapsed line then fits — which is a line the emitter never splits. On real enum values the forced second piece was joined back, so the file failedruff format --check. Splits are now exactly as deep as the budget requires. -
A long
exampleslist was never split. The splitter gave up on anything that was not a string, leaving a 229-character line. Lists and dicts are now exploded only where they overrun, and the key prefix is measured rather than glued on afterwards — checking the value alone and prepending"OPENPIX:…":afterwards left two lines over on the first attempt. -
return _validate(LongResponse, response.json())was never measured, and inline response schemas are named after their path. -
Synthesized class names are capped (
MAX_CLASS_NAME = 55). The binding constraint is not theclassstatement but a docstringAttributes:entry, where the annotation composes around the name (dict[str, Name] | Noneadds 18 columns at an indent of 12) andruff formatbreaks neither. Measured on the OpenPix specification: 6 of 358 names truncated, no new collision.
Known limit, stated rather than papered over. A very long field name
next to a long single-identifier annotation (x: SomeLongClassName =
Field() has no formatting that fits — ruff format collapses it, because
a bare identifier has nothing to split. Only shorter names help. Every
annotation with a union or a subscript in it does have a stable shape, and
the emitter now produces it.
[0.212.0] — 2026-08-09¶
Both entries below came from auditing the openapi-client recipe against
the code rather than from a bug report. No test catches prose that promises
a feature, which is exactly how one of them survived several releases.
Added¶
# openapi: unsupportedmarkers in the generated code. The docs had promised this comment for releases while the package emitted zero of them — the only record of a gap was the command's summary, which is terminal output that scrolls away. Someone openingschemas.pymonths later and finding anAnyhad no way to learn why short of regenerating from the same specification.
Now every gap is attributed to the field, parameter or operation that
raised it (FieldIR / ParameterIR / OperationIR.unsupported, filled
by a new _Parser.capture()), and the emitters write it above the
affected line:
# openapi: unsupported — `not` in ThingWeird rendered as Any (no Python
# equivalent)
weird: Any | None = None
Greppable on purpose: grep -rn "openapi: unsupported" src/integrations/
lists everything an integration lost. Two details are load-bearing — the
comment goes above the line rather than trailing it, so a long reason
wraps instead of overrunning the budget and ruff format has nothing to
move; and the per-target de-duplication is independent of the summary's,
because the summary must not repeat itself while two fields hitting the
same gap both need marking. A gap with nothing in the output to mark (a
dropped header parameter) stays summary-only.
Fixed¶
- The command summary's header claimed something false. It hardcoded
N construct(s) could not be modelled (rendered as Any, marked in the output), but roughly half the notes describe a different treatment entirely — aheaderparameter is skipped, a discriminator is ignored, a path placeholder is synthesized. None of those becomesAny, and until this release nothing was marked in the output at all. Each individual note was always accurate; only the header lied. It now says each line states what was generated instead.
[0.211.0] — 2026-08-09¶
Fixed¶
tempest openapi-client run against a real third-party specification
produced a package that did not import, did not lint, and in one case
silently changed what the specification said. Every case below is now a
test in tests/openapi/test_hostile_spec.py, generated with
run_format=False on purpose — the ruff --fix pass the command runs
afterwards was hiding three of them, so they only ever reached a caller
passing --no-format.
-
A description with a quote, a newline or a backslash emitted a broken literal. The emitter double-quoted a value by interpolating it raw, guarded by
"'" in repr(value)— which matchesrepr's own delimiter, not an apostrophe in the text, so every quote-free string took that path. A description carried over from a YAML block scalar emitted an unterminated string andschemas.pydid not import; one containing\bor\x41changed value in silence. Text is now rendered by a real literal writer, containers included, so a string nested in an objectexamplegets the same treatment. -
Quote style now matches what
ruff formatnormalizes to. Text carrying more"than'comes out single-quoted. The project's rule is double quotes, butruff formatprefers whichever escapes less, so the double-quoted form was correct code that failed the consumer'sruff format --checkon their first run. -
\#in the spec's prose emitted a docstring that warns. It is not a Python escape —W605, and aSyntaxWarningfrom 3.12. The docstring is nowr"""when any of its prose carries a backslash, and the decision is taken before wrapping, since thercosts a column on the line already closest to the budget. -
An over-long description or enum value overran the line budget.
ruff formatnever breaks a string, so the emitter has to split it — into two or more adjacent literals, because a lone parenthesized literal is joined straight back onto the long line. Concatenating the emitted pieces reproduces the specification's wording character for character. An enum member name is derived from the value, so a provider spelling a status out in a sentence overran before the value was reached at all; the name is now capped (the value never is). -
A summary longer than one line was left long. Both emitters wrapped only some of their prose, and
ruff formatdoes not rewrap a docstring. Wrapped continuation lines also no longer hang one level deeper than theAttributes:heading that follows them. -
transactionandTransactionin the same spec producedTransaction_2, which is not CapWords and fails the consumer'sN801. Class collisions now resolve toTransaction2; field collisions keepreference_2. -
A property named
2fawas emitted verbatim and the module did not parse. It becomesfield_2fawithalias="2fa". The prefix isfield_, not_: a leading underscore makes Pydantic treat the attribute as private, so the field would vanish from the model rather than merely be renamed. -
Path parameters and the path template did not have to agree. Three cases, each reported in the command summary rather than guessed at: a
pathparameter the template never interpolates is now dropped (the caller passed an identifier and the request dropped it on the floor — the one failure mode the generator must not produce in silence); a placeholder no parameter declares is synthesized as a requiredstr(the emitted path is an f-string, so skipping it left the method referencing an undefined name); and path parameters are ordered by their position in the template, since they are the generated method's only positional arguments and a spec listing them out of order handed a caller reading/{a}/{b}a signature spelled(b, a). -
The path and every wire name now go through the literal writer too. Both come from the specification, and one carrying a quote or a backslash emitted a module that does not parse.
[0.210.0] — 2026-08-09¶
Added¶
tempest pr-prompt— the prompt that makes an AI write the PR description. The branch is green and the description is the step everyone skips. 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 produced. The command assembles both and writes the prompt to stdout, so it pipes into whichever assistant the user runs:
The repository's own template wins — .github/pull_request_template.md
and the other conventional spellings, GitLab's included — because it is
the contract that project's reviewers read; --template overrides it,
and a bundled PT-BR / EN-US template covers the repository that has
none. Around it go the rules that stop the model from returning the
template with its placeholders still in it (no undecided Sim/Não, no
italic instruction text, no section dropped, no invented migration or
env var), and the branch context: commit subjects, --name-status and
bounded patch excerpts.
Three decisions the shape depends on:
- Patches are read as
base...head— the merge-base diff the forge shows on the pull request. The two-dot form would attribute every commit that landed on the base since the branch started to this PR. - Files are excerpted most-changed first, not alphabetically:
--max-filesspent in git's order goes to.github/andCHANGELOG.mdbefore reaching the file the PR is about. - Nothing is cut silently. A truncated patch is marked, the files left without an excerpt become a line in the prompt, and the cut respects line boundaries — half a diff line reads as code that does not exist.
Only the excerpts are bounded: the commit list and the changed-file
list always go in whole, so the model always knows what changed and
only how it changed is sampled. --full excerpts every file with its
whole patch, and refuses to run next to --max-files / --max-chars
rather than silently overriding a number the caller typed.
Public surface in tempest_fastapi_sdk.cli.pr_prompt:
generate_pr_prompt, collect_context, build_prompt,
resolve_template, bundled_template, files_by_churn,
diff_excerpts, commit_subjects, changed_files, resolve_base,
current_branch, repository_name, repository_root, plus
PullRequestContext / ResolvedTemplate / DiffExcerpt /
PromptLanguage / GitError. Recipe: docs/recipes/cli.md.
[0.209.0] — 2026-08-09¶
Fixed¶
prefetchreaches the class-based consumer path.mq.on(channel, prefetch=N)translated the cap into the FastStreamChannelthat carriesbasic.qos;Consumer/@subscribedid not, and FastStream has noprefetchkeyword — so the same knob that works on the decorator raisedTypeError: RabbitRegistrator.subscriber() got an unexpected keyword argument 'prefetch'on the class path. A team that had adoptedConsumercould only cap a consumer by hand-buildingchannel=Channel(prefetch_count=N), which is the FastStream detail the facade exists to hide.
subscribe(channel, prefetch=N) and Consumer(prefetch=N) are now
named, typed keywords, and a class-level prefetch covers every
binding the consumer declares (a @subscribe naming its own wins):
class OrdersConsumer(Consumer):
prefetch = 32 # every binding below
@subscribe("relatorios.gerar", prefetch=1)
async def gerar(self, pedido: Report) -> None: ...
@subscribe("orders.paid")
async def on_paid(self, event: OrderPaid) -> None: ...
Verified against a real RabbitMQ, not against the local object:
rabbitmqctl list_consumers queue_name prefetch_count reports the caps
the class declared. Checking that the keyword was accepted would pass
for an implementation that swallowed it.
-
The constructor-form
Consumerforwards subscriber options.@subscribetook**options(soexchange=and friends reached FastStream) whileConsumer.__init__accepted onlychannelandschemaand registeredoptions={}. Naming an exchange — or anything else the transport takes — forced the grouped form or the decorator.Consumer(channel=..., schema=..., **options)now forwards them, the same passthrough@subscribealready had. -
retry=works on the class-based task path — it was silently ignored.@tq.task(retry=RetryPolicy(...))rendered the policy into theretry_on_error/max_retrieslabels TaskIQ's middleware reads.@task_method(retry=...)did not: the policy object went through**optionsand landed as aretrylabel nothing looks at, so the task never retried and nothing raised. Worse than theprefetchcase above, which at least failed loudly.
Measured against a real RabbitMQ with a taskiq worker process and a
task that always fails: the decorator path ran it twice, the class path
once. After the fix both run twice.
retry is now a named keyword on task_method() and TaskDef, plus a
class attribute covering every task the definition declares (a
@task_method naming its own wins), and TaskQueue.register renders
it into labels exactly as task() does. TaskDef(name=..., **options)
also forwards extra labels, which only @task_method could before.
- The documented
taskiq worker/taskiq schedulercommands could not start. README, the queue recipe and three docstrings taughttaskiq worker src.tasks:tq.broker(and…:tq.scheduler,…:scheduler.scheduler). TaskIQ's CLI resolvesmodule:attrwith a plaingetattr, so every dotted form raisesAttributeError: module 'src.tasks' has no attribute 'tq.broker'before the worker starts. The docs now bind the objects to module-level names (broker = tq.broker) and point the CLI at those.
[0.208.0] — 2026-08-08¶
Added¶
Publisher— the class-based publish side of the queue.Consumerhas let a team group handlers in a class since the facade shipped, and the publish side had no equivalent:await mq.publish("orders.paid", event)took the channel as a loose string and the payload asAny, so nothing connected the two ends of what is, in practice, one contract.
class OrderPaidPublisher(Publisher[OrderPaid]):
channel = ORDERS_PAID
schema = OrderPaid
orders = mq.publisher_for(OrderPaidPublisher)
await orders.publish(OrderPaid(order_id="abc"))
Three properties the loose call could not have. Publisher[OrderPaid]
makes publish take an OrderPaid, so the wrong model is a type
error rather than a message the consumer rejects in production. The
declared schema is enforced on the way out — the consumer is a
process away and can only reject what already left. And a QueueSpec
on channel goes through the same binding on() uses, so a service
that only publishes still registers the dead-letter exchange it
names; without that the queue carries x-dead-letter-exchange pointing
at an exchange nobody declared, and rejected messages are dropped at
routing time.
Publisher.publish deliberately goes through MessageBroker.publish
rather than FastStream's own publisher object, so it keeps the
message_id deduplication keys on and the traceparent /
x-request-id headers tracing rides. A publisher built on the raw
object would look identical and silently break both.
MessageBroker.publisher() is unchanged and still returns the raw
object, which is what puts the channel in the generated AsyncAPI.
Class attributes are not the only way in: channel and schema are
also constructor parameters, on Publisher itself and on
publisher_for, where they override what the class declares. That is
what lets one subclass serve a channel only known at runtime — per
tenant, per environment — without a subclass per value. They are named
parameters rather than part of **options, so the type checker sees
them and a publish option sharing one of those names is not swallowed.
Subscriptionis now exported fromtempest_fastapi_sdk.queue. It was already the documented return type ofConsumer.subscriptions()and reachable only by importing the submodule.
Fixed¶
MessageBrokerkept five keywords hidden in**options. The four transport constructors poppeddeclare_topologyout of the forwarded keyword arguments, andrabbitmq()/on()poppedprefetch— so a parameter the facade consumes itself was invisible to the type checker, absent from autocomplete, and findable only by reading the source. Onredis(),kafka()andnats()it was not even in the docstring: a supported parameter nobody could discover. It also meant a future FastStream keyword of the same name would be swallowed by the facade instead of reaching the broker. All five are now named keyword-only parameters. Source-compatible — they were already keyword-only in practice.
This is the same defect the audit found on publisher_for, in the same
file, and the audit missed it — so tests/test_kwargs_guard.py now
walks the package with ast and fails on any function that reads a key
out of its own catch-all. The suite also asserts the guard fires on the
shape that shipped, because a guard that cannot fail is one nobody
should trust.
- The class-based path could not declare topology.
Consumer.channel,subscribe()andSubscription.channelwere typedstr, whileMessageBroker.registerbinds them through the same code path ason()— which has always acceptedstr | QueueSpec. Passing a spec worked at runtime and failed the type checker, so anyone using classes had to drop back to the decorator to get a dead-letter exchange or a quorum queue. The annotations now match what the code does.
[0.207.0] — 2026-08-08¶
Fixed¶
-
The retry budget was spent in half the attempts.
delivery_attempt()summed everyx-deathentry, but RabbitMQ keeps one entry per (queue, reason) pair and the delayed-retry chain dead-letters a message twice per round:rejectedleaving the main queue, thenexpiredleaving the waiting queue when its TTL fires. The counter therefore advanced by two per retry, soConsumerRetryPolicy(max_attempts=3)gave up after the second delivery. Entries whose reason isexpiredare now skipped — an expiry is the waiting room emptying itself, not a delivery that failed. An entry with noreasonat all still counts, so a non-conforming header keeps retries bounded rather than making them infinite. -
A concurrent delivery could dead-letter a message no handler ran. With
deduplicate()anddead_letter()both installed, theConcurrentDeliveryErrorraised when a sibling worker holds the claim was caught as an ordinary handler failure: it consumed an attempt and, on the delivery that exhausted the budget, sent the message to the dead queue. It is now re-raised untouched, so the copy is simply rejected and offered again. -
Deduplication inflated the metric an alert is built on. The same
ConcurrentDeliveryErrorcounted asqueue_messages_total{status="error"}. It gets its ownduplicatestatus, so a busy channel with healthy handlers no longer pages on its own deduplication working. -
consume_spanleaked the request id when closing the span failed.clear_request_idran after the span's__exit__rather than in afinally, so a tracer or exporter raising on exit left the contextvar set. The worker task is reused across consumes, so the next message would carry the previous request's id — worse than no correlation, because it reads as real. -
publish()introspected the broker signature twice per message._publish_acceptsraninspect.signatureon every publish for bothmessage_idandheaders, and the "logs once" warning in its docstring was emitted per call. Answers are cached on the underlying function, so the check happens once per process and the warning once per broker class. The warning also namedmethodinstead of the broker, sincetype()of a bound method ismethod.
Changed¶
-
QueueType.QUORUMdocumented itself as not supporting queue TTL, whileretry_queues(queue_type=QueueType.QUORUM)builds a retry queue that needs exactly that. Quorum queues do support message TTL; the real gap isx-max-priority, which is whatQueueSpec.__post_init__already refuses.UnsupportedTopologyErrorlikewise claimed to be raised at publish time —publish()only reads the channel name, since the topology belongs to the declaration. -
MessageBroker.dead_letter()and.deduplicate()took their defaults from literals (3,86_400) that duplicatedDEFAULT_MAX_ATTEMPTSandDEFAULT_DEDUP_TTL_SECONDS, and were free to drift from them.
[0.206.0] — 2026-08-08¶
Added¶
- Trace and request-id propagation across the queue (#129). A request opened a trace, published an event and answered 201; the consumer that charged the card, wrote the ledger and sent the mail showed up as three orphan traces with no parent and no relation to each other. The SDK traced FastAPI, SQLAlchemy, httpx and genai — the queue was the one hop left uninstrumented, and it is the hop where the causal link is lost.
publish() injects the W3C traceparent and the current request id
into the message headers when the transport takes a headers
keyword — checked independently of message_id, because Redis
accepts one and not the other, so a "is this RabbitMQ?" branch would
have been wrong; MessageBroker.enable_tracing() opens a span
per consume carrying the messaging semantic conventions
(messaging.system, messaging.destination.name,
messaging.operation) and marks it as failed when the handler raises.
The publishing trace is attached as a link, not as a parent. The convention recommends it for asynchronous consumption and the reason is practical: a consumer can run minutes after the publish, and a child span of that duration stretches the request's trace and makes its latency unreadable.
The consumer also adopts the publisher's request id for the
duration of the handler, so the worker's log lines carry the id of the
request that caused them. That is worth more day to day than the span
— it makes grep enough to correlate — and it is the half that works
with no [otel] extra at all.
[0.205.0] — 2026-08-08¶
Added¶
- Consume-side deduplication (#127). The facade has always said
delivery is at-least-once and handlers should be idempotent, and
offered nothing to make one.
MessageBroker.deduplicate(store, ...)runs each message id at most once across redeliveries, withMemoryDedupStorefor a single replica andRedisDedupStorefor more than one — whereclaimis a singleSET NX EX, so the atomicity two workers racing on the same id depend on comes from Redis rather than from a lock of our own.
Marking is two-phase: the first delivery claims in_flight and
runs, success marks done and the next delivery is skipped, and a
failure releases the key so a retry actually retries. Without that
third step the feature would trade "processed twice" for "processed
never", which is worse than the problem it solves.
A delivery arriving while another worker holds the claim raises
ConcurrentDeliveryError so the broker rejects it. Acknowledging would
be the dangerous choice — the in-flight worker may still fail, and the
copy that could have retried would be gone.
Documented as not exactly-once, because it is not: the mark and the
handler's effect are not atomic, and a crash between them leaves a claim
that expires and a message that runs again. The docs say so, and say
that when the effect is a row keyed by something the domain owns,
INSERT ... ON CONFLICT DO NOTHING is idempotent with no extra moving
part and is the better answer. This middleware is for effects that are
not rows.
Changed¶
MessageBroker.publish()fillsmessage_idwith a fresh UUID when the caller does not pass one and the transport accepts the keyword.RedisBroker.publishhas nomessage_idparameter and no**kwargs, so sending one unconditionally would turn every publish on that transport into aTypeError; the signature is introspected once and the keyword is dropped where it does not fit. A signature that cannot be read declines and logs — losing deduplication costs a feature, sending an unsupported keyword costs the publish. Without a stable id there is no key to deduplicate on and a redelivery is indistinguishable from a new event. An explicitmessage_idis kept untouched, which is how you key deduplication on something the domain owns instead.
[0.204.0] — 2026-08-08¶
Added¶
prefetchon the broker and per consumer (#128). Nothing in the facade capped how many unacknowledged messages RabbitMQ pushes, so the broker delivered as fast as the consumer acked. That shows up three ways, all in production: a slow handler accumulates messages in process memory, the first replica to connect takes the batch and leaves its siblings idle, and the unacked backlog sits in RAM until the pod is OOM-killed and the whole lot is redelivered.
MessageBroker.rabbitmq(url, prefetch=32) caps the connection;
@mq.on(channel, prefetch=1) overrides it for one consumer, which is
what keeps a heavy handler from hoarding deliveries without throttling
the cheap ones beside it. FastStream carries basic.qos on a Channel
object rather than as a scalar, so the flat keyword is translated; an
explicit channel / default_channel always wins, because rebuilding
one the caller configured would drop the confirms and QoS they also set
on it.
The default is unchanged — still uncapped. Picking a number without
measuring is the mistake DEFAULT_INTRA_OP_THREADS made before it was
re-justified: too small serializes consumption, too large recreates the
problem, and the right value depends on the handler's latency. The knob
is exposed and documented; changing the default is a separate, measured
decision.
Fixed¶
MessageBroker.on()and.publisher()shadowed FastStream's ownchannel=keyword. Both take the channel as their first parameter, which mademq.on("orders.paid", channel=Channel(...))aTypeError: got multiple values for argument 'channel'— so the raw AMQP channel, the escape hatch for anything the facade does not expose, was unreachable. The parameter is positional-only now. Calls that pass the channel positionally — every documented form — are unaffected;mq.on(channel="orders.paid")has to drop the keyword.
[0.203.0] — 2026-08-08¶
Added¶
- Dead letters, delayed retry and metrics on the event path (#126).
The consumer ack policy is
REJECT_ON_ERROR: a handler that raises issuesbasic.rejectwithrequeue=False. That avoids a poison-message loop and, on its own, means the message is discarded — no error surface, no dead queue, no metric.TaskQueuesolved this for background tasks in v0.157/v0.158;MessageBrokerhad nothing equivalent, 5 public symbols against 29.
MessageBroker.dead_letter(sink, max_attempts=...) hands every terminal
failure to the same DeadLetterSink protocol the task path uses, so
DbDeadLetterSink, the admin panel and make_requeue_action work
unchanged and a dead task and a dead event land on one screen. The
mapping is deliberate: task_name carries the channel, task_id the
broker's message id, kwargs["body"] the raw body. The sink fires once,
on the delivery that exhausts the budget — read from AMQP's x-death
header, so a consumer restart does not reset it. The exception is always
re-raised, so the broker still rejects and any dead-letter routing still
applies.
retry_queues(channel, ConsumerRetryPolicy(...), ...) builds the
delayed-retry topology and MessageBroker.declare_retry_topology()
declares and binds it. Binding is not optional: a chain whose
queues are declared but not bound routes a rejected message into an
exchange with nothing behind it, where RabbitMQ drops it silently.
Measured against a real broker — with the bindings the message returns
on schedule (1.5s gaps for a 1.5s TTL), without them it is delivered
once and vanishes. The topology itself is built out of QueueSpec: the main queue dead-letters
into a retry queue whose only job is to hold the message, and that
queue's TTL returns it to the main exchange when it expires. The
broker does the waiting, so a worker restart mid-delay changes
nothing — unlike an in-process retry loop, which dies with the pod. The
rabbitmq_delayed_message_exchange plugin is simpler and requires the
plugin installed, which several managed offerings (including the free
CloudAMQP tier) do not provide; this builds on stock AMQP instead.
QueueMetrics mirrors TaskMetrics on the shared registry:
queue_messages_total{channel,status} and
queue_message_duration_seconds{channel}, installed with
enable_metrics().
Documented together on purpose: the topology alone retries forever,
because AMQP counts redeliveries but does not stop on them. What
enforces max_attempts is the dead-letter middleware.
[0.202.0] — 2026-08-08¶
Added¶
QueueSpec— declarative queue topology onMessageBroker(#125). A channel was a plain string, which is the right default and is also everything the facade could express. The properties that decide whether a queue survives a broker restart, where a rejected message goes and how long it lives are not part of a name — on RabbitMQ they live in the queue declaration, and reaching them meant dropping tobroker.brokerand losing theConsumer/register()layer built around it.
QueueSpec(name=..., durable=..., dead_letter=..., message_ttl_ms=...,
max_priority=..., max_length=..., queue_type=...) carries that as typed
data and is accepted anywhere a channel string is, so adoption is
per-channel. DeadLetterSpec, QueueType (classic/quorum) and
UnsupportedTopologyError come with it.
A field the transport cannot express raises. dead_letter on Kafka
is an error naming the field, not a silent drop — a dropped
dead_letter produces a queue that looks configured and discards every
failure, which is the defect the type exists to prevent. Same call as
op.replace_enum raising on an unsupported dialect. A bare
QueueSpec(name=...) asks for nothing beyond a name and stays portable
everywhere.
connect() declares the dead-letter exchanges the registered specs
name, as durable topic exchanges — with the type passed as
ExchangeType.TOPIC, since FastStream reads exchange.type.value when
declaring and a bare "topic" string is an AttributeError at
startup. RabbitMQ accepts a queue pointing at
an x-dead-letter-exchange that does not exist and then discards at
routing time, so declaring it is what makes the setting mean anything.
MessageBroker.rabbitmq(url, declare_topology=False) opts out where the
broker is managed and the application cannot declare.
Impossible combinations are refused at construction: max_priority on a
quorum queue raises at import, pointing at the line that wrote it, since
RabbitMQ implements priorities only for classic queues.
[0.201.1] — 2026-08-08¶
Fixed¶
@asynccontextmanagerfunctions annotated their return asAsyncIterator, which typeshed now deprecates. The upstream stub gained a second overload and marks theAsyncIteratorone@deprecated: "Annotating the return type as-> AsyncIterator[Foo]with@asynccontextmanageris deprecated. Use-> AsyncGenerator[Foo]instead."
The reason is that AsyncIterator is too wide for what the decorator
needs. Anything with __anext__ satisfies it, while
asynccontextmanager also calls athrow and aclose — it requires a
real generator, which is what AsyncGenerator says. Runtime behavior is
untouched; the cost is a strikethrough in Pylance today and a mypy error
once its vendored typeshed catches up (mypy 2.3.0 still ships the old
single signature, which is why make check was silent).
Corrected in eleven shipped modules — transaction / savepoint
(v0.200.0), explain_queries (v0.200.0), AsyncRedisManager
.get_client_context, the four lifespan helpers on MessageBroker /
AsyncQueueManager / TaskQueue / AsyncTaskScheduler /
AsyncTaskBrokerManager, and test_database / test_session — and in
the tempest new app template, which stamps the annotation into every
generated service.
The two-parameter AsyncGenerator[T, None] is used rather than the
one-parameter form. Both work here (the PEP 696 default lives in the
stub, so the short form subscripts fine on the supported 3.11 floor and
mypy accepts it), but the explicit send type does not depend on a
third-party checker implementing that default.
Untouched on purpose: AsyncIterator is still correct for the FastAPI
dependency generators (client_dependency, broker_dependency), for
real streams (Agent.stream, AIChatPipeline), and for the
Callable[[], AsyncIterator[AsyncSession]] parameters the routers
accept — none of those go through asynccontextmanager.
Changed¶
-
Documentation examples follow the same correction across 35 pages, and ten
lifespanexamples in the queue/tasks and SSE recipes that carried no return annotation now have one, which the repository's typed-examples rule already required. -
ruffno longer formats Markdown code blocks (extend-exclude); see v0.201.0.
[0.201.0] — 2026-08-08¶
Security¶
-
Dependency audit: 31 known advisories across 8 packages, down to 1. Refreshing the lock cleared everything with a published fix. The remaining one has none:
chromadbPYSEC-2026-311, a pre-authentication code-injection in the ChromaDB server's collections endpoint whentrust_remote_codeis set. The SDK'sChromaVectorStoreuseschromadb.PersistentClient— embedded, no HTTP server — so nothing the SDK does reaches it; anyone runningchroma runthemselves is exposed and has no version to move to. -
Lower bounds raised where the flaw is reachable. A floor is what actually protects a consumer, since the lock ships to nobody:
-
pydantic-settings>=2.14.2(base dependency) — GHSA-4xgf-cpjx-pc3j,NestedSecretsSettingsSourcefollows a symlink out ofsecrets_dir, reading files outside it and bypassingsecrets_dir_max_size. The SDK never setssecrets_dir, butBaseAppSettingsis apydantic-settingsclass consumers extend and configure. cryptography>=50.0.0([webpush]and two more) — PYSEC-2026-3552, a Bleichenbacher oracle in PKCS#7EnvelopedDatadecryption. Not reachable through the SDK, which uses the library only to verify webhook RSA signatures; raised as hygiene on a cryptographic dependency.pillow>=12.3.0([genai-image],[vision],[all]) — fifteen image-parsing advisories. Reachable here, because processing third-party images is exactly what those extras are for.
torch was deliberately left at >=2.2.0. PYSEC-2025-194 is memory
corruption in torch.jit.script, local, and the SDK never calls it;
a >=2.13 floor is the most expensive constraint in this project —
it cuts CUDA and platform combinations and forces multi-gigabyte wheels
— which an unreachable local flaw does not justify.
Fixed¶
- The binary tree-ensemble defect was blamed on the wrong package. It
was recorded as a
skl2onnxconversion bug: a binary tree classifier exported to ONNX returned a decision score in[-1, 1]where a probability was expected. Holdingskl2onnx1.20.0, scikit-learn 1.9.0 andonnx1.22.0 byte-identical and moving only the runtime settles it — the graph was always correct andonnxruntimeevaluated it wrong: maximum absolute error of 1.0 againstpredict_probaon 1.27.0, 9.5e-08 on 1.28.0.
onnxruntime floors move to >=1.28.0 so a normal install cannot hit
it. The export still checks the running version through the new
BINARY_TREE_FIXED_IN_ONNXRUNTIME constant, because a floor only binds
the resolver — an environment assembled around it would otherwise be
wrong in silence. The recipe and the covers list are corrected too;
attributing a defect to the wrong layer sends the next reader to the
wrong changelog.
discover_modelsnarrowed through aboolpredicate, so every attribute read after it needed re-asserting once typeshed tightenedinspect.getmembers._is_concrete_modelis aTypeGuardnow.
Changed¶
mkdocs-only:pymdown-extensions>=11.0.1(CVE-2026-67422). Docs group, never resolved by a consumer.
[0.200.0] — 2026-08-08¶
Added¶
- Transaction control on
BaseRepository. Every write method used to end in an unconditionalCOMMIT, which is right for one statement and wrong the moment a business rule spans two —orders.add()was already durable whenitems.add_all()failed.
transaction(session) groups a block into a single commit, and the depth
counter lives in session.info rather than on the repository, so every
repository bound to that AsyncSession joins the same block. That is what
makes a service orchestrating several repositories work without threading
context between them. Nesting is re-entrant; only the outermost exit
commits.
The repository gained commit() / flush() / rollback() so a service
never has to reach for session — and so a durable point can be stated
outright instead of calling update() for its commit side effect.
commit() degrades to a flush inside an open block, which makes the call
safe to leave in place when someone later wraps the code; rollback()
inside a block raises RuntimeError, because it would discard other
repositories' work while the caller believes it is undoing its own step.
autocommit=False makes a whole repository explicit; it disables only the
implicit commit, never an explicit commit().
savepoint() isolates a step you intend to recover from. This exposed a
real backend divergence: the pysqlite driver under aiosqlite opens
transactions implicitly and emits no BEGIN, so SQLite treats the
SAVEPOINT as the outermost transaction and its RELEASE becomes a
commit — a nested block that exits cleanly turned durable even when the
outer block was rolled back afterwards. Invisible on the failure path,
which is why an initial probe missed it. AsyncDatabaseManager now applies
SQLAlchemy's documented remedy to every SQLite engine it builds
(enable_sqlite_savepoints), and the RELEASE path is pinned by a test, so
the test backend and the production backend agree about atomicity.
Recipe: docs/recipes/transactions.md.
- Text search, portable and full-text. Two layers.
search()is tokenized escapedILIKE— identical on PostgreSQL and SQLite, no index, no extension, no migration; words combine withAND, columns withOR, and the user's own%/_are escaped so a term like100%searches for the character instead of matching every row.full_text_search()usesto_tsvector/websearch_to_tsquery/ts_rankon PostgreSQL — stemming, stop words, the"quoted phrase"and-excludedsyntax users already type, per-field weighting throughTextSearchWeight, results ordered by relevance — and degrades to the portable layer elsewhere, withsupports_full_texttelling the caller which one it got.
The condition builders (search_condition() / full_text_condition())
return the clause instead of executing it, and where= now accepts a raw
SQLAlchemy clause as well as a Q, so a search paginates and counts
through the existing paginate() / count() rather than needing its own.
Column references accept either a name or the mapped attribute
(ArticleModel.title), and TextSearchLanguage / TextSearchWeight /
TokenMatch are enums — no magic strings at the call site.
The text-search configuration is inlined as a SQL literal rather than
bound as a parameter: asyncpg prepares statements server-side and
PostgreSQL cannot infer regconfig for an untyped placeholder, so the
parameterized form would have failed on the production driver while
passing every test here. Recipe: docs/recipes/text-search.md.
-
Enum columns that are type-safe in the database, not just in Python.
Mapped[MyEnum]on aBaseModelnow routes throughTempestEnum, which changes three SQLAlchemy defaults that each cost real safety: it stores the member value rather than its name (so a report or a sibling service readsin_progress, notIN_PROGRESS), it emits aCHECKconstraint on backends without a native enum type (the stock default gives SQLite a bareVARCHAR, so the test database accepts data production rejects), and it names the PostgreSQL typeorder_status_enuminstead oforderstatus, which collides with a table or column of that name.enum_column()spells the same thing out when the column needsdefault/index/server_default. Recipe:docs/recipes/enum-columns.md. -
Alembic enum migrations that actually work. Three separate gaps, all closed.
sync_enum_typesdetects a changed member list, which autogenerate misses on both backends — PostgreSQL keeps the labels inpg_enumand SQLite inside aCHECK, neither of which is compared, and SQLite'sVARCHAR(n)only moves when the longest value changes, so not evencompare_typenotices.op.replace_enum(...)performs the change by renaming the old type, creating the new one, casting every dependent column and dropping the old — ordinary DDL that runs inside Alembic's transaction, unlike theALTER TYPE ... ADD VALUEeveryone reaches for first, which cannot run in a transaction block on older servers and can neither remove nor reorder. Column defaults are read frominformation_schemaand restored after the cast,value_map=keeps rows through a rename, andreverse()givesdowngradefor free.
render_enum_types fixes the third gap: Alembic rendered TempestEnum as
a dotted path into this package in a file that imports only alembic.op
and sqlalchemy as sa, so every migration touching an enum column
failed on import. It now renders a plain sa.Enum with the values
spelled out, which also makes the migration a real snapshot. Both hooks
are wired into the generated env.py.
Detection is deliberately conservative: an enum the backend cannot report
on is skipped rather than diffed against a guess, since a wrong
replace_enum would drop values from live rows. Offline (--sql) mode on
PostgreSQL raises instead of silently emitting a script that loses the
column default.
explain_queries()— query plans for a block of code. Wrap the code, get one plan per statement:EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)on PostgreSQL with cost, measured time and actual-versus-estimated rows;EXPLAIN QUERY PLANon SQLite, reported honestly asExplainDetail.PLAN_ONLYwithtotal_costandduration_msleftNonerather than defaulted to zero. Statements are recorded during the block and explained on exit, so the explaining never perturbs what is being measured, and plans captured before a failure survive it.
Writes are never re-executed. EXPLAIN ANALYZE runs what it explains,
so only SELECT is analyzed; anything else — including an unrecognized
raw text(), classified as a write on purpose — is explained without
ANALYZE. Plans are fetched at the driver level because routing them
through session.execute applies the wrapped statement's result mapping
to the plan rows, handing a UUID processor an integer node id.
report.slowest falls back from measured time to planner cost so it still
answers "which one first?" on a backend that times nothing. Recipe:
docs/recipes/query-plans.md.
Changed¶
-
BREAKING:
Mapped[SomeEnum]now stores the member value, not its name. A column declared asMapped[OrderStatus]on aBaseModelpreviously wroteIN_PROGRESS; it now writesin_progress. Enums whose members are spelledOPEN = "OPEN"are unaffected. If yours are not, the existing rows hold the old spelling and need a data migration —op.replace_enum(..., value_map={"IN_PROGRESS": "in_progress"})performs exactly that. On SQLite the column also gains aCHECKconstraint it did not have, and on PostgreSQL the generated type name gains an_enumsuffix. A column that must keep the previous behavior can declare it explicitly withmapped_column(sqlalchemy.Enum(...)), which always wins over the annotation map. -
where=onget/get_or_none/first/list/paginate/cursor_paginate/count/exists/delete_many(and theirTenantScopedRepositorycounterparts) acceptsWhereClause, which isQ | ColumnElement[bool]— aQstill resolves lazily, and a ready-made clause now flows straight through. Purely widening; existing calls are unchanged.
[0.199.0] — 2026-08-02¶
Fixed¶
- Every model load printed a deprecation warning from
transformers. The five loaders passedtorch_dtype=tofrom_pretrained, whichtransformersrenamed todtype:
One line per load, in the logs of every service that hosts a model, for a keyword the caller never chose. The SDK now picks the name the installed version wants.
The choice is version-gated rather than switched outright because the SDK
supports transformers>=4.44, and the boundary was measured against the
released wheels instead of assumed: 4.55.0 rejects dtype and never
warns; 4.56.0 accepts it and starts warning about torch_dtype; 5.x keeps
both. Sending dtype to an older release would have been worse than the
warning — unknown keywords are forwarded to the config, so the precision
would be dropped in silence. DTYPE_KWARG_RENAMED_IN carries that
provenance, and a test pins both sides of the boundary.
Scoped to the transformers loaders (TextGenerator,
VisionTextGenerator, Embedder, Reranker, ClassifierModerator).
ImageGenerator goes through diffusers, which took dtype much later
than the diffusers>=0.31 this SDK supports, so the image path still
sends torch_dtype on purpose.
Added¶
GENAI_CACHE_DIR,GENAI_OFFLINEandGENAI_HF_TOKEN, plus theGenAISettingsmixin. Where the weights live and whether a load may use the network are deployment facts, and they were only expressible as arguments — repeated at everyTextGenerator(...),Embedder(...),SpeechToText(...)in the codebase.
The three now have environment variables that act as defaults, so a
service sets them once in compose or the chart. An argument always
wins, in both directions: with GENAI_OFFLINE=true in the
environment, an explicit local_files_only=False still reaches the
network. That is why local_files_only became bool | None — None
means "take the environment", which a plain False could never express.
GenAISettings is the typed face of the same three variables, for
services that want them in Settings and in tempest check-config. The
loaders read the environment directly, so declaring it is optional.
Changed¶
- The Model weights recipe explains the Hub's rate-limit warning. It
shows up on a run that downloads nothing, which reads like a bug: with
local_files_only=Falsethe load still contacts the Hub to resolve the revision, and that anonymous request is what prints it. Settinglocal_files_only=Trueremoves the request and the warning — now documented with the before/after.
[0.198.0] — 2026-08-02¶
Added¶
GET /auth/me, mounted bymake_auth_router. Resolving the bearer token to its account is the most generic thing an authenticated API does, and the SDK made every project hand-write it — the auth recipe literally shipped the snippet to copy. It is now part of the bundled router: no wiring, no schema to define, no route to maintain.
The response model defaults to the new AuthUserSchema, which
covers exactly the columns BaseUserModel guarantees (id,
is_active, created_at, updated_at, email, is_admin,
last_login_at). The handler returns the ORM instance and lets
FastAPI serialize through the model, so hashed_password cannot reach
the wire even though the row carries it — a test asserts exactly that.
A project whose user table has extra columns subclasses the schema and
passes it as me_response_model=:
class UserResponseSchema(AuthUserSchema):
name: str | None = None
app.include_router(
make_auth_router(
auth_service,
session_factory=get_db,
me_response_model=UserResponseSchema,
)
)
The endpoint reuses the router's existing authenticated-user
dependency, so it accepts the access-token cookie in the cookie /
both delivery modes with no extra configuration, and answers 401
for a missing or invalid token and 404 when the token is valid but
the account is gone.
[0.197.0] — 2026-08-02¶
Added¶
-
[websocket]extra — pullswebsockets, the protocol driver a bareuvicornlacks. Without it the handshake answers 404 and the reason (No supported WebSocket library detected) only reaches the server log; worse, Starlette'sTestClientimplements WS itself, so a full test suite passes while the deployed server rejects every connection.make_websocket_router's docstring and the recipe now say so up front. -
WebSocketHub.send_many({user_id: envelope, ...})— a different frame per user, dispatched withasyncio.gather.broadcastsends one payload to everyone andsend_toserves one user, which left per-recipient fan-out (fog of war in a game, a personalized feed) as N sequentialsend_tocalls, each waiting on the previous socket. The cost is now the slowest socket, not the sum.
Fixed¶
- The heartbeat never closed a half-open peer.
_heartbeat_looponly emitted pings: nothing read thepong,WS_HEARTBEAT_TIMEOUT_SECONDSwas dead configuration, and no socket was ever closed with4408— while the module docstring promised exactly that. A peer that stopped answering (and never fails asend) pinned its hub slot forever.
The router now records the last pong and closes with 4408 once the
gap crosses WS_HEARTBEAT_TIMEOUT_SECONDS. Clients must reply
{"type": "pong", "data": {}} to the router's ping; the frame is
consumed by the router and never reaches the handler.
WS_MAX_MESSAGE_BYTESwas announced and never applied. The setting documented "reject inbound frames larger than this", but the router passed everything straight to the handler — an advertised defense that did not exist, which is worse than none. Oversized frames now close the socket with1009before the handler allocates the payload.
Both are enforced by wrapping ws.receive once at accept time: every
receive_text / receive_bytes / receive_json funnels through it,
so the handler keeps owning the message loop.
- The scaffolded
src/core/exceptions.pytaught the warned-about pattern. It only re-exportedAppException/NotFoundException, so the first subclass a reader wrote inherited a genericcodeand trippedInheritedErrorCodeWarningon the first run. The template now ships a workedItemNotFoundExceptionwithcode = "ITEM_NOT_FOUND"and explains why the class body is wherecodebelongs.
[0.196.0] — 2026-08-02¶
Added¶
TextModel,EmbeddingModel,RerankerModel,VisionModel,ImageModel,SpeechToTextModel,TextToSpeechModel—StrEnums naming the model ids the SDK is exercised against, exported fromtempest_fastapi_sdk.genai. Every constructor still takes a plainstr; the enums exist so a typo is aNameErrorat import instead of a 404 mid-download, and so the choice itself is documented:
from tempest_fastapi_sdk.genai import TextGenerator, TextModel
gen = TextGenerator(TextModel.QWEN2_5_7B_INSTRUCT)
The new recipe Escolhendo o modelo / Choosing a model carries the
use-case table behind each pick — size, VRAM, language coverage, and
the traps (E5 needs query: / passage: prefixes; a turbo image
checkpoint wants ~4 steps where a full one wants ~30; changing the
embedder invalidates an existing index).
ChatBackendandToolCallingBackendprotocols intempest_fastapi_sdk.agents, withAgentBackendas the union.Agentnow takesgenerator: AgentBackendinstead ofgenerator: Any, so passing an embedder or a bare string is a type error at the call site rather than anAttributeErrorinside the loop.TextGeneratorandOllamaGeneratorsatisfyToolCallingBackendunchanged.
Fixed¶
tempest newscaffolded services as installable packages. The generatedpyproject.tomlcarried a[build-system]table (hatchling) plus[tool.hatch.build.targets.wheel] packages = ["src"]. A service is deployed, never published — so everyuv sync/uv addbuilt and installed a wheel out ofsrc/for nothing, and any deviation from the layout the build backend expects turned a routine dependency change into a build failure.
The scaffold now declares [tool.uv] package = false instead. Imports are
unaffected: python main.py runs from the project root, and
tests/__init__.py makes pytest prepend that same root to sys.path.
Existing projects apply the same fix by hand — drop the [build-system]
and [tool.hatch.build.targets.wheel] tables, then add:
- The
[admin]extra was missingpython-multipart. The admin login route declaresidentifier: str = Form(...), and FastAPI raises at import time whenpython-multipartis absent:
tempest new enables admin by default, so a freshly scaffolded service
crashed inside create_app() unless [upload] happened to be installed
too. python-multipart>=0.0.12 is now a hard dependency of the extra.
- 186 documented examples awaited at module level. Copy-pasting one
into a file raised
SyntaxError: 'await' outside functionbefore it did anything — the docs promise runnable examples and these were not. Every offending fence across 75 pages (both languages plus the README) now wraps the awaiting part inasync def main()and ends withasyncio.run(main()), or moves it into the FastAPI endpoint / lifespan it belongs to.
tests/test_docs_examples_compile.py keeps it from coming back: it
compile()s every Python fence in docs/ and README.md and fails on
async-context errors. It uses compile(), not ast.parse() — the
"await outside function" rule is enforced by the symtable pass, so
ast.parse("x = await f()") succeeds and a guard built on it would be
silently vacuous.
- The agents recipe told readers no extra was needed. The install line
said
uv add "tempest-fastapi-sdk", then every example injected aTextGenerator— which raisesImportError: Text generation requires the optional [genai] extra.on first instantiation. The page now installs[genai]and says which extra each tool pulls in, and the "tools over your local models" example instantiates every model it passes instead of referencing undefined names.
[0.195.0] — 2026-08-02¶
Fixed¶
- The compact reader routed rows differently from scikit-learn when a
value sat exactly on a split threshold.
sklearn.treecasts its input to float32 before traversing, so a threshold stored as5.099999904632568— a float32 value widened for storage — and an input of5.1compare equal there and go left. Comparing in float64 sends that row right instead.
On a 20-tree iris forest that flipped one tree's vote for 2 of 105 training rows, moving a probability by exactly 0.05. The export verification caught it and refused to write the file, which is the behaviour that format has verification for — but the reader was wrong, so the refusal was the symptom, not the bug.
Both decoders now compare in float32. Pinned by a test that asserts the boundary rows exist in the dataset, so a fixture that stopped exercising the case would fail rather than pass quietly.
Found while writing the beginner's first example for the docs: iris plus a random forest is the most obvious thing a newcomer runs, and it did not work.
Documentation¶
- A "Comece por aqui" opening for the modelops recipe, in both
languages: one block that runs as written (dataset included with
scikit-learn) taking a model from
fitto a published package to a prediction, plus a "which case is mine?" table routing the reader to the right section, plus a five-word glossary (ONNX, graph, quantise, drift, baseline) defined in plain language at the point a newcomer meets them.
Every example on the page previously assumed model, X_train and
y_train already existed — nothing was copy-pasteable, which the project's
own docs standard requires.
[0.194.0] — 2026-08-01¶
Added¶
export_sklearn_to_compact— a model format that needs no inference runtime. ONNX in a browser costs a 25.6 MB WebAssembly download (6.0 MB gzipped) before the first prediction, while the model itself is 20 KB. For an app whose only model is tabular, the runtime is the download — so this format drops the runtime instead of the model. A linear model is a dot product and a tree is a chain of comparisons; the reader intempest-react-sdk/tabularis 1.49 KB brotli.
Verified against scikit-learn, which is the only claim worth making about
a reimplementation of someone else's arithmetic: the exporter decodes the
file it just wrote and compares with the estimator's own predict /
predict_proba, refusing to ship a file that disagrees. Measured across
seven families, the largest probability difference was 1.6e-05.
Covers linear models (logistic, linear, ridge, SGD, linear SVC), trees,
forests, extra-trees, their regressors, and StandardScaler /
MinMaxScaler folded from a Pipeline. Anything else — gradient boosting,
MLPs, PCA in the pipeline — raises UnsupportedEstimatorError naming the
ONNX route, because a format that silently dropped a step would produce a
model that runs and answers wrongly.
The file is data, never code: no generated JavaScript, no eval,
nothing a strict CSP forbids. TMC1 is a magic, a JSON header padded to an
8-byte boundary (a JavaScript Float32Array cannot view an unaligned
offset) and typed-array sections. read_compact and predict_compact are
the reference decoder the browser's is checked against.
edge_pipeline(compact=True)andRuntimeArtifact. A package can now carry the same model twice — ONNX for coverage, compact for size — and the manifest's newruntimeslist lets the consumer pick by what it can actually run.tempest-react-sdkreads that list and defaults to compact when present.
The option raises rather than silently writing only ONNX when the estimator has no compact form: a package that quietly lacks the file the app expects fails later, in a browser, on someone else's phone.
Fixed¶
-
The reference guard read a stale build.
tests/conftest.pyrebuilt the docs whendocs/,mkdocs.ymlor the hooks changed — butmkdocstringsrenders the API reference from docstrings, so exporting a new symbol changed the expected HTML without touching any watched input. The guard then reported brand-new exports as undocumented. The package source now counts as a docs input. Found by hitting it while adding the compact format, not by review. -
The compact route returned
"0"where ONNX returns0. Two routes over one model that disagree on the type of a label break the day someone switches between them. The format now records scikit-learn's class dtype and both readers hand back the same type. Found by the cross-language test, not by review.
[0.193.0] — 2026-08-01¶
Added¶
edge_pipeline_from_pickleandload_sklearn_artifact— the bridge from what training hands over to what a browser can run. A training pipeline producesjoblib.dump(...); a browser cannot use it, because a pickle is a Python program rather than data, and running one would need a Python runtime in the page. The bridge belongs to the build: read the pickle once, in your own environment, and publish the ONNX package that devices and browsers actually load.
Loading a pickle executes arbitrary code, so load_sklearn_artifact
takes a local path and refuses a URL with an explicit error. Not as
protection — anyone can download first — but so the shape "load the model
from this URL" never exists in the code, since that is what turns a model
registry into a remote-code-execution surface.
It does four things joblib.load does not:
- Recovers the column order from the estimator's
feature_names_in_, so a model fitted on a DataFrame carries its columns into the manifest. That is the field that catches the failure no runtime check can — the right features in the wrong order. - Finds the estimator inside a dict (
{"model": est, "auc": 0.91}is what pipelines actually dump). One estimator resolves silently; two make it refuse and list what it found rather than guess.key=decides. - Records provenance — the pickle's name, SHA-256, size and the
scikit-learn version that performed the conversion — in a new optional
sourceblock on the manifest, so an artifact running on a device can be traced back to the file that produced it. -
Refuses what cannot predict with a direct message instead of letting the failure surface inside the converter.
-
ArtifactSourceonEdgeManifest. Optional and additive, soschema_versionstays1and existing readers — includingtempest-react-sdk/tabular, which ignores unknown fields by design — keep working unchanged.
Documentation¶
- A pickle section in the modelops recipe, both languages, stating the trust boundary plainly (ONNX is data, a pickle is a program) and a measured caveat: on scikit-learn 1.9 a model pickled by one version and loaded by another emits no warning and stores no version field. The mismatch is silent, which is another argument for converting at build time rather than shipping the pickle.
[0.192.0] — 2026-08-01¶
Changed¶
PredictionMonitor.observeis 11x faster, because measuring it showed monitoring cost more than inference. Running the SDK's own instruments over the serving stack found the drift binning at 67 us per single-row call against 7.5 us of actual inference — the monitor was 9x the model. The cause was the obvious shape: a Python loop per feature, then per bin.
It is now vectorised — one comparison against a padded edge matrix
resolves every feature of every row, and a single bincount folds the
batch into flat counters allocated once. Measured after: 6.0 us for one
row (from 67.1) and 49.7 us for 64 rows (from ~104). Counters are a
single int64 array instead of a list of lists, so the constant-memory
promise is now literally one allocation.
No behaviour change: the same bins, the same PSI, the same reports. The window reset refills the existing array rather than rebuilding it.
Documentation¶
-
A measured optimisation playbook in the modelops recipe, both languages, produced with
benchmark_models/analyze_onnx/rank/PredictionMonitorover 7 estimators on one dataset: -
The transport dominates, not the model. One row over HTTP costs 1.22 ms against 0.0075 ms of inference — 160x — measured with an in-process client, so a real network is worse. Batching moves the per-row cost from 1,223 us to 16 us (batch 512), a 74x win that no model choice can match. Per-row inference plateaus at ~1.5 us from batch 8.
- "Tabular means forest" cost 880x the size for worse accuracy on this
dataset: a 300-tree forest is 13.4 MB at 0.931, a small MLP is 15.3 KB
at 0.966, in the same latency band. Not a law — the point is that
benchmark_modelsanswers it in three lines. - gzip does not pay the same on every model: 15% on a forest, 23% on a tree, 84% on a logistic regression. The earlier "10-13%" figure is specific to tree ensembles, and the docs now say so.
- Cold start:
read_manifest0.15 ms,load_edge_package2.16 ms with the SHA-256 check and 2.00 ms without — so the digest check stays on by default, and the cheap manifest read is what makes polling for a new version viable. - Energy is reported as
unavailablerather than invented on a host without powercap or NVML — stated in the playbook with the reason.
[0.191.0] — 2026-08-01¶
Added¶
edge_pipeline— from a fitted estimator to a directory two runtimes consume.edge_bundleanswers "what does each optimisation stage cost on my model"; this answers the question after it: what you actually publish, and how the thing running it knows what it got. Writes the graph, a gzipped copy, the drift baseline and amanifest.json, and you ship the directory whole.
The manifest is a cross-language contract, with a pinned
schema_version: the same directory is served as static assets to
tempest-react-sdk/tabular in the browser. It carries the column order
used at training (the field that catches two swapped columns — nothing
else in the package does; a model fed the right columns in the wrong order
answers confidently and wrongly), the classes in score order, the SHA-256,
and a content-derived version so republishing identical bytes never looks
like a new model.
-
load_edge_package— one line to a predictor plus aPredictionMonitoralready wired to the package's baseline, with the version stamped onto every report. Verifies the model against the manifest digest first, so a truncated download fails as a digest mismatch rather than as a protobuf error — or worse, than not at all. -
read_manifest— reads the package description without loading the graph, so a device can answer "is the published version the one I have" on a schedule. -
edge_pipelinerefuses to package an export that does not reproduce the estimator. The known converter defects (binary tree ensembles on skl2onnx 1.20) produce a graph that runs smoothly and answers wrongly, so a silent pass is the one outcome this gate exists to prevent.
Changed¶
- Corrected the threading guidance in
DEFAULT_INTRA_OP_THREADSand the modelops recipe. The previous text justified the default of one thread with "on a small device the threads spend more time coordinating than computing". That was reasoned, not measured, and measuring does not support it: on a 300-tree forest, 1000 rows went from 16.6 ms at one thread to 2.3 ms at eight, and even a single row improved (0.019 ms to 0.010 ms). The default is still one, for a different and real reason — in a service with concurrent requests, per-request threads oversubscribe the CPU and every request gets slower. The rule is now stated as the shape of the workload: a batch or a large ensemble wants threads, a small graph is indifferent (a logistic regression measured 0.213 ms vs 0.214 ms for 1000 rows across 1 and 8 threads).
Documentation¶
- The measured edge-optimisation table, in both languages. Run on real
forests of 10 to 300 trees: graph optimisation changes the file by 0.1 KB
(the
ai.onnx.mloperators are single nodes, nothing to fuse),.ortconversion more than doubles it at every scale (381 KB to 878 KB; 12.1 MB to 27.0 MB), int8 quantisation does not apply at all, and gzip takes it to 10-13%. Three of the four stages do not pay, which is why the pipeline runs only the ones that do. - Where size is actually decided: a 50-tree forest at
max_depth=6is 257 KB at 0.881 test accuracy against 1444 KB at 0.922 unlimited — 5.6x the bytes for 4 points — while single-row latency moves from 0.0075 ms to 0.0079 ms. On the edge the currency is bytes, not milliseconds, and the estimator is the lever.
[0.190.0] — 2026-08-01¶
Added¶
-
Edge monitoring —
PredictionMonitor, because a device that answers in 3 ms tells you nothing about whether the answers are right. In production there are no labels, so accuracy is not measurable on the device. Three proxies are, and the module says they are proxies rather than pretending otherwise: -
Latency and volume — the only signal that catches a thermally throttled device or a provider that silently fell back to CPU.
- Input drift — live features against a training-set baseline, by Population Stability Index.
- Prediction distribution — what the model is answering, against what it answered on the training data. This catches the failure input drift misses: features within their usual ranges, combined in a way that pushes every row to one class.
Read together they diagnose: input moved with stable output is usually a harmless covariate shift; output moved with stable input means the model is extrapolating; both moved means retraining.
-
baseline_from_samples— summarises the training set into bin edges and proportions, never the rows. A few kilobytes, no records, versionable next to the model. The docstring says to build it at training time: building it from production traffic would describe the already-drifted population as normal. -
population_stability_indexwith the conventional thresholds as named constants —PSI_MODERATE = 0.1,PSI_SIGNIFICANT = 0.25, from credit-scorecard practice, pinned by a test. Documented as a convention, not a statistical test: PSI has no p-value and no null distribution, so a crossing is a reason to look, not to act automatically. BelowMIN_ROWS_FOR_DRIFT(100 rows) the verdict isinsufficient_datarather thanstable— with 30 rows across 10 bins an empty bin is the expected outcome of sampling, and "no traffic yet" is a different answer from "no drift".
A constant training feature gets a narrow middle bin instead of degenerate edges: with one catch-all bin, drift on that feature could never be detected.
-
Constant memory, by construction. Rows are counted into bins and discarded —
n_features x n_binscounters regardless of traffic. Nothing retains a copy of the requests, so no feature value stays in memory to leak into a log or a crash dump. Drift is measured per window (DEFAULT_WINDOW_ROWS), so the numbers track recent traffic rather than everything since boot. -
PredictionMetrics— the same numbers as Prometheus metrics on the registry the SDK's/metricsendpoint already serves. Split from the monitor on purpose: counters and the latency histogram are cheap per request, drift gauges only change when a window closes. Insufficient samples publish no gauge, so a noisy PSI from 12 rows never becomes a spike on a dashboard. -
make_prediction_router(..., monitor=, metrics=)— records every request and mountsGET /monitor. A422is not counted: it never reached the model.POST /model/syncresets the monitor when the version actually changes, since mixing two versions into one latency percentile hides exactly the regression a fleet update needs to catch.
[0.189.0] — 2026-08-01¶
Added¶
OnnxPredictor— running an exported model on the device that has to run it. Exporting produces a file; this is everything between that file and an answer, and it is code every consumer otherwise writes identically and gets subtly wrong: which input is the input (the name is not constant across exporters), which output is a label and which is a score (indexing[1]works until you serve a regressor), dtype coercion, and the warm-up that moves first-call allocation off the first real request.
Threads default to one intra-op. ONNX Runtime's own default is one per
core, which is right on a server and often wrong on a constrained device:
on a 4-core SBC running one small model per request, the threads spend
more time coordinating than computing. DEFAULT_INTRA_OP_THREADS = 1
says so, and the docstring says to measure on the target device before
raising it.
PredictorInfo.providers reports the providers actually in use, not
the ones requested — ONNX Runtime falls back to CPU silently, so a device
you believe is on CUDA may not be.
-
make_prediction_router—POST /predict,GET /model(what is loaded, which providers, how many threads), andPOST /model/sync. A row of the wrong width is a422, not a500. -
RegistryModelSource— updating a fleet without a deploy. The device asks the existingArtifactRegistrywhich version is current, downloads it if absent, and reloads.sync()is a no-op when the right version is already loaded, so it is safe on a schedule.
A bad rollout degrades to the previous version, never to nothing.
OnnxPredictor.reload builds the new session before dropping the old
one, so a corrupt or unloadable file leaves the device serving the
previous model. A fleet that can go silent from a deploy is worse than one
that is occasionally out of date. One file per version is cached, so a
rollback is a reload rather than a re-download, and nothing is deleted
automatically — on a small disk that is the operator's call.
[0.188.0] — 2026-08-01¶
Added¶
tempest_fastapi_sdk.modelops.sklearn— take a scikit-learn model to something an edge device runs. New extra[modelops-sklearn](skl2onnx, which declares only lower bounds and so constrains nothing). The rest of the edge path already existed here; this is the missing front end.
skl2onnx does the conversion. This module makes the decisions it leaves
to you, each of which is easy to get wrong in a way that only shows up in
production:
- float32 by default. scikit-learn works in double precision, edge runtimes want single. The conversion is almost always right and it changes the numbers, so it is stated and verified rather than assumed.
- ZipMap off by default.
skl2onnxotherwise wraps classifier probabilities in aZipMap, emitting a dictionary per row: convenient in Python, and unusable on a minimal runtime that does not implement the operator. The output is a plain tensor instead. verify_sklearn_onnx. Runs the estimator and the graph over the same rows and compares — label agreement for classifiers, maximum absolute difference for regressors. An export that silently disagrees with the model you trained is worse than one that fails, because you ship it.
edge_bundle chains export → verify → optimise → quantise → .ort and
reports the size after each stage.
Measured, and reported honestly¶
Running this against real estimators produced three findings the documentation now states outright rather than leaving to be discovered:
- Integer quantisation does not apply to most scikit-learn models. Trees,
linear models and scalers convert to
ai.onnx.mloperators whose parameters are node attributes, not weight tensors; the quantiser refuses withFailed to find proper ai.onnx domain.uses_ml_domaindetects this and the stage is skipped with the reason instead of failing opaquely. - The optimiser and
.ortconversion often make these graphs bigger. They are kilobytes; the metadata added outweighs what is saved. Soedge_bundleships the smallest artifact produced, not the last one — returning a larger file and calling it optimised would be a lie the tool tells by default. - Binary tree-ensemble classifiers convert incorrectly with
skl2onnx1.20 + scikit-learn 1.9: the probability output is a decision score in[-1, 1]and predicted labels disagree with the estimator on a significant fraction of rows. Multi-class trees and linear models are fine. No converter option changes it —zipmap,raw_scoresand four target opsets were tried.SklearnExport.warningsnow flags the combination at export time, and verification catches it.
This is exactly what the verification step is for, and it is why the recipe treats verifying as mandatory rather than optional.
[0.187.0] — 2026-08-01¶
Added¶
- A SQL console for the admin, with a policy in front of it — new extra
[admin-sql](sqlglot, zero further dependencies). Opt-in viamake_admin_router(sql_shell=...); without it the route does not exist.
The honest framing, stated in the module docstring and the recipe: a
SQL filter in the application is defence in depth, not a security
boundary. The analyser parses statements properly rather than matching
strings, which stops the ordinary accidents — a DROP typed by someone who
meant SELECT, an UPDATE with no WHERE, a query against a table holding
card data. It will not stop a determined operator, because SQL has CTEs,
subqueries, functions and dialect extensions and any parser-based allowlist
is a game of coverage. The boundary that holds is the database role;
point the console at a restricted connection and let the policy narrow
further and produce readable refusals.
SqlShellPolicy carries capabilities (READ / INSERT / UPDATE / DELETE /
DDL / DROP / ADMIN), allowed_tables, denied_tables, max_rows,
max_statements, require_where and statement_timeout_ms. Defaults are
the safest useful thing: read-only, one statement, WHERE required,
1000 rows.
Design points worth stating: deny beats allow, so a table in
denied_tables cannot be re-permitted by a broad rule elsewhere.
Unclassifiable statements land on ADMIN, the most privileged
capability, so a construct nobody anticipated needs the highest permission
instead of passing as harmless. Subqueries and CTEs are walked, because
a policy reading only the top-level FROM would miss exactly where a table
gets hidden — while CTE aliases are subtracted, so
WITH recent AS (SELECT … FROM orders) reports orders and not recent.
Multi-statement input is refused by default, since that is how an
allowed SELECT carries a DROP past someone skimming the box. Reads run
in a rolled-back transaction.
Every attempt is audited through SqlAuditor — including refusals,
because what someone tried to run is usually more interesting than what
worked. Auditor failures are swallowed so a broken sink cannot take the
console down.
The page shows the active policy before you type, warns when the console can write, and renders a policy refusal differently from a database error: "you may not do that" and "your SQL is wrong" lead to different fixes.
[0.186.0] — 2026-08-01¶
Added¶
-
Fact stores that survive a restart.
InMemoryFactStoreloses everything when the process dies, which is the one thing durable memory must not do. Two backends now keep it, both behind the same four-method protocol so swapping is a constructor change: -
DbFactStoreoverBaseFactModel/make_fact_model— a row per fact, for when facts are part of your domain and you want them in backups, in the admin, joined against a user. Each operation opens its own short transaction, so a fact the agent asserted mid-run survives a run that later fails. The unique index on(subject, key)is yours to declare in the migration — the SDK cannot know whether your table is shared or partitioned, and without it a write race leaves two rows. -
RedisFactStore— one hash per subject, so listing is a singleHGETALLand every operation is O(1). For preferences shared across replicas where a migration is more ceremony than the data deserves. -
tempest_fastapi_sdk.agents.testing— so you can test your agent. An agent's behaviour depends on what the model decides, which makes it feel untestable. But almost every bug worth catching is in your code — a tool that mishandles an argument, a budget that never fires, a skill whose tools never unlock — and all of it is testable by scripting the model's decisions.
ScriptedBackend (with replies / replies_with_tool /
replies_with_tools / tool_call) replays a plan you wrote and records
system_prompts, prompts and specs_seen per turn — which is how you
prove a skill's tools stayed hidden until loaded, or that facts were
actually injected. FailingBackend checks a backend outage becomes
StopReason.ERROR rather than an exception escaping into a request
handler. assert_completed / assert_used_tools / assert_artifact /
tool_steps / failed_steps cover the assertions, and assert_completed
exists because the common mistake is checking only run.output — a
budget-truncated run carries text too, so that assertion passes on
half-finished work.
These are the helpers the SDK's own 200+ agent tests use; they are exported because your agent deserves the same treatment, and because a test that boots a real model is slow, flaky and tests the wrong thing.
Documentation¶
- New recipe: testing and validating an agent (
agents-testing.md, both languages) — the minimal test, error recovery, budgets, proving on-demand skills and injected memory, backend outages, and a separate@modellayer for the one question scripting cannot answer: does the model pick the right tool.
[0.185.0] — 2026-08-01¶
Added¶
-
Three agent memory layers, all opt-in. "The agent should remember" hides three separate needs, and picking the wrong one is why memory features disappoint:
-
Scratchpad (
scratchpad_tools,scratchpad) — lives for one run. A long run derives something several steps before it needs it; without somewhere to park it the model either re-derives (slow, and the second answer may differ) or carries it in the conversation, competing for attention. Notes live onAgentContext.stateand vanish with the run, which is the feature: a note from an unrelated run turning up mid-task is worse than no notes. - Facts (
Fact,FactStore,InMemoryFactStore,fact_tools,facts_prompt) — durable and editable.subject=isolates by user or tenant;allow_forget=Falsemakes them read-only for the model, because a model that can delete what it disagrees with will.facts_promptinjects them rather than making the model ask — by the time it realises it needs the timezone it has usually answered in the wrong one. - Recall (
recall_prompt) — durable and fuzzy, over the existingChatMemory. Returns an empty string when the backend fails, so a vector-store outage never stops the agent.
The distinction that matters is facts vs recall. A fact is asserted and exact — listable, editable, showable to the user. Recall is retrieved and approximate, which is powerful and unauditable. Storing "the user's plan is enterprise" in recall means nobody can correct it; storing a whole conversation as a fact means nothing useful comes back. The recipe leads with that table.
[0.184.0] — 2026-08-01¶
Added¶
Skill— capabilities the agent loads only when it decides to use them. Every tool an agent can call sits in its prompt, and every line there costs context and dilutes attention. Ten well-documented capabilities is more instruction than a small local model can hold, and quality drops on all ten.
A skill splits what the model needs to choose from what it needs to
do: the name and one-line description are always in the prompt, while
the full instructions and the skill's own tools arrive only after the
model calls load_skill. A hundred capabilities cost a hundred short
lines, and the one in use gets the whole page. Agent(skills=[...]) wires
the loader and the prompt block; the tool list is recomputed each turn, so
a skill's tools genuinely do not exist until it is loaded — and calling
one early gets the usual unknown tool observation the model recovers
from.
skill_from_markdown and discover_skills read skills from
<dir>/<name>/SKILL.md with the same frontmatter format Claude Code
uses, so one file works in both places and a deployment can add a
capability without a code change. Tools stay in Python and are attached
afterwards. A missing skills directory returns [] rather than raising,
so a service starts fine without one. loaded_skills(context) reports
what a run actually reached for — usually the first thing you want when it
took a wrong turn.
[0.183.0] — 2026-08-01¶
Added¶
-
@tool— agent tools whose arguments are a Pydantic model. Writing JSON-schema by hand next to a handler means two descriptions of the same thing, drifting apart from the first edit: the schema sayscity, the handler readsarguments["town"], and nothing catches it until a model calls the tool. The decorator reads the args model off the handler's first annotation and generates the schema from it, so there is one description. The handler receives a validated instance (args.cityis typed and checked), and because validation now runs before the handler, a model that invents an argument getsinvalid arguments for get_weather: city: Field requiredback as an observation — precise enough to correct from — instead of aKeyErrorhalfway through your code. Constraints (ge,le, enums) are enforced the same way.typed_toolis the non-decorator form;schema_ofexposes the schema generation, which inlines Pydantic's$defs/$refbecause local tool-calling models handle a flat object far more reliably. A malformed handler fails at decoration, not on first use. -
Agent.run_structured(goal, output=Model)→StructuredRun[Model]. An agent that ends in prose is useless to a pipeline. Rather than asking for JSON and parsing the reply, the agent is given a temporaryfinal_answertool shaped like your model, and calling it is how the model finishes — the arguments are the answer, already validated, carried by the tool-calling path the backend already handles well.run.datais an instance of your model;run.parse_errorsays why when it isNone.structured_verdictcomposes withrun_untilto retry until the shape arrives.
Extraction pass. Validating against a real local model showed the case
that actually happens: Qwen2.5-0.5B solved the task, called the right
tool, and then answered in prose anyway — leaving nothing to parse. So
when the prose carries no JSON, the SDK makes one more call whose only
tool is the answer tool, asking the model to restate what it already said
in that shape. With nothing else to call and no reasoning left to do, even
a 0.5B model fills the fields. It costs one model call against losing the
entire run; extraction_retry=False disables it.
[0.182.0] — 2026-08-01¶
Added¶
-
Delegation —
agent_tool(agent)/team_tools(...). There is no separate "team" object, and that is the design: an agent already knows how to pick a tool by name and read what it returns, so the cheapest way to hand work to a specialist is to make the specialist a tool. What delegation does need is three guards a plain tool does not: -
The clock is inherited.
AgentContext.deadlinecarries an absolute instant, and each run takes the earlier of its own budget and the one handed down. A sub-agent may finish sooner than its budget allows but never later than its parent's, because the parent is holding a request open. - Depth is bounded.
max_depth(3 by default) turns A→B→A from an infinite loop into a refusal the model reads and works around. - The child's work comes back. Artifacts merge into the parent
namespaced as
<agent>/<name>, so two specialists writingreport.mdcannot clobber each other, and a truncated child is reported as[stopped: timeout] …rather than passing partial work off as complete.
New StepKind.AGENT plus AgentStep.children / .agent /
.total_steps: a delegation is the one step that can cost as much as a
whole run, and a trace where the expensive step looks like a function call
is a trace you misread.
- Autonomous loops —
run_untilandrefine. A single run stops when the model says it is done, which often means "out of ideas".run_until(agent, goal, until=...)repeats until a predicate you wrote accepts — ordinary Python that can parse the JSON, import the module or call your service, which is a far harder gate than asking the model whether it is satisfied.refine(worker, critic, goal)runs generate-critique-revise with two agents; the critic approves with the exact tokenAPPROVED(free-form judgement hedges and cannot be branched on) and never rewrites, keeping the work and the accountability with the worker. Both carry every round inLoopResult/LoopIteration, bound total wall-clock across rounds, and reportaccepted=Falsewhen nothing passed — running out of rounds is not approval.
Fixed¶
- A delegated agent did not actually inherit its parent's clock. The
effective deadline was computed onto the run state but never written back
to the
AgentContext, socontext.child()handed the sub-agentNoneand it ran to its own budget. Caught by the test written for the behaviour; the deadline now lands on the context that delegation reads.
[0.181.0] — 2026-08-01¶
Added¶
tempest_fastapi_sdk.agents— AI agents over the models you already self-host. An agent takes a goal, decides what to do, calls tools and reports what it did. That last part is what separates it fromAIChatPipeline, which answers a chat turn: a run comes back with a step-by-step trace — arguments, outputs, timings, failures — plus the files it produced. Submodule import, no extra required; the weight lives in the objects you inject.
Agent.run(goal) → AgentRun, or Agent.stream(goal) yielding each
AgentStep as it lands. Three properties are deliberate, each avoiding a
specific bug:
- A tool that raises does not end the run. The failure is recorded on the step and handed back to the model as an observation — a model told "no artifact named 'chart.png'; available: bike.png" usually fixes itself next turn, where an escaping exception would discard the whole run.
- Every ceiling is enforced and named.
AgentBudgetbounds steps, wall-clock and tool calls, andStopReasonsays which one fired. Steps alone do not bound a run — one tool call can hang — somax_secondsdefaults to 120 rather than being optional.AgentRun.succeededisTrueonly forCOMPLETED, because a truncated run still carries text and a caller that ignores the reason ships half-finished work as done. - Binary results never enter the prompt. A tool returns
ToolResult(text for the model,AgentArtifactbytes for the caller), and artifacts are held by name on the run.
Ready-made tools over the local models — generate_image_tool
(ImageGenerator), describe_image_tool (VisionTextGenerator),
transcribe_audio_tool (SpeechToText), speak_tool (TextToSpeech),
retrieve_tool (Retriever), web_search_tool (WebSearch),
save_artifact_tool, plus text_tool for hand-written ones. They chain
through named artifacts: an agent draws bike.png and the next step asks
the vision model about bike.png, with the bytes never touching disk and
base64 never touching the prompt. AgentTool.from_tool adapts an existing
AIChatPipeline tool.
Persistence is opt-in: nothing by default, InMemoryAgentRunSink for a
bounded ring buffer (bounded because runs carry their artifacts, and an
unbounded list of image-generating runs is a slow memory leak), or
BaseAgentRunModel / make_agent_run_model / DbAgentRunSink for a table.
The table keeps the trace and the artifact names, not the bytes — a run
table is not a blob store. A sink failure never fails the run.
make_agent_router(agent, run_store=...) serves POST /run,
POST /run/stream (SSE, one event per step and a trailing done),
GET /runs and artifact download. Artifacts travel as metadata in JSON and
as raw bytes with a real media type on their own endpoint, so an <img
src> works and a megabyte image does not get inflated by base64.
Validated end to end against a real local model: Qwen2.5-0.5B on CPU chose the tool, extracted its argument, read the result and answered — three steps, 5.1 seconds.
[0.180.0] — 2026-08-01¶
Added¶
-
GenAIMetrics.observe_inventory(report)— the runtime inventory as Prometheus gauges:genai_models_loaded{kind,device},genai_models_known, andgenai_gpu_vram_free_bytes{index}when the report carried a hardware snapshot. The existing counters answer "how much work went through"; these answer "what is resident now", which is the question that explains an OOM. Labelled series are cleared on every call because a gauge here describes a snapshot: a model that unloaded between two observations has to stop being reported, or the stale series reads as residency. Feed it from a periodic task. -
make_model_cards(models, include_vram=True)(tempest_fastapi_sdk.genai.admin) — the same inventory as admin dashboard cards forAdminSite(dashboard_cards=[...]): resident count (2 of 5), a per-device breakdown, and free VRAM. The cards ignore theAsyncSessionthe dashboard passes them, since this data comes from process memory rather than the database, and they read the handles at render time — a registry that is empty when the site is built still reports correctly once models load.include_vram=Falsedrops the only card that probes the host.
Documentation¶
- The
genaiAPI reference was a hole.docs/reference.mddocumentedgenai.hub,genai.imageandgenai.inventory— the submodules added this week — while the module those sit on top of had no reference entry at all. The top-leveltempest_fastapi_sdk.genaisurface plusgenai.ragandgenai.audioare now rendered there, 269 symbols in total.
[0.179.0] — 2026-08-01¶
Added¶
tempest_fastapi_sdk.genai.inventory— what is loaded right now. A self-hosted service can hold a language model, an embedder, a reranker and a diffusion pipeline at once, each holding gigabytes of VRAM for as long as it stays resident, and nothing could report which.ModelRegistryknew how many entries it held, not which; every loader kept its idle clock to itself. Pure Python — imports with no extra installed.
describe_model(handle, key=...) turns any SDK loader (or any object that
walks like one) into a LoadedModel, reading attributes only: calling
it on a generator that has never loaded reports loaded=False and leaves
it unloaded, which is what makes it safe inside a health check. What a
handle does not expose comes back None rather than guessed, and a handle
implementing only is_loaded still appears — vanishing from a memory audit
is worse than appearing with blanks. LoadedModel.idle_past_threshold
answers "is this due to be freed", False whenever any input is unknown.
runtime_report(models, ...) puts those next to probe_hardware() in a
ModelRuntimeReport, sorted loaded first, longest-idle first — the
order an operator reads when a card is full. probe=False skips the NVML
read, the only part of the call that costs anything.
-
ModelRegistry.inventory()/.items()/.unload_idle().unload_idle()calls each handle'sunload_if_idle()and returns the keys it freed; entries stay registered, because a generator that dropped its weights is still the right object to hand out and reloads on next use. That makes it the method a periodic task wants, whereevict()/evict_all()forget the entry entirely. Handles without the hook are skipped rather than unloaded on a guess. -
GET /modelsonmake_genai_router(models=...), accepting aModelRegistryor a plain dict/list of handles. -
A uniform idle clock on the three loaders that lacked one.
ClassifierModeratortracked_last_usedwithout exposing it and had nounloadat all;SpeechToTexthadunloadbut no idle clock;OnnxEmbedderhad neither. All three now carryseconds_idle/unload_if_idle/idle_unload_seconds(OnnxEmbeddergainsunload), matching the loaders that already had them.
Fixed¶
make_genai_routerrefused an emptyModelRegistry. The guard tested the injected objects for truthiness, and a registry defines__len__, so an empty one — the normal state at startup, before anything is loaded — read as "nothing injected" and raised. It now testsis None, so the endpoint can be mounted at the only moment you can mount it. An emptydict/listof handles was affected the same way.
[0.178.0] — 2026-08-01¶
Added¶
ImageGenerator(pipeline_kwargs=...)— extra keywords forwarded tofrom_pretrained, applied last so they override what the SDK computes (includingtorch_dtype).
Found by validating v0.177.0 against a real diffusion pipeline: the load
itself takes decisions the SDK does not model, and .pipeline cannot
express them because by the time you hold it the cost is already paid.
The three that come up immediately are {"safety_checker": None} (Stable
Diffusion 1.x/2.x repositories bundle an extra CLIP purely to filter — it
costs memory, and a tiny test pipeline crashes on it outright),
{"variant": "fp16"} (roughly halves the download) and
{"use_safetensors": True} (refuse a pickle checkpoint). The dictionary
is copied, so a later mutation by the caller cannot change what a
subsequent load sends.
[0.177.0] — 2026-08-01¶
Added¶
ImageGenerator— the generative modality the SDK was missing. It already generated text, understood images (VLM), embedded, transcribed and synthesized speech; it could not draw.tempest_fastapi_sdk.genai.imageruns a HuggingFace diffusion pipeline on your own hardware, behind a new[genai-image]extra (diffusers+pillow); the module imports without it.
It deliberately mirrors TextGenerator — same device/precision
resolution, same lazy load, same unload / unload_if_idle, same Hub
pinning keywords — so a service that already self-hosts a language model
gains images without learning a second set of conventions.
generate(prompt, config=...) returns list[GeneratedImage], not
loose bytes, because the seed travels with the image. Diffusion is
deterministic given a seed, so returning the one actually used is the
difference between an image you liked and an image you can reproduce —
and when the caller passes none, only the generator knows which was drawn.
edit(prompt, image, strength=...) redraws an existing image (path,
bytes, PIL or NumPy) through AutoPipelineForImage2Image.from_pipe, which
reuses the already-loaded UNet, VAE and text encoders: an SDXL pipeline is
~7 GB, and loading a second copy is how a service OOMs at the first edit.
ImageGenerationConfig types negative_prompt, width, height,
steps, guidance_scale, seed and num_images, forwarding only what
you set. That matters more here than for text — a distilled turbo model
wants 4 steps at guidance 0.0, a full SDXL wants 30 at 7.5, and
applying one model's numbers to the other either wastes the compute or
degrades the image.
Concurrency defaults to one render: unlike an LLM, a single diffusion
call already saturates the GPU, so running two concurrently makes both
slower and doubles peak VRAM. .pipeline is the escape hatch for swapping
the scheduler, attaching a LoRA or enabling a memory optimization the SDK
does not wrap.
make_genai_router(image_generator=...)→POST /image. The response body is the encoded image itself, typed from the generator'simage_format, with the seed in anX-Image-Seedheader so a client can reproduce the render. Only the first image is returned — batches go through the class.
Note on dependencies¶
diffusers declares httpx<1.0.0 and huggingface-hub<2.0. Neither bites
today (httpx is still on the 0.28 line) and, being an optional extra, the
bound only enters the resolution of whoever installs [genai-image] — it
does not reach a service that skips it. It is accepted because diffusion
schedulers, pipelines and VAE decoding are real engineering with a long tail
of correctness, not a preset table we could restate in tens of lines.
[0.176.0] — 2026-08-01¶
Added¶
tempest_fastapi_sdk.genai.hub— the weight lifecycle of a self-hosted HuggingFace model. Loading a model by id is the easy half. The other half is everything the firstfrom_pretrainedcall hides: which commit you actually got, how many gigabytes it wrote to which directory, whether the disk had room, and how to make the next boot reproduce the same weights without a network. New extra[genai-hub](huggingface-hubalone, already contained in[genai]); the module imports with neither, because every Hub call resolves its dependency inside the function that needs it.
Pinning. resolve_revision(model_id, revision="main") returns the
immutable commit sha behind a branch or tag. An unpinned service loads
whatever main holds the day it restarts — the author pushes, the pod
cycles, and it is serving different weights without a line having changed.
The function returns None rather than raising when the Hub is unreachable
or the repository does not exist, so the caller decides whether to proceed
unpinned or abort the deploy.
Fetching. download_model materializes a revision on local disk with
allow_patterns/ignore_patterns (most repositories ship the weights twice
— .bin and .safetensors — so restricting the globs usually halves both
the download and the disk), and returns a ModelSnapshot carrying the path,
the byte total and the file count. It sizes the repository through
model_disk_bytes — Hub metadata, no download — before writing anything and
raises OSError when the filesystem cannot hold the estimate times a 1.1
margin: failing in two seconds with a number beats failing forty minutes
later with a half-written cache. can_run answers whether a model fits RAM
or VRAM; this answers whether it fits the volume, and a healthy deploy asks
both.
Cache. list_cached_models / cache_size_bytes / remove_cached_model
report and reclaim what the local cache holds. Weights are the biggest thing
a self-hosted service writes to disk and nothing prunes them — every model
ever loaded stays until removed. Removal reports the bytes freed, supports a
dry_run, accepts a revision by sha or by ref name, and returns 0 for a
model that is not cached, which is a successful no-op rather than an error.
revision=,local_files_only=andtrust_remote_code=on every loader.TextGenerator,Embedder,VisionTextGenerator,ClassifierModeratorandRerankertake the same three keywords next to thecache_dir=/hf_token=they already had, each building aModelRefand forwarding it — so there is no per-class way to pin.ModelRefemits only the values that differ from the default, which keeps an unpinned call byte-identical to what the SDK sent before and keeps the same dictionary usable with narrower loaders (tokenizers.Tokenizer.from_pretrainedtakesrevisionbut nottrust_remote_code).
local_files_only=True makes a load purely local, so an air-gapped or
deploy-frozen host fails immediately instead of quietly reaching the
network. trust_remote_code stays opt-in per model: some architectures
require it, and it executes Python from the same repository the weights came
from.
Two loaders differ, both because of the library underneath. SpeechToText
gains revision=, local_files_only= and hf_token= mapped onto
faster-whisper's own names (download_root / use_auth_token) and has no
trust_remote_code — CTranslate2 loads weights, never repository Python.
OnnxEmbedder already holds the graph on disk, so only its tokenizer can be
pinned: tokenizer_revision= and hf_token=.
tempest model pull/cache-list/cache-rm. The weight lifecycle from a Makefile, aDockerfileor a deploy job.pull --pindownloads and prints the commit sha to feed back as--revision;cache-list --revisionsbreaks the cache down per commit;cache-rmconfirms before deleting (--yesskips,--dry-runreports the size without touching anything).
Changed¶
- The
[genai]extra's README description no longer says model runners are upcoming — they have shipped since v0.98.
[0.175.0] — 2026-07-31¶
Added¶
tempest_fastapi_sdk.modelops— export, benchmark and quantize the models a service serves. Three jobs that only make sense together: you quantize to make a model cheaper, you benchmark to find out whether it actually got cheaper, and you export to the format the target device runs. Two new extras so nobody installs weight they do not use —[modelops](psutil + nvidia-ml-py) and[modelops-onnx](onnx + onnxruntime). The module itself imports with neither; every heavy dependency is resolved inside the function that needs it and its absence raises anImportErrornaming the extra. Nothing in modelops constrains yourtransformersversion — see the quantization paragraph below for why that took deliberate work.
Benchmark. benchmark(call, ...) times any zero-argument callable;
benchmark_onnx, benchmark_torch and benchmark_models build on it, so an
ONNX session, a torch module and a closure all produce the same
BenchmarkProfile. The loop runs N warm-up calls and discards them, times N
repetitions, and reports median plus IQR first — inference latency is
heavy-tailed, and a mean hides the tail a p99 SLO cares about — alongside
p95/p99, throughput, RSS peak and delta, CPU usage and GPU memory. Symbolic
input dimensions are resolved from dynamic_dims= or input_shapes= and
raise rather than being guessed: feeding a 1x1 image to a convolutional model
produces a confidently wrong number.
Energy. Four samplers behind one PowerSampler protocol, so the loop
never branches on the host. NvmlPowerSampler prefers the driver's monotonic
total-energy counter (nvmlDeviceGetTotalEnergyConsumption, Volta+) and falls
back to integrating sampled power on older cards; NvidiaSmiPowerSampler
polls the binary when pynvml is absent; RaplEnergySampler reads CPU
package energy from the Linux powercap tree, summing package domains only and
handling counter wraparound; NullPowerSampler measures nothing and reports
it. Every sampler degrades into that last behaviour instead of raising, and
every reading carries an EnergySource because a GPU figure and a RAPL figure
are not the same quantity — and neither is wall-plug. A CPU run does not
resolve a GPU sampler by default: attributing a shared card's idle draw and
other processes' VRAM to a CPU model would be worse than reporting nothing.
Ranking. composite_scores weighs min-max-normalized cost dimensions
(DEFAULT_COST_WEIGHTS tuned for edge/mobile) and renormalizes twice so a
missing measurement cannot distort the result: a dimension no profile measured
is dropped entirely, and a dimension one profile lacks is skipped for that
profile alone. pareto_points annotates the non-dominated set, skipping any
axis either side did not measure, and degrading to a cost-only frontier when
no quality was supplied — the SDK never invents a quality number. rank
returns a BenchmarkReport carrying the sorted profiles, the frontier, the
effective weights and the host description.
Export. export_torch_to_onnx (opset, dynamic axes, fp16),
export_onnx_to_ort (file or directory → .ort, FIXED/RUNTIME style,
target_platform, type reduction) and optimize_onnx_graph (persist ONNX
Runtime's fusions into a new .onnx). The ORT conversion also surfaces the
.required_operators.config it writes, which is what lets a minimal ONNX
Runtime build drop from tens of megabytes to a few.
Quantization. quantize_onnx_dynamic (no calibration data),
quantize_onnx_static (calibration reader built from any iterable of feed
dicts, QDQ/QOperator, MinMax/Entropy/Percentile), the transformers-export
path — optimize_hf_onnx (O1–O4) and quantize_hf_onnx
(arm64/avx2/avx512/avx512_vnni) — plus quantize_hf_bnb for int4/int8
weights that stay loadable by AutoModelForCausalLM.
The transformers path runs on onnxruntime.transformers and
onnxruntime.quantization, not on HuggingFace optimum. optimum-onnx
declares transformers<4.58, and an upper bound on a published package
propagates to every consumer; meanwhile ORTOptimizer and ORTQuantizer
delegate to the same onnxruntime entry points this SDK already depends on,
so the dependency bought a wrapper and cost a ceiling. The preset tables it
did carry — the O1–O4 levels, the per-ISA weight types, and the
HuggingFace-to-fusion architecture map — are now explicit named constants in
modelops/quantize.py, each documenting its provenance, and
tests/modelops/test_quantize.py pins them against the installed runtime.
optimize_hf_onnx gained model_type= (override the fusion type, or work on
a bare graph) and use_external_data_format=; both functions now carry the
export's config.json and tokenizer into the output directory.
Producing the ONNX export is the one capability with no substitute
outside optimum, and it is now a documented build step instead of a
dependency: uvx --from "optimum[onnxruntime]" optimum-cli export onnx ...
resolves the cap inside a throwaway environment that never touches your
project. See docs/recipes/modelops.md.
Static analysis. analyze_onnx sums parameters from the initializer
dimensions without loading a weight; analyze_ort reports shapes and size
(the serialized format drops the initializer table, so the parameter count is
honestly 0); analyze_torch counts parameters and, given an example input,
forward GFLOPs; analyze_model dispatches on the suffix.
Recipe: docs/recipes/modelops.md.
tempest modelCLI (tempest_fastapi_sdk/cli/model.py) —analyze/bench/optimize/quantize/export-ort/hardware, with--jsonon the reporting commands so the loop runs from a Makefile or a CI step.tempest model hardwarereports what the host can measure, not just what it can run: whether NVML, thenvidia-smifallback or the RAPL counters are actually readable here. A missing extra exits 2 with the install line rather than a traceback.
Changed¶
-
[all]now includesonnx. No modelops extra pullsoptimum, souv sync --all-extraskeeps resolvingtransformersto the 5.x series and the GenAI suite stays exercised against it in CI. -
export_torch_to_onnxchecks foronnxscriptup front. From torch 2.9 on,torch.onnx.exportdefaults to the dynamo exporter, which importsonnxscriptlazily — a missing install surfaced as aModuleNotFoundErrorraised from inside torch, midway through the export. It now raises the same actionableImportErrorevery other optional dependency in this module produces.onnxscriptjoined thedevgroup so CI exercises the path.
[0.174.0] — 2026-07-30¶
Second half of the security sweep: the reachable-by-a-request crashes, and the hardening items around them. None of these needed a valid session to hit.
Fixed¶
-
A password over 72 bytes answered 500 instead of 422 (
auth/service.py,settings/mixins.py,utils/password.py). bcrypt refuses input past 72 UTF-8 bytes —hashpwraisesValueError— and the policy only had a lower bound, so signup, password-reset confirm and password change all crashed on a long password. NewAUTH_PASSWORD_MAX_BYTES(default72) rejects it as a validation error. Bytes, not characters:"🔒" * 19is 19 characters and 76 bytes. -
GET /logscrashed on an offset-freestart/end(api/routers/logs.py). Pydantic hands back a naivedatetimefor2026-01-01T00:00:00, log timestamps are aware (...Z), and comparing the two raisesTypeError. A bare bound is now read as UTC. -
GET /logsread every selected file into memory whole (api/routers/logs.py,admin/router.py). Filtering and pagination happened after the load, so a service whose log directory had grown to gigabytes took the worker down instead of answering. Each request now reads the newestDEFAULT_MAX_RECORDS_PER_FILE(20k) records per file through a bounded deque, tunable viamake_logs_router(max_records_per_file=...), and logs a warning when the cap bites. The admin logs page shares the fix. -
An unknown
order_bycrashed pagination (db/repository.py).BasePaginationFilterSchema.order_byis a plainstrquery parameter, andpaginatepassed it to a baregetattr— an unknown name raisedAttributeError, and a name that exists but is not a column (metadata,registry) raised it one frame later on.desc(). Both are now resolved through the mapper's column set and raiseValidationException(422).cursor_paginatevalidated already but had the same non-column hole; it now shares the check. -
A client-supplied filename could add a header to the response (
utils/download.py).build_content_dispositiontook the basename but left control characters in, so a name containing\r\nproduced a header value with a real line break — which an ASGI server that does not validate header values (uvicorn onhttptools) writes to the socket verbatim. Every C0/C1 character is now stripped. In the documented usage the name isUploadFile.filename, so this is caller-controlled input. -
BodySizeLimitMiddlewarecould crash the response it was protecting (api/middlewares/body_size.py). The 413 was emitted in afinally, after the app had answered — and FastAPI does answer, converting the guard'sClientDisconnectinto a400. The secondhttp.response.startmakes uvicorn raiseRuntimeError: Response already started. The 413 is now sent the moment the count is exceeded, while the app is still reading the body, and anything the app sends afterwards is dropped. A streaming oversize body therefore answers413where it recently answered400. -
make_csrf_token_dependencynever set the cookie its docstring promised (api/middlewares/csrf.py). It returned the token and stashed it onrequest.state, leaving the cookie absent — so the double-submit check the page was rendered for rejected the followingPOSTwith a 403. It now sets the cookie (Secure+SameSite=Lax, and deliberately notHttpOnly, since the client must read it), withsecure=/samesite=/max_age=overrides.
Security¶
-
OAuthUser.email_verified(api/oauth.py).emailwas surfaced with no indication of whether the provider had verified it. A service linking a social login to an existing account by email hands over the victim's account when the address was never verified — GitHub'sGET /userreturns the public profile email, which GitHub does not require verifying. Google and generic OIDC now report theemail_verifiedclaim (string forms normalized); GitHub reportsNone, meaning unknown, rather than implying either answer. -
Opt-in webhook replay window (
api/webhooks.py). The signature covers the body only, so a captured delivery stayed valid forever.verifier.dependency(timestamp_header=..., max_age_seconds=...)additionally requires a fresh unix-timestamp header. Opt-in because a provider that sends no such header would have its legitimate traffic rejected — and documented as bounding opportunistic replay only, since an attacker who rewrites the request can rewrite the timestamp too. -
PostGISRepositoryMixin.nearbyvalidates its column names (geo/db.py).latitude_field/longitude_fieldare interpolated into atext()fragment (a column name cannot be a bind parameter). They are developer-supplied today, but nothing stopped a route from forwarding a query parameter; they are now checked against the mapper. -
tempest secrets rotatewrites0600(cli/secrets.py). The rewritten.envand its.env.bakinherited the process umask, commonly0644— world-readable, for a file that now holds freshly minted secrets.
[0.173.0] — 2026-07-30¶
Security¶
- A refresh token or an MFA-pending token no longer authorizes a request
(
tempest_fastapi_sdk/utils/token_types.py(new),api/dependencies/auth.py,auth/service.py).
UserAuthService signs three JWTs with the same secret: the access token, the
refresh token, and the intermediate token that bridges the two steps of an MFA
login. Nothing distinguished them once the signature verified, and
make_jwt_user_dependency authorized any token carrying a sub. Since
POST /auth/login hands the mfa_token back to a client that has proven only
the password, that token worked as a bearer on every authenticated route — the
second factor was skippable. The long-lived refresh token was accepted the same
way, which defeats the point of a short access token.
Every issued token now declares a typ (ACCESS_TOKEN_TYPE /
REFRESH_TOKEN_TYPE / MFA_TOKEN_TYPE), and the bearer, current-user, role
and permission dependencies accept access only. accepted_typ= widens that
per call site. A token with no typ — one a project signed itself with
JWTUtils.encode() — is still accepted, so upgrading does not invalidate live
sessions; the legacy markers the SDK did stamp (refresh: True,
purpose: "mfa_pending") are recognized and rejected as access.
token_type_allowed() exposes the same decision outside a dependency.
ResponseCacheMiddlewareno longer serves one caller's response to another (api/middlewares/response_cache.py).
The cache key was method|path|query plus the vary= headers — nothing about
who asked. An authenticated GET that did not set Cache-Control: private
itself (most routes do not) was stored and replayed to the next caller on that
path, anonymous ones included. The emitted Cache-Control also defaulted to
public, telling browsers and CDNs to keep personalized bodies.
A request carrying Authorization or Cookie now bypasses the shared store;
it still gets its ETag and 304, which are per-response. Cache-Control
defaults to private, max-age=N. cache_credentialed=True opts a deployment
back into caching credentialed traffic, folding a digest of those headers into
the key so each caller gets its own entry.
IdempotencyMiddlewarescopes each entry to the caller that created it (api/middlewares/idempotency.py).
The key was (method, path, key) with key chosen by the client, so two
callers picking the same string shared an entry — and a replay returns the
stored response, headers included. The key now also carries a digest of the
request credentials; principal_resolver= replaces that with your own identity
(an API-key id, a tenant header). A Set-Cookie is dropped from the stored
copy, so a replay can never re-issue the original caller's session.
Changed¶
IdempotencyMiddlewareno longer caches5xxresponses, so a client retry after a transient failure actually reaches the handler instead of replaying the error for the entry's whole TTL.cache_server_errors=Truerestores the old behavior.- Concurrent requests sharing an idempotency key are serialized within a process: the second waits and replays the first's response rather than running the handler again. Across replicas the store still only deduplicates completed requests.
ResponseCacheMiddlewareemitsX-Cacheonly when astore=is configured. It previously reportedMISSin ETag-only mode, where there is no cache to miss.
[0.172.1] — 2026-07-30¶
Fixed¶
- The admin logs table is readable on a phone, and a copied row keeps its
separators (
tempest_fastapi_sdk/admin/static/admin.css,admin/templates/logs.html). Two problems with the0.172.0traceback view, both found on real screens rather than in tests.
Below 600px the four-column table only fit by scrolling sideways, and the
fourth column is the one that matters — message, request context, traceback. An
opened trace showed up as a tall blank row until the operator dragged the table
across (measured at 414px: table.scrollWidth 676 against a 390px wrap). The
logs table now stacks into cards at that breakpoint: thead is dropped and each
cell names itself from a new data-label, so the message and its traceback sit
in the viewport with no horizontal scroll (375px table in a 375px wrap). Scoped
to this table — the other list views keep the scroll treatment, which suits a
list you skim rather than read.
The request-context chips (path, method, status_code, request_id) were
separated only by a flex gap. That renders separation but contributes no
character, so copying a row to paste into an issue yielded status_code404 and
request_idd7332783-…. The markup now carries a real space between the label
and the value, and the visual gap widened, since an uppercase micro-label
against a monospace value reads as one token.
Migration: none.
[0.172.0] — 2026-07-30¶
Added¶
- The admin logs page now shows the traceback of a 500, and exports the
selection as markdown or JSON (
tempest_fastapi_sdk/admin/router.py,admin/templates/logs.html,api/routers/logs.py). Diagnosing an unhandled error from the panel was impossible: the table rendered four columns (timestamp, level, logger, message) and dropped every other field, so theexceptionvalue — the formatted traceback the SDK's own exception handlers write withexc_info=True— sat on disk and never reached the screen. The only way to read it was to shell into the server, and there was no way to hand it to anyone. This bit during the0.171.1investigation: the single clue to a production 500 was a log line with the trace stripped off.
Each record carrying a traceback is now a collapsed disclosure: the record's
own message is the <summary>, so clicking anywhere on the entry reveals the
trace, with the request correlation fields (path, method, status_code,
request_id) alongside it. Plain <details>/<summary> — the admin ships no
JavaScript — collapsed by default so a page full of 500s stays scannable, and a
record without a traceback grows no toggle at all.
The traceback wraps rather than extending: a <pre> is sized by its longest
line and sits in a table cell, which sizes to content, so overflow-x on the
<pre> could not shrink it — the table grew past its scroll container (1364px
against a 1012px wrap) and the trace could only be read by dragging the table
sideways. Wrapping costs the alignment of the caret markers on screen; the
export carries the exact unwrapped text.
New GET {prefix}/logs/export?format=md|json downloads the filtered
selection (the page's own ?source= and ?q=), newest first, capped at 500
records:
format=mdputs each traceback in a fenced```pytbblock, so it survives a paste into an issue or PR with its indentation intact. The header states the source, the record count, the active filter and — when the cap bites — how many records matched in total, so a partial export never reads as a complete one.format=jsonemits the records verbatim, including every field the application attached throughextra=, for tooling rather than reading.
Page and export share one collector, so the file always matches what the operator was looking at. The export inherits the admin session guard — a traceback is exactly the payload that must not be world-readable.
render_entries_markdown/render_entries_jsonare exported at the package level (tempest_fastapi_sdk/api/routers/logs.py) so a service can build its own export — a CLI command, a CI step, a chat-ops hook — off the same rendering the admin panel uses.render_entries_jsonfalls back tostr()for non-serializable values, so one exoticextra=value cannot fail a whole export.
[0.171.1] — 2026-07-30¶
Fixed¶
make_auth_routernow loads the authenticated user on the request session, soPOST /auth/password-changeactually rotates the password (tempest_fastapi_sdk/auth/router.py). The router built its authenticated-user dependency without thesession_dependencyseammake_jwt_user_dependencyprovides, so_make_user_loaderopened a private session, fetched the user and returned it after that session closed. Every authenticated route of the bundled router therefore received a detached instance, and the damage was silent-then-loud: assigninguser.hashed_passwordsucceeded,session.flush()wrote nothing (the instance is absent from that session's identity map), and the followingsession.refresh(user)raisedInvalidRequestError: Instance is not persistent within this Session. The endpoint answered 500 and left the old password valid — reported from production as "changing the password does nothing".
UserAuthService.current_user_dependency already defaulted to
self.db.session_dependency and the docs already promised an attached
instance; only the bundled router bypassed it. The loader is now a
two-argument (user_id, session) callable and the dependency is wired with
session_dependency=_session — the same callable the route bodies depend
on, because FastAPI caches a sub-dependency by its callable and a different
wrapper would open a second session and detach the user again.
The existing router tests could not catch this: they wire a session_factory
that yields one shared AsyncSession, making the loader's "private" session
and the route's session the same object.
tests/auth/test_router_session_sharing.py drives the router through a factory
that opens a new session per call, the way
AsyncDatabaseManager.session_dependency does in a real app, and asserts a
single request opens exactly one session.
Same root cause fixed for POST /auth/mfa/confirm and POST /auth/mfa/disable,
which mutate totp_secret / totp_enabled_at through the same dependency.
UserAuthServicemethods that receive an already-loaded user now re-attach it to the session they write through (tempest_fastapi_sdk/auth/service.py).change_password,mfa_enroll,mfa_confirmandmfa_disabletake the user from their caller instead of fetching it, which made every one of them a session mismatch away from losing its writes — with the error surfacing atrefresh, one line past the real cause. A private_attachhelper returns the user untouched when it already belongs to the session andmerges it in otherwise (mergerather than a re-fetch by primary key, so pending in-memory changes are carried over instead of discarded). This makes the flows correct for callers driving the service directly — a background task, a CLI command, a test — not only through the bundled router.
Migration: none. Behavior only becomes correct where it previously failed; no signature changed.
[0.171.0] — 2026-07-28¶
Changed¶
BaseStrEnum/BaseIntEnumnow render their value understr()and f-strings (tempest_fastapi_sdk/core/enums.py). Both bases arestr/int+Enummixins, andEnumowns__str__/__format__on those — sostr(OrderStatus.PAID)returned"OrderStatus.PAID"even thoughOrderStatus.PAID == "paid"holds. Members compare, serialize and bind to the database as their value, but the moment one was interpolated into an f-string, a log line, a query string or written to a raw column, the member name leaked instead. The SDK had already paid for this once: the OpenAPI client generator needed an explicit.valueso enum query params did not go out asstatus=CustomerStatus.PAST_DUE(tests/openapi/test_generate.py)._EnumHelpers— first in the MRO for both bases — now overrides__str__and__format__(f-strings go through the latter, so overriding only the former fixes nothing) to delegate to the value, matchingenum.StrEnum. Numeric format specs keep working onBaseIntEnum(f"{Priority.HIGH:03d}"->"002"), andrepr()is untouched, so members stay debuggable.
Migration: str(member) / f"{member}" now yield "paid" instead
of "OrderStatus.PAID". Code that wanted the qualified name — log
messages, debug output — should switch to repr(member) or
member.name. Everything that wanted the value (the overwhelming majority,
and every place that was silently wrong) needs no change, and
member.value behaves exactly as before.
[0.170.3] — 2026-07-27¶
Added¶
- Docs signature guard (
tests/test_docs_signature_guard.py, runs inmake check). The existing guard proved a doc snippet parses and that every__all__name resolves — neither catches the example that passes a keyword the function does not accept, hands a keyword-only parameter positionally, or imports a symbol that no longer exists. Those raiseTypeError/ImportErroron the reader's first run, and the prose written around the invented parameter documents behavior that never existed. Four static checks now cover it: keywords exist in the real signature, positional arity fits (which also catches thef(obj, ..., kw=1)elision, whose literalEllipsisis a real argument), SDK imports resolve, and no install snippet requires a version above the packaged one. Symbols resolve per block, from that block's own imports, so the two differentRetryPolicyclasses (tempest_fastapi_sdkfor HTTP,.tasksfor TaskIQ) are never confused; a snippet that uses a symbol without importing it is left alone. - Social login recipe (
docs/recipes/oauth.md+.en.md).GoogleOAuthClient/GitHubOAuthClient/OIDCProvider/OAuthUser/OAuthTokens/OAuthError/generate_oauth_statehad shipped for releases with no page of their own — they appeared only as names in the module tables. The recipe walks the whole flow (register the app, build the client once,statein anHttpOnlycookie, the callback, linking to a local user and minting your own token), plus the GitHub and generic-OIDC variants and the CSRF reasoning behindstate.
Fixed¶
- Documentation for shipped-but-unmentioned surface. Each of these worked
and was reachable only through the API reference:
BodySizeLimitMiddleware(max_bytes/exclude_paths, the header-vs-streaming checks, the 413 envelope) in the HTTP recipe;CSRFMiddleware+make_csrf_token_dependency+generate_csrf_tokenin the security recipe — the sessions recipe already linked to a CSRF section that did not exist;DatabaseBackup(+BackupToolMissingError/UnsupportedBackupBackendError) in the safe-deploys recipe;AuthCookieConfig/apply_auth_cookies/clear_auth_cookiesin the auth recipe;make_web_push_subscription_modelin the Web Push recipe;UploadStorage.write_stream+UploadResultin the uploads recipe;require_x_tokenin the HTTP recipe. - Doc examples that raised
TypeErrorwhen copied (found by the new guard): the README opaque-token section treatedgenerate_opaque_token()as returning a string and passed asecret=pepper neither hash nor verify accepts — and contradicted the security recipe, which correctly documents plain SHA-256 with no pepper;RSAWebhookSignatureVerifierwas shown withencoding=/hash_algorithm=(the parameter isalgorithm=, and PSS padding is not supported); the React-SPA recipe passedsession_factorypositionally tomake_auth_router; the admin RBAC example passed a literalEllipsis, so a copy-paste failed on the requireddb/auth_backend/secret_keykeywords. current_user_dependencysession semantics. The auth recipe said it "opens its own session"; it loads the user on the request session (db.session_dependencyby default), which is the point of the sharing and the reasonsession_dependency=must be the exact callable the repositories use. The HTTP recipe's hand-rolled loader now takes the shared session too, and documents the header → cookie → query lookup order.- Generic install floors in
docs/installation.mdand the recipes landing now reference the current release instead of>=0.161.0/>=0.167.0. Per-feature floors (>=0.89.0on auth/MFA/metrics) stay as the version the feature landed in. - A red test on
main:test_unparsable_source_falls_back_to_a_commaasserted the_separator_beforefallback using an offset inside the first line. Up to Python 3.11 the tokenizer emitsERRORTOKENfor an unterminated quote and only raisesTokenErrorat EOF, so that offset broke out of the loop before the failure and exercised the normal path. The offset now sits past the malformed literal. Test-only —--fixbehavior is unchanged.
[0.170.2] — 2026-07-26¶
Fixed¶
- A bare call no longer resolves to an instance method.
f()andobj.f()were the same kind of edge, and they are not: a call with no receiver cannot reach a method. The cost showed up on the most ordinary code there is — a repository doingawait self.session.execute(update(UserModel).where(...))with SQLAlchemy's importedupdateregistered a call toupdate, which then matched an unrelatedCoinPackService.updateand reported a coin pack's 404 on a category route.delete,insertandselectcollide the same way, so any project using the expression API alongside a service method of the same name was affected. Bare calls now resolve against module-level functions only, which keeps imported helpers and@requiresguards reachable; a call on an unannotated receiver keeps the wide resolution, since there the target really is unknown. NewFunctionInfo.attr_callsholds that second kind.
Two consequences worth expecting on upgrade: routes stop being blamed for
exceptions from unrelated domains, and a declaration that only existed
because of that blame now surfaces as unreachable. In the service this
was found on, both happened — one wrong undocumented disappeared and one
wrong declaration got reported.
[0.170.1] — 2026-07-26¶
Follow-up to 0.170.0, found by running it on the same service: the new delegation chain broke at the classes the layering encourages most.
Fixed¶
- A pass-through layer no longer breaks the chain. A service that overrides
nothing has no
__init__to read —class CategoryService(BaseService[ CategoryRepository, CategoryResponseSchema])states what it delegates to only through the base's generic parameters. 0.170.0 read attributes and constructor parameters, so the walk stopped there and the route that deletes a category was reported as declaring two unreachable exceptions it actually raises. That is worse than silence: it invites deleting a correct declaration. NewGENERIC_DELEGATESmapsBaseService[RepositoryT, …]torepositoryandBaseController[ServiceT, …]toservice; only those two bases are interpreted, sinceBaseRepository[Model]'s parameter is an ORM model and reading positions blindly would invent a delegation link to a table class. - An override further down the chain is now walked, not just mined. Only
constructor configuration was collected along the chain, so a repository whose
own
deletetranslates anIntegrityErrorinto a domain 409 contributed nothing — nothing configures that exception, it is raised outright. Those methods are returned as ordinary call-graph targets now, so theirraisestatements andRaises:sections are read like any other.
[0.170.0] — 2026-07-26¶
Two findings from openapi-errors on a real service, in opposite directions: it
was reporting exceptions a route cannot raise, and staying silent about the ones
it raises through the SDK's own methods.
Added¶
- A class handed to a base constructor now counts as raised. A repository
writes
not_found_exception=CoinPackNotFoundExceptiononce and never names it again — theraiseis insideBaseRepository, outside the scanned tree — so the 404 of every route reading or deleting that model went unreported. The analyzer now reads the exception kwargs passed tosuper().__init__(...)and attributes them to the inherited methods that raise them, walking the delegation chain a layered service creates (a controller'sservice, a service'srepository, plus known bases). NewCONFIGURED_RAISERSmaps each kwarg to those methods, so a create conflict is never attributed to adeletenor a 404 to anadd. Onlyserviceandrepositoryare followed as delegation links (DELEGATION_ATTRS): following every annotated attribute made a service holding extra repositories donate their 404s to every route that reached it.test_openapi_errors_configured.pyasserts the table againstBaseRepositoryitself, transitively overself.*calls — which is howsoft_deleteandrestorewere found to also surface the update conflict, viaself.update(...).
Fixed¶
- A DELETE route no longer inherits another domain's exceptions. The
analyzer walked the whole function node for calls, decorators included, so
@router.delete("/{id}")registered a call todelete. Every edge then resolved by unqualified name, so that call reached everydeletein the project. In a service whose onlydeletewasCategoryRepository.delete, every DELETE route was reported as raisingCategoryInUseExceptionandCategoryNotFoundException— and--fixwrote those wrong names into the decorators.getandpostcollide identically wherever a project defines methods with those names. Only the function body is walked now. - Calls resolve through the receiver's type when it is annotated.
self.svc.f(),self.f(),super().f()and a call on an annotated parameter (a route handler'scontroller: UserController) now resolve inside that class's hierarchy, walking bases breadth-first. Finding nothing there means the method is inherited from outside the scanned tree — the SDK's ownBaseController.delete, say — so no edge is followed at all, instead of falling back to the project method that happens to share the name. A receiver that cannot be typed still resolves by name, keeping the deliberate over-approximation where precision is unavailable. - The permission checker resolves guards with the same edges. Both commands
now build one shared
CallGraph(build_call_graph), so a guard's reachable exceptions and a route's are computed identically rather than by two indexes that had drifted apart in precision.
Documentation¶
docs/recipes/openapi-errors.md/.en.md— the "it's a guide, not a proof" warning now describes typed-versus-name resolution, states that a method inherited from outside the scanned tree is not followed, and records what 0.170.0 fixed.
[0.169.0] — 2026-07-26¶
Added¶
BaseRepositoryaccepts a conflict exception class per operation. Every write raised the SDK's ownConflictException, whosecodeis the generic"CONFLICT"— so a duplicate name and a violated FK reached the client as the same 409, anderror_responses()had nothing specific to document. Only the message was customizable (create_conflict_messageand friends), and a message is not something a frontend can branch on. Each*_messagekwarg now has a matching*_exception:conflict_exceptionas the blanket default pluscreate_conflict_exception,update_conflict_exception,bulk_create_conflict_exceptionandbulk_update_conflict_exceptionfor the individual write paths. Resolution is most-specific-first (per-operation → blanket →ConflictException), the resolved classes are exposed as attributes likenot_found_exceptionalready was, and all tenraisesites acrossadd/add_all/save_with_outbox/add_audited/update/update_audited/update_many/bulk_update/bulk_create_values/bulk_upserthonor them. Every kwarg is optional and the default path is unchanged, so existing repositories keep raising exactly what they raised before. The class is instantiated ascls(message=...)— the same contractnot_found_exceptionhas — so a subclass must accept amessagekeyword.
Documentation¶
docs/recipes/database.md/.en.md— new "exception classes per repository" tip: why a message alone is not enough, the coin-pack example, the kwarg-to-operation table, and thecls(message=...)contract.
[0.168.3] — 2026-07-26¶
Second post-release finding from --fix running against a real project. The
first one only a foreign project could show; this one only a formatted
project could show.
Fixed¶
tempest openapi-errors --fixno longer emits a double comma into a formatted decorator. The insertion point is the closing parenthesis of the route decorator, and the appended text was prefixed with an unconditional", ". A decorator with more than one argument is wrapped over several lines byruff format, which leaves a trailing comma, so the splice producedstatus_code=201,\n, responses=error_responses(...)— two commas in a row.render_filethen calledast.parseon it and raisedSyntaxError, aborting the whole run before anything was written: on a service with 18 drifting routes the command was unusable, and its own claim that "nothing depends on how the decorator happens to be formatted" was false. The separator is now derived from the source viatokenize—""when the call already ends with a comma or is empty,", "otherwise. Tokenizing rather than scanning text backwards is what makes it correct when a,or#sits inside a string literal (description="a, b # c"). New_separator_before()and_char_offset_of()(tokenizereports character columns whereastreports UTF-8 byte columns — mixing the two lands past the intended character on any non-ASCII line).RouteInfo.declares_empty_callis no longer load-bearing. The writer used it to decide the comma, but the flag only knows whether the call has zero arguments — it cannot see a trailing comma, which is the case that broke. It stays as descriptive metadata; its docstring now says so.
Documentation¶
docs/recipes/openapi-errors.md/.en.md— the "edits anchored on the AST" guarantee now explains how the separating comma is derived, and why it is tokenized rather than pattern-matched.
[0.168.2] — 2026-07-26¶
The documentation-organization rule becomes executable. Documentation and tooling only — no code change, no API change.
Added¶
tests/test_docs_organization.py(renamed fromtests/test_nav_ordering.py, 15 tests) — the project rule "the docs stay organized and in order" is now asserted instead of reviewed. On top of the ordering checks the previous release added, it covers the structural half, each item of which has drifted at least once:- every page exists in both languages (
docs/<page>.md+docs/<page>.en.md), and no.en.mdis orphaned; - every page on disk is reachable from its language's nav — MkDocs warns for the default nav, the EN nav is ours to check;
- the two navs cover the same set of pages, with no duplicates;
- the alphabetical sections are alphabetical in both languages;
- the Recipes landing tables are sorted and list every recipe in the nav (twelve were missing when last audited).
- every page exists in both languages (
- A "Regra de organização da documentação" section in
CLAUDE.md— the six-step checklist for adding or renaming a page (two languages, two navs, alphabetical position, landing table, reference stub, strict build), what the guard covers, and what is deliberately not alphabetical (top-level tabs,learning/pages, the landing tour, and the submodule grouping inside the reference). - The same rule on the contributor-facing pages —
docs/contributing.md+.en.mdnow state it under Docs, and.github/PULL_REQUEST_TEMPLATE.mdgrew three checklist items (both navs, landing table, the guard).
[0.168.1] — 2026-07-26¶
Documentation navigation only — no code change, no API change.
Changed¶
- Everything a reader scans to find a page is alphabetical now. The recipe
list had grown to 53 entries in insertion order, which turns a lookup into a
full pass over the list. Sorted, accent- and case-insensitively:
- the
Receitas/Recipesnav section (including theExemplos completos/Complete examplessub-section, itself at its alphabetical position); - both tables on the Recipes landing (
Tema/Themewith all 53 recipes, and the complete-examples table), each sorted in its own language; - the themed groups of
docs/reference.md, with## Superfície de topopinned first; - the module table in
README.md; - the extras table in
docs/installation.md+.en.md, with[all]last because it is the catch-all, not an entry to look up.
- the
- English gets its own nav.
mkdocs-static-i18ntranslates labels but cannot reorder a shared nav, so an alphabetical PT nav left the EN sidebar in Portuguese order (File in the servicebeforeVersioned artifactsbeforeAudit trail). Theenlocale now declares a fullnavof its own, sorted in English.nav_translationsstays for the labels the plugin still resolves.
The top-level tabs and the learning-project pages are deliberately not alphabetical: they follow a reading order (install before tutorial, business rules before endpoint map), where sorting would be the regression.
Added¶
tests/test_nav_ordering.py— guards what the sweep above fixed: the two navs cover the same pages (no page reachable in one language only), no page is listed twice, the alphabetical sections are alphabetical in both languages, and the Recipes landing tables are sorted. Eight tests; a swapped entry fails them.
[0.168.0] — 2026-07-26¶
Permission guards gain injected metadata: one generic guard, a specific check per call site.
Added¶
- A guard may declare a second parameter
meta: dict[str, Any]. That is what turnsmanager_only/auditor_only/admin_onlyinto onehas_role(user, meta)whose call sites declare@requires(has_role, meta={"role": "manager"}). One-parameter guards are untouched — the second argument is passed only to guards that declare it, so both shapes mix freely in the same decoration. requires(..., meta=...)— literal metadata, copied at decoration time, so a later mutation of the passed mapping cannot change what the route enforces. Must be aMapping[str, Any]; anything else is aTempestPermissionErrorat import.requires(..., include_args=True)— merges the arguments the decorated function was called with into the same mapping, so an ownership guard readsmeta["order_id"]without the route handing it over. The user parameter is excluded (the guard already receives it), a parameter the caller omitted contributes its default so the guard sees the values the body will run with, a default that is a framework injection marker (Depends(...)) is dropped rather than passed as a value, and ameta=literal wins a name clash with an argument — the decoration is the explicit declaration. The mapping is fresh per call and shared by that call's guards, so one guard may write a key the next one reads without anything leaking into the next request.guard_metadata(fn)— reads themeta=literals off a decorated function, next todeclared_guards/guarded_user_param. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.authz.- Four static checks in
tempest permissions:meta-unused(error — the decoration passesmeta=/include_args=Truebut no guard declares a second parameter; held back when a guard in that decoration could not be resolved, since it may be the consumer),guard-meta-missing(warning — a two-parameter guard whose decoration supplies no metadata reads an empty dict),guard-meta-annotation(warning — the metadata parameter is annotated as something that cannot hold adict[str, Any]) andmeta-key-collision(warning — ameta=key shadows a parameter name underinclude_args=True, so that argument never reaches the guard).
Changed¶
guard-arityaccepts one or two required parameters. A guard taking(user, meta)used to be rejected at import (expected 1 (user)); the contract is now one or two, and the message readsexpected 1 (user) or 2 (user, meta). Three or more is still an error. No existing single-parameter guard changes behavior.
[0.167.2] — 2026-07-26¶
Documentation layout only — no code change, no API change.
Changed¶
- The tab bar is down from 13 tabs to 11, and every section is reachable from
the landing page.
ExemplosandSSR (páginas tipadas)were loose top-level nav entries, which crowded the Material tab bar; the landing page meanwhile pointed only at Installation / Architecture / Tutorial, so Recipes, Reference and Learning projects existed on the bar and nowhere in the prose. Both fixed:Exemplosis gone as a tab. The five integrated walkthroughs (Pix checkout, neighborhood marketplace, full store admin, fullstack web, GenAI flows) now live under Recipes as theExemplos completos/Complete examplessub-section, and are indexed in a table on the Recipes landing. Their URLs are unchanged.SSR (páginas tipadas)is now a recipe, listed in the Recipes index alongside the others. URL unchanged.- The SDK tour was merged into the Recipes landing
(
docs/recipes/index.md) as its opening section, so one page is the entry point instead of two competing for the role.docs/tour.md/docs/tour.en.mdare removed and the/tour/URL is gone — inbound links now target the tour section of the Recipes landing. - The Recipes index table covers all 53 recipes; twelve were missing
(
authz,permission-guards,introspection-auth,geo,genai,file-store,artifact-registry,openapi-errors,openapi-client,system-checks,management-commands,react-spa) plus SSR. - The landing page gained a "Por onde continuar" / "Where to go next"
card grid naming every section — Installation, Architecture, Tutorial,
Recipes, Reference, Learning projects, Roadmap, Migration guide and
Changelog — and its stale Status row (
630+ tests, ≥ 89 %) now reads2.650+ / ≥ 90 %.
- Contributing is issue-first. The page now opens with the policy: the
useful contribution is an issue, not a PR, because the public surface is
versioned and the bilingual docs ship in the same commit as the code. It
explains what makes an issue actionable, why the ordering exists, and what a
PR needs after an issue is accepted. "Docs typo → PR straight against
docs/<page>.md" is gone: a fix touching only one of the two language mirrors leaves the site inconsistent, so typos are issues too.
Added¶
- GitHub issue forms under
.github/ISSUE_TEMPLATE/— bug, feature / API idea, documentation and usage question — withconfig.ymldisabling blank issues and pointing at the docs site and the private security contact. Plus aPULL_REQUEST_TEMPLATE.mdcarrying the project's PT-BR PR shape and a documentation checklist.
[0.167.1] — 2026-07-26¶
Fixed¶
BaseControllernow accepts services typed over a concrete update schema.ServiceT's bound was writtenBaseService[Any, Any], which is not a partial application: PEP 696 fills the omittedUpdateTwith itsBaseSchemadefault, and since that parameter is invariant the bound admitted only services whose update schema was exactlyBaseSchema. Every service written the documented way —BaseService[Repo, Resp, MyUpdateSchema]— was rejected by the type checker withType parameter "UpdateT@BaseService" is invariant, but "MyUpdateSchema" is not the same as "BaseSchema", forcing a# type: ignore[type-var]on the controller declaration. The bound is nowBaseService[Any, Any, Any], which leaves the invariant parameter open while keeping theBaseSchemaconstraint. Type-check only — runtime behavior is unchanged, and the# type: ignore[type-var]workarounds can be dropped.- The suite can now see this class of bug.
mypyruns over the package, not overtests/, sotests/controllers/test_base.pyalready declared exactly this service/controller pair and stayed green. Newtests/controllers/test_generic_bounds.pyinspectsServiceT.__bound__directly and runsmypy --strictover a downstream-shaped snippet, asserting no[type-var]diagnostic.
Documentation¶
docs/architecture.md/docs/architecture.en.md— the controllers & services section now shows the 3rd generic parameter on both layers and notes that the pair requires 0.167.1+.
[0.167.0] — 2026-07-26¶
Permission guards: a decorator that runs plain (user) -> user | None
functions before a function body, plus the two-layer misuse check the user
asked for — fail-fast at import time, static gate in CI.
Added¶
@requires(*guards, user_param=None)(tempest_fastapi_sdk.authz, re-exported at the package root). Runs each guard on the user before the decorated body, left to right, on a route handler, a controller or a service method, sync orasync. A guard receives the user, denies by raising anAppExceptionsubclass (so the status, the errorcodeand the{detail, code, details}envelope come fromregister_exception_handlers), and returns the user orNone; a non-Nonereturn replaces the user the next guard and the body see, which is howrequire_authenticated/require_active/require_adminnarrowUserT | NonetoUserT. The user parameter is resolved from the annotations — the single parameter typed as aBaseModel/BaseUserModelsubclass — withuser_param=to name it explicitly or disambiguate two user models. The wrapper keeps the decorated signature (with annotations already evaluated, so FastAPI resolves them against the right module), leaving dependency injection and the OpenAPI schema untouched.TempestPermissionError— raised at decoration time (import time) for@requires()with no guard, a non-callable guard, a guard that does not take exactly one fillable parameter, anasyncguard under a synchronous function, or a decorated function whose user parameter cannot be resolved. The application refuses to start instead of running a check that never fires.GuardContractWarning— warned at call time when a guard raises outside theAppExceptionhierarchy (the API layer would answer HTTP 500 with no error code) or returns a non-user value such asFalse(a predicate-style check whose denial cannot be honored). The original exception still propagates; the warning names the guard.declared_guards(fn)/guarded_user_param(fn)— read a function's guards and its user parameter without calling it, for a router audit or a test asserting every write route carries a guard.tempest permissions [--check] [--strict] [--path]— static contract check over the project source (ast, without importing the application), for what runtime validation cannot see: a guard whoseraiseno test exercises, a guard never wired. Errors:no-guards,user-param-missing,user-param-ambiguous,guard-arity,guard-async-in-sync,guard-returns-bool,guard-foreign-exception. Warnings:guard-never-denies,guard-missing-annotation,guard-return-type,guard-unresolved.--checkexits non-zero on errors,--stricton warnings too. Guards resolve by name; an ambiguous or out-of-scope name is reported asguard-unresolvedrather than checked against the wrong definition.
Changed¶
tempest openapi-errorsnow follows@requiresguards. A guard denies by raising, so its exceptions are as reachable from the route as those of any function the body calls; they now show up asundocumenteduntil the route declares them, and--fixwrites them like any other.
[0.166.1] — 2026-07-25¶
Post-release validation of --fix against the published wheel, in a project
that is not this repository. Three things only that setting can show.
Fixed¶
--fixnow formats with the target project's ruff config. The scratch file it hands to ruff was created in the system temp directory, where ruff finds nopyproject.tomland falls back to its own defaults. A project online-length = 120, or with customisortsections, got output formatted to ruff's defaults — which its ownruff format --checkthen rejected, on a file the command had just written. The scratch file is now created next to the file being rewritten, so settings resolve the same way they do for the project.normalize()takes anear=directory for this.- Silent skip of that formatting is now reported.
ruffwas resolved as "onPATH, elseuv run ruff", and neither branch was verified. Invoked by absolute path from a venv (/…/.venv/bin/tempest), that venv'sbin/is not onPATH, so resolution fell through touv run ruff— which exists but cannot run outside a uv project, failing silently undercheck=False. The write then emitted an unsorted import and an over-long decorator with no hint why. Newruff_runner()addspython -m rufffor the importable-but-not-on-PATHcase, probes every candidate with--version, and the command prints anote:line pointing attempest fixwhen none works. - The
docs-marked tests no longer trust a stalesite/. Both fixtures rebuilt only when the built file was absent, so an out-of-date local build was read as current: 14 tests failed on a cleanmainnaming pages and symbols that were in fact present. CI never saw it, having nosite/at all. A singlebuilt_sitefixture now lives intests/conftest.pyand rebuilds whenevermkdocs.yml,docs/ormkdocs_hooks/is newer than the output.
Added¶
ruff_runner()intempest_fastapi_sdk.cli.openapi_fix— returns a probed argv prefix that runs ruff, orNone. Exported for callers that want to make the same "can I format this?" decision.
[0.166.0] — 2026-07-25¶
Added¶
tempest openapi-errors --fix— writes the missing error declarations back into the routes the check was already pointing at. On a route that declares nothing it injectsresponses=error_responses(...)into the decorator plus the imports the new names need; on a route that already declares some of them it appends to the existingerror_responses(...)or@raises(...)call, preserving the original order.--dry-runprints a unified diff instead of writing, and runs on a dirty tree since it is read-only. Both paths go through the sameruff check --select I --fix+ruff formatpass, so the preview is exactly what a real write produces.- A route that declares nothing always gets
error_responses, never@raises:@raisesis only read byTempestAPIRouter, so injecting it into a project on a plainAPIRouterwould produce a decorator that silently does nothing. An existing@raisesis extended in place. - It only ever adds.
unreachablefindings are deliberately not acted on — reachability resolves by call name and cannot see a dynamic raise, so removing a declaration on its word could delete a correct one. - Every edit is anchored on an AST position (the closing parenthesis of a call node), never on a regex, so nothing depends on how the decorator is formatted and the rest of the file is untouched.
- An exception whose defining module cannot be resolved unambiguously is
reported as
unresolvedand its route is skipped, rather than writing an import that would break the application. tempest_fastapi_sdk.cli.openapi_fix— the module behind the flag:plan_file/render_file/normalize/unified_diff/ensure_clean_worktree/FilePlan/DirtyWorkingTreeError.
Changed¶
RouteInfo(incli.openapi_errors) now records the source positions of a route's decorator and of any existing declaration call, which is what makes the merge possible.exception_locations()maps an exception class name to its defining file, dropping names defined in more than one file.
Notes¶
- Requires a clean git working tree to write. With a clean tree
git diffis the review andgit checkoutis the undo — the real safety net for a tool that edits code you wrote. A dirty tree exits 1 with instructions.
[0.165.0] — 2026-07-25¶
Changed¶
- Docstring completeness sweep across the whole public surface. Nothing in the API changed; what changed is that the rendered API reference now carries a complete parameter/return/raises table for every symbol it documents.
- 115 explanatory comments moved out of function bodies into the enclosing docstring, per the project rule that the why belongs in the docstring. They are now visible in the rendered reference instead of only to whoever opens the source.
- 139 missing
Args:/Returns:sections added across 29 modules — mostly the repeated interface methods of the feature-flag backends, session stores, idempotency and response-cache stores, the tenant repository mixin and the storage backends. - 15 rendered symbols had no docstring at all: the response-cache
Protocolstubs, three OIDC URL properties, a nested token-counting helper, and nine auth/session route handlers. - 8
Raises:sections on functions that raise from their own body. make_auth_router'srecovery_code_modelparameter was undocumented.
Notes¶
- Three measurement corrections worth recording, since each one changed what "done" meant:
- A line-based comment scan misses trailing comments (
assert x # why) and counts YAML comments inside generated-file string literals as code comments. Both were wrong: the first under-reported, the second would have stripped documentation out of the generateddocker-compose.yaml. The sweep was redone withtokenize. - Section banners in a class body (
# Signup,# Login) are outside the rule, which is about function/method bodies. Removing them would have made a 1900-line service harder to navigate for no documentation gain. - A
Raises:on amake_*_dependencyfactory would be false: the raise happens in the dependency it returns, which those docstrings already state underReturns:. Nine of seventeen reported gaps were this.
[0.164.0] — 2026-07-25¶
Added¶
- The generated
Dockerfileis fullstack-aware. When the project holds a frontend,tempest generate --dockerfile(andtempest new) emit a Node stage that installs and builds it, and copy only the resultingdist/into the runtime image — neithernode_modulesnor the Node toolchain reach the final layer. The.dockerignoregains the matching<spa>/node_modules/+<spa>/dist/entries, since the stage produces the build and a local copy would ship the developer's machine output instead. - Detection is by
package.jsonunderweb/,frontend/,client/orui/(SPA_CANDIDATE_DIRS), exposed asdetect_spa_dir. An empty directory deliberately does not count: emitting a stage for a placeholder folder would fail the image build insidenpm ci, with an error pointing nowhere useful. --spa-dir <dir>selects an unconventional layout;--no-spaforces a backend-only image. A--spa-dirholding nopackage.jsonexits 1 rather than silently degrading to a backend-only build.npm cirequires a lockfile, so a freshly scaffolded frontend would break the first build; the stage falls back tonpm installwhenpackage-lock.jsonis absent.- A project with no frontend renders a byte-identical Dockerfile to before, verified against the pre-change output.
[0.163.0] — 2026-07-25¶
Added¶
make_spa_router(dist_dir)(tempest_fastapi_sdk.api.spa): serve a compiled React/Vite SPA from the FastAPI process, making a fullstack service one origin and one deployment. MountingStaticFilesalone is not enough — a client-side router owns paths that exist in the browser and not on disk, so a bare static mount 404s on every deep link and refresh. This router adds the SPA fallback plus the details that are easy to get wrong:index.htmlis servedno-storewhile content-hashed assets areimmutablefor a year. Inverting this is the classic "users keep running the old bundle after a deploy" bug, since the document is the one file whose URL never changes.- API prefixes (
DEFAULT_EXCLUDED_PREFIXES, overridable) are excluded from the fallback, so a typo'd endpoint stays a JSON 404 instead of a 200 with an HTML body — which surfaces in the client as a confusing parse error. - Only
GET/HEADfall back;..traversal cannot escape the build directory in any encoding; a missing build raises at wiring time rather than booting a service that 404s every page. - Bilingual recipe "React SPA inside FastAPI" covering the three modes: dev
with a Vite proxy, same-origin production, and the scaffold + multi-stage
Dockerfile. Documents the shared contract with
tempest-react-sdk—createViteConfig,createApiClient,createTempestAuth,AuthGuard, and the{detail, code, details}error envelope both sides agree on.
Fixed¶
- The SSE recipe imported
tempest-react-sdkunder a scoped name (@mauriciobenjamin700/tempest-react-sdk) that does not exist on npm; the package is published unscoped astempest-react-sdk. The snippet could never have resolved.
[0.162.0] — 2026-07-25¶
Fixed¶
- The API reference was missing 190 public symbols while claiming, in its
own opening paragraph, that every exported symbol was documented. Whole
feature areas had a recipe but no reference at all —
tasks(29),geo(29),chat(12),reviews(11),queue(4) — and the top-level directive carried an!^[a-z_]+$filter that silently excluded every function from it. Coverage is now 100% of__all__across the public modules, bar ten deliberately excluded names. llms.txtwas missing 25 of the 73 pages on the site. The generating hook used a hard-coded page list that drifted, so every feature shipped after it was written (generative AI, geolocation, chat, reviews, vision, SSR, both OpenAPI tools) was invisible to LLM consumers while being perfectly visible on the site. Its summary also advertised ten extras when the package ships more than twenty.docs/installation.mdanddocs/installation.en.mdhad drifted from each other — the PT page pinned>=0.137.0, the EN mirror>=0.133.1. Both, and theREADME.mdsnippet, now reference the current release.- Three links in
docs/admin-showcase.en.mdpointed at the Portuguese anchor slug of the SSE recipe, so they resolved to the top of the English page instead of the section. Server-Sent Events (SSE)andTipagem (estático + runtime)had nonav_translationsentry, leaving Portuguese labels in the English navigation.
Changed¶
mkdocs_hooks/llmstxt.pyderives its sections from the MkDocsnavand its extras list from[project.optional-dependencies], so neither can drift from the site again.docs/reference.mduses whole-module:::directives forgeo,vision,ssr,queue,tasks,chatandreviewsinstead of hand-listed symbols — a namespace cannot fall behind its own exports.
Added¶
tests/test_reference_coverage.py— renders the reference page and asserts every__all__name across the public modules emitted an anchor. Markdown parsing cannot catch this class of gap (a:::directive may name a module, and filters may exclude names), so the guard checks the built HTML. Intentional exclusions live in anALLOWED_ABSENTmapping that requires a written reason, with a companion test that fails once an entry becomes stale.tests/test_llmstxt.py— asserts the LLM index links every nav page, keeps the llmstxt title/summary shape, advertises the real extras, and thatllms-full.txtcarries inlined page bodies.- A
docspytest marker for the two guards above, which trigger onemkdocs buildper session.
[0.161.0] — 2026-07-25¶
Added¶
tempest openapi-client <spec>— generate Pydantic schemas and a typed HTTP client from a third party's OpenAPI 3 specification, ending the manual transcription of their documentation (#97). Point it at a URL or a file (JSON, or YAML with the new[openapi]extra) and it writes a self-contained<src|app>/integrations/<name>/package. Options:--name,--out,--header(repeatable, for a spec behind authentication),--path,--schemas-only,--force,--no-format.- Generated schemas carry the specification's metadata. One
BaseSchemaclass per component, withtitle/description/exampleson everyField, so the generated module doubles as the integration's documentation and survives the third party changing or retiring their docs site. Field names are Python-idiomatic with the wire name attached as analiasandpopulate_by_nameenabled, so both spellings are accepted on input andmodel_dump(by_alias=True)returns the wire shape. Reserved words are resolved (class→class_), optional collections default to an empty list (neverlist[X] | None), string/integer enums becomeBaseStrEnum/BaseIntEnumsubclasses,allOfis flattened, and recursive or mutually-recursive models getmodel_rebuild()calls. Nothing is invented where the specification documents nothing. - Generated client wraps an injected
HTTPClient. Oneasyncmethod per operation with typed path/query parameters, a validated request body and a validated response, plus a full Google-style docstring listing the documented error statuses. Because the transport is injected, the retry policy, circuit breaker and credentials stay with the caller and anhttpx.MockTransportexercises the whole integration without a network. tempest_fastapi_sdk.openapiexposes the pieces for programmatic use:load_spec,parse_spec,emit_schemas,emit_client,generate_integration,GenerationResult,default_output_dir,SpecErrorand theSpecIR/SchemaIR/FieldIR/ClientIR/OperationIR/ParameterIRintermediate representation.[openapi]extra (pyyaml) for YAML specifications. JSON needs nothing beyond the standard library, and the download uses the already-basehttpx.
Notes¶
- The emitter's output passes
ruff checkandruff format --checkbefore any formatting pass, which the test suite asserts against the raw (--no-format) output. That means--no-format, or a machine with no ruff installed, still produces a usable package — theruffpass the command runs by default is polish, not correctness. - Regenerating an unchanged specification produces a byte-for-byte identical
file, so the
git diffafter a--forceis the integration's changelog: every line is a real change from the third party. - Unrepresentable constructs are never guessed.
not, external$ref, Swagger 2.0, non-JSON bodies andheader/cookieparameters degrade toAnywith a# openapi: unsupportedmarker and a line in the command's summary. A wrong schema that looks right is worse than a documented gap.
[0.160.0] — 2026-07-25¶
Added¶
ErrorResponseSchema(tempest_fastapi_sdk.schemas): the{detail, code, details}envelope every SDK exception handler already emitted, now an exported Pydantic model. Before this a route wanting to declareresponses={409: ...}had nothing to point at and had to retype the shape inline (#96).error_responses(*exception_classes)(tempest_fastapi_sdk): builds the FastAPIresponses=mapping by readingstatus_code/code/message/details_exampleoff each class — no instantiation. Groups by status code, since OpenAPI allows exactly one response object per status, and distinguishes the codes through anexamplesmap that Swagger UI and ReDoc render as a selector — so two 404s with different codes both stay visible.summarycomes from the class docstring,detailfrom the classmessageor from an optionalMessageCatalog(catalog=/locale=), anddescriptions=overrides a status's description.raises(*exception_classes)+TempestAPIRouter: the same declaration written next to the handler.raisestags the endpoint and returns it unchanged (no wrapper, so FastAPI still sees the original signature);TempestAPIRouter— a drop-inAPIRouter— expands the tag intoresponses=before the route is constructed, so the model reachescomponents.schemasas a real$ref. An explicitresponses=wins per status code.declared_raises(endpoint)/RaisesSpec/RAISES_ATTRIBUTEexpose the tag for tooling.AppException.details_example: documentation-only class attribute feeding the OpenAPI example'sdetailsobject. Never read at runtime.InheritedErrorCodeWarning(tempest_fastapi_sdk.exceptions): emitted at class creation when anAppExceptionsubclass declares neithercodenormessage_keyand therefore answers with one of the SDK's generic identifiers ("CONFLICT","NOT_FOUND", …). That is a silent defect — clients cannot tell the failure apart from any other with the same status, anderror_responses()cannot document it. Inheriting a domain code (declared by a project-owned ancestor) is deliberate specialization and never warns. Silence it withwarnings.filterwarnings("ignore", category=InheritedErrorCodeWarning).tempest openapi-errors [--check]: static drift check between the exceptions each route declares and the ones reachable throughrouter -> controller -> service -> repository. Parses the project withast(never imports the application) and reads bothraisestatements and Google-styleRaises:docstring sections. Reportsundocumented(the documentation hole) andunreachable(an inflated list), exits non-zero with--checkso it doubles as a CI gate;--allow-unreachablenarrows the failure to undocumented only,--pathselects what to scan (defaults to./srcor./app). Calls resolve by name and dynamic raises are invisible — both blind spots are covered by theRaises:section, and the analyzer over-approximates rather than hiding a hole.
Changed¶
AppException's documented pattern is now declaringcode(andstatus_code) in the class body, with__init__building onlymessage/details. Passingcode=at the raise site still works identically at runtime, but hides the value from static introspection —Exc.codeanswers the inherited generic whileExc("x").codeanswers the real one, and reading it would require knowing each__init__signature. The class-body form is what makeserror_responses()possible.
[0.159.1] — 2026-07-25¶
Added¶
AppSettingsMeta(tempest_fastapi_sdk.settings): the metaclassBaseAppSettingsnow uses. It pre-checks base ordering and raises an actionableTypeErrorwhenBaseAppSettingsis listed before one of its own subclasses (every SDK settings mixin), replacing Python'sCannot create a consistent method resolution order (MRO) for bases ...— which never names the fix — and the misleading[metaclass]error the pydantic mypy plugin emits on the same line. The new message names both classes and prints the corrected base list. It keeps the phrasemethod resolution order (MRO)so existing matches on Python's wording still hold.- Doc guard
test_doc_settings_put_base_app_settings_last: every fencedpythonblock inREADME.md/docs/**/*.mdthat declares a class withBaseAppSettingsin its bases must place it last. Wrong ordering is valid syntax that only fails at class creation, so the existing parse guard could not catch it — and the bilingual docs duplicate every snippet.
Fixed¶
- Docs demonstrated the forbidden settings base ordering (#95). Since
v0.138.1 every settings mixin subclasses
BaseAppSettings, so the base must come last.docs/recipes/email.md/.en.mdshipped a copy-pasteableclass Settings(BaseAppSettings, EmailSettings)that raisedTypeErroron import; thedocs/tutorial.md/.en.mdproject tree, the responsibility table and the matchingREADME.mdrows describedSettings(BaseAppSettings, mixins...). All corrected, plus a!!! warningon the tutorial settings page and a0.138.1section indocs/migration.md/.en.mdstating the rule with the realTypeErrortext. - Pagination field named
sizeinstead ofpage_size(#60).BaseRepository.paginatereturns{items, total, page, page_size, pages}; theREADME.mdrepository table anddocs/recipes/chat.md/.en.mdlisted the key assize.
[0.159.0] — 2026-07-25¶
Added¶
ResponseCacheMiddleware(tempest_fastapi_sdk.api.middlewares): HTTP response caching in two layers.- ETag + conditional GET (always on) — every cacheable response gets a
strong
ETag(sha256 of the body) and aCache-Control; a matchingIf-None-Matchshort-circuits to304 Not Modifiedwith an empty body. - Server-side cache (opt-in via
store=) — a cacheableGET/HEADresponse is stored forttl_secondsand a later matching request is served without running the handler (X-Cache: HIT); the stored ETag still drives conditional-GET304s. - Stores mirror the idempotency shape:
ResponseCacheStoreprotocol +MemoryResponseCacheStore+RedisResponseCacheStore(raw client). Only safe methods and successful statuses are cached;no-store/private/Set-Cookieresponses are never stored.vary=folds request headers into the key (and emitsVary);cacheable=/exempt_paths=scope it. First slice of the HTTP performance layer roadmap theme.
[0.158.0] — 2026-07-24¶
Added¶
- Dead-letter panel + task inventory (
tempest_fastapi_sdk.tasks): persist terminally-failed tasks and see/re-run them in the admin. BaseDeadLetterModel(abstract, over the SDKBaseModel) +make_dead_letter_model(tablename=, class_name=)— one row per terminal failure (task name/id, error + type, retries, args/kwargs as JSON).DbDeadLetterSink(db, model)— a readyDeadLetterSinkthat writes each dead letter to that table (tq.dead_letter(DbDeadLetterSink(db, model))).make_dead_letter_admin_model(model, tq=None)— a read-mostlyAdminModel(list/filter by task + error type, search, export) with an optional requeue bulk action (make_requeue_action) that re-enqueues the selected calls with their storedargs/kwargsand deletes the rows.task_inventory(tq)→list[TaskInfo](name / schedule / retry policy) read off the broker, for a "what tasks exist" view.
Deliberately no live queue introspection — TaskIQ exposes none (Flower is
Celery-specific), so the panel shows what is real and persisted: terminal
failures and the declared task set. Everything imports without the [tasks]
extra. Closes the queue observability + genai tracing roadmap theme.
[0.157.0] — 2026-07-24¶
Added¶
- TaskQueue reliability + observability (
tempest_fastapi_sdk.tasks): opt-in retry, dead-letter, and per-task metrics, wired onto TaskIQ so application code never touches the broker middleware API. - Retry —
RetryPolicy(max_retries=, on_error=)carried as task labels (@tq.task(retry=RetryPolicy(...)));TaskQueue.enable_retries(...)installs TaskIQ'sSimpleRetryMiddlewarethat honours them. - Dead-letter —
DeadLetter+DeadLetterSinkprotocol +TaskQueue.dead_letter(sink, default_max_retries=): a task that fails with no retry configured, or after retries are exhausted, is handed to your sink exactly once (aMessageBrokerchannel, a DB row, an alert — the target is yours; the SDK assumes no backend). A failing sink is logged, never crashes the worker.make_dead_letter_middlewarefor manual wiring. - Metrics —
TaskMetrics(namespace=, registry=)recordstasks_runs_total{task,status}+tasks_duration_seconds{task}from a middleware into the shared Prometheus registry ([prometheus]extra);TaskQueue.enable_metrics(...).
Everything imports without the [tasks] extra (TaskIQ is only touched at
wiring time). Second slice of the queue observability + genai tracing
roadmap theme (after the genai spans in v0.156.0).
[0.156.0] — 2026-07-24¶
Added¶
- OpenTelemetry spans on genai calls (
tempest_fastapi_sdk.genai.genai_span). Ambient tracing that reuses the globalTracerProviderset up bysetup_tracing— call it once at app startup andTextGenerator/OllamaGenerator(generate/chat),Embedder.embed, and the RAGRetriever.search/.retrieveemit spans automatically, alongside the existing FastAPI / SQLAlchemy / httpx instrumentation. Spans follow the OpenTelemetry GenAI semantic conventions (gen_ai.system/gen_ai.operation.name/gen_ai.request.model, plusgen_ai.usage.input_tokens/gen_ai.usage.output_tokenson the Ollama path); errors mark the spanERRORand record the exception. Zero-config and zero-cost by default: no-op when the[otel]extra is absent and non-recording when no provider is configured. Nothing needs to be injected into the generators. First slice of the queue observability + genai tracing roadmap theme.
[0.155.0] — 2026-07-24¶
Fixed¶
- Constrained structured output works on transformers 5.x.
build_prefix_allowed_tokens_fn(used byTextGenerator.generate_structured(constrained=True)) no longer importslm-format-enforcer'sintegrations.transformersmodule, whosePreTrainedTokenizerBaseimport fromtransformers.tokenization_utilsbroke on transformers 5.x. The adapter is now built from the library's stable core (JsonSchemaParser+TokenEnforcer+TokenEnforcerTokenizerData). Validated on Qwen2.5-3B (GPU) end to end. VisionTextGeneratorloads on transformers 5.x. UsesAutoModelForImageTextToText(falling back to the renamedAutoModelForVision2Seq), andtorchvision— required by modern VLM processors (Qwen2-VL) — is now part of the[genai-vlm]extra. Validated loading + generating on Qwen2-VL-2B (GPU).
Added¶
@gpubehavioral validation suite (tests/genai/test_gpu_validation.py) now covers the constrained-structured and VLM paths on real models (was a skip); a@modeltest builds the lm-format-enforcer adapter on a real tokenizer under transformers 5.x.
[0.154.0] — 2026-07-24¶
Added¶
HybridRetriever.retrieve+SupportsRetrieveprotocol. The hybrid retriever gains the one-shotretrieve(query, top_k) -> contexthelper mirroringRetriever, andmake_genai_router(retriever=...)now accepts anySupportsRetrieve(aRetriever— reranking throughRetriever(reranker=...)— or aHybridRetriever), so both back the/ragendpoint unchanged. (Schema-constrained output stays a programmatic API viagenerate_structured, not a generic HTTP endpoint.)
[0.153.0] — 2026-07-24¶
Added¶
- Metrics + cache on the local generators.
TextGeneratorandEmbeddernow acceptmetrics=(aGenAIMetrics) and record request count + latency forgenerate/chat(opgenerate/chat) andembed(opembed) — the metrics were previously wired only intoOllamaGenerator.TextGenerator.chatalso honors thegeneration_cache(deterministic calls), matchinggenerate.
[0.152.0] — 2026-07-24¶
Added¶
AIChatPipelinemoderation + context truncation. New constructor args:moderator=(aModerationBackend) screens the user input before generating and the reply after — a flagged turn is answered withblocked_messageinstead (input check short-circuits the model); andtokenizer=+max_context_tokens=trim the oldest turns (viatruncate_messages) so the built message list fits the model's window before generating. Both are opt-in; the pipeline is unchanged when they are unset.
[0.151.1] — 2026-07-24¶
Fixed¶
import tempest_fastapi_sdknow works on a base install. The top-level package eagerly importsauth(which useshttpxinauth.introspectionandEmailStrinauth.schemas), so importing the package — or any submodule liketempest_fastapi_sdk.genai— failed withModuleNotFoundErrorunless an extra happened to pullhttpx+email-validator. Both are now base dependencies, sopip install tempest-fastapi-sdk[genai](and every other minimal install) imports cleanly. Long-standing issue, masked by[all]/dev environments.
[0.151.0] — 2026-07-24¶
Added¶
- Content moderation (
tempest_fastapi_sdk.genai) — a pluggable layer to screen prompts and completions:RuleModerator(dependency-free whole-word block-list, the deterministic default) andClassifierModerator(a local toxicity/text-classification model over transformers,[genai], lazy + to_thread,flagged_labels/threshold). Both satisfy theModerationBackendprotocol and return aModerationResult(flagged/categories/score), so a caller orAIChatPipelinecan block or annotate per policy.
[0.150.0] — 2026-07-24¶
Added¶
GenAIMetrics(tempest_fastapi_sdk.genai) — Prometheus counters + histogram for inference: request count, latency, and tokens in/out, labelled by model and operation (the[prometheus]extra; accepts an explicit registry so it composes withPrometheusMiddleware//metrics). Opt-in via atrack(model, op)async context manager or themetrics=argument onOllamaGenerator(which recordsprompt_eval_count/eval_countfrom the daemon response). No overhead when unset.
[0.149.0] — 2026-07-24¶
Added¶
make_vision_router(tempest_fastapi_sdk.vision) — an opt-in FastAPI router mirroringmake_genai_router: inject the loadedClassifier/Detector/Segmenteryou have and it mounts only the matching endpoints (POST /classify//detect//segment, multipartUploadFile), mapping results through the existingto_*_schemashelpers. RaisesValueErrorwhen nothing is injected. No import-timeort-vision-sdkdependency (objects are injected already-constructed).
[0.148.0] — 2026-07-24¶
Added¶
- Token counting + context management (
tempest_fastapi_sdk.genai) —count_tokens(text, tokenizer),count_message_tokens(messages, tokenizer)andtruncate_messages(messages, max_tokens, tokenizer, keep_system=True). Truncation keeps system messages and the most recent turn, dropping the oldest until the chat fits. Works over any tokenizer exposingencode(text) -> sequence(HuggingFaceAutoTokenizerqualifies), so the count uses the model's real vocabulary, not a heuristic.
[0.147.0] — 2026-07-24¶
Added¶
- Generation cache (
tempest_fastapi_sdk.genai) —InMemoryGenerationCache/RedisGenerationCache+GenerationCache/AsyncGenerationCacheprotocols, wired intoTextGeneratorandOllamaGeneratorvia ageneration_cache=argument. Only deterministic generations (do_sample=False/temperature=0) are cached — sampling calls always run the model. Keys hash model id + prompt + parameters (make_generation_key);is_deterministicandcached_generateare exposed for reuse. The generator awaits sync-or-async caches at one call site.
[0.146.0] — 2026-07-24¶
Added¶
OnnxEmbedder(tempest_fastapi_sdk.genai.OnnxEmbedder) — torch-free text embeddings over ONNX Runtime (onnxruntime+tokenizers, the new[genai-onnx]extra). Runs a sentence-embedding model exported to ONNX on CPU with a light dependency set, satisfying the sameSupportsEmbedprotocol asEmbedder, so it drops into aRetriever/make_genai_routerunchanged. Pooling is the attention-mask-weighted mean of token embeddings (never diluted by padding);normalize=TrueL2-normalizes for cosine.
[0.145.0] — 2026-07-24¶
Added¶
HybridRetriever(tempest_fastapi_sdk.genai.rag) — hybrid retrieval fusing dense vectors and sparse BM25 with Reciprocal Rank Fusion, so exact terms (proper nouns, codes, acronyms) a dense retriever misses are recovered.indexbuilds both a dense store and an in-memory BM25 index (rank-bm25, added to the[genai-rag]extra);search(query, top_k, candidates)fuses the two rankings.reciprocal_rank_fusion(rankings, k=60)is exposed standalone for arbitrary rank lists.
[0.144.0] — 2026-07-24¶
Added¶
Reranker(tempest_fastapi_sdk.genai.rag.Reranker) — a cross-encoder second stage for RAG. Scores each(query, chunk)pair jointly with anAutoModelForSequenceClassificationmodel (e.g.cross-encoder/ms-marco-MiniLM-L-6-v2), far more precise than dense cosine.Retriever(embedder, store, reranker=...)over-fetches candidates (search(..., rerank_candidates=20)) and the reranker narrows them totop_k; without a reranker the retriever stays dense-only. Lazy load,unload/unload_if_idle, auto device/dtype (the[genai]extra). Adds theSupportsRerankprotocol.
[0.143.0] — 2026-07-24¶
Added¶
VisionTextGenerator— local vision-language generation over transformers (AutoModelForVision2Seq+AutoProcessor), the multimodal sibling ofTextGenerator.generate/chattake optionalimages(path /bytes/PIL.Image/ NumPyndarray, normalized by_load_image) and stay text-compatible, giving the transformers path the multimodal reachOllamaGeneratoralready had via itsimagesargument. Lazy load, idle unload, auto device/dtype. New[genai-vlm]extra (Pillow). Targets the commonprocessor(text=, images=)interface (LLaVA, Qwen2-VL); other families may need a thin adapter.
[0.142.0] — 2026-07-23¶
Added¶
- Schema-constrained structured output.
OllamaGenerator.generate_structuredandTextGenerator.generate_structuredtake a Pydantic schema and return a validated instance. Ollama sends the schema as the daemonformatfield (native JSON-schema enforcement, no extra library) — the recommended route. The transformers path constrains decoding withlm-format-enforcer(new[genai-structured]extra) whenconstrained=True; on a version skew with the installed transformers it raises a clear error pointing toconstrained=False(best-effort) or the Ollama backend. Newparse_structured(text, schema)extracts + validates JSON from any raw completion (tolerating Markdown fences and surrounding prose), andbuild_prefix_allowed_tokens_fnbuilds the transformers constraint.
[0.141.0] — 2026-07-23¶
Added¶
TextGenerator.chat_with_tools— tool calling on the local transformers backend. Renders the tokenizer chat template withtools=(transformers >= 4.44), generates, and parses the emitted tool calls (<tool_call>{...}</tool_call>for Qwen/Hermes, or a bare Llama-style JSON object) into the same{"content", "tool_calls"}shapeOllamaGeneratorreturns. This closes the gap whereAIChatPipeline's bounded tool loop only worked with Ollama — the same pipeline now runs tools on local weights with no daemon. Use a tool-capable instruct model (e.g.Qwen/Qwen2.5-*-Instruct).
[0.140.0] — 2026-07-23¶
Added¶
HTTPClient.stream— line-by-line streaming (e.g. NDJSON) with retry and circuit-breaker covering the stream open; a non-retried error status raiseshttpx.HTTPStatusErrorbefore the first line, so callers never inspectstatus_codeon a stream.HTTPClient(transport=...)— inject an explicithttpx.AsyncBaseTransport(e.g.httpx.MockTransport) for tests without reaching into the private client.
Changed¶
- Ollama and SearXNG backends now run over
HTTPClient.OllamaGenerator,OllamaEmbedderandSearxngBackendbuild (or accept) anHTTPClientinstead of a barehttpx.AsyncClient, so every daemon/search call gets retry, exponential backoff, a per-host circuit-breaker andX-Request-IDpropagation.OllamaGenerator/OllamaEmbeddergaintransport=andretry_policy=constructor arguments for the lazily-created client. Breaking: thehttp_client=parameter of these three classes now takes anHTTPClient, not anhttpx.AsyncClient. Migrate by wrapping:SearxngBackend(url, http_client=HTTPClient())(tests can passtransport=httpx.MockTransport(...)).ContentExtractoris unchanged and still takes anhttpx.AsyncClient.
[0.139.0] — 2026-07-23¶
Fixed¶
GenerationConfig.seed/.stopnow take effect on the local transformers path.TextGeneratorpreviously dropped both fields (to_generate_kwargsstrips them and nothing reapplied them), so a configuredseednever made sampling reproducible andstopstrings were ignored. The generator now reappliesseedviatransformers.set_seedbefore generating and wiresstopintomodel.generate'sstop_stringsargument (transformers >= 4.44) — for bothgenerate/chatandstream, with per-call overrides winning over the config.OllamaGeneratoralready honored both. No API change.
[0.138.2] — 2026-07-23¶
Changed¶
BaseAppSettingsmust now be the last base of a composedSettings. This documents a constraint introduced by 0.138.1: because every mixin now inheritsBaseAppSettings, Python's C3 linearization forbids listingBaseAppSettingsbefore any mixin — a base cannot precede its own subclass. ASettingsthat listedBaseAppSettingsin the middle of its bases (valid before 0.138.1, when mixins inherited rawBaseSettings) now raisesTypeError: Cannot create a consistent method resolution order (MRO)at import. The fix is a one-line reorder — moveBaseAppSettingsto the end of the bases, which was already the documented convention. The value of.envloading is still order-independent; only the base ordering is now enforced by the interpreter.
Docs¶
- The settings-composition recipe (
recipes/http, PT-BR + EN) and thesettings/mixinsmodule docstring now state thatBaseAppSettingsmust be the last base (not merely "by convention"), and explain theTypeErrorraised otherwise. Corrects the 0.138.1 note that implied any base ordering was safe.
[0.138.1] — 2026-07-23¶
Fixed¶
- Settings mixins now always honor
.env. Every settings mixin (ServerSettings,DatabaseSettings,RedisSettings, … — all 16) now inheritsBaseAppSettingsinstead of rawpydantic_settings.BaseSettings. Pydantic materializes a completemodel_configonto every settings class, so a mixin that inherited rawBaseSettingscarriedenv_file=None; when it was listed beforeBaseAppSettingsin the bases (the documented order), that full config overwrote the canonical one and.envwas silently ignored — every field fell back to its default (most visiblyDATABASE_URL→ the SQLite default) unless the variable was already exported into the process environment. Because the app reads real env vars in containers, the bug only surfaced locally (CLI,.env-driven runs). InheritingBaseAppSettingskeepsenv_file=".env",extra="ignore"andcase_sensitive=Trueon the composedSettingsregardless of base ordering. No consumer code change is required.
[0.138.0] — 2026-07-20¶
Added¶
LocaleField(tempest_fastapi_sdk.LocaleField) — the schema-ready counterpart of theLocaleenum, mirroring howUFFieldpairs withUF. It isAnnotated[Locale, BeforeValidator(normalize_locale_tag)], so a request field normalizes loose input ("pt_BR","PT-BR", the bare primary subtag"pt") into aLocalemember and rejects unsupported tags with a422. Use theLocaleenum for canonical values you already hold, andLocaleFieldon request schemas where the input is client-supplied.normalize_locale_tag(tempest_fastapi_sdk.normalize_locale_tag) — the loose-string →Localenormalizer behindLocaleField(also usable standalone), analogous tonormalize_uf.
Docs¶
- The fields recipe (PT-BR + EN) documents
LocaleFieldwith an Enum-vs-Field note cross-linked toLocaleColumnMixin.
[0.137.0] — 2026-07-20¶
Added¶
Localeenum (tempest_fastapi_sdk.Locale) — a curated, dependency-freeBaseStrEnumof common BCP-47 locale tags (Locale.PT_BR == "pt-BR",Locale.EN_US, plus ~30 more). Each member is the tag itself, so it compares to and binds to aStringcolumn as that value.LocaleColumnMixin(tempest_fastapi_sdk.LocaleColumnMixin) — an opt-in SQLAlchemy mixin that adds a nullablelocalecolumn (BCP-47,VARCHAR(35)) to a model, so a user row can carry the language its notifications and localized text should render in without every project re-declaring the column.NULLmeans "no preference" — resolve to the app default (e.g. viaMessageCatalog). Pairs with theLocaleenum and the Web Push recipe.
Docs¶
- The database recipe (PT-BR + EN) gains a "Locale" section documenting
LocaleColumnMixin+Localeblock-by-block, cross-linked from the Web Push recipe; the API reference lists the new mixin.
[0.136.1] — 2026-07-20¶
Docs¶
- Rewrote the Web Push recipe (PT-BR + EN) to the layered
router → controller → service → repositorypattern. Each code block now carries its file path and a block-by-block explanation, and the wiring matches the conventions used across Tempest services: WebPushDispatcheris an infrastructure singleton built lazily inresources.py(never fails at import without VAPID keys).- Concrete
WebPushSubscriptionRepositorysubclass instead of a bareBaseRepository; the SDKWebPushSubscriptionServiceis used as-is (no pass-through wrapper); a thinWebPushControllerholds the auth gate. - Per-layer DI providers with
sessionresolved viaDepends(get_session)(the previousget_push_service(session)example could not be used as aDepends()target). - Router uses a bare
/webpushprefix mounted under/api, avoiding the/api/api/...double-prefix;make_web_push_routeris framed as an opt-in shortcut that bypasses the controller layer. - No public API changes — documentation only.
[0.136.0] — 2026-07-20¶
Added¶
- New
<column>__<op>filter operators, available everywhere the convention filters are (dict filters,Q, and therefore everyBasePaginationFilterSchemasubclass viaget_conditions): between→col BETWEEN lo AND hi; value is an ordered two-item(lo, hi)list/tuple (a malformed value is skipped, not raised).iexact→ case-insensitive equality (lower(col) == lower(value)).like/ilike→ rawLIKE/ILIKEwith the caller's own%/_wildcards, not escaped (usecontains/startswith/endswithfor escaped user input).ilikeis always case-insensitive; plainlikecase-sensitivity is backend-defined — preferilike/iexactfor portable case handling.not_in→ readability alias for the existingnotin.
Changed¶
- Convention filters accept any non-string iterable for membership, not
just
list.build_filter_condition— shared byBaseRepositorydict filters andQ— now treats aset,tuple,frozenset,range,dictview or one-shot generator the same as alist, emittingcol.in_(values). A bare filter ({"id": some_set}) and the__in/__notinsuffixes both benefit, so callers no longer wrap asetinlist(...)just to hand it to a filter. The iterable is materialized once (so generators survive the count/page double-use), andstr/bytes/Mappingstay scalars — a plain string value is still equality, never a character-wiseIN.
[0.135.0] — 2026-07-19¶
Added¶
query_paramonUserAuthService.current_user_dependency. The service wrapper now forwards aquery_paramtomake_jwt_user_dependency, so a cookieless client (e.g. the browserEventSource/SSE, which can't send anAuthorizationheader) can authenticate off?access_token=<jwt>without dropping to the low-level factory. Unlikecookie_nameit is never auto-derived — it's an opt-in escape hatch; enable it only over TLS with short-lived access tokens.
[0.134.0] — 2026-07-19¶
Added¶
SSEDatatype alias (exported from the top level andtempest_fastapi_sdk.sse):str | bytes | Mapping[str, Any] | Sequence[Any] | int | float | bool | None— the payload contract for SSE publishers.
Changed¶
- Type the SSE
dataparameter withSSEDatainstead ofAnyonServerSentEvent.data,EventStream.publishandSSEBroker.publish. Runtime behavior is unchanged (str/bytessent as-is, everything else JSON-encoded viajson.dumps(..., default=str)); this only tightens the static type. Passing a bare object that relies solely ondefault=strserialization (e.g. a top-levelUUID) now needs an explicitstr(...)/dict wrap to satisfy the type checker.
[0.133.1] — 2026-07-18¶
Changed¶
- Bump the
tempestwebfloor to>=0.60.0in the[ssr]and[all]extras (previously>=0.9.0). Thetempest_fastapi_sdk.ssrbridge (Page,html_response,make_htmx_router,make_web_app_router,build_web_app,detect_build_mode) is developed against the currenttempestwebline; pinning the floor to the latest release keeps a fresh install from resolving a years-stale backend that predates those APIs.
[0.133.0] — 2026-07-18¶
Added¶
- Batch object storage on
AsyncMinIOClient— three concurrent, order-aware helpers for the common "resolve one key per row on a list endpoint" pattern, replacing serialawaitloops over the single-key methods: presigned_get_urls(keys)→dict[str, str]— signs many download URLs at once, deduplicating keys.put_objects(items)→dict[str, str]— uploads many objects, each described by the newPutObjectItemdataclass (mirrorsput_object's per-object arguments:content_type,metadata,length,part_size).get_objects_bytes(keys)→dict[str, bytes]— downloads many small objects, deduplicating keys.
All three are fail-fast (the first failure aborts the batch and propagates)
and bound their in-flight work with a semaphore via max_concurrency (default
16; 0/negative raises ValueError), so a large page cannot saturate the
thread executor.
- StoredFileServiceMixin.file_urls(keys) — the batch counterpart of
file_url. Drops None/empty keys, collapses duplicates and returns a
dict[str, str] keyed by object key, so a page of rows resolves its presigned
URLs in one bounded fan-out (urls.get(row.key) yields None for an empty
key). PutObjectItem is now exported from the package root.
[0.132.0] — 2026-07-15¶
Added¶
WebPushSettings.enabled— a property returningbool(VAPID_PRIVATE_KEY), so services can gate dispatch on "is Web Push configured" without hand-rolling the check next towebpush_kwargs().make_web_push_router(vapid_public_key=...)— optional; when set (a string or a zero-arg callable), mounts a publicGET {prefix}/vapid-public-keyreturning{"public_key": ...}so the browser can subscribe without baking the key into the frontend build. Omitted by default.
[0.131.0] — 2026-07-15¶
Added¶
- Versioned artifact registry (
tempest_fastapi_sdk.artifacts) — the generic core of a "DB-backed, activatable binary artifact" registry, for serving any versioned blob (ML models, rule bundles, config packs) from object storage: ArtifactVersionMixin— SQLAlchemy 2.0 declarative mixin (name,version,file_key,is_current) to mix into a concreteBaseModel.ArtifactRegistry[TModel]—current(name),list_current()andactivate(version_id)(setsis_currenton one row and clears the same-namesiblings in a single transaction).build_manifest_entries(registry, digest_source=...)→ArtifactManifestEntry(name/version/file_key/sha256/size), serialization-agnostic so the app owns the wire shape and URL scheme.file_digest(path)/object_digest(client, bucket, key)— streamed (1 MiB chunks) sha256 + size, memoized by immutable identity.make_activate_artifact_action(label=...)— an admin-action factory (register viaactions=[action.handler]) reusing the SDK admin context.
Object serving/object_digest need the [minio] extra; the mixin, registry,
file_digest and the admin action run on core deps. See the new
Artifact registry recipe.
[0.130.0] — 2026-07-15¶
Added¶
- Introspection-based bearer auth (
tempest_fastapi_sdk.auth.IntrospectionAuth) — a reusable dependency for services that do not issue their own tokens but validate an opaque bearer against an upstream userinfo/introspection endpoint (OAuth2 resource-server pattern). Validates a token by callinguserinfo_url(a string or a lazily-resolved callable), caches successful lookups in-process forcache_ttl_seconds(evicting on 401/403), optionally gates access on an app-membership claim (required_app/app_claim), and extracts the user id fromsubject_claim. ExposeDepends(auth.get_claims)andDepends(auth.get_user_id)on any route. See the new Introspection auth recipe.
Changed¶
WebPushSubscriptionService.notify_user(..., exclude_endpoints=...)— skip specific devices when fanning a payload out, the common case being a multi-device sync notification where the device that made the change must not notify itself. Excluded devices are never contacted and never pruned.CORSSettings.CORS_ORIGIN_REGEX+apply_cors(origin_regex=...)— allow a regex matched against the requestOriginfor session-varying origins (ngrok / Cloudflare dev tunnels, preview deploys). Empty disables it; unlike["*"], it is compatible withCORS_ALLOW_CREDENTIALS=True.
[0.129.0] — 2026-07-11¶
Added¶
- Typed SSR attribute builders (
tempest_fastapi_sdk.ssr) —htmx(),aria()anddata()assemble a widget's openattrs: dict[str, str]from typed keyword arguments, movinghx-*/aria-*/data-*call sites from stringly-typed dicts to autocompleted, statically-checked code. Each returns exactly the plaindict[str, str]you'd write by hand (mergeable via{**htmx(...), **aria(...), "id": "x"}), so nothing is hidden.htmx()renders booleans as"true"/"false", JSON-encodes a mapping passed tovals/headers, and turnson={":after-request": "…"}intohx-on::after-request;data()mapsuser_id→data-user-id. Pure functions, no extra dependency — the baseattrstype staysdict[str, str]because the HTML attribute space is open.
[0.128.0] — 2026-07-11¶
Added¶
- Serve a compiled
tempestwebbuild from FastAPI (tempest_fastapi_sdk.ssr) — host atempestweb buildartifact directly from an SDK service. Two entry points, each with the shape that fits the artifact: make_web_app_router(directory)→ anAPIRouterthat serves a static (wasm) SPA build with a single-page history fallback (unmatched paths →index.html), correct MIME for.wasm/.mjs/.webmanifest,no-cacheon the shell + service worker (Service-Worker-Allowed: /),asset_cache_controlfor other assets, and path-traversal protection. No CSP is imposed (first-party code + Pyodidewasm-unsafe-eval); passsecurity_headers=to add one. Include it last so API routes win over the catch-all.build_web_app(directory)→ aFastAPIsub-app hosting a server build (WebSocket/SSE viatempestweb.server.create_app,/staticclient + shell at/) — the same wiring the artifact's generatedserver.pydoes, in-process.detect_build_mode(directory)returns"wasm"or"server".
The SDK only serves an already-built dist/ — building stays in the tempestweb
CLI/CI flow. tempestweb is imported lazily (only the server path needs it).
[0.127.0] — 2026-07-11¶
Added¶
- Admin in-place inline editing —
Inline(editable=True, can_delete=True)turns a read-only child table on the parent's detail view into an editable formset: one input row per existing child plus a blank row to add another, posting toPOST /admin/m/<parent>/<id>/inlines/<child>. Existing rows are updated, a blank row with any value becomes a new child, and a checked delete box removes a row — all in one transaction. The parent foreign key is implied (forced to the parent, never rendered as an input), every child row is scoped to the parent (a mismatched foreign key is ignored, never cross-edited), upload/autocomplete columns stay on the child's own form, and validation errors re-render the formset in place with per-field messages. Requires the child model's own registeredAdminModeland itscan_edit(andcan_deletefor deletion).build_form_fields/parse_submissiongained anonly=filter and a newinline_editable_nameshelper backs the field selection.
[0.126.0] — 2026-07-11¶
Added¶
- Model factories for tests (
tempest_fastapi_sdk.testing) —ModelFactorybinds aBaseModel+ default column values to a session so tests build rows tersely:build()(unsaved),create(**overrides)(add + flush + refresh) andcreate_many(count). A default (or override) that is callable receives the row's incrementing index — how unique fields are generated — andseq("u{n}@x")is a helper for the common case. No magic: the factory never guesses required values, you declare the defaults;flush(notcommit) keeps rows in the test's transaction. Framework-agnostic (nopytestimport).ModelFactoryandseqare exported fromtempest_fastapi_sdk.testing.
[0.125.0] — 2026-07-11¶
Added¶
- Outbound webhooks —
WebhookSender(tempest_fastapi_sdk.api) — the counterpart toWebhookSignatureVerifier: POSTs a JSON event to a subscriber URL, signs the exact body with the same verifier instance (so the receiver validates with that verifier), and retries transient failures — connection errors,5xx,429— with exponential backoff; other4xxare not retried. SendsX-Webhook-Event/X-Webhook-Id(unique uuid) /X-Webhook-Timestampheaders plus the signature.send()returns aWebhookDelivery(delivered/status_code/attempts/error/delivery_id);send_many()fans the same event out concurrently. Thehttpx.AsyncClientis injected (caller owns the lifecycle).WebhookSenderandWebhookDeliveryare re-exported at the package root and fromtempest_fastapi_sdk.api. Pairs with the outbox (OutboxRelay) for at-least-once signed delivery.
[0.124.0] — 2026-07-11¶
Added¶
BusinessMetrics(tempest_fastapi_sdk.api,[prometheus]extra) — a typed factory for application metrics bound to the shared Prometheus registry, so a service declares its owncounter/gauge/histogram(orders placed, queue depth, job duration) without repeating theregistry=wiring or touching the global default registry. Optionalnamespace=prefix; creation is de-duplicated by name (calling a factory twice with the same name returns the same metric instead of raisingDuplicated timeseries). The returned objects are the realprometheus_clientmetrics — no wrapping, no magic — and land on the same/metricsendpoint as the built-in HTTP collectors. Re-exported at the package root and fromtempest_fastapi_sdk.api.
[0.123.0] — 2026-07-11¶
Added¶
- More
field__opfilter operators (shared byQand theBaseRepositorydict filters):in/notin(IN/NOT IN),isnull(IS NULLwhen truthy,IS NOT NULLwhen falsy — noteisnullis exempt from the "Nonevalue skips" rule since its value is a bool), and case-insensitivecontains/icontains/startswith/endswith(ILIKE). These join the existinggt/gte/lt/lte/ne. Kept explicit (one branch per operator, no operator-name magic) so the supported set is greppable and typed.
[0.122.0] — 2026-07-11¶
Fixed¶
- Admin CSS: the components added this cycle (audit history timeline, inline
tables, dashboard cards, lens tabs, autocomplete dropdown, CSV-import report)
referenced an undefined
--tempest-bordervariable — borders fell back to the text color. Defined--tempest-border,--tempest-surfaceand--tempest-accent-softin:root, and repointed the dashboard cards and the autocomplete dropdown off the dark sidebar background vars (--tempest-bg-soft/--tempest-bg) onto the light--tempest-surface, so they no longer render as dark boxes on the light content area.
Changed¶
- Admin detail layout: related-record inlines now render immediately after
the record's own fields (before the Audit / History metadata), and
JSONcolumns are pretty-printed in a monospaced block — matching the JSON edit widget — instead of showing a rawdictrepr.
[0.121.0] — 2026-07-11¶
Added¶
- Admin JSON + time field widgets — the create/edit form now renders
JSONcolumns as a monospaced JSON editor (pretty-printed on load, parsed + validated on submit — invalid JSON is a field error, not a stored string) andTimecolumns as an<input type="time">. Previously a JSON column fell through to a plain text input (storing the raw string) and a time column had no dedicated widget. Part of the admin refinement pass.
[0.120.0] — 2026-07-11¶
Added¶
- Admin lenses (saved list-view presets) —
AdminModel(lenses=[Lens(...)])renders named presets as tabs above the list view. ALens(name, filters=..., order_by=..., label=...)bundles filter conditions (same dict conventions as the repository) and an optional ordering; selecting it (?lens=<slug>) ANDs its filters under the user's own search/filters and applies its ordering unless the user clicked a column sort. An "All" tab clears the lens; the active lens is preserved across pagination, sort and export links.Lensis exported at the package root and fromtempest_fastapi_sdk.admin. This closes the Tier 3 "lenses" item — the admin-panel evolution is now essentially complete (only in-place inline editing remains).
[0.119.0] — 2026-07-11¶
Added¶
- Admin granular RBAC —
make_admin_router(access_policy=...)takes an optional(principal, admin, AdminPermission)→ bool hook (sync or async) consulted for every model action. A deniedVIEW/CREATE/EDIT/DELETEyields403; deniedVIEWalso hides the model from the dashboard and the sidebar nav, and deniedCREATE/EDIT/DELETEhide the corresponding buttons/links. Enforced across list, detail, create, edit, delete, bulk (delete →DELETE, others →EDIT), export, import and FK autocomplete. The policy composes with — does not replace — theAdminModel.can_create/can_edit/can_deleteflags (both must allow). With no policy the behavior is unchanged.AdminPermission(enum) andAdminAccessPolicy(type) are exported at the package root and fromtempest_fastapi_sdk.admin. This closes the Tier 3 "granular RBAC" item of the admin-panel evolution.
[0.118.0] — 2026-07-11¶
Added¶
- Admin CSV import —
AdminModel(can_import=True)exposes a CSV import page (GET/POST {prefix}/m/{slug}/import) that bulk-creates rows from an uploaded file. Each CSV row is validated and coerced with the same rules as the create form; valid rows are inserted best-effort (one bad row never aborts the rest) and the page reports the created count plus a per-row error table. A "Import CSV" link appears on the list view when enabled. Opt-in (defaultFalse) and also gated oncan_create. This is the counterpart to the existing CSV/JSON export (Tier 3 of the admin-panel evolution).
[0.117.0] — 2026-07-11¶
Added¶
- Admin dashboard business-metric cards —
AdminSite(dashboard_cards=[...])renders value / trend / partition cards at the top of the dashboard, each computed from the application's own data (distinct from the system CPU/RAM/disk panel). AMetricCard(label, compute, help_text=...)pairs a heading with an asynccompute(session)returningMetricValue(a number + optional unit),MetricTrend(current vs previous, exposingdelta/pct/direction) orMetricPartition(labeled segments with atotal+ rendered bars). A card whose compute raises is skipped so one broken metric never blanks the page.MetricCard/MetricValue/MetricTrend/MetricPartitionare exported at the package root and fromtempest_fastapi_sdk.admin. This closes the Tier 2 "dashboard metrics" item of the admin-panel evolution.
[0.116.0] — 2026-07-10¶
Added¶
- Admin inlines / nested relations —
AdminModel(inlines=[Inline(Child, Child.parent_id)])lists a model's 1-N children on its detail view as a compact table (DjangoTabularInlineanalog). Each row links to the child's own admin and an "Add" button pre-fills the parent foreign key via a create query param; the inline reuses the child admin'slist_displayand CRUD routes (falling back to read-only rows when the child is unregistered). The newInlineconfig (model +fk_field+ optionallist_display/label) is exported at the package root and fromtempest_fastapi_sdk.admin. Rows are capped at 50 per inline. This closes the Tier 2 "inline / related editing" item of the admin-panel evolution (in-place editing on the parent form remains a follow-up). - The admin create form now pre-fills editable fields from query parameters, which is what an inline "Add" link uses to seed the parent foreign key.
[0.115.0] — 2026-07-10¶
Added¶
- Admin autocomplete FK fields —
AdminModel(autocomplete_fields=[...])renders the listed foreign-key columns as a typed HTMX search box instead of a<select>of every related row, removing the 1000-row cap and the plain-UUID fallback for large target tables. A newGET /m/{slug}/autocomplete/{field}endpoint (session-guarded) searches the referenced admin'ssearch_fields(ILIKE, ORed, capped at 20 results) and returns an<li>option fragment; the edit form pre-fills the current row's label. The target table must have its own registeredAdminModel. This closes the Tier 2 "autocomplete FK fields" item of the admin-panel evolution.
[0.114.0] — 2026-07-10¶
Added¶
- Admin audit-history viewer —
AdminModel(audit_model=...)renders a per-row change timeline in the admin detail view, read from aBaseAuditLogModeltable (matched onentity= the model name andentity_id= the row id, newest first, capped at 50 entries). Each entry shows the action (create/update/delete, color-coded), the actor and timestamp, and a field-by-field before/after diff. Pair it withBaseRepository(audit_model=...)+add_audited/update_audited/delete_auditedso the trail is written. Withoutaudit_modelthe detail view is unchanged (only thecreated_by/updated_bystamps). This closes the Tier 1 "audit history viewer" item of the admin-panel evolution.
[0.113.0] — 2026-07-10¶
Added¶
- Management commands — project-registered
tempest <cmd>— a service can now plug its own commands into thetempestCLI, like Django'smanage.py <command>. Expose atyper.Typernamedcommands(orapp) in a discovered module —src/commands.py,app/commands.pyorcommands.py, auto-detected — and its commands appear as first-classtempest <cmd>entries sharing the SDK's help rendering. Override the location with[tool.tempest] commands = "src.management"(string or list) inpyproject.toml. A project command whose name collides with a built-in is skipped (with a stderr warning) so the SDK's commands always win; discovery is best-effort and never blocks the built-in commands. Nested Typer groups work (tempest ops resync).mount_project_commandsintempest_fastapi_sdk.cli.commandsimplements the discovery.
Changed¶
- The
tempestconsole script now points attempest_fastapi_sdk.cli.main:main(a thin wrapper that mounts project commands before running) instead of theappobject directly. TheappTyper instance is unchanged for programmatic use.
[0.112.0] — 2026-07-10¶
Added¶
- System checks (
tempest_fastapi_sdk.checks) +tempest check-config— a Django-style framework to validate configuration before serving traffic. A check is a(context) -> Iterable[CheckMessage]function registered with@check(*tags)/register_check;CheckMessagecarries aCheckLevel(DEBUG/INFO/WARNING/ERROR/CRITICAL), message, hint and id, built via thedebug/info/warning/error/criticalhelpers.run_checkscollects messages;run_system_checksraisesSystemCheckErrorwhen any reachesfail_level(default ERROR) — call it from a FastAPI lifespan to fail fast on a misconfigured deploy.CheckRegistrybacks isolated sets;default_registryis the process-wide one. - Built-in checks flag common misconfigurations off any
*Settingsshape (best-effort viagetattr): empty/weak signing secret (JWT_SECRET/SECRET_KEY/TOKEN_SECRET, < 32 chars), CORS*with credentials, SQLiteDATABASE_URLwhileDEBUGis off,DEBUGenabled,0.0.0.0bind. tempest check-configruns the checks against the project's settings (auto-detected from conventional locations, or--settings module:attr), with--tagfiltering,--importfor extra check modules, and--fail-level; exits non-zero when a message reaches the threshold.- The framework API (
CheckLevel,CheckMessage,CheckRegistry,SystemCheckError,check,register_check,run_checks,run_system_checks) is re-exported at the package root.
[0.111.0] — 2026-07-10¶
Added¶
F/Qexpression wrappers (tempest_fastapi_sdk.db) — Django-style ergonomics over SQLAlchemy, wired intoBaseRepository:Freferences a column by name and builds arithmetic against it (F("stock") - 1,100 - F("stock"),F("price") * F("qty")). Passed as abulk_updatevalue it computes the new value in the database — an atomic update with no read-modify-write race.Qcaptures the repository's dict-filter conventions (nameILIKE,field__gtecomparisons, listIN, …) as an object combined with&/|/~for realOR/NOTtrees. Pass it as the newwhere=argument onget/get_or_none/first/list/count/exists/paginate/delete_many; it is ANDed with any dictfilters.TenantScopedRepositorythreadswhere=through its scoped overrides.- Both are re-exported at the package root and from
tempest_fastapi_sdk.db.
Changed¶
BaseRepository._apply_filtersnow shares its per-field logic withQvia the newbuild_filter_conditionhelper (single source of truth for the filter conventions); behavior is unchanged.
[0.110.0] — 2026-07-10¶
Added¶
- Object-level permissions (
tempest_fastapi_sdk.authz) — authorization that takes the row into account ("may this user edit this order?"), complementing the token-only static guard. Register a(user, obj) -> boolrule (sync or async) with@permission("order.delete")/PermissionRegistry.register; ask withhas_perm(user, perm, obj=...)or enforce withcheck_permission(...)(raisesForbiddenException). Resolution:Noneuser denied → superuser bypass (is_superuser, defaultuser.is_admin) → object rules (any truthy grants) → static permission-set fallback (permission_resolver, defaultuser.permissions); rules match exact strings ororder.*/*wildcards.make_permission_checker(perm, get_user=..., get_object=...)builds a FastAPI route guard (omitget_objectfor a model-level check).PermissionMixinaddsawait user.has_perm(perm, obj=...). Superuser predicate and resolver are injectable perPermissionRegistry;PermissionRegistry.clear()aids test isolation. The main API (PermissionRegistry,PermissionMixin,has_perm,check_permission,permission,make_permission_checker,default_registry) is re-exported at the package root. Imports without any extra (FastAPI is a core dependency).
[0.109.0] — 2026-07-10¶
Added¶
BaseRepositoryeager-loading viawith_=— every read method (get,get_or_none,get_by_id,first,list) now acceptswith_=["author", "orders.items"]to eager-load relationships in the same query. Dotted paths traverse nested relationships; each hop usesselectinload, so N related rows cost one extra query per level (not N) and both collection and scalar relationships work. Kills theMissingGreenleterror from touching a relationship after the async session closed. An unknown relationship name raisesValueErrorup front.BaseRepositorylifecycle signals (tempest_fastapi_sdk.db.signals) — a process-global registry emittingPRE_SAVE/POST_SAVE/PRE_DELETE/POST_DELETEaround the unit-of-work write path. Register sync or async handlers per model withconnect/on_signal(decorator) /disconnect;RepositorySignalenum +SignalHandlertype +clear_signals(test isolation) are exported. Handlers registered on a base model apply to subclasses (MRO-resolved).add/add_all/update/update_many/soft_delete/restore/deletefire signals; the set-based bulk methods (bulk_update,bulk_create_values,bulk_upsert,delete_many,delete_batch) bypass them by design. APRE_SAVEhandler that raises vetoes the write (rollback + re-raise).PRE_DELETE/POST_DELETEonly load the row when a delete handler is registered — zero overhead otherwise — and the row is detached before commit so its columns stay readable inPOST_DELETE.RepositorySignalandon_signalare also re-exported at the package root.
Changed¶
BaseRepository._raise_not_foundis now typedNoReturn, so type-checkers narrow correctly after a not-found guard (removed a redundantcast).
[0.108.0] — 2026-07-10¶
Added¶
- Self-hosted AI chat, end to end (
tempest_fastapi_sdk.genai) — enough to run an LLM chat app in-process, so a separate inference service becomes an organizational choice rather than a necessity: AIChatPipeline— composable orchestrator: memory recall → optional web-search augment → build messages (system + memory + context + history + user turn, images on the user turn) → generate (with a bounded tool-calling loop when tools + a tool-capable backend are present, else plain chat) → optional TTS → best-effort index of both turns into memory.respond()returns anAIChatResult(reply, sources, memory_hits, tool_calls_made, audio_base64);stream()yields tokens.Tool(name/description/parameters/handler +to_spec()) for function calling, andmake_ai_chat_routerexposingPOST /chat+POST /chat/stream(SSE). The router is stateless (history comes from the request).ChatMemory— recency-aware, per-user long-term chat memory over a Chroma collection:index()embeds + upserts and evicts oldest over a per-user quota;search()does a metadata-filtered query scoped to the user (optionally excluding the current chat), applies a similarity floor, then blends a recency decay (0.5 ** (age/halflife)) before returning top-kMemoryHits. Takes anySupportsEmbed.ChromaVectorStore— aVectorStorebacked by ChromaDB (ephemeral, persistent, or injected client) under the new[genai-chroma]extra.OllamaGeneratorvision + tools —generate(images=[...])and per-messageimagesonchat()for multimodal models;chat_with_tools()returns the full Ollama message (content +tool_calls).SpeechToTextparity —beam_size/vad_filter(constructor defaults + per-call overrides) andlanguage_probabilityonTranscription.- New
[genai-chroma]extra (chromadb). Install withpip install tempest-fastapi-sdk[genai-chroma].
[0.107.0] — 2026-07-06¶
Added¶
- Ollama backend for GenAI (
tempest_fastapi_sdk.genai.ollama) — run text generation and embeddings against a local (or remote) Ollama daemon over HTTP instead of loading HuggingFace weights withtorch: OllamaGeneratormirrorsTextGenerator'sgenerate/chat/streamsurface (talking to/api/generateand/api/chat), so it drops straight intomake_genai_routerwith no other changes. Notorch, no local weights, noload()step — Ollama owns model download and VRAM.OllamaEmbedderimplements theSupportsEmbedprotocol (/api/embed), so it plugs intoRetrieverand the/embedendpoint in place of thetorch-backedEmbedder(e.g.nomic-embed-text).GenerationConfigfields are mapped to Ollamaoptions(max_new_tokens→num_predict,repetition_penalty→repeat_penalty, plusseed/stop;do_sample=False→ greedytemperature=0).- New
[genai-ollama]extra (justhttpx). Install withpip install tempest-fastapi-sdk[genai-ollama]. TextBackendprotocol (tempest_fastapi_sdk.genai.text) — theruntime_checkabletext-generation surface (generate/chat/stream) that bothTextGeneratorandOllamaGeneratorsatisfy. Implement it to plug in any other engine (vLLM, TGI, a hosted API).
Changed¶
make_genai_routernow type-hintstext_generatorasTextBackend | NoneandembedderasSupportsEmbed | None(wasTextGenerator | None/Embedder | None). Backward compatible — the concrete classes still satisfy the widened protocols; the router only ever duck-typed them.
[0.106.0] — 2026-07-06¶
Added¶
- Geolocation expansion (
tempest_fastapi_sdk.geo) — a big round of spatial features, all still no-paid-API: - Offline geometry (zero deps):
bounding_box(the coarse SQL pre-filter for a radius),within_radius/nearest(in-memory proximity filter + k-nearest, generic via akey=extractor),initial_bearing+destination_point(projection along a bearing),point_in_polygon+polygon_area_km2(polygon geofences), andpath_length_km. - Database radius search —
GeoPointMixin(indexedlatitude/longitudecolumns) +GeoRepositoryMixin.nearby(portable bounding-box pre-filter + Haversine refine, any DB) +PostGISRepositoryMixin.nearby(pushes the query into PostGISST_DWithin, nogeoalchemy2dep);make_geo_point_modelfactory. - Geocoding —
GeocodingBackendProtocol +NominatimBackend(address <-> coordinate via OpenStreetMap Nominatim, injectedhttpx.AsyncClient);GeocodeResultschema. - Routing —
OSRMBackend.matrix(many-to-many distance/duration via the OSRMtableservice →DistanceMatrix),OSRMBackend.route(..., with_geometry=True)decoding the route polyline intoTravelEstimate.geometry, per-mode OSRM profiles (DEFAULT_MODE_PROFILES+mode_profiles=). - Polyline codec —
encode_polyline/decode_polyline(Google/OSRM algorithm, precision 5 or 6), pure Python. - Travel modes — added
TravelMode.BICYCLEandTravelMode.PEDESTRIANwith duration factors. - Brazil —
uf_centroid(offline approximate centre of each of the 27 federative units) +UF_CENTROIDS;cep_to_coordinate(resolve a CEP via an injected geocoder). - New schemas
BoundingBox,GeocodeResult,DistanceMatrix;TravelEstimate.geometryfield.
[0.105.0] — 2026-07-05¶
Added¶
- GenAI ergonomics in
tempest_fastapi_sdk.genai: GenerationConfig— a typed Pydantic schema for generation parameters (max_new_tokens/temperature/top_p/top_k/repetition_penalty/do_sample/seed/stop). Pass it toTextGenerator.generate/chat/streamviaconfig=instead of loose**kwargs; only the set fields layer over the defaults, and explicit**kwargsstill win over the config.make_genai_router— an opt-in FastAPI router that mounts only the endpoints backed by the GenAI objects you inject:POST /generate(+/generate/stream, token-by-token SSE) and/chatfor aTextGenerator,/embedfor anEmbedder,/ragfor aRetriever,/transcribefor aSpeechToText, and/tts(returnsaudio/wav) for aTextToSpeech. Raises when handed nothing.RedisEmbeddingCache— an async, Redis-backedEmbeddingCacheshared across workers (JSON vectors, optional TTL).Embeddernow accepts sync or async caches (it awaitsget/setwhen they return an awaitable), so swappingInMemoryEmbeddingCacheforRedisEmbeddingCacheneeds no call-site change. NewAsyncEmbeddingCacheProtocol documents the async shape.- Chat module (
tempest_fastapi_sdk.chat) — a reusable threaded-chat layer over the SDK primitives. Abstract tablesBaseConversationModel/BaseConversationParticipantModel/BaseMessageModel(+make_*factories), aChatService(start_conversation/post_message/list_messages/list_conversations/is_participant), and an opt-inmake_chat_router. When anSSEBrokeris injected, every posted message is also published to the conversation's channel for real-time delivery, reusing the existing SSE fan-out. - Reviews module (
tempest_fastapi_sdk.reviews) — comments and 0-to-5-star ratings on any polymorphic target (target_type+target_id). Abstract tablesBaseCommentModel(threaded viaparent_id) /BaseRatingModel(one vote per user, unique(target_type, target_id, user_id)) +make_*factories, aReviewService(add_comment/list_comments/rateupsert /get_user_rating/aggregate→ average + count + per-star distribution), and an opt-inmake_reviews_router. RatingFieldintempest_fastapi_sdk.utils—Annotated[int, 0..5]for a star score; re-exported at the package root.
[0.104.0] — 2026-07-05¶
Added¶
- Geolocation (
tempest_fastapi_sdk.geo) — distance and travel-time estimates between two coordinates without any paid API. Two layers over shared schemas: - Offline heuristic (zero deps, zero network):
haversine_kmfor the great-circle distance andestimate_travel(origin, destination, mode)for road distance (Haversine x circuity factor) and per-mode travel time (car average speed x mode factor). Returns aTravelEstimatewithsource="heuristic". - Real routing (
OSRMBackend,[geo]extra =httpx): talks to any OSRM server (public demo or self-hosted) for true road geometry via an injectedhttpx.AsyncClient; satisfies theRoutingBackendProtocol and returns aTravelEstimatewithsource="osrm". TravelModeenum (CAR/MOTORCYCLE/BUS); motorcycle and bus derive from the car by scaling the duration viaDEFAULT_MODE_DURATION_FACTORS, so both layers work against a car-only profile.Coordinate(validated lat/long) + tunableDEFAULT_CIRCUITY_FACTOR/DEFAULT_CAR_SPEED_KMH. Submodule import likevision/genai; the heuristic imports without the extra.
[0.103.0] — 2026-07-05¶
Added¶
- Audio language presets (PT-BR / EN-US) in
tempest_fastapi_sdk.genai.audio. ALanguageenum (PT_BR/EN_US) hides engine-specific identifiers: SpeechToText.transcribe(..., language=Language.PT_BR)resolves the Whisper code ("pt"/"en"); still accepts a raw code orNone(auto-detect).TextToSpeech.for_language(Language.PT_BR)builds a voice with a sensible default Coqui model for the language;synthesize(..., language=...)accepts the enum too.LanguagePreset+preset_for(language)expose thewhisper_language/tts_model/tts_languagemapping for inspection or override. Dependency-free (no[genai-audio]needed to import).
[0.102.0] — 2026-07-05¶
Added¶
- Self-hosted audio (
tempest_fastapi_sdk.genai.audio) — voice in and out, on your own hardware (the leviathan pattern): SpeechToText— transcription via faster-whisper (Whisper / CTranslate2). Lazy load, worker-thread inference, concurrency semaphore; auto device (CUDA/CPU) + compute type (float16/int8).transcribe(audio, *, language=None, with_segments=True)→Transcription(text, language, duration, timestampedsegments). Accepts a path orbytes.TextToSpeech— synthesis via Coqui TTS. Same lazy/threaded discipline.synthesize(text, *, out_path=None, speaker=None, language=None, speaker_wav=None)→ WAVbytes(voice cloning viaspeaker_wavon XTTS models).- New
[genai-audio]extra (faster-whisper+coqui-tts); everything imports lazily without it. Exported helpersresolve_audio_device/resolve_compute_typeand theTranscription/TranscriptionSegmentschemas.
[0.101.0] — 2026-07-05¶
Added¶
- RAG over your own corpus — vector store + retriever
(
tempest_fastapi_sdk.genai.rag), closing the RAG loop (index once, retrieve by similarity, don't re-embed per request): VectorStoreProtocol —add(chunks, vectors)+search(vector, top_k).InMemoryVectorStore— dict-backed cosine scan for dev/tests/small corpora.PgVectorStore— Postgres +pgvector, reusing the service's existing database (table created on demand, cosine<=>search). Addedpgvectorto the[genai-rag]extra.Retriever— ties an embedder + store:index(chunks),search(query, top_k)(returnsChunks with ascore), andretrieve(query)→ prompt-ready context. Works with any store via theSupportsEmbed/VectorStoreprotocols.Chunkgained an optionalscorefield (set by vector search).
[0.100.0] — 2026-07-05¶
Added¶
- GenAI refinements for RAG + semantic search:
WebSearch.retrieve(query, *, extractor=None, ...)— one-shot RAG: search → optional parallel body extraction →build_context, in a single call.ContentExtractor.extract_many(urls, *, concurrency=5)— bounded concurrent page extraction, order preserved, failures absorbed.chunk_text(text, *, source, max_chars, overlap, ...)— a generic, dependency-free chunker (any string, not just PDFs);PdfReader.chunksnow uses it. Exported fromtempest_fastapi_sdk.genai.rag.Embedder(normalize=True)L2-normalizes returned vectors, andcosine_similarity(a, b)ranks them — semantic-search essentials (exported fromtempest_fastapi_sdk.genai).
[0.99.0] — 2026-07-05¶
Added¶
- Self-hosted GenAI — embeddings + scale, slice 4 (completes the planned module scope):
Embedder— local text → vectors over transformers (mean pooling), batched, with an optional per-text vector cache (EmbeddingCacheProtocol + bundledInMemoryEmbeddingCache; a cache hit skips loading the model). Same device/precision resolution +unload/unload_if_idleasTextGenerator.BatchScheduler— coalesce concurrent inference calls into one batched handler call (max_batch/max_wait_ms); each caller still awaits its own result. Pure asyncio, model-agnostic, imports without the[genai]extra.ModelRegistry— share loaded models by id with LRU eviction (unload()on evict), so call sites don't load the same model twice.- All exported from
tempest_fastapi_sdk.genai.
[0.98.0] — 2026-07-05¶
Added¶
- Self-hosted GenAI — local LLM text generation
(
tempest_fastapi_sdk.genai.TextGenerator), slice 3. Loads a HuggingFace causal LM once and generates on your own hardware: generate(prompt, ...),chat(messages, ...)(tokenizer chat template) andstream(prompt, ...)(token-by-token) — all async, running the blocking model inasyncio.to_thread.- Automatic device (
auto→ CUDA → MPS → CPU) and precision (auto→ bf16 on GPU, fp32 on CPU) resolution; int8/int4quantizationvia BitsAndBytesConfig ([genai-quant]). - Lazy
load()on first use;unload()frees VRAM;unload_if_idle()idle_unload_secondsreclaim memory between bursts (call it from a@tq.intervaltask — no background thread).
- Exported helpers
resolve_device/auto_dtype_name.torch/transformersare imported lazily, so the module and its resolution helpers import without the[genai]extra.
[0.97.0] — 2026-07-05¶
Added¶
- Self-hosted GenAI — RAG context (
tempest_fastapi_sdk.genai.rag), slice 2: feed a local LLM with web + PDF knowledge, without shipping data to a third party. - Web search —
WebSearchBackendProtocol +SearxngBackend(the leviathan pattern: SearXNG JSON API over an injectedhttpxclient)WebSearchfacade. ReturnsSearchResults.
- Content extraction —
ContentExtractorfetches a URL and pulls the clean article body viatrafilatura; failures surface asExtractionResult(failed=True), never raised. - PDF reading —
PdfReader(PyMuPDF, detailed reading-order extraction) →Document(text + per-page + metadata) and overlappingChunks (read/chunks). - Context assembly —
build_context(question, sources)rendersSearchResults and/orChunks into one prompt-ready, source-labeled block (mix web + PDF, optional per-source truncation). - New
[genai-rag]extra (httpx + trafilatura + pymupdf); everything imports lazily without it. Import fromtempest_fastapi_sdk.genai.rag.
[0.96.0] — 2026-07-05¶
Added¶
- Self-hosted GenAI — capacity check (
tempest_fastapi_sdk.genai), the first slice of running HuggingFace models on your own hardware. Before downloading gigabytes of weights, check whether the host can run a model: probe_hardware()→HardwareInfo(CPU, total/available RAM, CUDA GPUs with per-device VRAM, Apple MPS, free disk). Degrades gracefully withoutpsutil/torch.can_run(...)→CapacityReport(fits, chosendevice, estimated vs available bytes,headroom_pct, and a concretesuggestionwhen it doesn't fit — quantize, offload, or pick a smaller model).recommend(...)picks the first precision (bfloat16→int8→int4) that fits.estimate_model_bytes/bytes_per_param(the estimation math) andfetch_num_params(reads a model's parameter count from the Hub viahuggingface_hub, without downloading weights).ModelDtypeenum.- New
[genai]extra (transformers + torch + accelerate + safetensors + huggingface-hub) and[genai-quant](bitsandbytes). The capacity functions import without the extra —torchis only used to probe real GPUs. Import fromtempest_fastapi_sdk.genai. - Upcoming slices:
TextGenerator(+ quantization),Embedder, model/result caching,BatchScheduler, and RAG context (web search + PDF reading).
[0.95.0] — 2026-07-05¶
Added¶
- PIX key field + helpers (
tempest_fastapi_sdk.utils).PixKeyFieldis anAnnotatedschema type that validates any of the five BACEN PIX key types (CPF, CNPJ, e-mail, E.164 phone, random UUID) in one field and normalizes to a canonical form (CPF/CNPJ → digits, e-mail → lowercase, phone →+55…, random → lowercase UUID). Companions:PixKeyType(the enum),detect_pix_key_type(value)(returns the type orNone),is_valid_pix_key(value)andnormalize_pix_key(value). Detection is by shape plus CPF/CNPJ check digits — all exported from the package root.
Changed¶
- The validated-fields recipe was expanded (both locales): a full
schema + route + 422 walkthrough, a "compose your own field" section,
common gotchas (
CentsFieldvsPriceField,PercentFieldvsRatioField), and the new PIX-key section.
[0.94.0] — 2026-07-05¶
Added¶
- Human-friendly cron helpers (
tempest_fastapi_sdk.tasks) — schedule periodic tasks without writing cron syntax: Cron— ready-made expressions (Cron.EVERY_WEEKDAY_9AM,Cron.EVERY_5_MINUTES, …).CronOffset— timezone offsets by place (CronOffset.BRASILIA=-03:00, plusFERNANDO_DE_NORONHA/MANAUS/ACRE/UTC).Weekday— day-of-week tokens.- Builder functions —
daily,weekdays,weekends,hourly,every_minute,every_n_minutes,weekly,monthly— each returning a plain cron string with range validation. @tq.cron(...)/AsyncTaskScheduler.cron(...)accept these directly, coercing enum members to their plain string value. The module has no third-party dependency (imports without[tasks]).- Class-based message consumers (
tempest_fastapi_sdk.queue) — an alternative to the@mq.ondecorator, in two explicit styles:Consumerwith a constructorchannel=+ Pydanticschema=(no annotation-sniffing) and overriddenhandle, or grouped@subscribemethods (one class, many channels). Wire withMessageBroker.register. - Class-based background tasks (
tempest_fastapi_sdk.tasks) — symmetric to consumers:TaskDefwith an overriddenrun(name in the constructor) or grouped@task_methodmethods, wired withTaskQueue.register(returns aTaskor adict[str, Task]).
Changed¶
AsyncBrokerManagerrenamed toAsyncQueueManager— a clearer name for the thin lifecycle wrapper around an injected broker, matching theAsync*Managerfamily.AsyncBrokerManagerstays as a backward-compatible alias.MessageBrokerremains the recommended batteries-included facade.
[0.93.0] — 2026-07-05¶
Added¶
- Typed facades over FastStream and TaskIQ — application code no
longer imports
faststreamortaskiq. MessageBroker(tempest_fastapi_sdk.queue) — transport-agnostic pub/sub over FastStream behind a single channel concept. Pick the transport with a constructor (MessageBroker.rabbitmq(url)/.redis/.kafka/.nats), declare consumers with@mq.on("channel")(the handler's Pydantic type hint validates the message), and publish channel-first withawait mq.publish("channel", model)..brokerstays as the escape hatch.TaskQueue(tempest_fastapi_sdk.tasks) — TaskIQ broker + scheduler folded into one object.TaskQueue.rabbitmq(url)/.redis/.memory();@tq.taskreturns a typedTaskwithawait task.enqueue(...)(to a worker) andawait task.run(...)(inline, no broker); periodic tasks via@tq.cron(...)/@tq.interval(...);start_scheduler()/stop_scheduler()for dev, withtq.broker/tq.schedulerexposed for the standalonetaskiq worker/taskiq schedulerCLIs.- Both facades keep the SDK-standard lifecycle (
connect/disconnect/lifespan/health_check/is_connected). - The
OutboxRelaypublishcallable plugs straight intoMessageBroker.publish(channel-first).
Changed¶
- The Queue & Tasks recipe was rewritten in the tiangolo didactic
style around the new facades, and its stale claim that the SDK ships no
outbox primitive was corrected (it ships
BaseOutboxModel/OutboxRelay/save_with_outbox).
Deprecated¶
AsyncBrokerManager,AsyncTaskBrokerManagerandAsyncTaskSchedulerremain fully functional but are superseded byMessageBroker/TaskQueue; new code should prefer the facades.
[0.92.0] — 2026-07-05¶
Added¶
- Email change / re-verification / recovery flow on
UserAuthService make_auth_router, mirroring the password reset/change surface:- Change email (authenticated) —
request_email_change(verifies the current password, stages the new address, emails a confirmation link to the NEW address) +confirm_email_change(consumes the token, flips the email, and — whenAUTH_EMAIL_CHANGE_NOTIFY_OLDis on — sends a security notice to the OLD address). Routes:POST /auth/email-change/request(202) andPOST /auth/email-change/confirm. - Re-verify current email —
request_email_verification/confirm_email_verification(resend a verification link to the current address; confirming marks the account active). Routes:POST /auth/email-verify/request(202) andPOST /auth/email-verify/confirm. - Recovery (lost mailbox access) —
request_email_recovery, an unauthenticated entry point that proves identity with the account password plus a valid MFA code when TOTP is enrolled, then emails the confirmation link to the new address. Always returns a generic202for soft failures (unknown email, wrong password, bad/missing MFA code) so it can't enumerate accounts. RoutePOST /auth/email-recovery/requestis opt-in viaAUTH_EMAIL_RECOVERY_ENABLED(off by default). - Old-email security notice on a confirmed change, toggled by
AUTH_EMAIL_CHANGE_NOTIFY_OLD(defaultTrue). - Backend HTML pages (when
AUTH_BACKEND_LINKS=True):GET /auth/email-change/{token}andGET /auth/email-verify/{token}render self-contained success/error pages — no frontend needed. - New schemas exported from the package root:
EmailChangeRequestSchema,EmailChangeConfirmSchema,EmailRecoveryRequestSchema,EmailChangeResponseSchema,EmailChangeToken,EmailVerificationToken. - New
UserTokenPurpose.EMAIL_CHANGE; the existingEMAIL_VERIFICATIONpurpose now backs the re-verify flow. - 14 bundled bilingual templates (PT-BR + EN-US):
email_change.html,email_verification.html,email_changed_notice.html, plusemail_change_success/error.htmlandemail_verification_success/error.html. - Localized subjects/bodies for the three new emails.
- New settings:
AUTH_EMAIL_CHANGE_TTL_SECONDS,AUTH_EMAIL_VERIFICATION_TTL_SECONDS,AUTH_EMAIL_CHANGE_URL_TEMPLATE,AUTH_EMAIL_VERIFICATION_URL_TEMPLATE,AUTH_EMAIL_CHANGE_TEMPLATE,AUTH_EMAIL_VERIFICATION_TEMPLATE,AUTH_EMAIL_CHANGED_NOTICE_TEMPLATE,AUTH_EMAIL_CHANGE_NOTIFY_OLD,AUTH_EMAIL_RECOVERY_ENABLED,AUTH_EMAIL_CHANGE_SUCCESS_TEMPLATE,AUTH_EMAIL_CHANGE_ERROR_TEMPLATE,AUTH_EMAIL_VERIFICATION_SUCCESS_TEMPLATE,AUTH_EMAIL_VERIFICATION_ERROR_TEMPLATE.
Changed¶
BaseUserTokenModelgains a nullablepayloadcolumn (VARCHAR(320)) carrying flow context — the pending new email for anEMAIL_CHANGEtoken. Requires a migration in consuming projects (additive nullable column, safe). See the migration guide.
[0.91.0] — 2026-07-05¶
Added¶
- SSE backpressure — bounded queue + overflow policy.
EventStream(andSSEBroker-created streams) now cap the buffered events atmax_queue(default1000) instead of growing without limit when a client stalls. When the buffer fills,overflowdecides what gives: "drop_oldest"(default) — evict the stalest event, keep the freshest data."drop_newest"— discard the incoming event, keep the backlog."block"— apply real backpressure (the producer waits for a slot).EventStream.dropped_eventscounts events lost to overflow for metrics / logs.max_queue=0restores the pre-0.91 unbounded behavior. Theclose()sentinel is never dropped or blocked, so a stream can always terminate.SSEBroker(max_queue=..., overflow=...)applies the same policy to every stream it opens.- SSE lifecycle helpers — no more hand-rolled
try/finally. sse_response(..., on_disconnect=...)runs a cleanup callback (awaited if a coroutine) when the client disconnects or the stream ends — the one place guaranteed to fire — so a bound producer task is cancelled or a channel unregistered without boilerplate.EventStream.response(*, on_disconnect=..., status_code=..., headers=...)wrapsstream()in an SSE response in one call.SSEBroker.response(channel, ...)bundles the whole per-connection lifecycle:register+sse_response+unregister-on-disconnect. This removes the leak-prone manual wrapper the recipe used to need.- JWT via query string for cookieless clients.
make_bearer_token_dependencyandmake_jwt_user_dependencygained aquery_paramargument (e.g.query_param="access_token"). Token lookup order becomes header → cookie → query string. This unblocks browserEventSource(SSE), whose constructor accepts neither headers nor a body. Documented with a security warning: use short-lived access tokens only, over TLS, and scrub the value from access logs. Prefer a session cookie (withCredentials) whenever the client shares the API's origin. OverflowPolicyis exported fromtempest_fastapi_sdk.sseand the package root.
Fixed¶
tempest_fastapi_sdk/sse/__init__.pyre-exports now use the explicitX as Xalias form required by the repo's re-export convention (was a structural defect flagged by strict type-checkers).
[0.90.0] — 2026-07-04¶
Added¶
- Unified file store (
FileStoreUtils). A single facade over the three pieces a service usually wires by hand —UploadUtils(validate + persist),DownloadUtils(serve bytes through the API) and the presigned-URL helpers ofAsyncMinIOClient— behind one object with one configuration, targeting one storage backend. - The backend is picked once from
source: a directory path for local disk ([upload]extra), or anAsyncMinIOClientfor MinIO/S3 ([minio]extra). - Convenience surface:
save/replace/delete/exists,download/file_response/stream/resolve,validate, andpresigned_get_url/presigned_put_url(the latter returnNoneon the local backend, keeping the call site uniform). - Escape hatches for the internal pieces:
uploader,downloader,backendandclient. - A single
UploadStoragebackend is built and shared with the upload half; on MinIO the same client instance is reused by the download half, so the connection pool is shared, not duplicated. UploadUtilsaccepts an injected backend. New keyword-onlybackend: UploadStorage | Nonebypasses thesource-based backend selection, lettingFileStoreUtilsbuild one backend and share it.sourceis now optional whenbackendis given; passing neither raisesValueError. The[upload]extra is only required when the local backend is actually selected (MinIO-only use no longer needsaiofiles).
Docs¶
- New bilingual recipe File store (unificado) / Unified file store
(
docs/recipes/file-store.md+.en.md) and an API-reference stub forFileStoreUtils(plusDownloadUtils).
[0.89.0] — 2026-07-04¶
Added¶
- Typed server-side rendering (
tempest_fastapi_sdk.ssr, extra[ssr]). A first-class SSR surface: FastAPI routes return typed Python components rendered to HTML — full-stack, typed, no template language. Page— a typed component base (tempest_coreComponent). Declare typed fields, implementbody() -> Widget, and optionally overrideshell(body)to wrap every page in a shared header/nav/footer layout inherited through normal Python inheritance.render()composesshell(body())for you.html_response(widget, *, title=None, status_code=200, htmx=False, document=True, lang="pt-BR")— renders a widget tree to a FastAPIHTMLResponse.document=Trueemits a full HTML5 document (requirestitle);document=Falseemits a bare fragment for HTMX partial swaps.htmx=Trueinjects a locally-served HTMX<script>tag (never a CDN).make_htmx_router(prefix="/_ssr")— serves a bundled HTMX 2.x (shipped inside the wheel) atGET {prefix}/htmx.jswith anapplication/javascriptmedia type — CSP- and offline-friendly, no external host contacted.- The renderer (
tempestweb) is imported lazily, soimport tempest_fastapi_sdknever hard-requires the extra; install withpip install "tempest-fastapi-sdk[ssr]".
[0.88.0] — 2026-07-03¶
Added¶
- Split-endpoint presigned URLs for MinIO/S3.
AsyncMinIOClientgainedpublic_endpoint/public_secureargs, andMinIOSettingsthe matchingMINIO_PUBLIC_ENDPOINT/MINIO_PUBLIC_SECURE. When set,presigned_get_url/presigned_put_urlare signed against the public host (so the browser can reach them) while every server-side operation keeps using the internalMINIO_ENDPOINT(fast private network). A secondminio.Minioclient signs the URLs — the host is part of the SigV4 signature, so it must be signed against the public endpoint rather than rewritten afterwards. Fully opt-in: withoutMINIO_PUBLIC_ENDPOINT, presigned URLs are signed withMINIO_ENDPOINTas before. Ahttps://scheme (or trailing path) on the public endpoint is tolerated and stripped;https://implies HTTPS.
[0.87.1] — 2026-07-02¶
Fixed¶
UserAuthService.current_user_dependency()now honours cookie delivery. WhenAUTH_TOKEN_DELIVERYis"cookie"or"both"it auto-derives the access-token cookie name fromAUTH_ACCESS_COOKIE_NAME, so any business route guarded by the dependency authenticates off the cookie the bundled login set — theAuthorizationheader still wins when present. Previously the dependency was bearer-only, so cookie-mode clients hit401 Authorization token is missing or invalidon protected routes even with the cookies in the browser. A newcookie_name=argument lets callers force a specific cookie (orNonefor header-only).
[0.87.0] — 2026-07-02¶
Added¶
- Configurable token delivery for the auth router.
make_auth_routernow supports three ways of handing back the JWT pair, selected byAUTH_TOKEN_DELIVERY(or thetoken_delivery=argument): "bearer"(default) — tokens in the JSON body only. Unchanged, fully backward-compatible behaviour."cookie"—access_token/refresh_tokenset asHttpOnlycookies on/auth/login,/auth/refresh,/auth/logout; the body omits the token values (safer against XSS).POST /auth/refreshreads the refresh token from the cookie and rotates the pair;POST /auth/logoutclears the cookies (and revokes the refresh family when arefresh_token_modelis wired)."both"— the bearer endpoints stay at/auth/*and a parallel cookie set is mounted at/auth/cookie/*, so one backend can serve web (cookie) and mobile/API (bearer) clients.- New
AUTH_COOKIE_SECURE,AUTH_COOKIE_SAMESITE,AUTH_COOKIE_DOMAIN,AUTH_ACCESS_COOKIE_NAMEandAUTH_REFRESH_COOKIE_NAMEsettings tune the cookie security attributes. - New public exports:
TokenDelivery,AuthCookieConfig,apply_auth_cookies,clear_auth_cookies. make_bearer_token_dependency/make_jwt_user_dependencygained acookie_name=argument: when set, the access token is read from that cookie if theAuthorizationheader is absent (header still wins), so the same guarded routes work in cookie mode.
Notes¶
- Activation, signup auto-login and the MFA-verify step still return the
JWT pair in the body regardless of
AUTH_TOKEN_DELIVERY— cookie delivery covers the login / refresh / logout session lifecycle.
[0.86.0] — 2026-06-28¶
Added¶
- Admin rich list filters.
list_filterfields now auto-pick a widget by column type instead of rendering a useful dropdown only for booleans: enum columns become a member dropdown, foreign keys (whose target has a registeredAdminModel) become a related-row dropdown, date/datetime columns become an inclusive date-range (two date inputs →<field>__gte/<field>__lte), and any other column becomes a text input (equality). Booleans keep the Yes/No dropdown. All filters preserve search / sort / pagination in the URL.
[0.85.0] — 2026-06-28¶
Added¶
- Admin file / image upload fields.
AdminModelgainedupload_fields=[...]+upload_storage=...: listed String columns render as file inputs in the create/edit form (which auto-switches tomultipart/form-data), the posted file is streamed to the storage backend (LocalUploadStorage/MinIOUploadStorage), and the returned storage key is written to the column. On edit, omitting the file keeps the current value; on create, a missing file for a non-nullable column is a required-field error. Registeringupload_fieldswithoutupload_storageraisesValueError.
[0.84.0] — 2026-06-28¶
Added¶
- Admin custom actions (
@admin_action). Beyond the three hardcoded bulk operations (activate / deactivate / delete), the admin now takes user-defined actions: decorate an async function with@admin_actionand register it viaAdminModel(actions=[...]). Each renders in the list view's bulk dropdown (namespacedcustom:<name>so it can't collide with the built-ins), runs on the checked rows, and flashes a banner from itsAdminActionResult. The handler receives anAdminActionContext(selected ids, a request-scoped repository, the DB session, the request, the admin session, and the acting principal) and stays directly callable/testable — the decorator only attaches metadata. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.admin(admin_action,AdminAction,AdminActionContext,AdminActionResult).
[0.83.0] — 2026-06-28¶
Added¶
- Computer-vision integration via the
[vision]extra (ort-vision-sdk). Newtempest_fastapi_sdk.visionsubmodule wraps the ONNX Runtime inference library with the FastAPI layer it lacks: Pydantic response schemas (DetectionSchema,ClassificationSchema,SegmentationSchema,BoundingBoxSchema,ClassProbabilitySchema) and mappers (to_detection_schemas,to_classification_schema,to_segmentation_schemas) that convert a model result into them. TheDetector/Classifier/Segmentertask classes are re-exported lazily — accessing one without the extra raises a clearImportErrorpointing at[vision]; the schemas and mappers carry no such dependency. Likecache/queue/tasks, vision is submodule-only (from tempest_fastapi_sdk.vision import Detector).
[0.82.1] — 2026-06-28¶
Fixed¶
- Docs: removed code that the API never offered. A doc audit
(cross-checking every
tempest_fastapi_sdkimport and example against the package) found and fixed drift in the README: - The brute-force throttling recipe invented
MemoryThrottleBackend/RedisThrottleBackend,throttle.check()returning aThrottleStatusenum,record_failure(), andlock_seconds=— none exist. Rewritten to the real API (AttemptThrottle(backend, max_attempts=, window_seconds=)withraise_if_blocked/hit/reset/status). - The client-IP recipe used
trusted_proxies=/accept_private=— the real parameter istrusted_header=. - The cookie recipe treated
SameSiteas aBaseStrEnum(SameSite.LAX) withsame_site=/key=kwargs and an auto-Secureclaim;SameSiteis aLiteralalias andset_cookietakes positionalname/valuesamesite=.
AsyncRedisManagerwas imported from the top level in several recipes (cache / sessions / security) — it lives intempest_fastapi_sdk.cache(submodule-only, likequeue/tasks). No code changes.
[0.82.0] — 2026-06-28¶
Added¶
SSEBroker— multi-worker SSE fan-out. The SSE recipe described a Redis Pub/Sub bridge for broadcasting across workers but shipped no primitive;SSEBrokeris that primitive. It keeps a per-channel registry of localEventStreams and fanspublish(channel, ...)out to them. Pass a[cache]Redis client and the same broker publishes via RedisPUBLISHwhile a backgroundrun()taskPSUBSCRIBE-s the channel prefix and relays every message to each worker's local streams — sopublishbecomes cross-process with no call-site change (register/unregister/publishare identical in both modes). Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.sse. The SSE recipe now shows the in-memory and Redis-lifespan setups.
[0.81.2] — 2026-06-28¶
Changed¶
- Docs: API reference stubs for symbols the top-surface filter skips.
The auto-generated reference's top block excludes lowercase names, so
the session's new free functions were missing — added explicit
mkdocstringsentries forstrict_types/typed/require_annotations(new "Core" section) anduf_choices/region_choices/city_choices(Utils). No code changes.
[0.81.1] — 2026-06-28¶
Changed¶
- Docs: dedicated Server-Sent Events (SSE) recipe. Promoted SSE from
a section of the real-time recipe to its own bilingual page covering a
single endpoint, the connection-lifecycle pattern, event anatomy
(
event/id/retry/heartbeat comments), a broadcast-to-many "hub" (with the multi-worker Pub/Sub caveat), and alignment withtempest-react-sdk'screateEventStream/useEventStream. The real-time recipe now links to it. No code changes.
[0.81.0] — 2026-06-28¶
Added¶
make_web_push_router— opt-in subscribe/unsubscribe router. MountsPOST {prefix}/subscribeandPOST {prefix}/unsubscribe(default prefix/api/push) wired straight to aWebPushSubscriptionService, mirroringmake_auth_router. Both accept the rawPushSubscription.toJSON()body, sotempest-react-sdk'sWebPushClientonSubscribe/onUnsubscribecallbacks hit them directly. The caller injectsservice_factory,session_factoryand acurrent_user_iddependency; the requestUser-Agentis stored as the device label by default. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.webpush.
[0.80.0] — 2026-06-28¶
Added¶
- Web Push subscription storage + service. The webpush module gained
the missing persistence layer so apps no longer hand-roll it:
BaseWebPushSubscriptionModel(abstract table, one row per user device, uniqueendpoint) +make_web_push_subscription_model(user_table=...)factory, mirroring theBaseUserTokenModelpattern; andWebPushSubscriptionService(generic over the concrete model) withsubscribe(idempotent upsert keyed by endpoint),unsubscribe,list_for_user,prune, andnotify_user— which fans a payload out to every device and auto-prunes the ones the push service reports as gone (404/410). The wire shape matchesPushSubscription.toJSON(), so it lines up 1:1 withtempest-react-sdk'sWebPushClientonSubscribe/onUnsubscribecallbacks. Exported fromtempest_fastapi_sdk,tempest_fastapi_sdk.webpushandtempest_fastapi_sdk.db.
[0.79.0] — 2026-06-27¶
Changed¶
BaseService/BaseControllerupdatepayload is now generically typed. Both classes gained an optional third type parameterUpdateT(bound toBaseSchema, defaultBaseSchema) soupdateaccepts the project's own update schema instead of the bareBaseSchema:BaseService[Repo, Resp, MyUpdateSchema]/BaseController[Service, Resp, MyUpdateSchema]. The default keeps every existing two-argument subclass working unchanged. Implemented with a PEP 695/696TypeVardefault viatyping_extensions(already present through Pydantic).
[0.78.0] — 2026-06-27¶
Added¶
BaseService.update(andBaseController.update). The service skeleton now ships a generic update method: fetch by primary key, copy the fields present in the payload (data.to_dict(), which drops unset andNonevalues) onto the instance, persist viarepository.updateand return the mapped response. Because unset fields are skipped, the same method serves full (PUT) and partial (PATCH) updates.BaseController.updateforwards to it, matching the existing pass-through layer. Override either when an update needs orchestration.
[0.77.0] — 2026-06-27¶
Added¶
- Generic validated field types (
tempest_fastapi_sdk.utils.fields). A base set ofAnnotatedPydantic types that bake a validation rule into the type, following the*Fieldconvention (so the schema reads as what it is instead of repeatingField(gt=0, ...)): integersPositiveIntField,NonNegativeIntField,CentsField(money in minor units),PortField; floatsPositiveFloatField,NonNegativeFloatField,PercentField(0..100),RatioField(0..1),LatitudeField,LongitudeField;PriceField(non-negativeDecimal, 2 places); and stringsNonEmptyStrField(trim + non-empty),SlugField,HexColorField. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.utils.
[0.76.0] — 2026-06-27¶
Changed¶
- BR field types now carry a
Fieldsuffix. The Pydantic annotated-type aliases were renamed to make their role obvious at the import site, matchingUFField/CityNameField:CPF->CPFField,CNPJ->CNPJField,CPFOrCNPJ->CPFOrCNPJField,PhoneBR->PhoneBRField,CEP->CEPField. The old names remain exported as deprecated aliases (identical types), so existing imports keep working; prefer the*Fieldnames. Slated for removal in a future major.
[0.75.0] — 2026-06-27¶
Added¶
- Frontend
<select>choices for BR localities (ChoiceBR,uf_choices,region_choices,city_choices). The bundled states/cities dataset already backed theUFField/CityNameFieldvalidation fields; these helpers make the other role first-class — feeding dropdowns. Each returnslist[ChoiceBR], a typed Pydantic schema (value/label) that serializes as{"value": ..., "label": ...}and shows up typed in OpenAPI:uf_choices()pairs each acronym (the same valueUFFieldvalidates) with the full state name,region_choices()lists the 5 IBGE macro-regions, andcity_choices(uf)lists a state's municipalities. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.utils.
[0.74.0] — 2026-06-27¶
Added¶
- Runtime type-enforcement decorators (
strict_types,typed,require_annotations). Type hints are erased at runtime; these close the gap.strict_typesvalidates arguments and return against the annotations with no coercion (astrwhereintis annotated raises);typeddoes the same but coerces when Pydantic safely can ("1"->1); both are built onpydantic.validate_call(already a dependency).require_annotationsenforces at decoration time that a function is annotated, raisingTypeErrorlisting any unannotated parameter / return —self/clsand*args/**kwargsare exempt andAnycounts as a valid annotation. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.core. [tool.tempest] typing_strictnessknob for the CLI gates. A new config field (lenient/standard/strict, defaultstandard) controls how strictlytempest lint/fix/type/checkenforce typing: it layers ruff ANN rules and mypy flags on top of the project's own config without relaxing it. Override per run with--strictness/-s.ANN401(which flagsAny) is never enabled at any level — the point is that things ARE annotated, not that they avoidAny. Read viatempest_fastapi_sdk.cli.config(TempestConfig,load_tempest_config).
Changed¶
- ruff
ANNis now enabled in the SDK and intempest newtemplates. Generated projects shipselect = [..., "ANN"]withignore = [..., "ANN401", "ANN002", "ANN003"]plus the[tool.tempest] typing_strictness = "standard"knob, so a fresh service requires annotations out of the box while leavingAnyallowed. Generated templates were also fixed to pass their owntempest checkcleanly (import ordering,known-first-party = ["src"], sorted__all__).
[0.73.0] — 2026-06-27¶
Added¶
BaseStrEnum/BaseIntEnumgainedchoices,from_value,has_value, andhas_key. Alongside the existingvalues/keys/to_dict, the shared_EnumHelpersmixin now exposes:choices()returning(value, name)pairs for HTML<select>/ form widgets;from_value(value, *, default=...)a lenient constructor that resolves a member from a raw value or member name (exact, then case-insensitive), raisingValueErroron no match or returning an explicitdefaultwhen supplied; and thehas_value/has_keymembership predicates. Both bases inherit them, so every service enum gets the helpers for free.
Changed¶
- Email recipe documents production SMTP and credential handling.
The
emailrecipe (PT-BR + EN) gained a "Production" section: read everySMTP_*field from the environment (never hardcode or commit the password), use a Gmail App Password with 2FA, a verifiedSMTP_FROM_ADDRdomain (SPF/DKIM/DMARC), and a provider/port/TLS table (Gmail 587 STARTTLS vs 465 implicit TLS, AWS SES, SendGrid, MailHog). Mirrors the two real production setups in use.
[0.72.0] — 2026-06-26¶
Added¶
AdminTheme— typed theming for the admin panel. A newAdminThemedataclass carries appearance overrides (accent / accent_hover / danger colors, header & sidebar backgrounds, page background, border radius, font family, logo image + alt, favicon, footer text, dark mode, and acustom_css_urlescape hatch) through typed, documented parameters. Pass it viaAdminSite(theme=...); the SDK injects a<style>block of:rootoverrides afteradmin.css(so it wins) plus the favicon / logo / footer chrome.AdminTheme()is a no-op that reproduces the stock look, so existing sites are unchanged. String fields reject< > { } "at construction to keep a value from breaking the injected markup. Exported fromtempest_fastapi_sdkandtempest_fastapi_sdk.admin.
[0.71.1] — 2026-06-26¶
Fixed¶
- File logging no longer crashes the app on a non-writable filesystem.
configure_loggingnow treats file logging as best-effort: iflog_dircannot be created or its files cannot be opened (read-only mount, missing write permission, hardened container, serverless, CI), the file handlers are skipped, a warning is emitted (to the logger when stdout is on, else straight tostderr), and the service keeps running with stdout logging instead of dying at import time withPermissionError: [Errno 13] ... 'logs'._build_file_handlersalso closes any handlers it opened before a mid-build failure so no file descriptors leak. - Scaffold
Dockerfilefixed so the non-rootappuser can writelogs/.WORKDIR /appcreated/appasrootbefore theCOPY --chown=app:app, and--chownonly sets ownership on the copied contents — not on the pre-existing/appdirectory node — so theappuser could not createlogs/(or the SQLiteapp.db) inside it and the container crash-looped at startup. The template now runsRUN mkdir -p /app/logs && chown -R app:app /appafter the copy. Existing projects: regenerate withtempest generate --dockerfile --forceor add that line by hand.
[0.71.0] — 2026-06-26¶
Added¶
Dockerfile+.dockerignorein the scaffold —tempest newnow ships a multi-stage, uv-basedDockerfile(builder stage installs deps into/app/.venv; final stage copies only the venv + source and runs as a non-rootappuser) plus a.dockerignorethat keeps the build context lean and never bakes.env/*.db/logs/into the image. The final stage setsSERVER_HOST=0.0.0.0so the container is reachable without a.env.tempest generate --dockerfile— regenerate theDockerfile+.dockerignorein an existing project. TheEXPOSE/SERVER_PORTis read from the project's.env/.env.example(SERVER_PORT), falling back to8000. Refuses to overwrite without--force, like the other generators, and composes with--docker/--src.
Notes¶
- The generated
docker-compose.yamlstays infra-only (noappservice); theDockerfileis standalone. Add anapp:service withbuild: .by hand if you want a one-command stack.
[0.70.1] — 2026-06-26¶
Fixed¶
AlembicHelper.current()async fallback now triggers — the 0.70.0 fix only caught the missing-DBAPI error aroundengine.connect(), but SQLAlchemy 2.0 imports the DBAPI eagerly insidecreate_engine(), so asyncpg-only projects still crashed withModuleNotFoundError: No module named 'psycopg2'. The guard now also wrapscreate_engine, socurrent()/tempest db currentcorrectly fall back to the async driver.
[0.70.0] — 2026-06-26¶
Fixed¶
AlembicHelper.current()on async-only projects —current()built a sync engine from the stripped URL (postgresql://…), which defaults to thepsycopg2driver. Projects that install only an async DBAPI (e.g.asyncpg) crashed withModuleNotFoundError: No module named 'psycopg2'(and so didtempest db current). It now falls back to readingalembic_versionthrough the async driver when no sync DBAPI is available.
Added¶
AlembicHelper.stamp(..., purge=True)+tempest db stamp --purge— clearalembic_versionbefore stamping. Required after a manual squash where the recorded revision no longer exists in the script directory: a plain stamp fails withCan't locate revision, while--purgedrops the stale pointer and stamps the new baseline cleanly.
[0.69.0] — 2026-06-25¶
Added¶
tempest db squash+AlembicHelper.squash(...)— collapse the whole migration history into a single fresh root revision. Migration files accumulate without bound as a project evolves;squashrunsdowngrade baseon the configured (development) database, moves the old revisions intoalembic/versions/_squashed_<oldhead>/(a subdirectory Alembic ignores — pass--no-backup/backup=Falseto delete instead), autogenerates one root migration fromBaseModel.metadata, and re-applies it. The CLI requires--yesbecause the flow drops every table in the target database. Production databases are untouched — reconcile them with the newtempest db stamp headafter deploying the collapsed tree.tempest db stamp <revision>— CLI surface for the existingAlembicHelper.stamp. Marks an already-populated database (e.g. production after a squash) as migrated without recreating tables. Defaults tohead.tempest db backup/tempest db restore+DatabaseBackup— snapshot a database to a file and back, dispatching per dialect. PostgreSQL usespg_dump/pg_restore(custom-Fcby default, or plain.sqlviapsql— chosen from the file extension); SQLite copies the database file. Backups default to a timestamped path underbackups/.restoreis a clean restore by default (drops + recreates so it is a faithful copy) and requires--yes; pass--no-cleanto apply on top of the current schema. The Postgres password is passed viaPGPASSWORDso it never appears inps.DatabaseBackup,BackupToolMissingErrorandUnsupportedBackupBackendErrorare re-exported at the top level.
[0.68.0] — 2026-06-21¶
Added¶
AdminSite.automap(source, ...)+discover_models(source, ...)— register every concreteBaseModelunder a package in one call instead of oneregisterper table. Point it at a dotted module path ("src.db.models") or a module; abstract bases (no__tablename__) are skipped automatically. Supportsexclude=(class / class name / table name),skip_registered=(defaultTrue, so hand-tuned admins registered first are preserved), and**admin_kwargsapplied uniformly.discover_modelsis re-exported at the top level.AdminSite(brand=...)— optional centered header brand text, exposed to templates via the newAdminSite.brand_textproperty (falls back totitlewhen unset, so existing sites are unchanged).
Changed¶
- Admin panel layout — the header brand is now centered on
screen, and on desktop (≥769px) the sidebar is fixed full-height and
overlays the header and footer (raised
z-index); the mobile off-canvas behavior is unchanged. Bundled-CSS only, no config.
[0.67.0] — 2026-06-21¶
Added¶
backfill_non_nullable_defaultsAlembic hook — autogenerate now gives every addedNOT NULLcolumn aserver_defaultderived from its scalar Pythondefault=, so adding a non-nullable column to a table that already has rows backfills them instead of raisingNotNullViolationError: column "x" contains null valueson PostgreSQL. Coversbool/int/float/str/Enum(uses.value); leaves callable / SQL-expression defaults (uuid4,func.now()) and default-less columns untouched (those need a hand-written data migration).CreateTableOpcolumns are never touched. Re-exported at the top level and fromtempest_fastapi_sdk.db.
Changed¶
- The scaffolded
env.pynow composes both revision hooks —compose_hooks(reorder_base_columns_first, backfill_non_nullable_defaults)— so freshly generated migrations are both column-ordered and backfill-safe out of the box. Existing projects: update youralembic/env.pyimport +process_revision_directiveswiring to pick up the new hook (see the "A newNOT NULLcolumn no longer explodes" admonition in the Database recipe).
[0.66.2] — 2026-06-21¶
Changed¶
- Docs: the auth-flow refresh section now points to the built-in
DB-backed refresh tokens instead of telling readers to roll their
own table. The "both tokens rotate" warning now clarifies that
stateless is just the default, and a new tip links to the
docs/recipes/refresh-tokens.mdrecipe (opt-inrefresh_token_modelwith rotation, reuse detection andPOST /auth/logout). Docs-only.
[0.66.1] — 2026-06-21¶
Changed¶
- Docs: the "Receitas" / "Recipes" nav is now sorted alphabetically
by label (the landing
recipes/index.mdstays first), so readers can scan and find a recipe predictably. Docs-only change — no public API delta.
[0.66.0] — 2026-06-21¶
Added¶
- DB-backed (opaque) refresh tokens with rotation, reuse detection and
revocation — opt-in via a new
refresh_token_model=argument onUserAuthService. When wired, the refresh token becomes an opaque value whose SHA-256 hash is persisted (the access token stays a stateless JWT). EveryPOST /auth/refreshmarks the presented token single-use and mints a new one in the same rotation family; replaying an already-rotated token is treated as theft and revokes the whole family (401). Without the model the service keeps the legacy stateless JWT refresh behavior — no breaking change. BaseUserRefreshTokenModel+make_user_refresh_token_model— the abstract opaque-refresh-token row (token_hash,family_id,expires_at,used_at,revoked_at) and the one-call factory to bind a concrete table to the project's user table, mirroringBaseUserTokenModel/BaseUserRecoveryCodeModel. Re-exported at the top level.UserAuthService.issue_token_pair(session, user, *, family_id=None)— async issuance path used by the router at every login-equivalent step; opaque+persisted when a refresh-token model is wired, stateless JWT otherwise.UserAuthService.revoke_refresh_token(session, *, refresh_token, all_sessions=False)— logout: revoke the token's family (or every active token of the user). Idempotent.POST /auth/logouton the bundled router — revokes a DB-backed refresh token (family, or all sessions withall_sessions=true). Mounted only when arefresh_token_modelis wired; absent in stateless mode. Request body: newLogoutSchema(re-exported at the top level).- Recipe — "Refresh tokens (rotação/revogação)" (
docs/recipes/ refresh-tokens.md+.en.md), wired into the docs nav.
Changed¶
UserAuthService.refresh_tokensnow branches on whether a refresh-token model is wired: DB-backed rotation + reuse detection when present, the previous stateless JWT decode path when absent. ThePOST /auth/refreshendpoint commits the rotation and its docs cover both modes.
[0.65.0] — 2026-06-21¶
Added¶
POST /auth/refreshon the bundled auth router — exchange a valid refresh token for a brand-newaccess_token+refresh_tokenpair without re-entering email + password. The token must carry therefreshclaim (a replayed access token is rejected with401), the subject must resolve to an active user (inactive →403), and an expired / malformed / wrongly-signed token returns401. Both tokens rotate on success. Response reusesLoginResponseSchema.UserAuthService.refresh_tokens(session, *, refresh_token)— the public service method behind the endpoint, returning(user, access_token, refresh_token)for callers that drive the flow without the router.RefreshSchema— request body for the new endpoint, re-exported at the top level (from tempest_fastapi_sdk import RefreshSchema).
[0.64.1] — 2026-06-21¶
Fixed¶
StoredFileServiceMixinnow composes cleanly withBaseServiceunder strict mypy. The mixin declaredrepository: BaseRepository[ModelType], which clashed withBaseService's own genericrepositoryattribute and madeclass X(BaseService[...], StoredFileServiceMixin[...])fail type checking (Definition of "repository" ... is incompatible). The mixin no longer re-types the host-providedrepository; its public methods stay precisely typed viaModelType.
[0.64.0] — 2026-06-21¶
Added¶
StoredFileServiceMixin[Model]— a service mixin that encodes the single-key stored-file flow once, parameterized by field name:set_file(ref, file, *, field, subdir=..., filename=..., keep_original_name=...)resolves the entity (detach-safe), uploads the new file and deletes the old one viaUploadUtils.replace(new written before old deleted), writes the key back and commits.clear_file(ref, *, field)deletes the object and nulls the field (no-op, no commit, when the field is already empty).file_url(key, *, expires=...)returns a presigned download URL, orNonefor an empty key.
Removes the ~13-line boilerplate every service reimplements for avatars,
banners, covers and attachments. Reads its upload_utils and storage
collaborators off self, so the owning service keeps configuration (size
limits, allowed types, bucket). Covers the common "one key field →
presigned URL" case; resize/thumbnail pipelines, multi-variant assets and
galleries are out of scope (compose UploadUtils directly). See the
Arquivo no serviço (mixin) recipe.
- SupportsUpload and SupportsPresign — structural-typing
protocols describing the collaborators StoredFileServiceMixin needs
(satisfied by UploadUtils and AsyncMinIOClient), so importing the
mixin never pulls the optional [upload] / [minio] extras.
[0.63.0] — 2026-06-21¶
Changed¶
UserAuthService.current_user_dependency()now loads the authenticated user on the request-scoped session (db.session_dependencyby default) instead of opening its own short-lived session throughload_user. Previously the returnedUserModelwas detached — mutating it and committing/refreshing on the request's repository session raisedInvalidRequestError: Instance is not persistent within this Session, and lazy-relationship access raisedDetachedInstanceError. The user is now attached to the same session repositories use. Breaking only for apps whose repositories do not share the auth service's session callable; passsession_dependency=to point both at the same provider. See the Migration guide (docs/migration.md). The single-argumentuser_loaderpath ofmake_jwt_user_dependencyis unchanged; the new behavior is opt-in via the newsession_dependency=parameter (whichcurrent_user_dependencynow passes by default).BaseRepository.resolve()re-attaches detached instances viasession.merge()instead of returning them as-is. A detached model passed to a mutating service is brought back into the active session, so the subsequentupdate()commits instead of raising.mergeissues aSELECTonly when the row is not already in the session's identity map.
Added¶
make_jwt_user_dependency(..., session_dependency=...)— when given a request-scoped session provider, the dependency injects it and calls the two-argument loaderuser_loader(subject, session), sharing the session with the request's repositories.UserAuthService.current_user_dependency(session_dependency=...)— override the session provider shared with repositories (defaults toself.db.session_dependency). Now raisesRuntimeErroreagerly when the service was built withoutdb=.
[0.62.0] — 2026-06-20¶
Added¶
-
BasePaginationFilterSchema.get_pagination_conditions()andCursorPaginationFilterSchema.get_pagination_conditions()— the counterpart toget_conditions(). Whereget_conditions()strips the pagination keys to expose the domain filters, this returns only the pagination/sort keys (page/page_size/order_by/ascendingfor offset,cursor/limit/order_by/ascendingfor cursor). A service can now forward a filter schema topaginate/cursor_paginatewithout manually unpacking the model:
This replaces the **filter_schema idiom, which leaked domain filters
(e.g. is_active) into keyword arguments the repository does not accept.
[0.61.0] — 2026-06-15¶
Added¶
POST /auth/password-change— amake_auth_routerendpoint for an authenticated user to change their own password while logged in. Requires a valid beareraccess_token; the user re-enters theircurrent_password(mismatch → 401) and thenew_passwordis validated against the configured password policy (violations → 422). Returns 204; existing tokens stay valid (no session revocation). Distinct from the email-token reset flow.UserAuthService.change_password(session, *, user, current_password, new_password)— the backing service method.PasswordChangeSchema— request body (current_password+new_password), exported at the package root.
[0.60.0] — 2026-06-15¶
Added¶
BaseRepository.resolve(id_or_instance)— accepts either a primary-keyUUIDor an already-loaded model instance and always returns the instance (get_by_idwhen aUUID, pass-through otherwise). Removes theif isinstance(x, UUID): ...boilerplate every service reimplements for methods that takeUUID | Model.BaseRepository.exists_excluding(filters, *, exclude_id)— "is this value already used by another row?" The uniqueness check needed when updating a unique field (email / phone / username): plainexistswould match the row itself; this excludesexclude_id. Passexclude_id=None(the create case) to behave likeexists.UploadUtils.replace(old_key, file, ...)— save a new object and delete the one it replaces, in one call. The new file is persisted first (so a validation/write failure leaves the old object intact), thenold_keyis deleted through the same configured backend — avoiding the save-through-one-backend, delete-through-another mistake.old_key=Noneskips the delete, so the same call serves first uploads and replacements.
[0.59.1] — 2026-06-14¶
Changed¶
- Database recipe rewritten in the tutorial-first (tiangolo) pattern.
The
docs/recipes/database.*page was shallow — it covered only the mixins, a hand-rolled cursor query, and Alembic, and skipped most of the DB layer. It is now a progressive, nine-section guide with complete runnable examples, admonitions and per-section recaps:BaseModel— the four canonical columns,NAMING_CONVENTION, auto__tablename__, and theto_dict/update_from_dict/ equality helpers.AsyncDatabaseManager— engine/pool config, the per-requestsession_dependency, lifespan wiring,health_check,db_url_safe.BaseRepository— direct vs. subclass usage, the mappers, and the full async CRUD surface (get/get_or_none/first/list/exists/count/add/update/delete*/soft_delete/restore).- Convention-based filters —
nameILIKE,bool,list,date,start_in/end_in, and the<col>__<op>comparison suffixes. - Bulk operations —
add_all/update_manyvs.bulk_create_values/bulk_update/bulk_upsert. - Pagination — both the built-in
paginate(offset) andcursor_paginate(cursor), replacing the old hand-rolled cursor example that reinvented logic the SDK already ships. SlowQueryLogger— new section. No public API changed — documentation only.
[0.59.0] — 2026-06-14¶
Added¶
- Bilingual auth emails and backend pages (
AUTH_DEFAULT_LOCALE). The bundled activation / password-reset emails and the backend-only HTML pages now ship in two languages out of the box — Brazilian Portuguese (pt-BR, the new default) and US English (en-US) — so a service gets fully localized account flows with zero custom templates.AUTH_DEFAULT_LOCALEsetting (default"pt-BR") — language of the bundled emails and pages. Normalized case-insensitively, soPT-BR,pt_brandptbrall resolve topt-BR;EN,en_USandenustoen-US.- Emails always render in
AUTH_DEFAULT_LOCALE(subject, plain body and HTML), since they have no request context. - Backend HTML pages (
AUTH_BACKEND_LINKS=True) prefer the browser'sAccept-Languageheader and fall back toAUTH_DEFAULT_LOCALE, so the same backend serves a Portuguese or English page per visitor. - New public helpers under
tempest_fastapi_sdk.auth:normalize_locale,negotiate_locale,format_expires_at,SUPPORTED_LOCALESandDEFAULT_AUTH_LOCALE. EmailUtils.render_template(...)andrender_auth_page(...)gained an optionallocale=argument selecting a per-locale template subdirectory.
- Token-expiry timestamps in emails are now short and readable. The
activation / reset emails render the expiry as
21/06/2026 23:25 (UTC)(pt-BR) or2026-06-21 23:25 (UTC)(en-US) — no seconds, no microseconds — instead of the raw2026-06-21 23:25:49.742054+00:00.
Changed¶
make_auth_routerendpoints now carry rich OpenAPI summaries and descriptions. Every signup / activation / login / password-reset / MFA route (and the backend HTML pages) documents its request, response, status codes, side effects and related settings directly in/docs.- Bundled auth templates moved to per-locale subdirectories
(
auth/templates/<locale>/<name>.html). Projects overriding templates viatemplate_dirkeep working unchanged (the flat layout is still searched); to override a single language, place the file undertemplate_dir/<locale>/.
Migration¶
- No action required for projects that keep the defaults — they now
send Portuguese emails/pages instead of English. Set
AUTH_DEFAULT_LOCALE=en-USto restore English as the default. EmailUtils.render_template("activation.html")with notemplate_dirand nolocalenow resolves the bundled template from the default locale (pt-BR) instead of English. Passlocale="en-US"to render the English bundled template.
[0.58.1] — 2026-06-14¶
Fixed¶
- CLI no longer crashes with
ModuleNotFoundError: No module named 'click'.tempest_fastapi_sdk.cli.mainimports the publicclickpackage (forclick.echo/click.secho/click.UsageError/click.exceptions.Abortin the full-help group), butclickwas only ever present transitively through Typer. Newer Typer releases vendor their own Click copy undertyper._clickand no longer pull the publicclickpackage, so installs that resolved such a Typer broke at CLI startup (tempest new, everytempest …command).click>=8.0.0is now a direct dependency, guaranteeing the public package is always importable regardless of how Typer ships Click.
[0.58.0] — 2026-06-14¶
Added¶
- Per-entity audit trail — an append-only log of who changed what,
with a before/after diff, beyond the timestamp-only
AuditMixin.BaseAuditLogModel— abstractaudit_logtable (subclass and pick__tablename__, likeBaseOutboxModel):entity,entity_id,action,actor,changes(JSON diff) and optionalcontext. Shipsnew_entry/for_create/for_update/for_deleteconstructors and theAuditActionenum.snapshot_model(instance)/diff_snapshots(before, after)— capture a model's columns as a JSON-able dict and diff two snapshots into{field: {"before", "after"}}.BaseRepositoryopt-in hook — passaudit_model=...and useadd_audited(model, *, actor, context),update_audited(model, before, *, ...)(pair withsnapshot()taken before mutating) anddelete_audited(model, *, ...). The business row and the audit row commit in the same transaction, so the trail can never reference a rolled-back change.- All symbols exported from
tempest_fastapi_sdk.dband the package top level. Fully backward compatible: repositories withoutaudit_modelare unchanged.
[0.57.0] — 2026-06-14¶
Added¶
- Feature flags — toggle features without a redeploy (rollouts,
kill-switches, beta gating). New
tempest_fastapi_sdk.flagsmodule, all exported at the package top level.FeatureFlags(backend, default=False)service —is_enabled(name, default=...),enable/disable/set, andall().- Pluggable backends —
MemoryFeatureFlagBackend(dev/tests),EnvFeatureFlagBackend(static, read-only,FEATURE_<NAME>),RedisFeatureFlagBackend(runtime toggles in a Redis hash, shared across replicas) andCompositeFeatureFlagBackend(layered — a Redis override beats an env default). TheFeatureFlagBackendprotocol +coerce_flaghelper are public. make_flag_dependency(flags, name, *, enabled=True, status_code=404, ...)— a FastAPI dependency that gates a route on a flag, raising the SDK envelope (404 by default, so a disabled feature looks absent;enabled=Falseinverts it into a kill-switch).
[0.56.0] — 2026-06-14¶
Added¶
- Tag / namespace invalidation for
@cached— cache entries can be dropped on mutation instead of only expiring by TTL.@cached(..., namespace=..., tags=...)—namespaceis one coarse bucket per decorator;tagsare fine-grained labels, given as a static sequence or a per-call builder(args, kwargs) -> Sequence[str](e.g.f"user:{id}"). On each write the entry key is added to a Redis set per label, and those registry sets inherit the entry TTL so they self-prune.CacheInvalidator(redis, key_prefix=...)— drops every entry under a label viainvalidate_namespace(ns),invalidate_tag(tag),invalidate_tags(*tags)(deduped) andinvalidate_keys(*keys)(raw keys); each returns the number of entries deleted. Bind it with the samekey_prefixthe matching decorators use.namespace_registry_key/tag_registry_keyhelpers expose the registry set naming.- Fully backward compatible: without
namespace/tagsno registry sets are written and behavior is unchanged. New symbols are exported fromtempest_fastapi_sdk.cache.
[0.55.0] — 2026-06-14¶
Added¶
- Localized error envelopes (i18n) for
AppException— the errordetailcan now be resolved per request locale instead of being English-only, without callers hand-translating eachraise.MessageCatalog— maps(locale, key) -> template, with case-insensitive locale matching and primary-subtag fallback (a catalog holdingpt-BRanswerspt, and vice versa).resolve()interpolatesmessage_paramsviastr.format(a missing param returns the template instead of raising);negotiate()picks the best locale from anAccept-Languageheader;merge()overlays domain codes / new locales onto a base catalog without mutating it.default_message_catalog()— PT-BR (default) + EN-US strings for every built-in exception code (NOT_FOUND,CONFLICT,UNAUTHORIZED,FORBIDDEN,VALIDATION_ERROR,TOO_MANY_REQUESTS,INVALID_TOKEN,TOKEN_EXPIRED,FILE_TOO_LARGE,INVALID_FILE_TYPE,INTERNAL_SERVER_ERROR).parse_accept_language()+DEFAULT_LOCALE("pt-BR").AppExceptiongainsmessage_key/message_params— the catalog key (defaults to the exceptioncode) and template values.register_exception_handlers(app, catalog=..., default_locale=...)andmake_app_exception_handleraccept the catalog and localizedetailfrom the negotiated locale.- Fully backward compatible: with no
catalogthe literalmessageis used exactly as before; a missing translation falls back to the exception's owndetail. All new symbols are exported at the package top level.
[0.54.0] — 2026-06-14¶
Added¶
- Per-principal & distributed rate limiting —
RateLimitMiddlewaregains a pluggable store and ready-made key extractors, so limits can be per user / tenant / API key and shared across replicas.- Pluggable store —
RateLimitStoreprotocol withMemoryRateLimitStore(default, in-process) andRedisRateLimitStore(distributed). The Redis store uses an atomic Lua sliding-window log over a sorted set — no race between count and add — andfail_open=True(default) allows the request on a transient Redis error.RateLimitResultcarries theallowed/remaining/retry_afterdecision. - Key extractors —
key_by_ip,key_by_jwt_subject(per-user via thesubclaim),key_by_jwt_claim(per arbitrary claim, e.g.tenant_id) andkey_by_header(e.g. an API key). Because the middleware runs before FastAPI dependencies, thekey_by_jwt_*factories decode the bearer from the raw request (decode_or_none) and fall back to the client IP for anonymous traffic. - Fully backward compatible: the default behavior (in-process,
per-IP) is unchanged;
store=and thekey_by_*factories are opt-in. All new symbols are exported at the package top level.
- Pluggable store —
[0.53.0] — 2026-06-13¶
Added¶
- Brazilian states & municipalities dataset — an offline,
dependency-free table of every federative unit and its cities, plus
Pydantic building blocks. No network calls, no external API.
UF— aStrEnumwith the 27 federative unit acronyms (UF.SP,UF.RJ, …).Region— the five official IBGE macro-regions (Norte,Nordeste,Centro-Oeste,Sudeste,Sul). Every UF is statically mapped to its region.StateBR/CityBR— schemas for a state (acronym + full name + region + alphabetically sorted municipalities) and a single city (name + UF).- Query helpers —
list_states(),get_state(uf),cities_by_uf(uf),states_by_region(region). The single-UF lookups accept any-case acronyms or aUFmember. - Validators/normalizers —
is_valid_uf/normalize_uf(case- and whitespace-insensitive),is_valid_city/normalize_city(also accent-insensitive, returning the canonical proper-case name). - Annotated types —
UFField(coerces any-case acronyms to aUF) andCityNameField(trims a city name) ready to drop into schema fields. - Dataset bundled as
utils/data/br_locations.json(27 states, 5606 entries) and loaded lazily on first access. Municipality names come from the official IBGE list (current spellings, including post-2005 municipalities); the Distrito Federal is represented by its 36 administrative regions rather than a single Brasília row, for address-form use. All symbols are exported at the package top level.
[0.52.0] — 2026-06-12¶
Added¶
- Delta-sync primitives on
BaseRepository— the backbone of offline-first / mobile / PWA backends, so projects stop copy-pasting cursor logic per service.- Comparison filter operators. Filter keys now accept a
<column>__<op>suffix where<op>isgt/gte/lt/lte/ne(e.g.{"updated_at__gt": watermark}→updated_at > watermark). Timestamp-precise, unlike the whole-daystart_in/end_in. ANonevalue skips the condition, like every other filter. Works in every method that takesfilters(list,paginate,cursor_paginate,count,changes_since, …). BaseRepository.cursor_paginate(..., query=...). New optionalquery: Select | Noneparameter mirroringpaginate, so a hand-builtSelect(joins,IS NULLpredicates the filter dict can't express) can still be cursor-paginated.BaseRepository.changes_since(since, *, filters=None, cursor=None, limit=50, order_by="updated_at", include_deleted=True). Returns rows changed strictly after a high-water mark, ascending byupdated_atand tie-broken byid, cursor-paginated. Includes soft-deleted tombstones by default (so deletions propagate to the client) and returns aserver_timethe client persists as the nextsince— clock-skew-proof because it is captured server-side before the query runs.
- Comparison filter operators. Filter keys now accept a
SyncFilterSchema/SyncPaginationSchema— request/response DTOs mirroringchanges_since(the response carriesserver_time). Exported at the package top level.
[0.51.0] — 2026-06-12¶
Changed¶
- Refreshed the dependency lock to latest compatible. Notably
fastapi0.136.1 → 0.136.3,starlette1.0.0 → 1.3.1,sqlalchemy2.0.49 → 2.0.50,typer0.26.4 → 0.26.7,uvicorn0.47 → 0.49,cryptography48 → 49,redis7.4 → 8.0,faststream0.6.7 → 0.7.1. Project>=floors are unchanged exceptfaststream(see below). faststream[rabbit]floor raised to>=0.7.1(was>=0.5.30). faststream 0.7 renamedBroker.close()toBroker.stop();AsyncBrokerManager.disconnect()now callsbroker.stop(), which does not exist on faststream < 0.7. Services pinning the[queue]extra must allowfaststream >= 0.7.1.
Fixed¶
- redis 8.0 type stubs.
@cached'sdeserializerparameter is now typedCallable[[str | bytes], Any](redis returnsbytesunless the client setsdecode_responses=True;json.loadsaccepts both), and a stale# type: ignoreonRedisCacheManager.ping()was removed.
[0.50.0] — 2026-06-12¶
Added¶
- Imperative authorization guards —
require_authenticated,require_active,require_admin. Projects no longer hand-writeif user is None: raise .../if not user.is_admin: raise ...helpers. The new guards (intempest_fastapi_sdk.auth.guards, re-exported at the top level) take theUserT | Noneasoft=Trueauthenticated-user dependency yields, raise the canonicalUnauthorizedException(401) /ForbiddenException(403) on failure, and return the user narrowed to non-Noneand to its concrete subclass so the caller drops the| Nonefor the rest of the function:user = require_admin(current). Generic overBaseUserModelvia a boundTypeVar. Also mirrored as static methods onUserAuthService(auth_service.require_admin(user)) so a service already in scope guards without an extra import. The auth-flow recipe documents the path.
[0.49.0] — 2026-06-12¶
Added¶
UserAuthService.current_user_dependency()— built-in authenticated-user dependency. Projects no longer hand-write aload_usercallable plus a secondJWTUtilsto read thecurrent_userfrom a bearer token. The service now exposesget_user(subject, session)(session-explicit),load_user(subject)(opens its own session from thedb=handle), andcurrent_user_dependency(*, soft=False)which wrapsmake_jwt_user_dependencywith the service's ownJWTUtilsandload_user— so the token is verified with the same secret it was signed with, eliminating the divergent-secret footgun. Wiring collapses toget_current_user = auth_service.current_user_dependency()/auth_service.current_user_dependency(soft=True). Requires the service to be built withdb=(already an accepted constructor arg). The auth-flow and HTTP recipes now teach this path.
[0.48.0] — 2026-06-11¶
Changed¶
- Scaffolded services read their API
title/version/descriptionfrom.env.tempest newpreviously hardcodedFastAPI(title="<project>", version="0.1.0")and the health-router version insrc/api/app.py. The scaffoldedSettingsnow carriesTITLE,DESCRIPTIONandVERSIONfields (each withtitle/description/examples),app.pyreads them (FastAPI(title=settings.TITLE, version=settings.VERSION, ...),make_health_router(version=settings.VERSION),AdminSite(title=f"{settings.TITLE} admin")), and.env.exampleships a documentedTITLE/VERSION/DESCRIPTIONblock — so the OpenAPI docs and admin header are configurable without editing code.
[0.47.0] — 2026-06-11¶
Added¶
tempest db seed— runs a project seed callable (defaultsrc.db.seeds:seed, dottedmodule:callable, sync or async, taking oneAsyncSession) inside a managed session: commit on success, rollback on error. The SDK only wires the session lifecycle; the callable owns what gets inserted. Prints the row count when the callable returns anint.tempest secrets rotate— generates fresh URL-safe secrets for the keys a service signs/authenticates with (JWT_SECRET/TOKEN_SECRETby default; override with--keys) and rewrites the matching.envlines in place (existing keys replaced, missing keys appended) after a.env.bakbackup.--printwrites nothing and emits the values to stdout;--lengthsets the entropy;--no-backupskips the backup.
Docs¶
- CLI recipe (bilingual) gains
db seedandsecrets rotatesections; README CLI section and recipes index updated.
[0.46.0] — 2026-06-11¶
Added¶
AlembicHelper.safe_upgrade(revision="head", *, force=False)— runs the upgrade only after scanning each pending migration'supgrade()for data-destroying calls (op.drop_table/op.drop_column/op.drop_constraintandbatch_opvariants). When any are found it raisesDestructiveMigrationError(carrying the offending(revision, operation)pairs) and leaves the database untouched;force=Truelogs and proceeds. Source-based scanning is dialect-agnostic (no false positives on SQLite batch rebuilds) and ignores drops indowngrade().pending_destructive_ops()exposes the scan without running anything (CI-friendly).GracefulShutdownMiddleware— tracks in-flight requests and, once draining, replies503+Retry-Afterto new requests so a load balancer deregisters the instance.begin_drain()/wait_drained()(bounded bydrain_timeout) are driven from the lifespan shutdown (uvicorn ownsSIGTERM); an opt-ininstall_signal_handlers()chains the previous handler for servers that manage signals themselves. Wired viaapp.add_middleware(BaseHTTPMiddleware, dispatch=shutdown.dispatch).
Docs¶
- New bilingual recipe Deploy seguro / Safe deploys covering
safe_upgradeandGracefulShutdownMiddleware, added to the nav, the recipes index, and the API reference.
[0.45.0] — 2026-06-11¶
Added¶
TenantScopedRepository[ModelType]— aBaseRepositorylocked to a single tenant for shared-schema multi-tenancy. Bind atenant_id(and optionaltenant_field, default"tenant_id") at construction; it injectsWHERE tenant_id = ?into every read (get/get_or_none/get_by_id/exists/first/list/count/paginate/cursor_paginate/delete_many) and stamps the tenant id onto every write (add/add_all).delete/delete_batchadd the tenant predicate to theDELETEso a guessed id from another tenant matches nothing — cross-tenant access (even probing existence by id) is impossible through the repository. The constructor raisesAttributeErrorat boot if the model lacks the tenant column.tenant_columnproperty exposes the mapped column for custom queries.
Docs¶
- New bilingual recipe Multi-tenant, added to the nav (under Database), the recipes index, and the API reference.
[0.44.0] — 2026-06-11¶
Added¶
- Transactional outbox. New
BaseOutboxModel(abstract — the project subclasses it and picks__tablename__) carryingtopic,payload(JSON),status,attempts/max_attempts,available_at,sent_atandlast_error.OutboxModel.new_event( topic, payload)builds a pending row. BaseRepository.save_with_outbox(model, event)inserts the business row and the outbox event in the same transaction, so an event can never reference a rolled-back row (and a committed row always has its event durably queued) — the fix for the dual-write problem.OutboxRelaydrains pending rows and publishes them through a caller-supplied asyncpublishcallable (no hard broker dependency — works withAsyncBrokerManager, a webhook, a test spy). Marks each rowsent; on failure incrementsattempts, recordslast_error, reschedules with exponential backoff, and marksfailedonce the attempt budget is spent. Locks the batch withFOR UPDATE SKIP LOCKEDon PostgreSQL/MySQL (multi-worker safe), falling back to a plain select on SQLite.drain_once()for one batch (tests/cron),run(poll_interval=...)for the loop.OutboxStatusenum (pending/sent/failed).
Docs¶
- New bilingual recipe Outbox transacional / Transactional outbox, added to the nav, the recipes index, and the API reference.
[0.43.0] — 2026-06-11¶
Added¶
- Distributed tracing with OpenTelemetry.
setup_tracing(app, service_name=..., otlp_endpoint=...)installs an OTLP/gRPC span exporter and auto-instruments FastAPI (incoming requests), SQLAlchemy (queries, viasqlalchemy_engine=db.engine) and httpx (outbound calls) so one trace follows a request across services.otlp_endpoint=Nonefalls back to a console exporter for local debugging;sample_ratiocontrols head-based sampling;resource_attributesmerges extra span attributes. Behind the new[otel]extra — importing the SDK without it costs nothing and never crashes. ComplementsRequestIDMiddleware(which correlates logs). SlowQueryLogger— attachesbefore/after_cursor_executelisteners to a SQLAlchemy engine (sync or async) and logs every statement slower thanthreshold_ms. Bind parameters are omitted by default (PII);log_parameters=Trueandexplain=True(runsEXPLAIN) are opt-in for development. No extra required. Exposed at the top level and fromtempest_fastapi_sdk.db.AsyncDatabaseManager.engine— public property returning the liveAsyncEngine, so instrumentation (SlowQueryLogger, the OTel SQLAlchemy instrumentor) can attach to it directly.
Docs¶
- New bilingual recipe Observabilidade / Observability covering
setup_tracingandSlowQueryLogger, added to the nav, the recipes index, and the API reference.
[0.42.0] — 2026-06-11¶
Added¶
tempest user promote/tempest user revoke— flipis_adminfor an existing user, found by email (case-insensitive), without hand-written SQL.promotegrants/adminaccess (is_admin=True),revokeremoves it. Both exit1withno user foundwhen no user matches the email.tempest generate --src— add the optional source layers triggered by the project's pinned SDK extras to an existing project:[queue]→<root>/queue/(FastStream broker + handlers stub),[tasks]→<root>/tasks/(TaskIQ broker + jobs stub). The source root (srcorapp) is auto-detected and generated imports point at it. Idempotent — existing files are kept unless--forceis passed.--dockerand--srccan be combined in one invocation.tempest newnow scaffolds the chosen extras' source layers.tempest new svc --extras auth,queueshipssrc/queue/out of the box (andsrc/tasks/with[tasks]); projects without those extras get no placeholder packages.
Changed¶
- Usage errors now print the offending command's full
--help. An unknown command, an invalid option, or a missing required argument renders the complete help (every parameter, default and description) before the error line, instead of Click's terseTry '... --help'hint. Quality-gate exit codes still propagate unchanged. tempest user createprompts for the admin flag interactively. When neither--adminnor--no-adminis passed in an interactive terminal, it asksShould this user be an administrator? [y/N]. Non-interactive runs (CI, pipes, scripts) skip the prompt and default to a regular user — pass--adminexplicitly to create an admin without a TTY. The flag is now--admin/--no-admin(tri-state).
[0.41.0] — 2026-06-07¶
Changed (breaking)¶
UploadUtilsandDownloadUtilsnow take the backend at construction — a local folder or anAsyncMinIOClient— so callers stop passing it on every call and the same code works for disk or object storage:UploadUtils("var/uploads")/UploadUtils(minio),DownloadUtils("var/uploads")/DownloadUtils(minio).UploadUtils.save(...)dropped the per-callstorage=argument; it now returns the storage key (relativePath), not an absolute local path.UploadUtils.delete(key)is now async. The first constructor parameter was renamedupload_dir→source(UploadSettings.upload_kwargs()updated to match).- Migration:
UploadUtils(tmp) + save(file, storage=MinIOUploadStorage(c))→UploadUtils(c) + save(file);path = save(...)consumers that read the file back should store the key and useDownloadUtils.download(key).
Added¶
- Download objects from MinIO/S3 through the app.
AsyncMinIOClient.download_response(key, ...)stats + streams an object into a readyStreamingResponse(Content-Disposition / type / length) — no disk, no full-memory load.DownloadUtils(minio).download(key)wraps it so local and MinIO downloads share one call (download()), whilefile_response/resolvestay local-only.
Changed¶
- Scaffold: infra singletons moved to
src/api/dependencies/resources.py.tempest newnow builds the database manager once inresources.py(db = AsyncDatabaseManager(**settings.database_kwargs())) and exposesget_db/get_sessionproviders;app.pyimportsdbinstead of constructing it inline, keeping the factory thin. Storage/mail follow the same shape (commented, opt-in with[minio]/[email]). The generated admin now enables the logs page (show_logs=True). Docs (architecture, tutorial, admin recipe) teach the same pattern.
Fixed¶
- Admin "+ New" button was white-on-white (invisible): the
.tempest-admin-list__actions arule outweighed.tempest-admin-list__new, so the accent background was lost while the text stayed white. Scoped the button rule to win specificity. - Admin desktop sidebar didn't span the full height when page content was short. The layout is now a sticky-footer flex column so the sidebar always reaches the footer.
Added¶
- Docs: three previously-missing util recipes (bilingual) —
Downloads(DownloadUtils+build_content_disposition),HTTP client (outbound)(HTTPClient+RetryPolicy+ circuit-breaker, which had zero recipe coverage), andUtilities(utcnow/to_utc,modify_dict,get_client_ip, opaque tokens). Added to the nav and the recipes index.
[0.39.0] — 2026-06-07¶
Added¶
- Admin: application logs page.
make_admin_router(show_logs=True, log_dir=...)mountsGET {prefix}/logs, reading the structured JSON files written byconfigure_loggingand rendering them filtered (by source + message substring), paginated, with color-coded level badges. Opt-in (defaultFalse) since the payload exposes tracebacks; it adds a "Logs" entry to the sidebar and shows an empty state when no files exist. - Admin: sidebar navigation + mobile burger. Every authenticated page now has a persistent left sidebar (Dashboard, one link per registered model, and Logs when enabled), with the current page highlighted. On desktop it is always visible; on mobile (≤768px) it becomes an off-canvas drawer toggled by a burger button and dismissed via a scrim — pure CSS, no JavaScript.
*_kwargs()helpers on settings mixins that mirror an SDK constructor, so wiring is a one-liner instead of repeating field names:DatabaseSettings.database_kwargs()→AsyncDatabaseManager,RedisSettings.redis_kwargs()→AsyncRedisManager,JWTSettings.jwt_kwargs()→JWTUtils,UploadSettings.upload_kwargs()→UploadUtils,WebPushSettings.webpush_kwargs()→WebPushDispatcher,MinIOSettings.minio_kwargs()→AsyncMinIOClient(joining the existingEmailSettings.email_kwargs()). Each is splat-tested against the real constructor. Settings consumed by helpers that already accept asettings=object (run_server,apply_cors) keep that path.
[0.38.1] — 2026-06-07¶
Fixed¶
EmailUtilsno longer hard-fails against a plain SMTP server.send()forcedstart_tls=True, so any server that doesn't advertise STARTTLS — including the bundled MailHog dev server on:1025— crashed withSMTPException: SMTP STARTTLS extension not supported by server.(and the/auth/password-reset/requestendpoint returned 500). STARTTLS is now opportunistic (start_tls=None): the connection upgrades only when the server advertises STARTTLS, and is left plain otherwise. This fixes existing services whose.envpredates the 0.38.0.env.examplecorrection — noSMTP_USE_TLS=falseneeded for MailHog anymore. SettingSMTP_USE_TLS=falsestill forces plain with no upgrade attempt; implicit TLS (SMTP_USE_SSL/ port 465) is unchanged.
[0.38.0] — 2026-06-07¶
Fixed¶
- Email config from a generated
.envsilently did nothing, then crashed against MailHog. The[email]block thattempest new/tempest generate --dockerwrote to.env.exampleusedEMAIL_*names (EMAIL_HOST,EMAIL_PORT,EMAIL_USE_TLS,EMAIL_USE_STARTTLS), butEmailSettingsreadsSMTP_*. The values were ignored, leavingSMTP_USE_TLSat itsTruedefault, so STARTTLS was forced and aiosmtplib raisedSMTPException: SMTP STARTTLS extension not supported by server.against plain MailHog. The block now emits the correctSMTP_HOST/SMTP_PORT/SMTP_FROM_ADDRplusSMTP_USE_TLS=false+SMTP_USE_SSL=falsefor MailHog.
Added¶
EmailSettings.email_kwargs()— maps theSMTP_*settings onto theEmailUtilsconstructor (SMTP_USE_TLS→use_starttls,SMTP_USE_SSL→use_tls), so the long-documentedEmailUtils(**settings.email_kwargs())recipe actually works. The method was referenced in the docstring but never existed.- Recipes for transactional email and Web Push on the docs site
(
recipes/email.md+recipes/webpush.md, bilingual). Web Push moved out of the buried "Real-time" section into its own discoverable page.
Changed¶
- Every
*Settingsdocstring now lists its fields. All 16 settings classes insettings/mixins.pygained a Google-styleAttributes:section enumerating each field — i.e. the exact environment-variable name, its type, purpose, and default — so users no longer have to read the source to find which env var to set.
[0.37.0] — 2026-06-07¶
Added¶
[sqlite]and[postgres]install extras for the async database drivers. The SDK keepssqlalchemy[asyncio]as a core dependency but ships no DBAPI driver by default — the driver is a deploy choice. Installtempest-fastapi-sdk[sqlite](aiosqlite, dev default) or[postgres](asyncpg, production); both are bundled into[all]. Without one, the engine raisedModuleNotFoundErroron first connection.
Changed¶
-
tempest newscaffold now ships a working DB driver. The generatedpyproject.tomlpinsaiosqliteas a runtime dependency (it was previously dev-only, so the default SQLite URL failed under a no-dev install) and carries a commentedasyncpgline next to it, ready to uncomment when switchingDATABASE_URLto PostgreSQL — matching the commented Postgres URL already in.env.example. -
tempest new/tempest generate --docker: credentials now resolve from.env, not hardcoded in the compose. Everyenvironment:block in the generateddocker-compose.yamlnow uses the${VAR:-default}form so Docker Compose reads the value from the.envnext to the compose file, keeping secrets out of a VCS-tracked compose. The:-defaultpreserves zero-config dev boot before.env.exampleis copied to.env. Affected variables:POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB,RABBITMQ_DEFAULT_USER/RABBITMQ_DEFAULT_PASS/RABBITMQ_DEFAULT_VHOST,MINIO_ROOT_USER/MINIO_ROOT_PASSWORD(plus the MinIO bootstrap container'smc alias).env_block_fornow emits these credential keys into.env.exampleso the generated.envcarries them with their defaults.
[0.36.0] — 2026-06-06¶
Admin panel brought to Django-admin parity: the list view, write CRUD, bulk actions, dashboard, login, and audit trail all landed across the phases below.
Added¶
-
Admin list view — Phase 1 (read-only enhancements + responsive).
- Clickable column sorting on the list view
(
?sort=<column>&dir=asc|desc), validated against the displayed real columns; the admin's configuredorderingremains the default. - CSV / JSON export endpoint
(
GET /admin/m/{slug}/export.csv/.json) honoring the active search / filters / sort. Newmake_admin_router(export_max_rows=…)caps export size (default 5000). - Responsive admin UI — bundled templates + CSS now adapt to mobile (≤600px): stacked header, full-width search/filters/actions, horizontal-scroll table wrappers, single-column detail grid. Verified at 390px (mobile) and 1280px (desktop).
- Clickable column sorting on the list view
(
-
Admin write CRUD — Phase 2a (create / edit / delete).
GET/POST /admin/m/{slug}/new(create),GET/POST /admin/m/{slug}/{identity}/edit(edit), andPOST /admin/m/{slug}/{identity}/delete(delete), each gated by newAdminModel(can_create=…, can_edit=…, can_delete=…)flags (defaultTrue; a disabled view returns404).- CSRF-protected mutations — every write form carries the
session CSRF token, verified server-side (
403on mismatch). - Type-aware field widgets — text / textarea (long strings) /
number / checkbox /
datetime-local/ date / enumselect, derived from the column types, with required-field + per-field validation errors re-rendered on the form, and integrity errors surfaced inline. - Detail view gains Edit / Delete controls; list view gains a "+ New" button. All responsive (verified at 390px / 1280px).
-
Admin bulk actions — Phase 2b.
- List view gains row checkboxes + a select-all toggle and a bulk
action bar.
POST /admin/m/{slug}/bulkapplies delete (can_delete), activate / deactivate (can_edit, toggling theis_activeflag) to the selected rows, CSRF-verified. Backed byBaseRepository.delete_batch/bulk_update. Responsive (verified at 390px / 1280px).
- List view gains row checkboxes + a select-all toggle and a bulk
action bar.
-
Admin foreign-key select — Phase 2c.
- A foreign-key column whose referenced table has its own
AdminModelnow renders as a dropdown of the related rows (Django's FK select) on the create/edit form, instead of a raw UUID input. Option labels come from the referenced admin's firstsearch_fieldsentry (falling back to aname/title/emailattribute, then the id). Capped at 1000 rows. FKs to unmanaged tables stay plain UUID inputs.
- A foreign-key column whose referenced table has its own
-
Admin dashboard — counts + metrics (Phase 3a).
- The dashboard now renders each registered model as a card with its
live row count and Browse / + New links, plus a system
metrics panel (CPU / RAM / disk) via
MetricsUtils. The panel is on by default, silently omitted when the[metrics]extra is absent, and disabled withmake_admin_router(show_metrics=False). Responsive card grids (verified at 390px / 1280px).
- The dashboard now renders each registered model as a card with its
live row count and Browse / + New links, plus a system
metrics panel (CPU / RAM / disk) via
-
Admin MFA login — Phase 3b.
- The admin login now supports a TOTP second factor. After the
password step, a principal with MFA enabled gets a short
mfa_pendingsession and is redirected toGET/POST /admin/mfa(a CSRF-protected TOTP challenge); only a valid code upgrades the session to full access.AdminAuthBackendgainsmfa_enabled/verify_mfa(default off);UserModelAuthBackendimplements them againstMFAMixin'stotp_secret/totp_enabled_atviaTOTPHelper(newmfa_issuer/mfa_windowctor args). Pending sessions are denied every admin page until the challenge passes.
- The admin login now supports a TOTP second factor. After the
password step, a principal with MFA enabled gets a short
-
Admin audit trail — Phase 3c.
- Create/edit through the admin now stamps
created_by/updated_by(fromAuditMixin) with the acting admin's id. The detail view gained an Audit panel showing created/updated timestamps and — when the model has the audit columns — the actor, with the stored UUID resolved to a display name via the auth backend. Models withoutAuditMixinshow the timestamps only.
File-upload widget and inline/related editing remain tracked as later admin phases on the roadmap.
- Create/edit through the admin now stamps
[0.35.0] — 2026-06-06¶
Added¶
- MFA / TOTP (RFC 6238) — the bundled auth flow now supports
two-factor authentication with Authenticator apps. New
[mfa]extra (pyotp>=2.9.0). Public surface:TOTPHelper(issuer=...)— stateless TOTP issuer/verifier (generate_secret,provisioning_uri,verifywith a configurable clock-drift window). Lazy-importspyotp, soimport tempest_fastapi_sdkstill works without the extra.BaseUserRecoveryCodeModel+make_user_recovery_code_model— single-use recovery-code table (stores only the SHA-256 hash of each code, mirroringBaseUserTokenModel).MFAMixin— opt-in SQLAlchemy mixin addingtotp_secret+totp_enabled_atcolumns (plus anis_mfa_activeproperty). Mix it into the concrete user model (class UserModel(MFAMixin, BaseUserModel)) only when MFA is adopted, so projects that never enable it carry no dead columns.totp_enabled_at IS NULLmeans MFA is staged-but-not-active, so login stays single-step until the user confirms.UserAuthServiceMFA methods:is_mfa_enrolled,issue_mfa_token,mfa_enroll,mfa_confirm,mfa_verify,mfa_disable.make_auth_router(..., recovery_code_model=...)mounts four endpoints behind theAUTH_MFA_ENABLEDkill-switch:POST /auth/mfa/enroll,/auth/mfa/confirm,/auth/mfa/disable,/auth/mfa/verify.POST /auth/loginnow returnsmfa_required=True+ a short-livedmfa_token(instead of the JWT pair) for enrolled users; step 2 swapsmfa_token+ code for the real tokens.- New schemas:
MFAEnrollResponseSchema,MFAConfirmSchema,MFADisableSchema,MFAVerifySchema.LoginResponseSchemagainsmfa_required+mfa_token(andaccess_token/refresh_tokenare now nullable for the MFA-pending case). - New
AuthSettings:AUTH_MFA_ENABLED,AUTH_MFA_ISSUER,AUTH_MFA_RECOVERY_CODES_COUNT,AUTH_MFA_TOKEN_TTL_SECONDS,AUTH_MFA_VERIFY_WINDOW.
- Optional password complexity — new
AUTH_PASSWORD_REQUIRE_COMPLEXITYflag (defaultFalse). When off, any password meetingAUTH_PASSWORD_MIN_LENGTHis accepted; when on, signup + reset additionally require at least one lowercase letter, one uppercase letter, one digit, and one special (non-alphanumeric) character, and the effective length floor is raised to at least 8 (a configuredAUTH_PASSWORD_MIN_LENGTHbelow 8 is ignored while complexity is on). Enforced server-side on bothservice.signupandservice.confirm_password_reset.
Fixed¶
- Password minimum length is now honored end-to-end.
SignupSchema.passwordandPasswordResetConfirmSchema.new_passwordhardcodedmin_length=12, which overrodeAUTH_PASSWORD_MIN_LENGTHon the router path — a project lowering the floor still got a 422 from Pydantic, and raising it above 12 was enforced only by the service. The schemas now reject only empty strings;AUTH_PASSWORD_MIN_LENGTHis the single source of truth (nowge=1, fully configurable down to 4 or any value, default 12), applied server-side.
Notes¶
- Enabling MFA requires a migration: mix
MFAMixininto the concrete user model (class UserModel(MFAMixin, BaseUserModel)) to add thetotp_secret/totp_enabled_atcolumns, and create the recovery-code table.AUTH_MFA_ENABLED=Truewithout passingrecovery_code_modeltomake_auth_routerraises at router-build time.
[0.34.0] — 2026-06-04¶
Added¶
- Server-side session module — new
tempest_fastapi_sdk.sessionspackage ships a full alternative to the JWT auth flow. Public surface:SessionStoreProtocol +MemorySessionStore(dev/tests) +RedisSessionStore(production). Stores keep sessions by the SHA-256 hash of the cookie id; the plaintext only lives in the Set-Cookie. Redis store gets TTL eviction for free; both stores exposeget/set/delete/delete_by_user/list_by_user.Sessionschema —session_id(hashed),user_id,created_at,expires_at,last_seen_at,ip,user_agent,data(free-form JSON bag).SessionAuthservice — authenticates credentials viaPasswordUtils+UserModel, mints sessions, slides TTL on resolve, rotates on login (anti-fixation), revokes one or all sessions per user.SessionMiddleware— reads the cookie, populatesrequest.state.sessionso handlers never re-resolve.make_session_dependency(required=...)— FastAPI dependency returning the resolved session or raisingUnauthorizedExceptionwhen required.make_session_router(service, session_factory=..., prefix=...)— bundled 5-endpoint router:POST /auth/session/login,POST /auth/session/logout,GET /auth/session/me,GET /auth/session/list,DELETE /auth/session/{id}.SessionLoginSchema,SessionResponseSchema,SessionSummarySchema— typed DTOs for the router.
SessionSettingsmixin —SESSION_TTL_SECONDS,SESSION_SLIDING,SESSION_COOKIE_{NAME,DOMAIN,PATH,SECURE,HTTPONLY,SAMESITE},SESSION_ROTATE_ON_LOGIN. Every field carriestitle/description/examples.
Documentation¶
docs/recipes/sessions.{md,en.md}— new bilingual recipe with the JWT-vs-session decision table, setup wiring, endpoints, store comparison (Memory vs Redis vs custom), middleware semantics, security model (hash-at-rest, anti-fixation, SameSite, anti-enumeration, instant revocation) and when NOT to use sessions.docs/reference.md— newtempest_fastapi_sdk.sessionssection withmkdocstringsentries forSessionAuth,make_session_router,SessionMiddleware,make_session_dependency,SessionStore/MemorySessionStore/RedisSessionStore, every schema.mkdocs.yml— recipe added to the navigation in both languages with the matching i18n translation entry.
Tests¶
- 16 new cases under
tests/sessions/covering theMemorySessionStorelifecycle (set/get/expire/delete/list + user-scoped wipe),SessionAuth(authenticate / login / resolve+slide / rotate / revoke_all + anti-enumeration), and router integration (login sets cookie,mereturns session,mereturns 401 without cookie, logout clears cookie, list marks current).
Migration¶
- v0.34.0 is purely additive. No public-API breaking change. Existing JWT flows keep working untouched; sessions are a separate opt-in path.
[0.33.0] — 2026-06-04¶
Added¶
- WebSocket router (
tempest_fastapi_sdk.make_websocket_router+WebSocketHub). Newtempest_fastapi_sdk.websocketsmodule ships the three concerns every WebSocket endpoint has to get right:- Bearer auth at the handshake via
?token=<jwt>query string ORSec-WebSocket-Protocol: bearer,<jwt>subprotocol (preferred — does not leak to proxy logs). Thebearer_resolvercallable maps the token to a user UUID;Nonecloses the socket with code4401before the handler runs. - Heartbeat ping/pong with timeout. The router emits
{"type": "ping"}everyWS_HEARTBEAT_SECONDS(default30) and closes with code4408when the matching{"type": "pong"}does not arrive withinWS_HEARTBEAT_TIMEOUT_SECONDS(default60). - In-process registry (
WebSocketHub) tracking every live connection by user UUID + topic subscriptions. Exposessend_to(user_id, envelope),broadcast(envelope, topic=None),subscribe/unsubscribe,online_users()andconnection_count()— usable from any HTTP handler in the same FastAPI app. Per-user capWS_MAX_CONNECTIONS_PER_USER(default5) evicts the oldest connection with code4429when exceeded. Dead peers are evicted transparently onsend_jsonfailure.
- Bearer auth at the handshake via
WSEnvelopeschema — canonical{type, data, request_id}envelope for SDK-managed frames (ping/pong) and the recommended shape for application messages.WebSocketConnectiondataclass — public handle returned byWebSocketHub.registerso handlers can pass a stableconnection_idtosubscribe/unsubscribe.WebSocketSettingsmixin —WS_HEARTBEAT_SECONDS,WS_HEARTBEAT_TIMEOUT_SECONDS,WS_MAX_CONNECTIONS_PER_USER,WS_MAX_MESSAGE_BYTESwith fulltitle/description/examplesmetadata.
Documentation¶
docs/recipes/websocket.{md,en.md}— new bilingual recipe covering setup, query-vs-subprotocol auth comparison, JavaScript client snippet with heartbeat + reconnect, broadcast / send_to / topic patterns, every close-code table, settings reference, and the single-process vs multi-replica trade-offs.docs/reference.{md,en.md}— new sectiontempest_fastapi_sdk.websocketswithmkdocstringsentries forWebSocketHub,WebSocketConnection,make_websocket_router,WSEnvelope.mkdocs.yml— recipe added to the navigation in both languages with the matching i18n translation entry.
Migration¶
- v0.33.0 is purely additive. No public-API breaking change. The new module imports lazily; existing services that don't mount the router pay no startup cost.
[0.32.1] — 2026-06-04¶
Changed¶
- Top-level
__all__now re-exports the bundled auth surface. AddsUserAuthService,make_auth_router,BaseUserTokenModel,UserTokenPurpose,make_user_token_model,AuthSettings, every auth schema (SignupSchema/SignupResponseSchema/LoginSchema/LoginResponseSchema/ActivationToken/ActivationResponseSchema/PasswordResetToken/PasswordResetRequestSchema/PasswordResetResponseSchema/PasswordResetConfirmSchema) to the public re-export list. Runtime imports already worked; this satisfies strict re-export checkers (pyright/basedpyright/Pylance strict) without project-levelpyrightconfig.json.
Documentation¶
- Full audit + fix pass against the actual SDK code. Every recipe,
tutorial section, README block and learning-project example was
cross-checked against the source; whatever didn't match was rewritten.
Highlights of what changed:
docs/tutorial.{md,en.md}/README.md: router section now callscontroller.signup/controller.get_by_id/ a realcontroller.paginate(...)invocation — the previouscontroller.create/controller.get/controller.list_paginatednames did not exist onBaseControllerand would have raisedAttributeErroron every endpoint.page_sizeconsistently replaces the bogussizequery/JSON key throughout pagination snippets — the realBasePaginationFilterSchemafield ispage_sizeand thepaginate(...)dict returns"page_size", not"size".BaseUserModelexamples no longer claim apassword_hashcolumn — the real column ishashed_passwordand the docs now construct rows with it.docs/recipes/testing.{md,en.md}rewritten:async with TestClient(app)(which doesn't work —TestClientis sync) replaced byhttpx.AsyncClient(transport=ASGITransport(app=app));test_database/test_session/create_test_enginesignatures and return shapes corrected to match the helpers actually shipped undertempest_fastapi_sdk.testing.docs/recipes/security.{md,en.md}fully rewritten: every claim pointed at fictional API (RedisThrottleBackend,MemoryThrottleBackend,ThrottleStatus.LOCKED,throttle.check(),throttle.record_failure(),.attempts_left,set_cookie(key=..., same_site=SameSite.LAX),get_client_ip(..., trusted_proxies={...}),accept_private=False, an HMAC-pepper claim onhash_opaque_token(..., secret=...), aReferrer-PolicyinDEFAULT_STATIC_SECURITY_HEADERS). New recipe documents the realAttemptThrottle/ThrottleStatus/set_cookie/clear_cookie/get_client_ipsurface.docs/recipes/http.{md,en.md}:JWT_TTL_HOURS(does not exist) replaced byJWT_ACCESS_TTL_SECONDS;RSAWebhookSignatureVerifier(encoding="base64", hash_algorithm="sha256")(also fictional) rewritten to the realalgorithm="sha256"kwarg;request.client.host or "anon"rewritten to handlerequest.client is Nonesafely;controller.list_paginatedreplaced with a realcontroller.paginate(...)call.docs/recipes/auth-flow.{md,en.md}:SignupResponseSchemaexample body now matches the real shape (user_id/activation_required/activation_url/access_token/refresh_token— no fictionalemail/is_activefields);tempest db initprereq is called out beforetempest db revision; UUID example replaces the bogus ULID-style placeholder.docs/recipes/uploads.{md,en.md}: phantomsettings.UPLOAD_BACKENDfield (it isn't onUploadSettings) now has to be declared on the project's ownSettingssubclass in a copy-pasteable snippet;UploadFile.filename(typedstr | None) is now fallen back to"upload.bin"before being passed wherestris required; theUploadUtils.__init__mkdir side-effect is explicitly called out.docs/recipes/metrics.{md,en.md}no longer stops atMetricsUtils— a full Prometheus exposition section was added coveringPrometheusMiddleware+make_prometheus_registry+make_prometheus_router, the[prometheus]extra, scrape config, and the rationale for not mounting the JSON snapshot on/metrics.docs/recipes/queue-tasks.{md,en.md}:NameErrorin the outbox dispatcher fixed (broker/queue_brokershadowing that would crash on import).docs/recipes/realtime.{md,en.md}: missingStreamingResponseimport added; producer pattern rewritten to cancel on client disconnect (the previous fire-and-forget pattern leaked tasks).docs/recipes/admin.{md,en.md}:settings.ADMIN_SECRET_KEY(doesn't exist) replaced with the scaffold'ssettings.JWT_SECRET;__tablename__ = "user"replaced with the scaffold's actual"users".docs/recipes/database.{md,en.md}:filters={"deleted_at": None}(silently skipped — returns deleted rows) replaced with a rawselect(...).where(col.is_(None))query; the tuple comparison(col_a, col_b) > (val_a, val_b)(invalid in SQLAlchemy) replaced withtuple_(col_a, col_b) > tuple_(...); the cursor example now decodesstate["value"]back todatetimeso Postgres tuple comparison doesn't fail on a str-vs-timestamp clash; missingselect/AsyncSession/Anyimports added.docs/recipes/cli.{md,en.md}: default--extrasvalue corrected fromauthto the realauth,admin;--model myapp.models.user:Userexample renamed toUserModelwith a comment explaining theBaseUserModelsubclass requirement.docs/recipes/cache.{md,en.md}: the previously free-floatingawait cache.connect()is now shown inside a real@asynccontextmanagerlifespan — without itcache.clientraisesRuntimeErroron first use.docs/recipes/logging.{md,en.md}: malformed"2026-05-16T20:14:33.412+00:00Z"timestamp (impossible — the formatter strips+00:00and appendsZ) corrected to"2026-05-16T20:14:33.412Z".docs/recipes/br-helpers.{md,en.md}:request.json()(a coroutine in FastAPI/Starlette) nowawait-ed inside anasync defhandler with theRequestimport included.docs/recipes/storage.{md,en.md}: stale "v0.24.0 will introduceS3Backend" promise replaced with a pointer to the uploads recipe whereMinIOUploadStoragealready lives.docs/architecture.{md,en.md}:paginate(...)row in the BaseService table now listspage_sizeinstead ofsize; a note next toUserController(UserService(UserRepository(session)))explains the requiredBaseRepositorysubclass withmodel=UserModel.docs/installation.{md,en.md}: version pins bumped from>=0.19.0to>=0.32.0;tempest user createexample now passes the required--emailflag instead of relying on a non-existent prompt.README.md: SDK version pin in the pyproject snippet bumped from>=0.13.1to>=0.32.0; all the same router / pagination / model-field fixes applied.
Migration¶
- Zero breaking changes — this is a pure documentation + re-export audit on top of the v0.32.0 surface.
[0.32.0] — 2026-06-04¶
Added¶
- Backend-only auth mode (
AuthSettings.AUTH_BACKEND_LINKS=True). When enabled,make_auth_routermounts three extra HTML endpoints on top of the JSON ones already exposed — a project can run the full signup → activate → reset cycle without any frontend route handling tokens. New endpoints:GET /auth/activate/{token}— consumes the activation token and renders an HTML success page (or an error page on bad / expired / used tokens).GET /auth/password-reset/{token}— peeks the token (does NOT consume it) and renders an HTML form with the new-password input + confirmation.POST /auth/password-reset/{token}(application/x-www-form-urlencoded) — processes the form, validates the password floor, confirms the reset, and renders success or error HTML.
- Five new bundled Jinja2 templates under
tempest_fastapi_sdk/auth/templates:activation_success.html,activation_error.html,password_reset_form.html,password_reset_success.html,password_reset_error.html. All responsive, inline-styled, mobile-friendly. Shadow them by dropping same-named files into thetemplate_diryou pass tomake_auth_router. make_auth_router(template_dir=...)parameter — point the router at a project-owned directory whose templates override the bundled defaults. Only consulted whenAUTH_BACKEND_LINKS=True.UserAuthService.peek_token(session, token, purpose)— new service method that validates a token and returns the(token_record, user)pair without markingused_at. Used byGET /auth/password-reset/{token}to render the form before the user actually submits.AuthSettingsgains six fields documenting the new flow:AUTH_BACKEND_LINKS: bool(defaultFalse)AUTH_LOGIN_URL: str | None(defaultNone) — URL for the "Go to login" button rendered on success/error pagesAUTH_ACTIVATION_SUCCESS_TEMPLATE/AUTH_ACTIVATION_ERROR_TEMPLATEAUTH_PASSWORD_RESET_FORM_TEMPLATE/AUTH_PASSWORD_RESET_SUCCESS_TEMPLATE/AUTH_PASSWORD_RESET_ERROR_TEMPLATE
tempest_fastapi_sdk.auth.page_renderer.render_auth_page— standalone Jinja2 renderer reused by the router; doesn't requireEmailUtilsto be wired (only the[email]extra for Jinja2 itself).
Changed¶
make_auth_routersignature now accepts the optionaltemplate_dir: str | None = Nonekeyword. Existing call sites remain source-compatible.- All JSON endpoints (
POST /auth/signup,POST /auth/activate/{token},POST /auth/login,POST /auth/password-reset/request,POST /auth/password-reset/confirm) stay mounted exactly as in v0.31.x — Backend-only Mode E is purely additive.
Documentation¶
docs/recipes/auth-flow.{md,en.md}gains the new Mode E (backend-only) section:.envblock, Mermaid sequence diagram of the activation flow, bundled-template reference table, override walkthrough, trade-offs callout (zero frontend dep, no JWT auto-delivery, requires[email]extra). The "Four operating modes" section was renamed to "Five operating modes" and the TOC entry updated.
Migration¶
- v0.32.0 has no breaking changes. To opt into backend-only
mode, flip
AUTH_BACKEND_LINKS=Truein the.envand updateAUTH_ACTIVATION_URL_TEMPLATE/AUTH_PASSWORD_RESET_URL_TEMPLATEto point at your backend instead of your frontend. Everything else is wired automatically.
[0.31.4] — 2026-06-04¶
Changed¶
- Explicit re-exports across
settings/,auth/, anddb/__init__.py. Every symbol is now re-exported using the PEP 484from x import Y as Yform in addition to__all__. Reason: third-party consumers run a mixed bag of type-checkers (mypy, pyright, pylance, basedpyright) at different strictness levels and without project-awarepyrightconfig.json. Either form alone is theoretically PEP 484 compliant, but basedpyright and Pylance strict still flag barefrom foo import Barinside__init__.pyas "private import usage" unless the symbol is aliased withas Bar. Pairing the two patterns silences every IDE with no project-level config required. No behavior change — same runtime imports, same public surface, same wheel contents.
Documentation¶
- Auth-flow recipe rewritten end-to-end (
docs/recipes/auth-flow.{md,en.md}):- New table of contents at the top.
- New "Email anatomy" section disambiguating the three concepts that confused readers (opaque token vs URL template vs Jinja2 template) with a Mermaid sequence diagram of the full flow.
- "Operating modes" expanded from three to four explicit
modes (A. production / B. dev with local SMTP / C. dev without
SMTP / D. CI), each with a copy-paste
.envblock. - New "Mailhog vs smtp4dev" comparison table + ready-to-use
docker-compose.yamlsnippets for both containers — the recipe now covers local SMTP interception out of the box. - "Customizing templates" rewritten with clearer prose, the full
context-variable table, and a copy-paste minimal
emails/activation.htmlexample.
CLAUDE.mdgains a new "Explicit re-exports in every__init__.py" rule documenting the dualas Y+__all__pattern and flagging bare re-exports as a structural defect.
[0.31.3] — 2026-06-04¶
Documentation¶
- Comprehensive documentation refresh to reflect the v0.23.0 → v0.31.2 surface. No code change.
README.md: extras table now lists[http]+[prometheus]; module map coversBaseUserTokenModel,UserTokenPurpose,BASE_COLUMN_ORDER,reorder_base_columns_first,compose_hooks,AuthSettings,tempest_fastapi_sdk.auth,utils.http_client,utils.storage_backends. Roadmap section rewritten with every shipped release v0.23.0 → v0.31.2.docs/index.{md,en.md}: module map updated with the new exports (auth,storage,IdempotencyMiddleware,BodySizeLimitMiddleware,CSRFMiddleware,PrometheusMiddleware, OAuth clients,HTTPClient, bulk repo ops). Hero paragraph rewritten to mention the new layers.docs/installation.{md,en.md}: extras table now lists[minio],[http],[prometheus]; CLI section documentstempest generate,tempest db <subcommand>,tempest user <subcommand>.docs/tutorial.{md,en.md}: new "Auth flow already ships" admonition pointing readers at the bundledUserAuthService+make_auth_routershortcut.docs/recipes/auth-flow.{md,en.md}(new): full PT-BR + EN-US recipe covering the bundled signup / activate / login / password-reset flow —UserTokenModelconcretization, settings flags, the three operating modes (production, dev without SMTP, CI tests), template overrides, security guarantees.docs/roadmap.{md,en.md}: full rewrite with Tier S / A / B status tables (Status + Where columns), every release detailed v0.23.0 → v0.31.2, "What's next" section for v0.32.0+ (OpenTelemetry) and v0.33.0+ (outbox).docs/reference.{md,en.md}:mkdocstringsentries added forBodySizeLimitMiddleware,CSRFMiddleware, OAuth clients,PrometheusMiddleware+make_prometheus_router,HTTPClientRetryPolicy+CircuitOpenError, the fulltempest_fastapi_sdk.authmodule (service, router, schemas),reorder_base_columns_first,compose_hooks.docs/learning/marketplace/index.{md,en.md}: stack table now points atUserAuthService+make_auth_routeras the default auth path,BaseRepository.bulk_create_values/bulk_upsertfor stock seed,PrometheusMiddleware,BodySizeLimitMiddleware, OAuth clients,CSRFMiddleware,HTTPClient. Implementation order's step 1 rewritten to use the bundled auth recipe.mkdocs.yml:Auth flow (signup/reset)entry added to nav + i18n translation map.CLAUDE.md: "What the SDK currently covers" section rewritten as a structured category bullet list (Auth, DB, Observability, HTTP layer, Pagination, Settings, SSE, Throttle, Upload, MinIO/S3, Email, WebPush, Cache, Queue/tasks, BR validators, Admin panel, CLI).
[0.31.2] — 2026-06-04¶
Changed¶
UserAuthServicemethod signatures now typesessionassqlalchemy.ext.asyncio.AsyncSessioninstead ofAny. All seven public methods (signup,activate,login,request_password_reset,confirm_password_reset,_issue_token,_consume_token) declare the real type so mypy + IDE autocomplete can flag wrong shapes at the call site. No behavior change — only the annotations tightened. Aligned with the v0.25.1 "avoidAnyin SDK code" rule that the auth module had drifted away from when it landed in v0.31.0.
[0.31.1] — 2026-06-04¶
Changed¶
ActivationTokenandPasswordResetTokenare now PydanticBaseSchemasubclasses instead ofdataclassinstances. Keeps the auth module aligned with the SDK's gold-standard DTO convention — every "thing returned to the caller" is a Pydantic schema with fulltitle/description/examplesmetadata. The fields are the same; the constructor signature is the same. Callers that destructure via attribute access (activation.token,activation.url) keep working unchanged.- Both token schemas moved from
tempest_fastapi_sdk.auth.servicetotempest_fastapi_sdk.auth.schemas— re-exports at the package root are unchanged, so existing imports (from tempest_fastapi_sdk import ActivationToken) keep resolving. - Every auth schema now carries a thorough class-level
docstring describing the flow that uses it, the meaning of
each attribute, and the security / behavior contract (e.g.
why
PasswordResetResponseSchema.messageis always identical, whyActivationToken.tokenis only handed back once).
[0.31.0] — 2026-06-04¶
Added¶
-
Bundled auth flow — new
tempest_fastapi_sdk.authmodule ships service + router + schemas + templates so signup, activation, login, and password reset land end-to-end with a singleinclude_routercall: -
UserAuthService— generic over the concreteUserModelUserTokenModel. Methods:signup,activate,login,request_password_reset,confirm_password_reset,issue_jwt_pair. Every method accepts the activeAsyncSessionso the caller controls transaction boundaries.
make_auth_router(service, session_factory=…)mountsPOST /auth/signup,POST /auth/activate/{token},POST /auth/login,POST /auth/password-reset/request,POST /auth/password-reset/confirm.-
DTOs:
SignupSchema/SignupResponseSchema,LoginSchema/LoginResponseSchema,ActivationResponseSchema,PasswordResetRequestSchema/PasswordResetResponseSchema,PasswordResetConfirmSchema. Every field carriestitle/description/examplesper the SDK convention. -
BaseUserTokenModel(abstract) for one-shot activation / reset tokens, plusmake_user_token_model(user_table, …)for test fixtures. The plaintext token is returned exactly once; only the SHA-256 hash is persisted (via existinggenerate_opaque_token/hash_opaque_tokenhelpers). Tokens carry apurpose(UserTokenPurposeStrEnum:activation/password_reset/email_verification) -
expires_at+used_at. -
AuthSettingsmixin exposing every knob the bundled flow needs: -
AUTH_AUTO_ACTIVATE— skip activation email entirely (dev / CI mode); user is born active and the signup response carries JWTs immediately. AUTH_RETURN_TOKEN_IN_RESPONSE— surface the activation / reset link in the JSON body instead of (or in addition to) sending the email. Toggles automatically whenEmailUtilsisn't wired.AUTH_ACTIVATION_TTL_SECONDS(default 7d) /AUTH_PASSWORD_RESET_TTL_SECONDS(default 1h).AUTH_ACTIVATION_URL_TEMPLATE/AUTH_PASSWORD_RESET_URL_TEMPLATE— front-end URL skeleton with{token}placeholder.AUTH_ACTIVATION_TEMPLATE/AUTH_PASSWORD_RESET_TEMPLATE— Jinja2 template names.-
AUTH_PASSWORD_MIN_LENGTH(default 12). -
Default email templates bundled under
tempest_fastapi_sdk/auth/templates/activation.htmlandpassword_reset.html.EmailUtils.render_templatefalls back to the SDK directory when the caller-suppliedtemplate_dirdoesn't ship one with the same name, so the bundled flow renders out of the box. Override by placing a matching file in the project's template directory.
Changed¶
[email]extra now pullsemail-validatorso the PydanticEmailStrfields used bySignupSchema/ login / reset DTOs validate without a separate dependency.EmailUtils.render_templatenow accepts callers without an explicittemplate_dir— the SDK's bundled auth templates are reachable by default.
Security¶
- Password-reset request endpoint always returns 202 and a generic message. Probing emails can no longer enumerate account existence.
- Activation + reset tokens are stored hashed (SHA-256, 48
bytes of entropy on the plaintext). One-shot —
used_atprevents replay. TTL-bounded.
[0.30.3] — 2026-06-04¶
Fixed¶
-
Noisy lint output after
tempest db revision. The post-write hooks ran in the wrong order —ruff_fixfirst,ruff_formatsecond — so the linter loudly complained aboutW291(trailing whitespace in the docstring header) andE501(longsa.Columnlines) that the formatter would fix on the very next hook. The final file was correct but stdout looked like the revision failed. Two adjustments: -
Hooks reordered to
ruff_format, ruff_fixso the formatter wraps lines + strips whitespace before the linter sees them. - Both hooks pass
--quietso the "found N errors / N fixed / M remaining" preamble is suppressed when nothing actionable is left.
Existing projects: re-run tempest db init against an empty
alembic.ini to regenerate, or hand-edit
[post_write_hooks] to match the new layout.
[0.30.2] — 2026-06-04¶
Security¶
-
alembic.inino longer stamps the database URL. The generated ini ships withsqlalchemy.url =empty so credentials never enter version control. Both the SDKenv.pytemplate andAlembicHelper.configresolve the URL at runtime: -
db_urlpassed to the constructor or via--database-urlon the CLI. DATABASE_URLenv var (loaded from.env).src.core.settings.settings.DATABASE_URLin scaffolded projects.
When none of the three is set the env.py raises
RuntimeError("DATABASE_URL is empty. Set it on the
environment, in src/core/settings.py, or pass --database-url
to the CLI.") so missing config fails loudly instead of
silently connecting to whatever was left on the ini.
Migration for existing projects¶
Open alembic.ini and blank the sqlalchemy.url line:
Then rotate the leaked credential at the database (assume the
secret is compromised the moment it landed in a Git commit).
Append alembic.ini to a code-search / CI hook so the line
stays empty across future PRs.
If your alembic/env.py was older than v0.21.x and does not
import tempest_fastapi_sdk.db.alembic_hooks, rerun
tempest db init against an empty file to regenerate it, or
copy the new template from
tempest_fastapi_sdk/db/_alembic_templates/env.py.template.
[0.30.1] — 2026-06-04¶
Added¶
reorder_base_columns_firstAlembic hook intempest_fastapi_sdk.db.alembic_hooks. Wired into the scaffoldedenv.pyso every autogenerated migration emitsid→is_active→created_at→updated_atat the top of everyop.create_table, followed by the table's own constraints + subclass columns in their original relative order. The 4BaseModelcolumns now ship in the documented order without manual editing.compose_hooks(*hooks)helper for chaining multipleprocess_revision_directivescallables.BASE_COLUMN_ORDERtuple re-exported at the package root for tools that want to mirror the convention elsewhere.
Docs¶
- Existing projects: copy the new
env.pysnippet fromtempest_fastapi_sdk/db/_alembic_templates/env.py.template(theprocess_revision_directives=reorder_base_columns_firstargument is added to bothcontext.configurecalls), or re-runtempest db initagainst an emptyalembic.inito regenerate. Futuretempest db revision --autogeneratepicks up the hook automatically.
[0.30.0] — 2026-06-04¶
Added¶
-
tempest dbsubcommand group — Alembic wrapper backed by the existingAlembicHelper. Commands: -
tempest db init— scaffoldalembic.ini+alembic/env.py. tempest db revision -m "<msg>" [--manual]— create a new migration (autogenerate by default).tempest db upgrade [target]— apply pending migrations (headby default).tempest db downgrade [target]— roll back (default-1, i.e. one step).tempest db current— print the applied revision.tempest db history [-v]— list revisions newest → oldest.
DATABASE_URL resolves in this order: --database-url flag
→ DATABASE_URL env var →
src.core.settings.settings.DATABASE_URL →
sqlalchemy.url from alembic.ini. The async driver
suffix is stripped automatically before Alembic runs.
-
tempest usersubcommand group — seed and inspect users via the project's concreteUserModel(defaultsrc.db.models:UserModel, overridable with--model). Bootstraps the first admin row so/adminlogin works without manual SQL. -
tempest user create --email X --password Y [--admin]— insert one user. Omitting--passwordreads it interactively (no shell history leak). Password ≥ 8 chars enforced. tempest user list [--admin]— printid email +admin/... active/inactiveper row.
Docs¶
docs/recipes/cli{,.en}.mdadds full sections fortempest dbandtempest userwith flag reference + theDATABASE_URLresolution order.docs/learning/marketplace/index{,.en}.mdsetup block now runstempest db revision+tempest db upgrade+tempest user create --adminbetween the docker compose up and theuv run python main.pyso/adminlogin works on first run.README.mdCommand-line interface recipe grows the same two sections.
[0.29.1] — 2026-06-04¶
Fixed¶
-
Scaffold no longer ships an empty
usertable — the scaffoldedsrc/db/models/__init__.pywas empty, so Alembic's--autogeneratefound no models inBaseModel.metadataand never emitted theusertable. The result:/adminlogin failed because the table didn't exist. The fix: -
New
src/db/models/user.py.tmplships a concreteUserModel(BaseUserModel)mapped to theuserstable. src/db/models/__init__.py.tmplre-exportsBaseModelUserModelso Alembic sees the metadata.
src/api/app.py.tmplnow wiresAdminSite+AdminModel(UserModel)+UserModelAuthBackend+make_admin_routerout of the box.tempest newdefault extras bumped fromauthtoauth,adminso the admin wiring boots without a manual extras tweak.
Upgrade path for an already-scaffolded project: copy the new
UserModel definition into src/db/models/user.py,
re-export from src/db/models/__init__.py, then run
uv run alembic revision --autogenerate -m "user table"
followed by uv run alembic upgrade head.
[0.29.0] — 2026-06-04¶
Fixed¶
- Postgres 18 mount path. v0.26.0 bumped the pinned image to
postgres:18-alpinebut kept the historicalpostgres-data:/var/lib/postgresql/datamount. Postgres 18+ reorganized the data layout — the image now refuses to start withError: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster" (...) Counter to that, there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume). The generator now emitspostgres-data:/var/lib/postgresql(no/datasuffix); Postgres creates the version-specific subdirectory inside.
Upgrade path for existing projects:
docker compose down -v # WIPES local data — back up first
tempest generate --docker --force
docker compose up -d
Added¶
CSRFMiddleware+make_csrf_token_dependency— full double-submit-cookie CSRF guard for cookie-authenticated endpoints. Unsafe verbs (POST/PUT/PATCH/DELETE) must carry both thecsrf_tokencookie and a matchingX-CSRF-Tokenheader; mismatch returns 403 with the SDK envelope{"code": "CSRF_VALIDATION_FAILED"}.
Safe methods always pass. exclude_paths lets bearer-auth
/api/ routes skip the check (JWT bearer is not subject to
CSRF since the browser doesn't auto-attach it).
generate_csrf_token(n_bytes=32) mints fresh tokens;
make_csrf_token_dependency() returns a FastAPI dependency
that the login/template endpoint can call to seed the cookie.
-
OAuth2 / OIDC providers under
tempest_fastapi_sdk.api.oauth: -
GoogleOAuthClient— Google identity, OIDC-compatible, default scopesopenid email profile. GitHubOAuthClient— GitHub OAuth (not OIDC; user info viaGET /user), default scopesread:user user:email.OIDCProvider— generic discovery-driven OIDC client for Auth0 / Keycloak / Okta / Microsoft Entra / Cognito. Pass the authorize / token / userinfo URLs explicitly.
All providers share the same surface — build_authorize_url(state, **extra),
exchange_code(code) -> OAuthTokens, fetch_user(tokens) -> OAuthUser.
Identity is normalized to OAuthUser(provider, subject, email,
name, picture, raw) so the application sees one shape
regardless of IdP. CSRF-grade state via generate_oauth_state().
Built on the v0.28.0 HTTPClient for retries + circuit-breaker
on the IdP — handy when Auth0 / Google occasionally hiccup.
Requires the [http] extra.
[0.28.0] — 2026-06-04¶
Added¶
-
Prometheus
/metricsendpoint + middleware. Newtempest_fastapi_sdk.api.routers.metricsmodule exposes: -
PrometheusMiddleware— trackshttp_requests_total{method, path, status}(Counter),http_request_duration_seconds{method, path}(Histogram),http_requests_in_progress{method}(Gauge). Uses the matched route template as thepathlabel so cardinality stays bounded. make_prometheus_registry()— freshCollectorRegistrydecoupled from the default singleton.make_prometheus_router(registry=…, path="/metrics", dependencies=…)—GET /metricsrendering the exposition format. Pair withDepends(require_x_token)in production.
Requires the new [prometheus] extra (prometheus-client).
-
HTTPClient— typed httpx wrapper attempest_fastapi_sdk.utils.http_client: -
Bounded retries with exponential backoff (
RetryPolicy(max_attempts, backoff_initial_seconds, backoff_max_seconds, retry_statuses)); retries on network errors + configurable 5xx/429. - Per-host circuit breaker — trips after
failure_thresholdconsecutive failures, half-open afterrecovery_seconds; raisesCircuitOpenErrorwhile open. X-Request-IDpropagation from therequest_id_ctxcontextvar to outbound requests so correlation flows downstream.- Verb-level conveniences (
get/post/put/patch/delete) on top of the unifiedrequest()core.
Requires the new [http] extra (httpx).
-
BodySizeLimitMiddleware— short-circuits oversized requests at the ASGI layer: -
Header check on
Content-Length(fast path). - Streaming check for chunked / unknown-length bodies.
exclude_pathslets specific routes (e.g. media uploads) opt out and enforce their own per-endpoint limit.-
Responds
413with the SDK envelope{"code": "REQUEST_BODY_TOO_LARGE", "details": {"max_bytes": …}}. -
BaseRepository.bulk_create_values(rows)— singleINSERT … VALUES (…), (…)round-trip for batch persistence without unit-of-work overhead. -
BaseRepository.bulk_upsert(rows, conflict_columns, update_columns=None)— dialect-awareINSERT … ON CONFLICT DO UPDATE. Picks Postgres or SQLite syntax automatically; raisesNotImplementedErroron other dialects so the caller can fall back to aSELECT FOR UPDATEloop.
Changed¶
[all]extra now includeshttpxandprometheus-client.
[0.27.0] — 2026-06-04¶
Added¶
-
New documentation section: Learning Projects (PT-BR + EN-US). Didactic projects built end-to-end on the SDK so users can learn how the primitives compose in a realistic scenario.
-
First learning project: 🛒 Marketplace — Mercado Livre / Shopee–style multi-tenant sales platform without external integrations. Covers the full SDK stack:
-
Business rules — every domain invariant numbered (U-01… G-04) with rationale. 41 rules across 10 sections.
- Domain model — UML class diagram, ER diagram, enum diagrams (Mermaid), per-entity invariant table, and entity → SDK primitive mapping.
- Critical flows — sequence diagrams for the 5 trickiest
flows (signup, member invitation, product creation with
images, idempotent checkout, shipping with SSE), plus state
machines for
OrderandInvitation. - Endpoint map — full REST surface as a table (method + path + auth role + idempotency + status + description).
Exercises: BaseUserModel, PasswordUtils, JWTUtils,
make_jwt_user_dependency, make_role_dependency,
BaseRepository[T], generate_opaque_token,
EmailUtils.render_template, UploadUtils +
MinIOUploadStorage, IdempotencyMiddleware,
EventStream / sse_response, AsyncTaskBrokerManager,
AsyncBrokerManager, AsyncRedisManager + @cached,
MetricsUtils, register_exception_handlers + the
AppException hierarchy, configure_logging +
make_logs_router.
Docs¶
docs/learning/index{,.en}.md— section index with the catalog of learning projects (Marketplace shipped; library, scheduling, recurring billing planned).docs/learning/marketplace/{index,business-rules,domain,flows,api}{,.en}.md— 10 new bilingual pages.mkdocs.ymladds the section to PT nav and the i18nnav_translationsblock (now 31 navigation elements, was 23).
[0.26.0] — 2026-05-31¶
Added¶
tempest generate --docker— regeneratedocker-compose.yaml(and refresh the.env.exampleservice block) in an existing project. Reads the project'spyproject.tomlto discover the currently pinned SDK extras unless--extrasis given explicitly. Refuses to overwrite a hand-edited compose file without--force. The.env.exampleaddendum is idempotent — re-running the command does not duplicate the service blocks.
Flags:
--docker— selects the compose generator.--path / -p— project root (default: cwd).--extras— override discovered extras.--name— override the container-name prefix.-
--force / -f— overwrite existing compose file. -
All Pydantic schemas and settings mixins now ship
title+description+examplesmetadata on every field. JSON-Schema consumers (FastAPI/docs,/redoc, IDE tooling,pydantic.model_json_schema()) render rich metadata out of the box; OpenAPI examples populate the Swagger UI examples picker without further configuration.
Surfaces covered:
settings.mixins— every*Settingsmixin (ServerSettings,LogSettings,DatabaseSettings,RedisSettings,RabbitMQSettings,JWTSettings,CORSSettings,EmailSettings,UploadSettings,TokenSettings,WebPushSettings,TaskIQSettings,MinIOSettings).schemas.pagination—BasePaginationFilterSchema,BasePaginationSchema,CursorPaginationFilterSchema,CursorPaginationSchema.schemas.response—BaseResponseSchema.schemas.logs—LogEntrySchema.webpush.schemas—WebPushKeysSchema,WebPushSubscriptionSchema,WebPushPayloadSchema.
Changed¶
-
docker-compose.yamlimage tags bumped to the current major releases on Docker Hub: -
postgres:16-alpine→postgres:18-alpine. Postgres 14+ has usedscram-sha-256by default; no client-side change required. redis:7-alpine→redis:8-alpine. Note Redis 8.0+ ships under a tri-license (RSALv2 / SSPLv1 / AGPLv3); the earlier<=7.2.4line was 3-Clause BSD. Internal use is unaffected; redistribution may need to pick a compatible license tier.rabbitmq:3-management-alpine→rabbitmq:4-management-alpine.RABBITMQ_DEFAULT_USER/RABBITMQ_DEFAULT_PASSremain functional;RABBITMQ_DEFAULT_VHOST=/made explicit in the rendered compose.
Per-service tightening:
redisnow boots with--appendonly yesso dev data survives container restarts;start_period: 5slets the healthcheck wait for the AOF rewrite path.rabbitmqhealthcheck usesrabbitmq-diagnostics -q ping(quiet) withstart_period: 30sto absorb the broker's cold boot.postgreshealthcheck gainsstart_period: 10s.
[0.25.1] — 2026-05-31¶
Changed¶
-
Tighter typing across the SDK's public surface —
Anyremoved from most signatures. The recent backend/protocol additions landed withAnyin places where a concrete type orProtocolis just as ergonomic: -
UploadStorage.write_stream(..., validator=…)andUploadUtils.save(..., storage=…)now accept the explicitContentValidator = Callable[[bytes], bool]andUploadStorage | Nonetypes. Mypy and IDEs can now flag wrong shapes instead of waving them through. RedisIdempotencyStore(client=…)takes a new_RedisLikeProtocol(asyncget(key)/set(key, value, ex)) so the cache is decoupled fromredis-pyfor type-checking while still accepting any compatible client.make_app_exception_handler/make_http_exception_handler/make_unhandled_exception_handlerreturn typedCallable[[Request, ExcT], Awaitable[JSONResponse]]aliases (AppExceptionHandler,HTTPExceptionHandler,UnhandledExceptionHandler).AsyncMinIOClient.__aexit__andObjectStat.rawannotate their real types (TracebackType | None,minio.datatypes.Object) viaTYPE_CHECKINGimports.EmailUtils._jinja_env,_aiofiles,_aiosmtplibuseModuleType | None/jinja2.Environment | Noneinstead ofAny.
The behavior is unchanged — only the types tightened, so callers see better autocomplete and downstream refactors stay honest.
Removed¶
tempest_fastapi_sdk.utils.storage_backends._stream_upload_filehelper (was private, unused).
[0.25.0] — 2026-05-31¶
Added¶
-
tempest newnow generates adocker-compose.yamlwired with only the supporting infrastructure the picked extras require. The mapping: -
[cache]→ Redis 7 (alpine) [queue]or[tasks]→ RabbitMQ 3 with the management UI exposed athttp://localhost:15672[minio]→ MinIO + a one-shot bootstrap container that creates theuploadsbucket[email]→ MailHog (catches outbound SMTP, UI athttp://localhost:8025)
Postgres is always wired (the SDK's DB primitives are core), so
every scaffolded project gets a one-command path to a real
database via docker compose up -d. The scaffolded .env keeps
SQLite as the default URL so the smoke run works without Docker.
-
.env.examplegains a service-aware addendum matching the same extras → environment variables. Picking--extras cache,miniowritesREDIS_URL,MINIO_ENDPOINT,MINIO_ACCESS_KEY(etc.) into.env.exampleso the developer can copy the file straight to.envand the service connects to the compose-spawned containers without further editing. -
tempest_fastapi_sdk.cli.docker_composemodule exposesgenerate(project_name, extras)andenv_block_for(extras)as public helpers. Both pin image tags to specific versions that are smoke-tested with the SDK; bumping the pins should go through the smoke suite before being released.
Changed¶
- The post-scaffold "Next steps" hint printed by
tempest newnow reminds the developer to rundocker compose up -dbeforeuv run python main.py.
[0.24.0] — 2026-05-31¶
Added¶
-
UploadUtilspluggable storage backends. NewUploadStorageprotocol undertempest_fastapi_sdk.utilswith two ready implementations: -
LocalUploadStorage(base_dir)— disk-backed, matches the historicalUploadUtilsbehavior. MinIOUploadStorage(client, bucket=None)— wraps theAsyncMinIOClientshipped in v0.23.0, requires the[minio]extra.
UploadUtils.save() now accepts an optional storage=
keyword. When provided, the validated upload is sent to the
backend instead of the local filesystem — validation pipeline
(extension / MIME / size / magic bytes / content_validator)
is identical for both targets. Calls without storage=
continue to write to upload_dir unchanged.
IdempotencyMiddlewareundertempest_fastapi_sdk.api.middlewares. Caches the full response forPOST/PUT/PATCH/DELETErequests keyed by(method, path, Idempotency-Key)so client retries don't re-execute the handler. Opt-in per request — endpoints without the header pass through.
Two stores ship out of the box:
MemoryIdempotencyStore— async-lock-guarded dict with TTL eviction. Single-replica only.RedisIdempotencyStore(client, prefix="idem:")— backed by an async Redis client. Required in multi-replica deployments.
Custom backends can implement the IdempotencyStore protocol.
EmailUtils.render_template(template_name, context). Optional Jinja2 template rendering for transactional emails. Passtemplate_dirat construction time, then callrender_templateto produce the HTML / text body fed intosend(). HTML autoescaping is enabled for.html/.htm/.xmltemplates so caller-supplied values can't break out into markup.
Changed¶
[email]extra now ships Jinja2 alongsideaiosmtplibsorender_templateworks without a separate[admin]dependency. Existing installs should re-pull the extra:pip install -U "tempest-fastapi-sdk[email]".
Fixed¶
tests/utils/test_lazy_extras.py::hide_modulefixture now fully restoressys.moduleson teardown. The previous version only saved entries matching the_hidetarget, so tests that reimported the wholetempest_fastapi_sdkpackage leaked the freshly-built class objects into later tests — surfaced aspytest.raises(...)failing to catch exceptions that were raised under a different (re-imported) class identity.
[0.23.0] — 2026-05-31¶
Added¶
- MinIO / S3 object storage module. New
tempest_fastapi_sdk.storagepackage exportingAsyncMinIOClientandObjectStat. Async-friendly facade over the officialminiopackage — every blocking call is wrapped inasyncio.to_thread, so the FastAPI event loop stays responsive while uploads/downloads run in the executor.
Operations covered:
- Buckets —
bucket_exists,ensure_bucket,list_buckets,remove_bucket. - Objects —
put_object(bytes or file-like),fput_object(from disk),get_object_bytes,fget_object,stream_object(chunked async iterator),stat_object,list_objects(prefix + recursion),remove_object,copy_object. - Presigned URLs —
presigned_get_url,presigned_put_urlwithtimedeltaexpiry.
The full synchronous minio.Minio client stays accessible via
the .client attribute when you need surface beyond the wrapper
(SSE-KMS, lifecycle XML, bucket replication, etc.).
-
MinIOSettingsmixin undertempest_fastapi_sdk.settings, exposingMINIO_ENDPOINT,MINIO_ACCESS_KEY,MINIO_SECRET_KEY,MINIO_SECURE,MINIO_REGION,MINIO_DEFAULT_BUCKET. Re-exported at the package root. -
New
[minio]extra —pip install "tempest-fastapi-sdk[minio]". Theminiopackage is lazy-loaded atAsyncMinIOClient.__init__so projects without storage don't pay the import cost.
Docs¶
- New
docs/recipes/storage{,.en}.mdrecipe with end-to-end examples:UploadFileupload, streaming download, presigned upload (direct from browser), presigned download, prefix listing, copy/move. docs/reference.mdadds entries forAsyncMinIOClientandObjectStat.
[0.22.1] — 2026-05-31¶
Fixed¶
- File-descriptor leak in
configure_logging. Callingconfigure_loggingtwice (normal in tests, and in any service that supports hot-reload) removed the previous file handlers without closing them. After ~100 reconfigure cycles the kernel refused new file opens withOSError: [Errno 24] Too many open files. The previous handlers are nowclose()-d beforeremoveHandlerso each call releases its FDs.
Security¶
UploadUtils.delete()now refuses paths outsideupload_dir. Before,utils.delete("/etc/passwd")would happilyunlink()whatever the caller passed. Any service that forwarded a user-supplied filename todelete()was effectively giving the caller anrmprimitive bounded only by process permissions. The method now resolves the input againstupload_dir, treats relative inputs as relative toupload_dir, and raisesInvalidFileTypeException(withreason="escapes upload_dir") for anything that escapes — including absolute paths to unrelated directories and..-style traversal.
Callers that already passed paths returned by
UploadUtils.save() keep working unchanged; only path-traversal
attempts begin to raise.
Changed¶
make_app_exception_handlerdocstring now accurately states thatlog_leveldefaults tologging.INFO(the previous text claimedlogging.ERROR, which is what the function was originally intended to do but never matched the signature).
[0.22.0] — 2026-05-31¶
Changed¶
-
configure_loggingnow writes to stdout andlogs/by default. Before, file logging only activated when the caller passedlog_dir=...; the default scaffold often forgot it and the service ran with no on-disk audit trail. The new defaults: -
log_dirdefault is now"logs"(wasNone). - New
stdout: bool = Trueflag controls the terminal handler. - New
file_output: bool = Trueflag controls the per-level +500.logfile handlers. - Passing
stdout=False, file_output=FalseraisesValueError— that combination silences every handler and is almost always a mistake.
Backwards compatibility: passing log_dir=None (the old sentinel
for "no files") still works and produces stdout-only behavior.
Test suites that don't want logs/ files in cwd should now pass
file_output=False explicitly.
LogUtils(name=..., level=..., json_output=...)andLogUtils.configure(...)forward the same three flags (log_dir,stdout,file_output) so the imperative wrapper stays aligned withconfigure_logging.
Migration¶
If your service was relying on configure_logging(level=..., json_output=...)
to be stdout-only, either:
# Option A — opt out explicitly:
configure_logging(level="INFO", file_output=False)
# Option B — keep stdout-only via the old sentinel:
configure_logging(level="INFO", log_dir=None)
If your test suite spins up configure_logging or LogUtils
in-process, pass file_output=False to avoid stray logs/
folders in the working directory.
[0.21.3] — 2026-05-31¶
Changed¶
-
AppExceptionand 4xxHTTPExceptionnow emit anINFO-level log line. Before, both paths were silent — the response went out with the SDK envelope but operators saw nothing in stdout orinfo.logfor a401,404,422(etc.), making "API returned 4xx but I see no trace" debugging painful. The new behavior: -
AppExceptionwithstatus_code < 500→INFOlog, no traceback, no500.logmarker. AppExceptionwithstatus_code >= 500→log_level(defaultERROR) + traceback +HTTP_500_MARKERso the record lands in500.log.HTTPException4xx →INFOlog, no traceback. 5xx behavior unchanged (already logged atlog_level+ 500.log).- Unhandled
Exceptioncatch-all unchanged.
The log line includes the request method, path, status code,
exception code (for AppException) and request id — enough to
grep for in info.log without paging the operator at 3am.
Added¶
make_app_exception_handler(*, log_level)factory exposed viatempest_fastapi_sdk.api.handlersand re-exported at the package root. The existingapp_exception_handlercallable is kept as a thin wrapper for backwards compatibility.
[0.21.2] — 2026-05-31¶
Fixed¶
- Alembic wiped
configure_loggingduring lifespan. The stockalembic/env.py(generated byalembic init) ends withfileConfig(config.config_file_name). That call defaults todisable_existing_loggers=TrueAND honors[logger_root]inalembic.ini, which the SDK was writing aslevel = WARN, handlers = stderr. WhenAlembicHelper.upgrade()ran duringlifespan, every SDK logger configured viaconfigure_logginggot disabled and root was reset to WARN + stderr. The 500 catch-all handler kept building responses but nothing reached stdout,error.logor500.log— operators saw the 500 in the browser and zero log lines.
Two-part fix shipped in the SDK templates:
-
env.py.template— the call is now guarded on the presence of a[loggers]section AND usesdisable_existing_loggers=False, so it never wipes the host's logging tree: -
AlembicHelper.init()stops emitting the[loggers]/[handlers]/[formatters]/[logger_*]/[handler_*]/[formatter_*]sections into the generatedalembic.ini. Alembic's own loggers inherit from root (which the host configures), and the guardedfileConfigabove no-ops when no[loggers]section exists.
Upgrade path for existing projects: re-run tempest new --force
(or AlembicHelper.init) to regenerate the templates, OR
manually patch alembic/env.py to wrap the fileConfig call as
shown above and remove the [loggers]/[handlers]/[formatters]
blocks from alembic.ini.
[0.21.1] — 2026-05-31¶
Fixed¶
raise HTTPException(500, ...)bypassed the SDK 500 logger. Starlette intercepts everyHTTPExceptioninside its ownExceptionMiddlewareand routes it to a default handler that emits a bareJSONResponse({"detail": exc.detail})with no log entry. The 0.21.0 catch-allExceptionhandler never saw those raises, sotempest-fastapi-sdk[0.21.0]users hitting a 5xx endpoint reportedInternal Server Errorin the browser with zero output in stdout /error.log/500.log.
Added a third handler — make_http_exception_handler registered
for starlette.exceptions.HTTPException — that:
- logs every 5xx (
status_code >= 500) at ERROR withexc_info=excandHTTP_500_MARKERso the record lands in botherror.logand the dedicated500.log; - returns the SDK envelope (
detail/code/details), preserving the original status code and any custom headers, so frontends consuming the same envelope acrossAppExceptionand rawHTTPExceptiondon't need to branch; - leaves 4xx HTTPExceptions untouched (Starlette's default body and no log) since those represent normal client outcomes.
make_http_exception_handler and the existing log_traceback
/ log_level knobs on register_exception_handlers are wired
end-to-end; opt out of the trace with
register_exception_handlers(app, log_traceback=False) when an
APM is already capturing the stack.
[0.21.0] — 2026-05-31¶
Added¶
-
File logging to a
logs/directory + a/logsreader endpoint.configure_logginggained alog_dirparameter. When set (the scaffold defaults it to"logs"viaLOG_DIR), the stdout handler is kept and one JSON file per level is written —debug.log,info.log,warning.log,error.log,critical.log— each receiving only its own level (exact match, neverlevel >=). A dedicated500.logcaptures only uncaught-500 records: the catch-all exception handler now flags them withHTTP_500_MARKER, so grave failures are isolated and never buried. A 500 therefore appears in botherror.logand500.log. File handlers always emit JSON regardless ofjson_output. -
make_logs_router— a paginated, filterable, authenticated log reader.GET /logsreads the on-disk JSON files and returns aBasePaginationSchema[LogEntrySchema](newest first). Query params:source(all| each level |500),q(message substring),start/end(ISO-8601 range),page,page_size. Gated by a shared-secretX-Tokenheader viamake_token_dependency— an emptyTOKEN_SECRETdisables the check (dev only). New exports:make_logs_router,LogSource,LogEntrySchema. Thecreate_appscaffold wires the router and passeslog_dir/token_secretautomatically.
Fixed¶
tempest fixnow always formats, even when lint violations remain.run_ruff_fixranruff check --fixand short-circuited on its exit code before reachingruff format. Butruff check --fixexits non-zero whenever any residual violation it cannot autofix is left (an over-length string/comment, an undefined name, …) — so a single unfixable line silently skipped the formatter for the whole file, leaving long code lines un-wrapped and extra blank lines intact. The formatter now runs unconditionally; the lint exit code is still surfaced afterwards so CI keeps failing on the leftover issues.
Note: ruff format (and therefore tempest fix) never wraps long
string literals or comments — this matches Black. Those E501
lines stay and must be shortened by hand or silenced with
# noqa: E501.
- Autogenerated migrations are now lint-clean out of the box.
AlembicHelper.init()writes a[post_write_hooks]block into the generatedalembic.inithat runsruff check --fixfollowed byruff formaton every freshly created revision file. Previously the files Alembic emits failedtempest lint(ruff check) withW291(trailing whitespace in the docstring header whendown_revisionisNone—Revises:) andE501(over-lengthsa.Column(...)lines):
W291 Trailing whitespace
--> alembic/versions/...add_todos_table.py:4:9
E501 Line too long (120 > 88)
--> alembic/versions/...add_todos_table.py:30:89
The hooks resolve the project's own ruff configuration, so every
selected rule that is autofixable (I, UP, W, E501, …) is
cleared at generation time. Requires ruff on PATH — already a
dev dependency in every tempest new scaffold.
[0.20.0] — 2026-05-31¶
Changed¶
- BREAKING — pagination uses
page_sizeeverywhere instead ofsize. The field onBasePaginationFilterSchemawas namedsize(default10) while the controller / service / repository keyword argument was namedpage_size(default20), forcing every consumer to rename the attribute on the way through:
# before — required renaming + a default-value gotcha
result = await controller.paginate(
filters=f.get_conditions(),
page=f.page,
page_size=f.size,
...,
)
Aligned the request schema, the response envelope and the repository return dict on a single name + default:
BasePaginationFilterSchema.size→BasePaginationFilterSchema.page_size- Default
10→20 BasePaginationFilterSchema.get_conditions()strips["page", "page_size", "order_by", "ascending"]BasePaginationSchema.size→BasePaginationSchema.page_sizeBaseRepository.paginatereturn dict key"size"→"page_size".BaseService.paginateandBaseController.paginatepropagate the new key.build_pagination_link_header(size=..., size_param="size")→build_pagination_link_header(page_size=..., size_param="page_size"). URLs now look like?page=2&page_size=20by default. Passsize_param="size"to keep the old query-string spelling without renaming the function argument.
Migration: rename size to page_size on every consumer; if a
service relied on the previous default of 10 items per page,
pass page_size=10 explicitly. The admin router's _Pagination
helper now reads result["page_size"] from the repository
response.
[0.19.2] — 2026-05-31¶
Added¶
- Explicit
log_tracebackflag on the 500 catch-all handler. The default isTrue— every uncaught exception emits the full traceback vialogger.log(..., exc_info=exc)so the operator always has it. Setlog_traceback=Falseonly when an APM agent / Sentry / equivalent is already capturing the failure and the duplicated stack noise is unwanted. The flag is forwarded byregister_exception_handlersandmake_unhandled_exception_handler.
[0.19.1] — 2026-05-31¶
Fixed¶
- Unhandled exceptions returned a bare
Internal Server Errorstring with no log entry.register_exception_handlersonly wired a handler forAppException, so every uncaughtException(e.g.RuntimeError,KeyError, downstream library failures) fell through to Starlette's default — which writes nothing beyond the access line and returns a six-word body. Operators were left blind to real failures. -
Added a catch-all
Exceptionhandler that logs the full traceback at ERROR via thetempest_fastapi_sdk.api.handlerslogger (so the application'sLogUtils/configure_loggingsetup picks it up), attaches the activeX-Request-IDfor correlation, and returns the canonical SDK envelope: -
register_exception_handlers(app, include_traceback=True)embeds the formatted traceback underdetails.tracebackso development environments can surface the failure in the response body too. Production callers leave it off so module paths / SQL fragments / object reprs don't leak. register_exception_handlers(app, log_level=logging.WARNING)overrides the log level when needed.- Reads the request ID from the contextvar first, then falls
back to the
X-Request-IDheader —BaseHTTPMiddlewarespawns a child task so the contextvar set inRequestIDMiddleware.dispatchdoesn't always reach the handler. - New
make_unhandled_exception_handlerfactory exported fromtempest_fastapi_sdk.api.
Documentation¶
- Repository recipe in
docs/recipes/database.mdand the README Alembic walk-through still showed the deprecatedclass UserRepository(BaseRepository[UserModel]): model = UserModelDjango-style class-attribute pattern dropped in 0.16.0. Replaced with the constructor signaturesuper().__init__(session, model=UserModel).
[0.19.0] — 2026-05-30¶
Added¶
- MkDocs Material documentation site auto-deployed to GitHub
Pages at https://mauriciobenjamin700.github.io/tempest-fastapi-sdk/.
Sixteen pages total: landing, installation, architecture (with
Mermaid layering + request-lifecycle diagrams), the eleven-step
tutorial as one linear page, twelve thematic recipe pages
(Database, HTTP, Cache, Real-time, Queue & Tasks, Logging, Metrics,
Admin, Testing, CLI, Security, Brazilian helpers), an auto-generated
API Reference via
mkdocstrings, a migration guide, a contributing guide and the bundled CHANGELOG. - New
[docs]dependency group (mkdocs,mkdocs-material,mkdocstrings[python],pymdown-extensions,mkdocs-include-markdown-plugin) installed viauv sync --group docs. make docs-serve/make docs-build/make docsMakefile targets for local docs work (live reload athttp://127.0.0.1:8000)..github/workflows/docs.ymlpublishes the site to GitHub Pages on every push tomainthat touches docs, the package, the README or the CHANGELOG.- README now opens with a docs-site banner linking to Home / Tutorial / Recipes / API reference so readers landing on PyPI or GitHub reach the prose-rich version in one click.
[0.18.0] — 2026-05-30¶
Added¶
tempest fix— one-shot "organize the project" CLI command that runsruff check --fix <target>followed byruff format <target>. Sorts and dedupes imports, drops unused imports, normalizes string quotes to double, strips trailing whitespace, then normalizes indentation / line length / blank lines / trailing newlines. Pass--unsafeto also apply ruff's unsafe-fixes pass.py.typedmarker shipped inside the wheel so downstream mypy reads the SDK's inline type hints instead of bailing out withSkipping analyzing "tempest_fastapi_sdk": module is installed, but missing library stubs or py.typed marker. PEP 561-compliant.
[0.16.2] — 2026-05-30¶
Fixed¶
tempest new .still rejected the.shorthand when the cwd basename contained a hyphen. 0.16.1 special-cased.but then validated the derived name (Path.cwd().name) with the strict Python-identifier regex^[a-z][a-z0-9_]*$, so a real-world cwd liketodolist-apidied witherror: project name must match ^[a-z][a-z0-9_]*$. The derived name is now matched against a PEP 503 normalized distribution-name regex (^[a-z0-9](../[a-z0-9._-]*[a-z0-9])?$) — the same shape pyproject accepts under[project] name. Explicit names (tempest new myproject) keep the stricter Python-identifier rule because the string is also used as the package directory name.
Changed¶
tempest new(no positional argument) now defaults to.. Previously typer rejected the bare invocation withMissing argument 'NAME'. The default matches the scaffold-in-current-directory shape: runningtempest newinside an empty project directory writesmain.py/pyproject.toml/src//tests/directly under that directory. Pass an explicit name to keep the legacy "create a new subdir" behavior.
[0.16.1] — 2026-05-30¶
Fixed¶
- CLI required
jinja2even though[admin]was not installed. Importingtempest_fastapi_sdkeagerly walked intoadmin/router.py, which had a top-levelfrom fastapi.templating import Jinja2Templates. Starlette'stemplatingmodule raisesImportError("jinja2 must be installed to use Jinja2Templates")at import time, sotempest --version(or any other CLI command) blew up on environments that legitimately skipped the admin extra. The import is now deferred insidemake_admin_router, raising a clearInstall with pip install tempest-fastapi-sdk[admin]only when the router is actually constructed. tempest new .rejected the.shorthand for "scaffold here". The positionalnamewas always run through the Python-identifier regex before resolving the target, sotempest new .died witherror: project name must match ^[a-z][a-z0-9_]*$. The CLI now accepts.and treats it as "scaffold flatly in the current working directory"; the package name is derived from the cwd's basename (still validated).--pathis rejected alongside.because the target is unambiguous.
[0.16.0] — 2026-05-30¶
Repository and exception APIs join the admin in dropping Django-style class-attribute configuration. The constructor signature is now the contract; subclasses survive only when they add behavior (custom queries, except DomainError) — never to "fill in" a required class attribute.
Changed¶
- BREAKING —
BaseRepository.model/not_found_exceptionare constructor kwargs, not class attributes. Plain CRUD works without a subclass:
Subclasses kept around for custom queries forward both via super().__init__:
class UserRepository(BaseRepository[UserModel]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(
session,
model=UserModel,
not_found_exception=UserNotFoundError,
not_found_message="Usuário não encontrado",
)
Replaces the previous class UserRepository(BaseRepository[UserModel]): model = UserModel; not_found_exception: ClassVar[...] = ... form. The synthesized _build_default_repository_class helper in admin/config.py is gone — AdminModel.build_repository now calls BaseRepository(session, model=self.model) directly.
- BREAKING —
AppException.codeis a plainstrclass attribute and is overridable at the raise site viacode=. Same forstatus_code=. Thecode: ClassVar[str]annotation is removed from every shipped subclass (NotFoundException,ConflictException,ForbiddenException,UnauthorizedException,ValidationException,TooManyRequestsException,InvalidTokenException,ExpiredTokenException,FileTooLargeException,InvalidFileTypeException). Subclasses still exist forisinstance/except DomainErrormatching; class-level defaults still work; constructor wins when both are present:
raise NotFoundException(
"Pedido não encontrado",
code="ORDER_NOT_FOUND",
details={"order_id": str(order_id)},
)
[0.15.0] — 2026-05-30¶
Admin configuration is now a plain typed instance instead of a Django-style subclass. The class form (class UserAdmin(AdminModel[UserModel]) with ClassVar attributes and the @site.register decorator) is gone — register a constructed instance instead. Field options accept real SQLAlchemy column attributes, so typos surface in the editor rather than at runtime.
Changed¶
- BREAKING —
AdminModelis an instance, not a subclass. Replace
@site.register
class UserAdmin(AdminModel[UserModel]):
model = UserModel
list_display: ClassVar[list[str]] = ["email", "is_admin"]
ordering = "-created_at"
with
site.register(AdminModel(
model=UserModel,
list_display=[UserModel.email, UserModel.is_admin],
ordering=desc(UserModel.created_at),
))
list_display, list_filter, search_fields, readonly_fields and identity_field accept SQLAlchemy column attributes (UserModel.email) or plain strings. ordering accepts a column (ascending), desc(column) / asc(column), or a "-field" string. AdminSite.register / get / require / iter_models now take and return instances. The @site.register decorator form is removed.
Added¶
FieldRef/OrderRef— public type aliases for the admin field- and ordering-reference unions, exported from the package root.
Fixed¶
- Admin list-view descending
orderingraisedAttributeError. A configured"-created_at"was passed verbatim topaginate(order_by=...), which didgetattr(model, "-created_at"). Ordering is now normalized to a(column, ascending)pair, so descending orders anddesc()/asc()wrappers work correctly.
[0.13.1] — 2026-05-30¶
Fixed¶
- PyPI wheel duplicate-filename rejection.
tool.hatch.build.targets.wheel.force-includewas double-listing the admin templates and static assets (already picked up by the default package scan), producing a wheel that PyPI rejected with400 Invalid distribution file. ZIP archive not accepted: Duplicate filename in local headers. Removed the redundant directives;admin/templates/andadmin/static/continue to be bundled by hatchling's default sdist/wheel rules.
[0.13.0] — 2026-05-30¶
Django-style admin site — Phase 1 (read-only). Mount under /admin so the database port can stay private; operators sign in with a user row owned by the application instead of a shared admin password.
Added¶
BaseUserModel— abstractBaseModelsubclass withemail(unique, lowercased),hashed_password,is_admin,last_login_at, plusset_password()/check_password()/normalize_email()helpers.AdminAuthBackendABC +UserModelAuthBackenddefault. Enforcesis_admin=Trueandis_active=True, stampslast_login_at, exposesprincipal_id/load_principal/display_nameso custom backends (LDAP, OAuth, external IAM) plug into the same flow.AdminSite— slug registry withregister/unregister/requireand decorator-style usage (@site.register).AdminModel[ModelT]— Django-flavored declarative configuration:list_display,list_filter,search_fields,readonly_fields,ordering,page_size,identity_field,verbose_name(_plural),repository_class. Auto-synthesizes a default repository when one is not supplied.make_admin_router— wires the HTML routes: login / logout / dashboard / list (paginated + search + filter) / detail (read-only) / static. Jinja2 templates + minimal admin.css ship with the wheel.SignedCookieSessionStore— itsdangerousTimestampSigner, signed HttpOnly + Secure + SameSite=Lax cookie scoped to the admin prefix, 8-hour default lifetime, per-session CSRF token.- New optional extra
[admin](jinja2,itsdangerous).
[0.11.0] — 2026-05-30¶
Added¶
BaseStrEnum/BaseIntEnum— shared enum bases undertempest_fastapi_sdk.core.enumswithvalues()/keys()/to_dict()helpers so str- and int-valued enums no longer need a per-project base class. Exported from the package root.
Changed¶
BaseService.map_to_responseis now async-aware. The base awaits the repository'smap_to_responseonly when it returns an awaitable (inspect.isawaitable), so concrete services with async mappers no longer need to override the read methods. Existing sync mappers keep working unchanged.
[0.10.0] — 2026-05-30¶
Security hardening primitives, hoisted from a downstream service so every project inherits the same defenses instead of re-rolling them.
Fixed¶
RateLimitMiddlewarekeyed on the transport peer behind a proxy. The default key wasrequest.client.host, which is the reverse-proxy IP once the app is fronted by one — collapsing every client into a single bucket (one abuser exhausts everyone's quota; the limit is effectively global). Addedtrusted_ip_header=so the key is the client IP resolved from a single edge-set header (e.g."x-real-ip"). Default behavior is unchanged (peer IP) for the no-proxy case.
Added¶
get_client_ip/get_client_ip_from_scope— spoof-resistant client IP resolution. Trusts only a single, explicitly named edge-set header (never the client-controlledX-Forwarded-For), falling back to the transport peer.AttemptThrottle+TooManyRequestsException(429) — a backend-agnostic fixed-window failure counter for login / OTP / code verification flows. Keyed by any string, counts only failures, raises a 429 withRetry-Afterwhen the budget is exhausted, and fails open on a backend outage. Works with any async Redis-like client (ThrottleBackendprotocol).generate_opaque_token/hash_opaque_token/verify_opaque_token— single-use opaque tokens hashed at rest (SHA-256) with constant-time verification, for password reset / email verification / magic links. Pure standard library.HardenedStaticFiles— aStaticFilessubclass that stampsX-Content-Type-Options: nosniff, a locked-downContent-Security-PolicyandCross-Origin-Resource-Policyon every response, so serving user-uploaded files can't become a stored-XSS vector. Headers configurable viaDEFAULT_STATIC_SECURITY_HEADERS.set_cookie/clear_cookie— secure-by-default cookie helpers (HttpOnly,Secure,SameSite) with matching set/clear flags so logout actually drops the cookie.RSAWebhookSignatureVerifier— asymmetric (RSA-SHA256/384/512) webhook signature verification for providers that sign with a private key and publish a public key (OpenPix/Woovi-style), complementing the existing HMACWebhookSignatureVerifier. Requirescryptography.
[0.9.0] — 2026-05-30¶
Added¶
UploadUtilsmagic-byte content verification. New opt-inverify_magic_bytes=Trueconstructor flag sniffs the first bytes of every upload and rejects content whose real type does not match its declaredContent-Type/ theallowed_mimetypesallow-list — closing the polyglot hole where an HTML+JS payload served asimage/jpegpassed the extension/MIME check. Recognizes JPEG, PNG, GIF, BMP, WebP and PDF.sniff_mime(prefix)helper (exported at the top level) — magic-byte MIME detector usable on its own to build customcontent_validatorpredicates.UploadUtils.save(..., content_validator=...)— optional predicate run on the first chunk; returningFalseaborts the save and removes the partial file before any further bytes are written.UploadUtils.save(..., filename=...)— explicit, deterministic final filename (e.g.f"{user_id}.jpg"), reduced to its basename and guarded against path traversal. Takes precedence overkeep_original_name.
All four additions are backwards compatible — existing UploadUtils calls
behave exactly as in 0.8.0 unless the new options are passed.
[0.8.0] — 2026-05-17¶
Breaking changes¶
ServerSettingsfield rename.HOST,PORTandDEBUGwere renamed toSERVER_HOST,SERVER_PORTandSERVER_DEBUG, and a newSERVER_RELOADfield was added.LOG_LEVELandLOG_JSONmoved out to a newLogSettingsmixin.- Migration: rename the matching env vars in every
.env/ deployment manifest and replacesettings.HOST/settings.PORT/settings.DEBUG/settings.LOG_LEVEL/settings.LOG_JSONaccordingly. MixLogSettingsintoSettingsif the service was relying onServerSettingsfor the log fields. - See the Migration guide 0.7 → 0.8 in the README for the full checklist.
Added — Settings mixins (Tier 1)¶
LogSettings(LOG_LEVEL,LOG_JSON) — extracted fromServerSettings.EmailSettings(SMTP_HOST,SMTP_PORT,SMTP_USERNAME,SMTP_PASSWORD,SMTP_FROM_ADDR,SMTP_USE_TLS,SMTP_USE_SSL,SMTP_TIMEOUT_SECONDS).UploadSettings(UPLOAD_DIR,UPLOAD_MAX_SIZE_BYTES,UPLOAD_ALLOWED_EXTENSIONS,UPLOAD_ALLOWED_MIMETYPES).TokenSettings(TOKEN_SECRET).WebPushSettings(VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT,WEBPUSH_DEFAULT_TTL_SECONDS).TaskIQSettings(TASKIQ_BROKER_URL,TASKIQ_RESULT_BACKEND_URL).
Added — API helpers (Tier 2)¶
tempest_fastapi_sdk.run_server(app, *, settings=None, host=None, port=None, reload=None, **uvicorn_kwargs)— canonicalsrc/server.pyentry point.make_bearer_token_dependency(tokens, soft=False, ...)—Authorization: Bearer <jwt>decoder returning the claims dict.make_jwt_user_dependency(tokens, user_loader, *, soft=False, subject_claim="sub", ...)— bearer + user loader in one factory.is_valid_cep,normalize_cep,CEP,CEP_PATTERN— Brazilian zipcode validators intempest_fastapi_sdk.utils.regex.
Added — Opt-in primitives (Tier 3)¶
tempest_fastapi_sdk.cache.cached(redis, ttl=300, key_prefix="", serializer=..., deserializer=..., skip_cache=...)— Redis-backed function cache decorator.make_tool_spec_router(spec, *, path="/tool-spec", tag="meta")—/tool-specmanifest router; accepts dict / sync / async providers.make_role_dependency(tokens, roles, *, require_all=False, ...)andmake_permission_dependency(tokens, permissions, *, require_all=True, ...)— JWT-claim-based authorization.
Added — Advanced primitives (Tier 4)¶
tempest_fastapi_sdk.WebhookSignatureVerifier(secret, *, algorithm, header_name, encoding, prefix)— HMAC webhook signature verification with FastAPI dependency factory.tempest_fastapi_sdk.RateLimitMiddleware(max_requests, window_seconds, key_func, exempt_paths)— in-process sliding-window rate limiter.build_pagination_link_header(base_url, *, page, size, pages, extra_params, page_param, size_param)— RFC 8288Linkheader builder for offset paginated responses.
Docs¶
- README — full reorganization, every new primitive has a recipe with full code samples. New sections: Periodic tasks scheduler, Programmatic server entry point, JWT bearer / current-user / role dependencies, CEP, Cache decorator, Tool-spec router, Webhook signature verification, Pagination Link headers, Rate limit middleware, Utility helpers, Outbox dispatcher pattern, Migration guide 0.7 → 0.8.
- Tutorial sections 1–11 realigned to the canonical layout mandated by
the SDK consumers' shared
CLAUDE.md(singlemain.pyone-liner,src/server.pyexposingrun(),src/api/app.pywithcreate_app(),src/db/repositories/location, mandatorysrc/controllers/pass-through,src/api/dependencies/package). - Reference section — method tables for
AsyncDatabaseManager,AsyncRedisManager,AsyncBrokerManager,AsyncTaskBrokerManagerandAsyncTaskScheduler.
Dev¶
- Added
uvicorn>=0.30.0to the dev dependency group sorun_servertests can monkey-patchuvicorn.run.
[0.7.3] — 2026-05-17¶
- Hardened request-ID middleware, SSE writer, web-push dispatcher and database manager lifecycle.
[0.7.2] — 2026-05-16¶
- Release packaging fix only.
[0.7.1] — 2026-05-16¶
Changed¶
- Optional extras (
[auth],[email],[upload],[cache],[webpush],[metrics],[queue],[tasks]) are now lazy-loaded at first instantiation, soimport tempest_fastapi_sdkworks when only a subset of extras is installed.
[0.7.0] — 2026-05-15¶
Added¶
LogUtils,configure_logging,JSONFormatter,RequestIDMiddlewareand therequest_id_ctxcontextvar.MetricsUtils(CPU / memory / disk / GPU snapshots).AsyncBrokerManager(FastStream wrapper,[queue]extra) andAsyncTaskBrokerManager(TaskIQ wrapper,[tasks]extra).
[0.6.0] — 2026-05-13¶
Added¶
- SSE primitives (
EventStream,ServerSentEvent,sse_response). - Web Push dispatch (
WebPushDispatcher,WebPushSubscriptionSchema,WebPushPayloadSchema,WebPushGoneError,[webpush]extra).
[0.5.0] — 2026-05-10¶
Added¶
AsyncRedisManager([cache]extra).- CORS helpers (
apply_cors,CORSSettings). - Composable settings mixins (
ServerSettings,DatabaseSettings,RedisSettings,RabbitMQSettings,JWTSettings,CORSSettings).
[0.4.0] — 2026-05-07¶
Added¶
make_health_router, audit / soft-delete mixins, cursor pagination.
[0.3.0] — 2026-05-04¶
Added¶
BaseController+BaseServicegenerics, DI scaffolding, logging,tempest_fastapi_sdk.testinghelpers.
[0.2.0]¶
Changed¶
- Drop Python 3.10 support; SDK now targets Python ≥ 3.11.
[0.1.0]¶
- Initial public release.