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.
[Unreleased]¶
[0.316.0] — 2026-10-17¶
Added¶
UserAuthService.change_password_with_mfa(session, *, user, code, new_password, recovery_code_model=None, revoke_sessions=True, throttle=None)(#564): troca a senha de conta com MFA ativo provando o fator (TOTP ou recovery code), sem senha atual e sem link de e-mail. Recusa conta sem MFA ativo — inclusive comAUTH_MFA_ENABLEDdesligado — com a novaMFANotEnrolledException(422,code="MFA_NOT_ENROLLED", subclasse deValidationException, exportada no topo e emtempest_fastapi_sdk.auth, com tradução PT-BR e EN no catálogo padrão); confere a política de senha antes do código, então senha fraca não queima recovery code; confere o código pela rotina domfa_verify(TOTP de uso único, recovery code ganhaused_at) e respondeUnauthorizedException("invalid MFA code")no erro.throttle=usa a chavemfa:{user_id}, a mesma domfa_verify: erro aqui gasta o orçamento do segundo passo do login. Revoga as sessões por padrão; não emite token nem tocalast_login_at. Semrecovery_code_model, só TOTP. Testes:tests/auth/test_change_password_mfa.py.tempest user create --require-mfa(#565): inscreve o segundo fator antes do commit — mostra a URIotpauth://e o segredo, pede o código do app (até 3 tentativas) e só grava com código válido; sem ele sai com código 1 e nada é escrito. Usamfa_enroll/mfa_confirmdoUserAuthService, exigeMFAMixinnoUserModele a tabela de recovery codes (--recovery-code-model, defaultsrc.db.models:UserRecoveryCodeModel); os recovery codes saem uma vez, depois do commit.--audit-model(umBaseAuditLogModelcomAuditRequestMixin) gravarecord_event(actor="cli") na mesma transação, com o nome de--event(defaultuser.created) — vale também sem--require-mfa. Sem--audit-model, o--require-mfasegue e avisa no stderr que nenhum evento foi gravado.tempest user set-passwordnuma conta com MFA ativo (eAUTH_MFA_ENABLEDligado) pede o código — TOTP ou recovery code (--recovery-code-model, mesmo default, opcional aqui) — e troca a senha porchange_password_with_mfa; três códigos errados saem com código 1 sem tocar a senha. Testes:tests/cli/test_user_mfa.py.UserAuthService.mfa_reset(session, *, user, recovery_code_model, revoke_sessions=True) -> int(#574): desliga o MFA sem prova — limpatotp_secret,totp_enabled_atetotp_last_step, apaga os recovery codes e revoga as sessões por padrão, devolvendo quantas caíram. Caminho de operador para quem perdeu o celular e os recovery codes; conta sem MFA ativo levantaValidationException("MFA not active"). Omfa_disablepassa a delegar a limpeza a ele depois de conferir senha e código (sem revogar sessões, como antes).tempest user mfa-reset --email <e>(#574): o lado da CLI domfa_reset. Exige confirmação (prompt no terminal,--yesfora dele; sem ela sai com código 1 e nada muda), revoga as sessões por padrão (--refresh-token-model,--keep-sessionsexplícito, as duas juntas recusadas com código 2) e gravarecord_event(--audit-modelcomAuditRequestMixin,--eventdefaultuser.mfa_reset,actor="cli") na mesma transação — se a auditoria falha, o reset volta junto. Sem--audit-model, avisa no stderr. Conta sem MFA ativo, ouUserModelsemMFAMixin, sai com código 2. Testes:tests/cli/test_user_mfa.py.UserAuthService(refuses_email_link=...): predicado por conta que fecha os fluxos de link por e-mail (recuperação de senha e ativação). Quando casa,POST /auth/password-reset/requestePOST /auth/activation/requestrespondem o mesmo202genérico e não gravam token nem mandam e-mail; a confirmação (/password-reset/confirm,/activate/{token}) devolve 403 com o códigoEMAIL_LINK_FORBIDDEN, consome e commita o token e deixa senha eis_activeintactos. As páginas deAUTH_BACKEND_LINKS=true(GET /auth/activate/{token},GET/POST /auth/password-reset/{token}) renderizam a página de erro com status403, e oGETdo formulário de reset não mostra o formulário. A classe éEmailLinkForbiddenException(trocável emAuthExceptions, comoaccount_inactive), com tradução PT-BR e EN no catálogo padrão. DefaultNonemantém o comportamento anterior.POST /auth/signupcomAUTH_AUTO_ACTIVATE=falseconsulta orefuses_email_link(#573): a conta recusada nasce inativa, sem token de ativação gravado e sem e-mail, e a resposta é o mesmo201de um cadastro pendente comum (activation_required=true,activation_url=null, sem par de JWT) — só ouser_idmuda. Antes, o cadastro mandava um link que só falhava no uso.UserAuthService.signuppassa a devolver, no segundo item da tupla, o novoActivationWithheld(user_id)(exportado no topo e emtempest_fastapi_sdk.auth) nesse caso, e o router só emite JWT paraNone; quem chama osignupdireto e lê.urldo segundo item precisa tratar o tipo novo. ComAUTH_AUTO_ACTIVATE=trueo predicado não é consultado no cadastro: a conta nasce ativa e não depende de link. No modo que devolve o link no corpo (AUTH_RETURN_TOKEN_IN_RESPONSE, ou semEmailUtils), o recusado recebeactivation_url=null, como o/activation/requestjá fazia. Override designupsemfieldsque repassa para osuper()continua funcionando. Testes emtests/auth/test_email_link_refusal.py::TestSignupWithholdsActivation.make_value_guard(column, allowed, *, exception, unauthenticated=None)(#567): irmão domake_flag_guardpara coluna de valor (role == STAFF), com a mesma forma (FlagGuard,exception=/unauthenticated=). Compara por valor — membro de enum dos dois lados vira.value—, e colunaNULL, valor fora do conjunto e model sem a coluna recebem o 403 do projeto. Testes emtests/auth/test_guards.py, incluindo uma rota real.make_web_app_router(..., immutable=[...], immutable_manifest="immutable.json"): caminhos endereçados por conteúdo saem comCache-Control: public, max-age=31536000, immutable; o resto continua comasset_cache_control.immutablesão globs relativos à raiz do build (**atravessa diretórios,*/?ficam num segmento); o manifesto é um JSON opcional na raiz do build,{"immutable": [<globs>]}, lido uma vez na montagem do router, somado aimmutable=, e malformado derruba o boot comValueError.index.html,sw.js,bootstrap.js,register.js,manifest.webmanifeste o próprio manifesto nunca ficam imutáveis, nem comimmutable=["**"].- O router estático responde
HEAD(antes:405), fora do schema OpenAPI.
Fixed¶
make_web_app_routere o shell dobuild_web_apprespondem à revalidação. OETage oLast-Modifiedjá saíam em toda resposta, mas umGETcomIf-None-Matchigual recebia200com o corpo inteiro — entãoasset_cache_control="no-cache"virava "baixar tudo a cada visita" (~1,19 MB / ~85 arquivos por carga num celular, medido no tempest-webtunnel). AgoraIf-None-Match(comparação fraca, lista,*) ou, sem ele,If-Modified-Sincenão anterior aoLast-Modifieddá304 Not Modifiedvazio, comETag,Cache-Controle ossecurity_headers; vale para assets,index.htmle o fallback do SPA. Medido atrás de um nginx 1.27 comgzip on: oETagchega fraco (W/"...") e o304continua.shell=Path(...)com nome diferente deindex.htmlsaía comasset_cache_controlem vez deno-cache; o shell sobrescrito sai sempreno-cache. EGET /index.htmlserve oshell=quando há um, em vez do documento gerado.
Changed¶
- Quebra compatibilidade:
tempest user set-passwordem conta com MFA ativo passa a exigir o fator (TOTP ou recovery code); sem código válido em três tentativas sai com código 1 e a senha não muda. Para quem perdeu o fator,tempest user mfa-reset. Migração:docs/migration.md. - Quebra compatibilidade (tipo): o segundo item de
UserAuthService.signuppassa a serActivationToken | ActivationWithheld | None. Quem lê.urlsem testarActivationTokentomaunion-attrno mypy. Migração:docs/migration.md. - Docs: receitas de Kestra e n8n (#551, #552) — disparar flow/workflow com
HTTPClient, a ferramenta chamando rota protegida pormake_token_dependency, e entrega garantida comOutboxRelay+WebhookInbox. Exemplos executados contra Kestra 2.0.6 e n8n 2.43.7 em Docker. Sem código novo no pacote.
[0.315.0] — 2026-10-16¶
Added¶
RTR001eRTR002notempest lint-layers/ passolint-layersdotempest check, lidos porastnos módulos derouters/.RTR001: chamada a uma classe*Controllerou*Serviceem qualquer ponto do módulo (corpo de rota, função auxiliar, nível de módulo) — a mensagem manda a construção para um provider emapi/dependencies/controllers.py(ouservices.py) e a rota receber porDepends; opt-out por chamada com# tempest: router-buildsna linha.RTR002: rota (@<router>.get/post/put/patch/delete/head/options/trace/api_route) cuja anotação de retorno ouresponse_model=cita um model ORM em qualquer profundidade (list[...],Optional[...], união,Annotated, anotação em string); conta como model o nome importado de módulo com segmentomodelsou a classe pública definida num diretóriomodels/do projeto, exceto quando importada de móduloschemas. Sem opt-out. Medido: um router comProductController(ProductService(ProductRepository(session)))e-> ProductModelnum projeto dotempest newdá três achados e exit 1; o scaffold puro e o scaffold com os exemplos doCLAUDE.mdpassam limpos; os 78 blocos de router das docs, cada um num projeto próprio, só disparam nos dois exemplos que mostram o defeito de propósito. "Guard no lugar errado" fica sem regra, de propósito: o@requiresé válido na rota e no método do controller, e a receita de guards de permissão usa a rota como primeira forma. OCLAUDE.mddo template documenta os dois códigos e o marcador. As receitas de HTTP e de push passam a montar o service emsrc/api/dependencies/services.pyem vez de no módulo do router.
Changed¶
- Funções genéricas na sintaxe do Python 3.12 (PEP 695,
def f[T](../...)): as 32 ocorrências que o ruff acusava comUP047, e a regra saiu doignore. Mudança interna para quem chama:mypy --strictebasedpyrightsobre um consumidor dewithin_radius,nearest,parse_structured,parse_structured_list,require_authenticated,read_xlsx_as,typed_tool,form_dependency,parse_form,run_cancellable,strict_types,typederequire_annotationsdão o mesmo resultado antes e depois, etyping.get_type_hintssobre as 20 funções públicas convertidas falha nas mesmas 4 (import só deTYPE_CHECKING) que já falhavam. OsTypeVarde módulo que ficaram sem uso saíram; oStructuredTfica, porque os geradores dogenaio importam. Única saída visível: otempest openapi-clientpassa a emitirdef _validate[_T](../...)no cliente gerado, sem o_T = TypeVar("_T")de módulo — os clientes OpenPix, Mercado Pago e zap foram regenerados. UP046(classes genéricas) eUP040(type X = ...) continuam noignore, com o motivo medido nopyproject.toml: na classe, a variância passa a ser inferida (umFormResult[Sub]congelado vira aceito onde se pedeFormResult[Base]) eget_type_hintsnum método levantaNameErrorem 3.12, 3.13 e 3.14; no alias, os quatro (SSEData,Widget,GuardException,ShellSource) são exportados e oTypeAliasTypemuda o valor em runtime (get_argsvazio) e vira componente$refnomeado no OpenAPI do consumidor.- Template do
tempest newalinhado ao padrão de Arquitetura › Padrões de service, repository e controller. O projeto gerado trazsrc/api/dependencies/controllers.pycomget_service_info_controller, a fábrica doGET /api/placeholder, e o router recebe oServiceInfoControllerporDependsem vez de devolver umdictmontado no handler (a resposta continua{"service": "<TITLE>"}, agora comoServiceInfoSchema).src/core/settings.pyganhaget_settings()com@lru_cache, que a fábrica recebe porDepends(get_settings)— entãoapp.dependency_overrides[get_settings]troca as settings sem tocar router nem controller; osettingsde módulo continua sendo a mesma instância para recursos, lifespan,server.pye Alembic.get_mailereget_storagedeixam de vir comentados emresources.py: saem só com[email]/[minio], junto com oEmailSettings/StorageSettingsnoSettingse o re-export emsrc/api/dependencies/__init__.py. OCLAUDE.mdgerado ensina a acrescentar fábricas aocontrollers.pyexistente, importarget_product_controllerdo pacotesrc.api.dependenciese ganha a seção de guards (autenticação na dependência da rota, invariante com@requiresno método do controller). Medido gerando projetos padrão,--extras email,--extras minioe--extras adminfora do repo:tempest check(ruff, format, mypy, lint-layers, pytest com 3 testes) verde nos quatro eGET /health/readiness200 com o app de pé. Projeto já gerado não muda; para adotar, copie os três módulos novos (controllers.py,controllers/service_info.py,schemas/service_info.py) e a funçãoget_settings. - O extra
[cli](e o[all]) passa a exigirtempest-cli>=0.5.1, e o lock sobe de 0.5.0 para 0.5.1. O que chega aotempest fixdo consumidor, segundo o CHANGELOG dotempest-cli0.5.1: as passadas viramruff check --fix-only,ruff formate só entãoruff check, então relatório e exit code saem do arquivo já formatado — antes, uma violação que o formatter resolvia ainda aparecia e ainda derrubava o comando; cada comentárioE501é quebrado no limite (o do fim da linha sobe para cima dela) e cada string literal simples é dividida em partes concatenadas entre parênteses, com a edição mantida só quando a AST do arquivo fica idêntica; e oE501que sobra (nome pontilhado, f-string, string com aspas triplas, pragma, palavra maior que a linha) sai nomeado com o token que estoura, o tipo e a ação que resolve, separado entre sem autofix e corrigível porfix --unsafe. O exit code não muda: a pendência ainda falha o CI. Medido num projeto dotempest newcomline-length = 88: um comentário e uma string longos foram encurtados (saída 0) e uma f-string longa saiu explicada (saída 1). A receita da CLI (docs/recipes/cli.md) trocou o aviso "strings e comentários longos não quebram", que deixou de ser verdade, pelo comportamento novo. Otempest fixnão mudou nada no próprio repositório nem num projeto recém gerado, e omake checkdo SDK rodaruff/mypy/pytestdireto, sem otempest check, então o gate do SDK não muda.
[0.314.0] — 2026-10-16¶
Added¶
- Provedores hospedados intercambiáveis com o Ollama (#539). O
AnthropicGeneratorfala a Messages API (POST /v1/messages, headersx-api-key+anthropic-version, SSE tipado,tool_use/tool_result,output_config.format) e cumpre os mesmos protocolos que oOllamaGenerator—TextBackend,StructuredTextBackende oToolCallingBackenddoAgent—, traduzindosystem,tool_callse resultados de ferramenta nos dois sentidos. OOpenAICompatGeneratorganhouchat_with_toolsechat_structured(response_formatjson_schema, oujson_objectpara o DeepSeek,structured_output=),provider=emax_tokens_field=; oOpenAICompatEmbeddernovo cumpre oSupportsEmbedsobre/embeddings. O mixinHostedGenAISettingstrazANTHROPIC_*/OPENAI_*/DEEPSEEK_*(API_KEY,BASE_URL,MODEL) eanthropic_kwargs()/openai_kwargs()/deepseek_kwargs(). Extra[genai-hosted](sóhttpx, que já é base). Sem SDK de fornecedor: oanthropic1.15.0 e oopenai3.31.0 exigemhttpx2e trazem tetos de versão por um POST e um leitor de SSE. Os testes rodam sobrehttpx.MockTransportreproduzindo as respostas documentadas — nenhuma chamada a provedor real foi feita; o comportamento contra as APIs vivas é o que a documentação de cada uma descreve. Receita:docs/recipes/genai-hosted.md. - Erros tipados dos backends hospedados:
HostedAPIErrore as subclassesHostedAuthenticationError(401/403),HostedRateLimitError(429, comretry_afterdo header) eHostedServerError(5xx, incluindo o 529 da Anthropic e oevent: errorque chega no meio do stream), comprovider,error_type,detailerequest_id. WebhookInbox+BaseWebhookEventModel(tempest_fastapi_sdk.db): inbox genérico de webhook.record(provider, external_id, event_type, payload)éINSERT ... ON CONFLICT DO NOTHINGno PostgreSQL e no SQLite (reenvio devolveNone),claimé umUPDATEcondicional (de dois workers, um ganha),process(id, handler)roda o handler na transação que marcaprocessed(efeito e marca juntos),failvolta com backoff exponencial e viraFAILEDao atingirmax_attempts, saindo da varredura;reclaim_stalereadmite claim de worker morto dentro do mesmo orçamento;drain_once(handler)é o corpo da varredura agendada (#528).integrations.subscriptions.revenuecat(#528), escrito da documentação oficial do RevenueCat (páginas versionadas emvendor/com sha256 fixado por teste):make_revenuecat_webhook_router(autentica pelo headerAuthorizationdo painel e, opcionalmente, pelo HMACX-RevenueCat-Webhook-Signature;hmac.compare_digest; cada segredo preenchido é conferido e o vazio desliga só a própria checagem, entãosecret=""comsigning_secretpreenchido é só HMAC; nenhum preenchido recusa tudo com401; grava noWebhookInboxporevent.ide responde200, comon_recordedpara enfileirar),make_revenuecat_webhook_dependency,RevenueCatWebhook/RevenueCatEvent(extra="allow",subscriber_attributesfora domodel_dump),RevenueCatEventTypeEnumcom os 21 tipos documentados (comparado com a página por teste),normalize(event) -> SubscriptionSnapshot(SubscriptionStatusEnum; tipo desconhecido viraUNKNOWN),RevenueCatClient.get_subscriber(GET /v1/subscribers/{app_user_id}),RevenueCatSettings(REVENUECAT_WEBHOOK_SECRET,REVENUECAT_WEBHOOK_SIGNING_SECRET,REVENUECAT_API_KEY,revenuecat_kwargs();ENV=productionrecusa o boot quando nenhum dos dois segredos de webhook está preenchido) eFakeRevenueCatClient. Nenhum extra novo. Nada foi medido contra conta real do RevenueCat.
Changed¶
-
OpenAICompatGeneratorlevanta a famíliaHostedAPIErrorem vez dehttpx.HTTPStatusErrorcru. Toda classe nova herda dehttpx.HTTPStatusError, entãoexcept httpx.HTTPStatusErrorcontinua pegando tudo. Histórico de conversa comtool_callsde outro backend (argumentscomo dict) é serializado para a string que o formato OpenAI exige antes do envio. -
Quebra de compatibilidade: Python 3.11 deixa de ser suportado.
requires-pythonsobe para>=3.12e as versões suportadas passam a ser 3.12, 3.13 e 3.14 (classifiers,make test-matrix, smoke da wheel no piso 3.12 nomake smokee norelease-pypi.yml). Quem está em 3.11 fica na 0.313.x; o passo a passo está no guia de migração. Medido: num interpretador 3.11 ouvrecusa o projeto comThe requested interpreter resolved to Python 3.11.12, which is incompatible with the project's Python requirement: >=3.12(#547). - O
uv.lockperdeu o split do 3.11: saíramtomli,async-timeouteaudioread, enumpy(2.5.2),scipy(1.18.1) elibrosa(1.0.0) ficaram com uma versão só. Todos os extras instalam e importam em 3.14 (uv sync --locked --all-extras --python 3.14, CPython 3.14.6), sem marcador de ambiente. - ruff mira
py312e mypypython_version = "3.12", no SDK e nopyproject.tomlgerado pelotempest new. As regras PEP 695 que o alvo novo liga (UP047, 32 ocorrências, eUP040, 4) ficam desligadas, como já estava aUP046: reescrever genéricos é outra decisão. O override do mypy paralibrosa, que só existia por causa do alvo 3.11, saiu. - Ativação e reset por link carimbam
amr=["email"], não["pwd"](#543). Nenhum dos dois confere senha: a ativação não recebe nenhuma, e o reset prova a caixa de e-mail antes de gravar a nova. A constante públicaAMR_EMAIL_LINK = "email"(topo etempest_fastapi_sdk.utils) fica fora do registro da RFC 8176, como oext, e fora deMFA_AMR_METHODS— omake_mfa_required_dependencycontinua recusando. Com TOTP inscrito, o/auth/mfa/verifydesse desafio emite["email", "otp"]; orefreshrepete oamrda origem (DB-backed e stateless). Override deissue_token_pairsemamr=segue com o default dele. Quem lia"pwd" in amrpassa a ver"email"nesses caminhos:docs/migration.md. - Retornos do
tasksque eramAnycom tipo conhecido agora são o tipo do TaskIQ (#550), importado sobTYPE_CHECKING—import tempest_fastapi_sdkeimport tempest_fastapi_sdk.taskscontinuam funcionando sem o extra[tasks](medido numa venv semtaskiq).TaskQueue.scheduler→TaskiqScheduler, entãoscheduler = tq.schedulersai tipado sem anotação;Task.enqueue→AsyncTaskiqTask[R], com oRdo retorno da função (wait_result()devolveTaskiqResult[R]);AsyncTaskScheduler.cron/interval/schedule→ o decorator debroker.task, então o nome decorado viraAsyncTaskiqDecoratedTask[P, R]e deixa de seruntyped-decoratorsobmypy --strict;TaskMetrics.middlewareemake_dead_letter_middleware→TaskiqMiddleware;make_job_admin_model→AdminModel[Any];make_requeue_action→Callable[[AdminActionContext], Awaitable[AdminActionResult | None]].AsyncTaskBrokerManager.tasktrocou*args, **kwargsportask_name+**labelscom os dois overloads doAsyncBroker.task(@manager.taske@manager.task(task_name=...)seguem iguais em runtime). EstreitarAnypode fazer código de consumidor que passava no mypy por causa dele passar a falhar — é o caso de quem chamava a task decorada com argumento errado. Conferido no piso (taskiq0.12.6) e na 0.13.0. FicamAnyde propósito:TaskDef.run(retorno que o usuário define no override), o_entryinterno doregister(passthrough) eTaskQueue.register, que devolveTaskoudict[str, Task]conforme a forma da definição — a união obrigaria umisinstanceem todo call site.
Fixed¶
DeadLetterSinkeAgentRunSinkrecusavam todo sink cujo parâmetro não se chamassedead_letter/run(#555). Os dois são protocolos de callback, e ao casar uma função com um deles mypy e basedpyright comparam o nome do parâmetro:async def sink(letter: DeadLetter) -> Nonedavaarg-typeemmake_dead_letter_middleware(sink)/tq.dead_letter(sink)no mypy e erro no basedpyright 1.40.2 (medido: 3 erros em cada checker num snippet de consumidor, zero depois). O parâmetro do__call__agora é posicional (/), como oFlagGuardjá fazia. Implementação que já usava o nome do protocolo, função ou classe, continua aceita. Varredura do pacote: são os únicos protocolos com__call__que nomeavam parâmetro obrigatório. Guard:tests/test_protocol_shape_guard.pypassa a cobrir todo protocolo de callback do pacote, sem lista.
Security¶
- O login social de conta com TOTP pede o segundo fator (#543).
GET /auth/oauth/{provider}/callbackePOST /auth/oauth/{provider}/tokenemitiam o par direto a partir da palavra do provedor, e a conta pulava o fator que tinha inscrito. Agora respondem como o/auth/login:mfa_required=true+mfa_token, sem par e sem cookie de sessão, em qualquerAUTH_TOKEN_DELIVERY; o/auth/mfa/verifyemite o par comamr == ["ext", "otp"], que passa nomake_mfa_required_dependency. Conta sem MFA não muda (["ext"]). Omfa_tokenpassa a carregar oamrdo primeiro passo (issue_mfa_token(user, amr=...)) e oUserAuthService.second_factor_amr(mfa_token)monta oamrda sessão verificada — primeiro passo +otp, sem repetir. Para decidir antes de emitir, as rotas chamam o novoUserAuthService.authenticate_oauth(resolve a conta sem emitir); ologin_with_oauthcontinua emitindo o par e agora delega a ele. Override delogin_with_oauthdeixa de ser chamado pelas rotas, e override deissue_mfa_tokensemamr=volta ao["pwd", "otp"]; o router loga um aviso ao ser montado nos dois casos. Otests/test_mfa_token_paths_guard.pypassa a exigir as duas rotas comogated. Migração:docs/migration.md.
Tests¶
- Todo caminho do
make_auth_routerque emite par de tokens passa por uma allowlist com categoria (#543).tests/test_mfa_token_paths_guard.pylêauth/router.pypor AST e lista cada função que chama um emissor (issue_token_pair,_issue_pair,login_with_oauth,refresh_tokens, …). Cada uma precisa estar na allowlist comogated(retorna de umif service.is_mfa_enrolled(...)antes de emitir, como o/login),completes-mfa(/mfa/verify,/mfa/confirm, WebAuthn — e emite por_issue_pair, que carimba oamr),new-account(signup),rotation(/refresh) ouexternal-idp(OAuth,amr=["ext"]). Rota nova fora da lista, rotagatedsem o desvio ou com o desvio depois da emissão, e entrada velha falham; o guard prova que dispara com o/activatee o/password-reset/confirmcomo shipparam até a 0.312.0. Sem mudança de código entregue: o/activatee o/password-reset/confirmjá respondiammfa_requireddesde a 0.313.0 (#534). tests/auth/test_mfa_token_paths.pymede os caminhos que a #543 citava e a #534 não cobria: oGET /auth/activate/{token}e oPOST /auth/password-reset/{token}doAUTH_BACKEND_LINKSrenderizam a página sem token no corpo e semSet-Cookie, mesmo comAUTH_TOKEN_DELIVERY="both", e o login seguinte pede o segundo fator; o par que o/activateentrega a conta nova (sem fator inscrito) e o par de um login social de conta com TOTP são recusados pelomake_mfa_required_dependencycom403 MFA_REQUIRED.
[0.313.0] — 2026-10-15¶
Added¶
-
read_project_version(pyproject_path=None)emtempest_fastapi_sdk.settings: devolve[project].versiondopyproject.tomlcomostr(viatomllib). Sem argumento sobe a partir dePath.cwd()até achar o arquivo; ausente, oFileNotFoundErrorcita os caminhos procurados, e sem[project].version(inclusive comdynamic = ["version"]) oValueErrorcita o arquivo. Re-exportado no__init__.pydo pacote e no de topo (#536). -
op.rename_enum_type(old, new, columns=[...])(RenameEnumTypeOp):ALTER TYPE ... RENAME TOno PostgreSQL; no SQLite reconstrói a tabela de cada coluna com oCHECKrenomeado. Osync_enum_typesdetecta o tipo renomeado no autogenerate (PostgreSQL peloudt_namedas colunas, SQLite pelo nome doCHECK), escreve a operação com o reverso nodowngradee descarta oalter_columnde tipo que o Alembic propunha no PostgreSQL — que falhava comtype ... does not exist. Medido:upgrade→check→downgradeno SQLite e no PostgreSQL 16, com as linhas preservadas (#518). - Dependências declaradas no import, recursos criados no
create_app(#493):make_jwt_user_dependency,make_bearer_token_dependency,make_role_dependencyemake_permission_dependencyaceitam, no lugar doJWTUtils, uma função(request) -> JWTUtilschamada a cada request;make_rate_limit_dependencyaceita o mesmo para o store. E omake_auth_routerregistra osession_factorycomoDependsde verdade quando ele é uma função geradora, então umget_sessioncom sub-dependências funciona e oapp.dependency_overrides[get_session]vale para as rotas de auth; o callable sem argumentos de antes continua funcionando. Medido: dois apps com segredos diferentes e a mesma dependency de módulo recusam o token um do outro, e cada um conta o rate limit no seu store. SubjectGraph(value_matches={"invites.to_email": "email"}): coluna que identifica o titular por valor, e não por chave estrangeira (o convite enviado ao titular aponta para quem mandou).condition,exportecountrecebemvalues={"email": ...};erase(session, id, values=...)apaga essas linhas e a raiz e devolve a contagem;violations()acusa entrada fora do fecho ou com coluna inexistente (#507).columns=emBaseAuditLogModel.new_entry, nosfor_*, emadd_audited/update_audited/delete_auditede emrecord_event: cada chave vai para a coluna de mesmo nome que a subclasse de auditoria declarar (o titular do evento, o tenant), sem sobrescrevernew_entry. Chave sem coluna levantaValueErrore reverte a transação, inclusive a linha de negócio; chave com parâmetro próprio (AUDIT_RESERVED_COLUMNS:actor_id,event,entity,changes...) também é recusada (#508).RedactionPolicy(exact_keys=...): chaves comparadas por igualdade (depois da mesma normalização), para nome curto e comum que a substring pegaria demais. Comexact_keys={"code"},"GET /invite?code=123456 status_code=200"sai"GET /invite?code=[REDACTED] status_code=200"; antes,extra_keys={"code"}escondia tambémstatus_code, e a saída era sobrescreveris_sensitive_keynuma subclasse — que continua funcionando (#492).FakeObjectStorageClientemtempest_fastapi_sdk.testing.fakes: o client de object storage em memória, com as assinaturas e os retornos doAsyncMinIOClient(put_objectdevolve o ETag,remove_objectsosObjectDeleteErrorrecusados),objects,deletedefail_delete(chaves)para exercitar oSubjectErasureError. OSubjectObjectStoragepassa a aceitar qualquerSubjectStorageClient— protocolo novo emtempest_fastapi_sdk.privacy, que oAsyncMinIOClientjá cumpre —, então o fake passa no type-checker (mypy e basedpyright) (#509).RateLimitKeyFunc,RateLimitKeyResult,LOGIN_THROTTLE_MAX_ATTEMPTS,LOGIN_THROTTLE_WINDOW_SECONDS,MFA_THROTTLE_MAX_ATTEMPTS,MFA_THROTTLE_WINDOW_SECONDS,LOGIN_THROTTLED_MESSAGEeSIGNUP_THROTTLED_MESSAGEsaem do topo do pacote (os dois primeiros também detempest_fastapi_sdk.api, as constantes também detempest_fastapi_sdk.auth). Quem tipava a chave domake_rate_limit_dependencyou repetia o default do limite de login importava do submódulo ou copiava o número. Um guard novo (tests/test_factory_param_exports_guard.py) falha quando um tipo do SDK citado na assinatura dessas factories não sai do topo (#494).tempest lint-layers [TARGET]e o passolint-layersdotempest check(entre omypye opytest): regras estáticas das camadasservices/erepositories/, lidas porastsem importar o projeto, uma linhaarquivo:linha: CÓDIGO mensagempor violação e exit code1.SVC001classe pública emservices/que não herdaBaseService(opt-out com# tempest: no-base-servicena linha doclass);SVC002segunda classe pública ou função de módulo num módulo de service;SVC003__init__que só guarda o repository;REPO001método de repository que só encaminha para um método doBaseRepository. Medido num projeto dotempest newcom os três casos do #520:ruffemypylimpos, 14 achados nolint-layers; o scaffold puro e o scaffold com os exemplos doCLAUDE.mdpassam limpos. OCLAUDE.mddo template troca o aviso de que o check não aplicava as regras pela lista dos códigos (#520).ENUM001notempest lint-layers/ passolint-layersdotempest check: classe pública que herdaBaseStrEnum— direto, por alias de import ou via outra classe do projeto (resolvido por ponto fixo) — e cujo nome não termina emEnum. A mensagem diz o nome esperado e que renomear não pede migration: medido,enum_type_name(Party)eenum_type_name(PartyEnum)dão os doisparty_enum. Varre todo osrc/ouapp/, não só uma camada. OCLAUDE.mddo template ganha a convenção com um exemplo (ProductStatusEnum), e os exemplos das docs e do README que declaravamOrderStatus/ZapOutcomepassam a usar o sufixo. Medido num projeto dotempest new: limpo como gerado;class Party(BaseStrEnum)faz otempest checkparar nolint-layerscom uma linha e exit 1 (#518).
Changed¶
-
O
VERSIONdoSettingsque otempest newgera sai de[project].versionnopyproject.toml, lido porread_project_versionnumdefault_factorycom o caminho ancorado no própriosettings.pye não no cwd, então o serviço publicado roda de qualquer diretório e a versão continua sendo a do projeto. A linhaVERSION=0.1.0do.env.examplegerado vira comentário (override opcional), já que copiar.env.examplepara.envcongelava a versão pela env; a envVERSIONsegue vencendo quando definida (#536). -
O extra
[cli](e o[all]) passa a exigirtempest-cli>=0.5.0, a versão que ganhouCheckStepe oextra_stepsdoregister_commands— é por ali que olint-layersentra notempest check(#520). -
Quebra compatibilidade:
enum_type_nametira umEnumdo fim do nome da classe antes de acrescentar_enum—CandidateSlugEnumviracandidate_slug_enum, e não maiscandidate_slug_enum_enum;OrderStatuscontinuaorder_status_enum(#518). No PostgreSQL, o model atualizado contra um banco ainda com o nome antigo falha em todoINSERT,UPDATEeWHEREda coluna comtype "candidate_slug_enum" does not exist(medido no PostgreSQL 16 com asyncpg) até a migration de rename rodar. Veja o guia de migração. - O
CLAUDE.mdque otempest newgera escreve as regras da camada de service como regra, e não só como exemplo (#520): todo service herdaBaseService(exceção: service sem repository, dito na docstring), nenhum__init__que só guarda o repository, uma classe pública por módulo deservices/sem função solta (helper vai parasrc/utils/), e nenhum método de repository que só repassa paralist/get/paginate. Otempest checkas aplica pelo passolint-layers(ver Added). tempest newdeixa de fixaradmineauthem todo projeto (#522): opyproject.tomlgerado leva só os extras passados em--extras.authgera oUserModel, oJWTSettingsnoSettingse o blocoJWT_*do.env.example;adminmonta o painel noapp.pye traz oauthjunto, porque o login do painel verifica a senha peloPasswordUtils. Sem os dois, o serviço nasce sem tabela de usuário, sem painel e sembcrypt/PyJWTno lock — medido comtempest new x --extras postgres+uv sync: nenhum dos dois nouv.lock, o app sobe,/health/readinessresponde 200,/admin/login404, etempest checkpassa. Para o projeto de antes, passe--extras admin. Os templates ganham blocos@@if <extra>@@para o que depende de um extra. De carona, a docstring dosrc/db/models/user.pygerado deixa de estourar 88 colunas (otempest checkdo projeto novo falhava emE501).SubjectGraphdeixa colunaLargeBinaryfora da exportação por padrão; até a 0.312.x ela saía em base64 (opayloadde uma tabela de job, um blob de modelo).exclude_binary=Falsevolta ao comportamento anterior (#507).tempest db revision(eAlembicHelper.revision) escreve o nome do arquivo em ASCII —"Cria tabela de anotações"vira..._cria_tabela_de_anotacoes.py, e a docstring da migration mantém a mensagem com acento — e remove os marcadores# ### commands auto generated by Alembic ###que o autogenerate põe em volta das operações.tempest db initgrava umscript.py.makodo SDK, sem o# revision identifiersdo template do Alembic, então uma revisão nova sai sem nenhuma linha de comentário. Projeto já inicializado: a receita de migrations mostra como trocar o template (#510).- Os limites do
make_auth_routerrespondem429com código próprio:LOGIN_THROTTLED(o mesmo para o limite por e-mail e por cliente, para não revelar qual estourou),SIGNUP_THROTTLEDeMFA_THROTTLED, nas classesLoginThrottledException,SignupThrottledExceptioneMfaThrottledException— todas subclasses deTooManyRequestsException. Até a 0.312.x os três saíam com oTOO_MANY_REQUESTSgenérico, e o cliente só distinguia o limite de login pela URL; comdefault_message_catalog()odetailvinha a tradução genérica ("Requisições em excesso"). O catálogo embutido ganha a mensagem de cada fluxo em PT-BR e EN, e o OpenAPI das rotas documenta o código.AttemptThrottle.raise_if_blockedganhaexception=para levantar a subclasse. Veja o guia de migração (#495). - Um valor com a forma errada para o operador
betweenpassa a ser recusado comInvalidFilterValueException(422) antes de qualquer statement, em vez de ser descartado em silêncio:{"created_at__between": x}sem o par(lo, hi)(#484). O descarte sumia doWHEREe, embulk_update/delete_many/update_returning, ampliava a escrita para as linhas que o filtro não nomeava —details["value"]ecoa umreprdo valor recusado, truncado em 64 caracteres. Oin/notin/not_innão é recusado com escalar: um valor único continua virando lista de um item ({"status__in": "open"}→IN ('open')), que é o que o caller pediu e nunca fez a condição sumir doWHERE.
Fixed¶
- No PostgreSQL,
add_columnde coluna enum com tipo novo não criava oENUMe oupgradefalhava comtype ... does not exist(#527). O hook novocreate_enum_types_on_add_column(noDEFAULT_REVISION_HOOKS) põeop.create_enum_type(name, values)(CreateEnumTypeOp: consulta opg_typee só cria o que falta; no-op fora do PostgreSQL) antes doadd_column, e odrop_enum_types_on_downgradepassa a derrubar também o tipo de coluna removida pelo downgrade, quando nenhuma coluna que sobrevive o usa. Tipo que outra tabela já usa fica fora dos dois. Medido no PostgreSQL 16, com SQLAlchemy 2.0.52 e 2.1.4:upgrade→downgrade→upgradepassa nos três casos da #526. - No SQLite, coluna enum adicionada a tabela existente (#526): duas colunas do
mesmo enum ganhavam
CHECKcom o mesmo nome e o batch guardava um — a outra aceitava qualquer valor —, e o downgrade de toda coluna enum adicionada falhava comno such column. O hook novosqlite_enum_checks(noDEFAULT_REVISION_HOOKS) escrevebatch_op.create_enum_check(...)depois de cadaadd_columnenum ebatch_op.drop_enum_check(...)antes de cadadrop_column; as duas operações (CreateEnumCheckOp,DropEnumCheckOp) não fazem nada fora do SQLite. A segunda coluna de um enum numa tabela passa a terck_<tabela>_<enum>_<coluna>(noTempestEnume na migration); a primeira mantémck_<tabela>_<enum>. Medido nos três casos da issue, com SQLAlchemy 2.0.52 e 2.1.4:upgrade→downgrade→upgradepassa e valor fora do enum é recusado em cada coluna. op.replace_enumno SQLite mantém o nome doCHECKda coluna e não renomeia mais os outrosCHECKda tabela parack_<tabela>_ck_<tabela>_...(a reflexão rodava sob a naming convention).- O
sync_enum_typesescreve o reverso doop.replace_enumnodowngrade; até aqui odowngradegerado ficava vazio e deixava o tipo com os membros novos. - O
include_objectdoenv.pyesconde também oCHECKde enum refletido sob outro nome numa coluna enum declarada — antes oalembic checkpropunharemove_constraintpara oCHECKque protege a coluna. - O
CHECKde enum é reconhecido pela coluna no começo da expressão: o teste por substring confundiaage_rangecomstore_age_range. - O projeto do
tempest newaplica as próprias migrations (#519). Até a 0.312.x ele não tinhaalembic/nemalembic.ini, o lifespan só faziadb.connect(), edocker compose --profile prod upsubia a API contra um Postgres sem tabela. Agora o scaffold escrevealembic.ini+alembic/e autogera uma revisionbaselinecom as tabelas dos models que os extras trouxeram (num processo filho, contra um SQLite descartável fora do projeto), e o lifespan gerado chamaAlembicHelper(ALEMBIC_INI, db_url=settings.DATABASE_URL).sync_schema_async()antes dodb.connect()— desligável comDATABASE_MIGRATE_ON_STARTUP=false(novo campo doSettingsgerado e do.env.example). Medido comtempest new x --extras admin,postgresedocker compose --profile prod up --build --wait:/health/readiness200,usersealembic_versionnabaselineno Postgres, e um restart da API não rodou upgrade de novo; e o projeto sem extras, subido de outro diretório contra um SQLite vazio, ficou comalembic_versionno head. AlembicHelper.init(etempest db init) escrevescript_location = %(here)s/alembiceprepend_sys_path = %(here)s, ancorados no diretório do ini; comalembice.o ini só funcionava com o processo iniciado na raiz do projeto (FAILED: Path doesn't exist: alembic). Diretório absoluto continua escrito como veio (#519).AlembicHelper.revision(etempest db revision) remove, junto com o marcador# ### end Alembic commands ###, a linha em branco que oruff formatdo hook pôs antes dele; a revision combatch_alter_tableno fim doupgrade()saía com três linhas em branco antes dodef downgradee falhava noruff format --check(#519).tempest new --extras tasks-redisgera um projeto que sobe sem RabbitMQ e sem.env(#521). Até a 0.312.x o extra não gerava a camadasrc/tasks/(sótasksgerava), o.env.examplenão tinhaTASKIQ_*, o compose não tinha Redis, e oSettingsherdava o defaultamqp://doTaskIQSettings— o import de umTaskQueue.from_settings(settings)morria comImportError: RabbitMQ tasks require the optional [tasks] extra. Agora[tasks-redis]gera a mesma camadatasks, oSettingsgerado declaraTASKIQ_BROKER_URLcom defaultredis://localhost:6379/0, o.env.exampletraz a mesma URL e o compose sobe o Redis e aponta a API pararedis://redis:6379/0. Projeto sem extra de tasks deixa de herdar oTaskIQSettings. Medido num projeto gerado e sincronizado comuv sync(semtaskiq_aio_pikainstalado):from src.tasks import brokerdevolve umRedisStreamBroker, etempest checkpassa. E oImportErrordoTaskQueue.from_settingspassa a nomear oTASKIQ_BROKER_URL, o valor lido (senha mascarada), o extra que aquele transporte exige e a URL do outro transporte, em vez de só mandar instalar[tasks].- O
session_factorydemake_push_router,make_web_push_router,make_chat_router,make_reviews_router,make_wallet_router,make_session_router,make_voice_routeremake_app_error_routerviraDependsde verdade, pela mesma regra que a #493 pôs nomake_auth_router— agora num helper só (api/dependencies/_session.py). Umget_session(request: Request)ou comDepends(get_database)atende o request (antes:500nos sete primeiros, medido emtests/api/test_session_provider.py), eapp.dependency_overrides[get_session]vale para as rotas deles. Odb.session_dependencye o callable sem argumentos de antes seguem funcionando — nomake_app_error_router, que registravaDepends(session_factory)direto, o callable sem argumentos passou a funcionar. Omake_chat_routerganhastream_session_factory=para a releitura de participação do stream, que roda fora da resolução de dependências; com umsession_factoryque recebe parâmetro e a releitura ligada, ele recusa a montagem comValueError. Guard novo:tests/test_session_factory_guard.py(#524). - A releitura de participação do stream do
make_chat_routerfechava a factory de sessão comaclose(), que jogaGeneratorExitnoyielde não alcança um gerador que a factory itera por dentro (async for session in get_session(): yield session). A sessão interna ficava aberta até o finalizador de async generators do event loop chegar nela: medido com a contagem lida no instante de cada frame, todo frame do stream saía com uma sessão aberta, em Python 3.11, 3.12 e 3.13. Agora a factory é retomada depois doyieldaté terminar — como o FastAPI fecha uma dependência —, e a limpeza dela roda antes do próximo frame; oaclose()continua valendo quando a checagem levanta. Uma factory que é ela mesma o gerador (db.session_dependency) já fechava na hora. tempest doctormascara a senha da URL do RabbitMQ, do Redis e do banco em tododetaildeCheckOutcome— inclusive nos ramosfail, cuja mensagem de exceção pode repetir a URL crua —, na tabela e no--json(#491). A máscara passa a valer para a URL inteira: senha que contém@(amqp://u:p@ss@h/), lista de hosts com credenciais por host (amqp://u:pw@h1,u:pw2@h2/) e credencial na query (?password=,?pwd=,?secret=) deixam de vazar, e a URL dentro de uma frase (cannot reach redis://...) é mascarada no lugar em que aparece. O helper vira a única fonte da forma segura (tempest_fastapi_sdk.utils._url.mask_url_password), queAsyncDatabaseManager.db_url_safepassa a delegar. O--timeoutopcional no check do RabbitMQ, citado na mesma issue, não foi tratado aqui: o check do RabbitMQ segue sem o parâmetro--timeoutque Redis e SMTP já aceitam.RedactionPolicyvira idempotente sem deixar passar segredo nenhum: só considera um valor já redigido quando o valor inteiro é oreplacement, de modo quetoken=abc→token=[REDACTED]e de novo →token=[REDACTED], enquanto um segredo que apenas começa pelo marcador é redigido —password=***hunter2comreplacement="***"viravapassword=***hunter2, nãopassword=***. A comparação crua por prefixo tratava qualquer valor iniciado pelo marcador como já redigido e deixava o segredo em claro. O grupo de valor do_KEY_VALUE_PATTERNcontinua parando no](então[token=abc]mantém o colchete de fechamento:[token=[REDACTED]]), mas passa a aceitar o próprioreplacementcomo unidade, de modo que um marcador entre colchetes casa inteiro (token=[REDACTED]) e um valor real que só começa por ele (token=[REDACTED]supersecret) segue sendo redigido por inteiro. Como o padrão depende doreplacement, ele é compilado por policy, no__post_init__;_replace_pairmantém o match intacto só quando o valor é igual aoreplacement(#489).- A descoberta de settings do projeto vira uma só, compartilhada por
tempest db,tempest doctor,tempest usere oalembic/env.pygerado (tempest_fastapi_sdk.utils._settings_discovery). Os quatro aceitam, sobsrcouapp, o factory zero-arg documentado (get_settings(), ou qualquer callable anotado com retornoBaseSettings) além da instância, e todos leem o.envcomo fallback; antes cada um só procurava a instânciasettingssobsrc, então um projeto escrito como a SDK documenta — sóget_settings()— não migrava, não era diagnosticado e não achava o usuário. Uma instância presente ganha do factory, que nem é chamado nesse caso. O factory cuja anotação de retorno é uma string (from __future__ import annotations) continua sendo achado, porque a assinatura é resolvida comeval_str=True.tempest doctorpassa a falhar (não "skip") quando ocore/settings.pyexiste mas não rende nada, e tanto odetaildo doctor quanto o erro doenv.pycarregam a causa — o import que quebrou, ou oreprda exceção que oget_settings()levantou — em vez de engolir o erro e reportar só "nenhum settings utilizável" (#490). alembic check(AlembicHelper.check()) deixa de acusar drift fantasma logo depois de umupgrade headlimpo no SQLite, em toda tabela com coluna enum. O comparador deCHECKdo Alembic lia a constraint do banco mas a descartava do lado da metadata (são_type_bound, ligadas ao tipo da coluna), então propunharemove_constraintparack_<tabela>_<enum>e desligava o gate de CI. Orun_alembic_envpassa agora uminclude_objectque ignora esseCHECK— chaveado por(tabela, nome), não só pelo nome, para que duas tabelas com umCHECKhomônimo não escondam o drift uma da outra. Drift real continua acusado, inclusive uma mudança de membro do enum:ReplaceEnumOpganhato_diff_tuple, sem o qualalembic checklevantavaNotImplementedErrorao tentar imprimir o diff em vez de reportar o drift (#483).- Declarar um model com
Mapped[MeuEnum]sobreBaseModeldeixa de emitirSADeprecationWarningpor coluna a cada import, no SQLAlchemy 2.1. OTempestEnum._resolve_for_python_typeliaSchemaType.inherit_schema(virou propriedade deprecada em 2.1) e repassava o parâmetroinherit_schema(também deprecado); agora lê_inherit_schemadireto e só repassa quando for verdadeiro, então quem roda a suíte com-W errorsobre 2.1 não quebra mais no import dos models (#496). O DDL não muda nos dois dialetos. A suíte passa a tratarSADeprecationWarningcomo erro (filterwarnings).
Security¶
- O reset de senha e a reativação por link pedem o segundo fator quando a
conta tem MFA (#534). Antes,
POST /auth/password-reset/confirmconsumia o token, gravava a senha nova e emitia o par direto, sem olharis_mfa_enrolled— quem controlava a caixa de e-mail da vítima entrava sem o TOTP, exatamente o cenário (e-mail comprometido) que o MFA existe para cobrir. Agora, com TOTP inscrito eAUTH_MFA_ENABLED=True, a rota responde como o/login:mfa_required=true+mfa_token, sem par; o cliente conclui em/auth/mfa/verify, que emite o par comamrde MFA. A senha é trocada de todo jeito. Conta sem MFA mantém a resposta de antes (par comamr == ["pwd"]). POST /auth/activate/{token}segue a mesma regra (#534). Um token de ativação normalmente pertence a uma conta recém-cadastrada, sem fator — e aí nada muda. Mas o/activation/requestemite link para qualquer conta inativa, inclusive uma que tinha TOTP e foi desativada (o CLI do SDK desativa viais_active); nesse caso a rota passa a responder o desafio MFA (mfa_required=true+mfa_token) em vez do par, e o par sai do/auth/mfa/verify.ActivationResponseSchemaganhamfa_required/mfa_token, eaccess_token/refresh_tokenpassam a ser opcionais, espelhando oLoginResponseSchema.- O reset de senha revoga as sessões já abertas (#534).
confirmgravava a senha nova e deixava todo refresh token o usuário tinha ainda trocando por par novo — trocar a credencial não expulsava quem já estava dentro. AgoraUserAuthService.confirm_password_resetganharevoke_sessions: bool = Truee chamarevoke_user_sessionsna mesma transação que grava o hash, o mesmo default do/auth/mfa/confirme dotempest user set-password(cujo--keep-sessionsé o opt-out explícito). O router não passa nada; quem chama o service comrevoke_sessions=Falsemantém as sessões de propósito. Refresh token stateless (semrefresh_token_model) não tem linha a revogar e segue válido até expirar. Medido: refresh token DB-backed emitido antes do reset responde 401 na troca seguinte depois da correção (antes respondia 200); comrevoke_sessions=Falsecontinua trocando (tests/auth/test_password_reset_revoke.py). - Override de
issue_token_pair/confirm_password_resetcom a assinatura antiga continua funcionando:is_mfa_enrolledeissue_mfa_tokensão métodos deUserAuthService, herdados por qualquer subclasse, e o router chamaconfirm_password_resetsem orevoke_sessionsnovo, que cai no default seguro. Medido emtests/auth/test_password_reset_mfa.py(4 testes: reset com e sem MFA, reativação de conta com MFA e ativação de conta nova) e emtests/auth/test_password_reset_revoke.py(override antigo delega e ainda revoga). - O
/unregisterdomake_push_routere o/unsubscribedomake_web_push_routersó removem dispositivo do próprio usuário (#523). Antes, a rota exigia login mas apagava pelo token/endpoint sem olhar o dono: qualquer conta autenticada que conhecesse o token de outro usuário cortava o push dele, com200e sem aviso. AgoraDeviceService.unregister(token, *, user_id=None)eWebPushSubscriptionService.unsubscribe(endpoint, *, user_id=None)aceitam o dono; com ele, a linha de outra conta fica intacta e o retorno éFalse, o mesmo de "não existe", e os dois routers passam o usuário autenticado — a resposta continua200nos dois casos, então a rota não revela de quem é o token. Semuser_ido delete continua sem escopo, para a poda de token recusado pelo transporte (notify_user,prune). Override deunregister/unsubscribecom a assinatura antiga não quebra: o router confere a posse porlist_for_usere só então chama o override. Medido emtests/push/test_router_ownership.pyetests/webpush/test_router_ownership.py(o usuário B recebe200e a linha de A continua; nos dois serviços, o atual e um override antigo). - O access token diz se a sessão passou pelo MFA, e confirmar o MFA encerra
as sessões antigas (#525). Antes, o token do
/auth/mfa/verifyera idêntico ao de um login só com senha, e omfa_confirmdeixava todo refresh token vivo: a sessão aberta antes do fator continuava rotacionando, comiatposterior aototp_enabled_at. Agora: - todo access token carrega o claim
amr(RFC 8176):["pwd"]no login com senha (e signup, ativação, reset),["pwd", "otp"]depois de/auth/mfa/verifyou/auth/mfa/confirm(código TOTP ou de recuperação),["hwk"]no WebAuthn e["ext"]no OAuth. A família de refresh token guarda oamrde origem (coluna novaBaseUserRefreshTokenModel.amr; no modo stateless, o refresh JWT o carrega) e orefresho repete, então a rotação nunca promove a sessão.issue_token_paireissue_jwt_pairganhamamr=(defaultPASSWORD_AMR). mfa_confirm(..., revoke_sessions=True)revoga todos os refresh tokens do usuário por padrão;/auth/mfa/confirmresponde200com um par novo deamrMFA (no corpo para bearer, em cookie no modo cookie), em vez de204.make_mfa_required_dependency(tokens, methods=MFA_AMR_METHODS)recusa com403ecodeMFA_REQUIRED(MFARequiredException) o token sem"otp"noamr— inclusive o token semamr, emitido antes desta versão.- Exportados:
AMR_CLAIM,AMR_PASSWORD,AMR_OTP,AMR_HARDWARE_KEY,AMR_EXTERNAL,PASSWORD_AMR,MFA_AMR,MFA_AMR_METHODS,token_amr. - Override de
issue_token_paircom a assinatura antiga não quebra: router erefresh_tokenssó passamamrquando o override aceita, e o token sai com o default dele, que nunca afirma MFA (o router avisa no log ao montar).
Exige migration para quem usa refresh_token_model — sem a coluna, o
signup com ativação automática respondeu 500 (medido com SQLite). Passo a
passo em docs/migration.md. Medido em tests/auth/test_mfa_amr.py: o
refresh da sessão pré-MFA responde 401 depois do confirm; com
revoke_sessions=False emite amr == ["pwd"]; o refresh de uma família
nascida no verify mantém "otp".
[0.312.1] — 2026-10-11¶
Fixed¶
POST /auth/signuprespondia500 TypeErrorem todo cadastro quando o service sobrescreviasignupsem o parâmetrofields— a forma de antes da 0.305.0. Desde a 0.305.0 omake_auth_routerpassavafields={...}em toda chamada, mesmo vazio. Visto em produção em 2026-10-11, num serviço que pulou de antes da 0.305.0 direto para a 0.312.0. Agora a rota só passafieldsquando há coluna a repassar e osignupaceita o argumento; um override sem ele é chamado como antes da 0.305.0, e a montagem do router avisa no log quais colunas dosignup_schemaficam para oon_signup.
[0.312.0] — 2026-10-10¶
Seguimento da 0.311.0 no Mercado Pago: estorno de Pix, Pix vencido como
estado próprio, FakeCardProvider, a busca de chargebacks que passa a
funcionar e as operações GET medidas de novo. As mudanças foram medidas no
sandbox com a vendedora de teste em 2026-10-10 (vendor/mercadopago-evidence.md, seções
9.6 a 9.8). Duas mudanças de comportamento — Pix vencido e
search_chargebacks —: veja o guia de migração.
Added¶
MercadoPagoPixProvider.refund_pix_charge(charge_id, amount_cents=None): estorno parcial ou total de um Pix pago, com o mesmo retry de "ainda não" e a mesma chave entre tentativas dorefund_card_charge. Fica fora do ProtocolPixProvider, porque o adapter da OpenPix não estorna. Medido no sandbox em 2026-10-10: parcial deixa a cobrançaPAID(opartially_refundedfica emraw), o resto a deixaREFUNDED; num Pix não pago,409 cannot_refund_order(#505).FakeCardProvideremtempest_fastapi_sdk.testing.fakes: o contratoCardProvidersem rede — aprova, autoriza, captura, cancela, estorna em partes;decline_next(motivo)devolve uma cobrançaFAILED, eadvance(id, status)chega ao chargeback (#504).
Changed¶
- Mercado Pago: as 34 operações
GETque só uma sondagem sem credencial sustentava foram medidas de novo, com o token da vendedora de teste e cada uma contra um irmão inventado (#488). 20 se distinguiram; 12 não, e agora carregam**Unverified.**na docstring (23 no total);get_release_reporteget_settlement_reportresponderam como não roteadas e carregam**Not routed.**(4 no total), sem sair do cliente. Nenhuma assinatura muda. - Mercado Pago:
MercadoPagoClient.search_chargebacksagora exigepayment_id(keyword) e devolveChargebackSearchResponse(paging+results) em vez dedict[str, Any];limiteoffsetsaíram. Medido em 2026-10-10 com o token da vendedora de teste: sempayment_id, a busca responde400 Wrong parameters in Search Cases— inclusive só comlimiteoffset, a única forma que o método aceitava, então ele nunca funcionou —, elimit=10voltoupaging.limit25. Os itens deresultsnão foram observados e continuamdict[str, Any](#226).
Fixed¶
- Mercado Pago: um Pix vencido (order
canceled/expired, observado comexpiration_timePT60S) agora éPaymentStatus.EXPIREDe geraPixEventType.CHARGE_EXPIRED; antes saíaCANCELLED(#500).
Documentation¶
- Guia do sandbox do Mercado Pago: como ver um Pix pago (
first_nameAPRO) e um vencido; o Mastercard5031 4332 1540 6351responde422porque o BIN não é reconhecido no Brasil, e o5474 9254 3267 0366aprova (#501). Receita: o provedor honra aX-Idempotency-Key(mesmo corpo, mesma order; outro corpo,409 idempotency_key_already_used) e a conta de teste só oferece 1x (#502).
[0.311.0] — 2026-10-10¶
A integração do Mercado Pago passa a cobrar pela API de Orders e deixa de
modelar o que o provedor está aposentando. O que está abaixo foi medido no
sandbox em 2026-10-09, com uma conta vendedora de teste
(vendor/mercadopago-evidence.md, seções 8 e 9) — exceto o formato da
notificação de Orders, que segue o documento do provedor e ainda não foi
observado numa entrega real. Quebra compatibilidade para quem usava a API de Payments:
veja o guia de migração.
Added¶
CardProvider,CardChargeRequesteCardCharge(integrations.payment): contrato canônico de cartão. Cobrar ou só autorizar (capture=False), capturar, cancelar a autorização e reembolsar em parte ou no todo. Recusa é resultado, não exceção:CardChargecomstatusFAILEDe o motivo emstatus_detail.PaymentStatusganhaAUTHORIZED(aprovado pelo emissor, não capturado).MercadoPagoPixProvidereMercadoPagoCardProvider(integrations.payment.adapters), os dois sobre/v1/orders. Medido:- Pix cria
action_required/waiting_transfercomqr_codeeqr_code_base64, cancela emcanceled;expires_invai comoPT1800Se, sem ela, a order expira em 24 horas; - cartão aprovado volta
processed/accredited; recusado volta HTTP 402 com o motivo emerrorse a order emdata, e o adapter devolveFAILED/rejected_by_issuerem vez de levantar; capture_mode: manualvoltawaiting_capture(AUTHORIZED); capturar leva aprocessed, cancelar acanceled;- reembolso parcial endereça o pagamento (
PAY…) com valor, o total vai sem corpo;refunded_centssoma os processados; captureerefundrespondem semtotal_amount, então o adapter relê a order para devolver a cobrança inteira;- order sem
payervolta400; - capturar, cancelar ou reembolsar logo depois de criar às vezes responde
"ainda não" (
409 processor_communication_errorem 3/10 cancelamentos,422 unprocessable_entityem 7/10 reembolsos imediatos): o adapter repete essas respostas, e só essas, com a mesma chave, após 1, 2 e 4 s (action_retry_delays=). UmaX-Idempotency-Keypor chamada, reaproveitada nos retries doHTTPClient(testado com transporte simulado);idempotency_key=troca a regra. make_mercado_pago_webhook_delivery_dependencyeMercadoPagoOrderDelivery. A notificação assina só odata.id; a dependency verifica a assinatura e relê a order por esse id. O tipo do evento sai do estado relido, e oparse_webhookrecusa a notificação crua (TypeError), que só poderia virarUNKNOWN.404na releitura é resposta (a simulação do painel assina um id inventado; medido: order inexistente responde404), e id fora de[A-Za-z0-9]+nem é buscado.MercadoPagoCardProvider.charge_from_deliverylê a cobrança de cartão da mesma entrega.PixProviderRouter(integrations.payment): rollout entre dois provedores de Pix. Fatia estável dos pedidos novos (SHA-256 dareference), leitura e cancelamento peloPixCharge.providerguardado,candidate_percent=0como kill switch.- Receitas novas: contas e credenciais de teste do Mercado Pago (o caminho que funciona e o erro que cada desvio produz) e testando webhooks (simulação assinada local relendo uma order real, sem URL pública), nas duas línguas.
Changed¶
- A suíte local caiu de ~3m30s para ~2m15s (12 núcleos lógicos, 6 físicos; duas medições de cada). Quatro causas, todas medidas:
make testusava-n auto, que compsutilinstalado conta núcleos físicos — 6 workers numa máquina de 12 lógicos. Agora é-n logical: 2m14s e 2m21s, contra 4m11s e 4m16s comautono mesmo código.- Cada worker abria um pool de BLAS/OpenMP do tamanho da máquina; o alvo
agora fixa
OMP_NUM_THREADS=1,OPENBLAS_NUM_THREADS=1eMKL_NUM_THREADS=1(2m52s e 2m50s, contra 3m15s–3m38s sem o teto, com 12 workers). --dist worksteal: worker ocioso pega a fila de quem está atrasado.tests/test_agent_docs_guard.pypercorria.claude/worktrees/inteiro (40 worktrees, 25 GB com os.venv) na coleta e só depois filtrava: 33 s de uma coleta de 49 s sob cProfile, pagos por worker. Agora não entra no diretório e calcula a lista uma vez — coleta de 30,7 s para 7,1 s, mesma lista de 7 arquivos.-
Os testes que rodam mypy (
test_queue_typing,test_generic_bounds) usam cache persistente por repositório em vez de um cache frio emtmp_path: 21,7 s para 0,58 s com o cache quente. Provado que o cache não mascara defeito: com o bound antigo doServiceTreintroduzido, o teste falhou com o cache quente. -
Mercado Pago: estados de
OrdereOrderTransactionPaymentaceitam qualquer string. Antes,create_orderlevantavaValidationErrorao criar um Pix (waiting_transferfora do enum do pagamento), e ler uma order cancelada, reembolsada ou recusada também. O enum vira componente próprio e o campo viraEnum | str—OrderStatuse companhia continuam existindo. O passo foi extraído do overlay do OpenPix parascripts/overlay_enums.pye serve aos dois. - Mercado Pago: dois enums ganham nome próprio depois que os modelos de
Payments saíram: o processing mode de
list_payment_methodsviraListPaymentMethodsResponseItemProcessingModesIteme o status do corpo deupdate_advanced_paymentviraUpdateAdvancedPaymentBodyStatus. - Mercado Pago:
get_authenticated_userdevolveAuthenticatedUserem vez dedict[str, Any](issue #226), lido da resposta observada: todo campo declarado veio com valor, nenhum é obrigatório, o resto fica emmodel_extra. As outras seis operações do SDK que o documento omitia continuamdict[str, Any]. - Mercado Pago: as 47 operações não-
GETsem evidência foram classificadas com requisições que não podem dar certo, comparadas a um path inventado no mesmo prefixo (status sozinho não basta: vários prefixos respondem401/403antes do roteamento). Das que continuam no cliente, 27 perdem o**Unverified.**, 2 ganham**Not routed.**(update_chargeback,create_qr_integrator_config) e 11 seguem marcadas. A mesma régua mostrou que 11 das operaçõesGET"sondadas vivas" não se sustentam (issue #488).
Removed¶
- Mercado Pago: a API de Payments (
/v1/payments) saiu do cliente. O painel do provedor a marca "Esta API será descontinuada em breve" ao escolher o tipo de API de uma aplicação. Saemcreate_payment,search_payments,get_payment,update_payment,cancel_payment,create_refund,list_refundseget_refund, e os modelos que só elas alcançavam (Payment,PaymentRequest,PaymentPayer,PaymentUpdateRequest,PaymentSearchResult,PaymentItem,PaymentAdditionalInfo,RefundRequeste os enums e respostas derivados). O SDK oficial ainda chama essas 7 rotas; a regra "o que o SDK chama, a gente modela" ganhou essa exceção, fixada por teste. - Mercado Pago: as 7 operações de QR presencial que a própria spec marca
deprecated: true(create_instore_order_v1,delete_instore_order_v1,create_instore_order_v2,get_instore_order_v2,delete_instore_order_v2,create_dynamic_qr_order,create_qr_tramma_dynamic). create_pix_payment,get_pix_payment,parse_pix_payment,PixPayment,PixPointOfInteraction,PixTransactionDataePAYMENTS_PATH(integrations.payment.mercado_pago). Existiam para recuperar o QR que oPaymentgerado descartava; na API de Orders o QR vem declarado emtransactions.payments[].payment_method.
[0.310.0] — 2026-10-09¶
Cache de geocoding (CachedGeocodingBackend) e o fluxo de release novo: a
suíte roda em paralelo e só na máquina local, e a CI só publica.
Added¶
- Cache de geocoding:
CachedGeocodingBackend— envolve qualquerGeocodingBackend(NominatimBackend, um provedor pago, oFakeGeocodingBackenddos testes) em frente de um cache plugável com a interfaceGeocodeCacheStore(get(key) -> str | None+set(key, value, ttl_seconds)):InMemoryGeocodeCacheStore(TTL checado na leitura, umdictdo processo) para testes e dev, eRedisGeocodeCacheStoresobre um cliente compatível com o subset deredis.asyncio.Redisque o SDK precisa (posicional,Awaitable, sem import deredis). Só a primeira chamada por chave chega ao backend; a segunda vem do cache. Parâmetros keyword-only:backend=,store=,precision=3,ttl_seconds=2_592_000(30 dias),key_prefix="geocoding",cache_misses=False. - Chaves estáveis e privadas.
reversearredonda a coordenada a 3 casas (~111 m de grade, medido comhaversine_km; duas leituras a ~40 m caem na mesma chave) antes de chavear e antes de chamar o backend — a leitura exata não deixa o processo, e o-0.0não divide a chave em duas entradas.geocodeentra na chave comosha256(texto.strip().casefold()), nunca o texto cru. Formato:geocoding:reverse:v1:<lat>:<lon>egeocoding:search:v1:<sha256>. - Falha de cache nunca é erro de request.
get/setque levantam e payload que não parseia logamWARNINGno loggertempest_fastapi_sdk.geo.cachee caem para "perguntar de novo"; a única exceção que sobe é a do próprio backend.cache_misses=False(default) não cacheiaNone— um lugar que o Nominatim ainda não indexou pode ser achado depois; comTrue, o "não encontrado" também é guardado.
Changed¶
- A suíte roda em paralelo e sem cobertura por padrão.
make test(e, por ele,make checkemake release) chamapytest -n auto -p no:cacheprovider; a cobertura saiu doaddoptsdopyproject.tomle foi para o alvo novomake test-cov. Medido em 2026-10-09 numa máquina de 12 núcleos: 12 262 testes em 4m44s com-n 12 --no-cov, contra ~36 min em série com cobertura. Mudança só de tooling — nenhuma superfície pública muda. - A CI só publica; os testes rodam na máquina local. Saíram os
workflows
ci.yml(matriz 3.11/3.12/3.13) enightly-model.yml. Orelease-pypi.ymlconfere tag × versão × lock, rodamake audit, builda, faz smoke da instalação base (sem[all], que baixava torch a cada release) e publica — sem suíte, lint nem mypy. Oaudit.ymlpassou a rodar só no agendamento diário. O gate antes de uma tag é omake checkque omake releaseroda localmente (~7 min com a suíte em paralelo); a matriz de Python viroumake test-matrix(uma venv por versão em~/.cache/tempest-fastapi-sdk/venvs). Guard novo:tests/test_release_flow_guard.py. Na v0.309.0 o workflow da tag levou 33 min, quase todos rodando a suíte pela terceira vez. - Actions dos workflows atualizadas (Dependabot, #472):
actions/checkoutv4 → v7,astral-sh/setup-uvv3.2.4 → v10.2.0 (fixada por SHA,c18668ad, conferido contra a tag),actions/upload-artifactv4 → v6,actions/download-artifactv4 → v7,actions/upload-pages-artifactv3 → v5 eactions/deploy-pagesv4 → v5.
Documentation¶
- Receita de geocoding ganha a seção "Cache de geocoding" (
geo.md+.en.md): por que cachear, as chaves, a tabela de precisão medida (3 casas ≈ 111 m), ocache_misses, e os dois stores com exemplo executável. Readme e landing de receitas citam a superfície nova; a referência ganha stubs para as constantes degeo.cache.
[0.309.0] — 2026-10-09¶
Rodada de 29 issues: chave desconhecida em filters passa a levantar
(breaking), guard de produção nos settings (EnvironmentSettings),
StorageSettings no lugar de MinIOSettings, limite de tentativas no login e
rate limit por rota, redação de PII no logging, exportação e exclusão de
titular (LGPD), auditoria com evento/IP/user agent, env.py do Alembic dentro
do SDK, leitores de planilha async, gate de CVE na CI e as fábricas de
webhook completas (Mercado Pago). Guia de migração: docs/migration.md,
seções 0.309.0.
Added¶
- Auditoria com evento de domínio, origem da requisição e autor com FK
(#458).
AuditRequestMixin(opt-in) acrescenta à tabela de auditoria as colunasevent(String(128), indexada),ip(String(64)) euser_agent(String(512)).new_entry,for_create/for_update/for_deleteeadd_audited/update_audited/delete_auditedganhamevent=,ip=,user_agent=,request_context=eactor_id=, todos keyword-only, e cada valor vai para a coluna de mesmo nome, sem passar porcontext.AuditRequestContext.from_request(request, trusted_ip_header=...)lê o IP peloget_client_ipe oUser-Agent;trusted_ip_headernão tem default, valor que não é endereço IP viraNoneeUser-Agentacima de 512 caracteres é cortado.BaseRepository.record_event(...)eBaseAuditLogModel.for_event(...)gravam evento sem mutação de linha (AuditAction.EVENT,action="event",entity_idvazio ou o id dosubject=). O autor com FK é ponto de extensão: a subclasse declaraactor_id(ex.:ForeignKey("users.id", ondelete="SET NULL")) e passaactor_id=, sem sobrescrevernew_entry— o teste apaga o usuário e lêactor_idNULLno SQLite com FK ligada. Sem migration para quem já tem a tabela: as colunas ficam no mixin, não noBaseAuditLogModel, e chamadas só comactor/contextnão mudam. Acrescentar o mixin a uma tabela existente exige migration. Valor passado para coluna que a tabela não tem levantaValueError(a transação, linha de negócio incluída, é revertida) em vez de ser descartado;request_context=junto deip=/user_agent=também. Receita:docs/recipes/audit-trail, seção "Evento, origem da requisição e autor". configure_logging(..., redact=True | RedactionPolicy(...))+RedactionPolicy/RedactionFilter(#445). Comredactligado, todo handler que oconfigure_logginginstala ganha um filtro que reescreve o registro antes do formatter: chave sensível (password,token,secret,authorization,cookie,email,api_key... —DEFAULT_REDACT_KEYS, por substring) tem o valor trocado por[REDACTED]emextra=, em argumento dict e em dict aninhado; mensagem, argumentos, traceback (exc_text),stack_infoe valor string passam por e-mail,Bearer, JWT echave=valorcom chave sensível. O serviço acrescenta domínio comRedactionPolicy(extra_keys=..., extra_patterns=...)sem reescrever o filtro.LogSettings.LOG_REDACTliga a política default pelologging_kwargs(). Comredactligado,msgvira a mensagem já renderizada eargsé limpo. Custo medido (20 000 registros, melhor de 5, Python 3.11, saída em/dev/null): linha de access log de ~13,5 µs para ~26 µs,logger.exceptionde ~44 µs para ~66 µs. O defaultredact=Falsenão instala filtro nenhum.JobStore.succeed(values=...)eJobStore.redispatch_queued(#459).succeed(job_id, *, result_id=None, values=None)grava colunas que o projeto adicionou ao modelo de job (umobject_key, por exemplo) no mesmoUPDATEcondicional que marcaDONE. Antes o worker gravava a coluna numa sessão própria e depois chamavasucceed— duas transações, e um cancelamento entre elas deixava uma linhaCANCELLEDapontando para o arquivo que o worker ia apagar (o teste força essa intercalação na forma antiga e reproduz a linha cancelada com a chave). Agora um job cancelado não recebe o valor; numa disputasucceedxcancelem duas conexões SQLite liberadas por barreira (N=20, os dois desfechos apareceram), toda linha terminouDONEcom a chave ouCANCELLEDsem ela. Nome que não é coluna do modelo, ou que está emSTORE_OWNED_JOB_COLUMNS(nova constante: as colunas deBaseJobModel, fixadas por teste contra um modelo concreto), levantaValueErrorantes de qualquer SQL.redispatch_queued(dispatch, *, older_than, limit=100)reenvia linhasQUEUEDcujoupdated_até mais velho queolder_than— o envio à fila que se perdeu depois doenqueueda linha — e renova oupdated_atdepois de cada envio, então cada linha sai no máximo uma vez por janela. Exceção dodispatchpropaga e as linhas ainda não enviadas ficam para a próxima varredura. Receita:docs/recipes/jobs, seções 3 e 5.make_rate_limit_dependency— rate limit por rota, com chave que lê o corpo (#452). ORateLimitMiddlewarelimita o app inteiro e okey_funcdele é síncrono e só recebe oRequest; não havia como dizer "sóPOST /api/invites, por IP e por e-mail convidado". A dependency nova conta no mesmoRateLimitStore(memória ou Redis), aceita uma ou várias funções de chave (síncronas ou assíncronas — askey_by_*do middleware servem) e recusa comTooManyRequestsExceptioncarregando os mesmosdetailse headers (Retry-After,RateLimit-*) do middleware, montados pelos mesmos helpers, agora extraídos emrate_limit.py. O teste compara os dois 429: corpo e headers de rate limit iguais. Com umMessageCatalognoregister_exception_handlers, odetailda dependency sai traduzido pelocodee o do middleware não (medido:"Requisições em excesso"). O balde é prefixado por método + path template da rota, então a mesma dependency em duas rotas dá um orçamento para cada;scope=junta.trusted_ip_headermolda a chave default e é recusado (ValueError) junto comkey=.key_by_body_field("email")lê o campo do JSON — o endpoint continua recebendo o payload validado (medido: o FastAPI lê o corpo antes das dependencies e o Starlette guarda os bytes), normaliza e guarda SHA-256 por padrão. Receita:docs/recipes/http, "Limitar um endpoint por IP e por campo do corpo".login_ip_throttle,signup_throttleetrusted_ip_headeremmake_auth_router(#456). Orçamentos por IP do cliente: o de login conta só credencial errada (login certo não zera, para uma conta válida não resetar o contador entre chutes) e é lido antes do orçamento do e-mail; o de signup conta toda tentativa. Os dois vêm desligados: atrás de um proxy semtrusted_ip_header, todo cliente divide o IP do proxy e um orçamento por IP travaria o login de todo mundo./auth/login,/auth/cookie/logine/auth/signupdeclaram o 429 no OpenAPI só quando o limite correspondente está ligado.tempest_fastapi_sdk.privacy— exportação e exclusão de titular (LGPD, #460).SubjectGraph(metadata, root=..., retained=..., secret_columns=...)deriva daMetaDatao fecho deON DELETE CASCADEa partir da tabela raiz do titular:tables()(raiz primeiro, em largura),export(session, id)(linhas do titular por tabela, valores prontos para JSON, colunas secretas fora — nome comhash/secret/password,info={"secret": True}ousecret_columns),count(session, id)(prova de exclusão: tudo0depois doDELETEda raiz),condition(table, id)(oWHEREpor tabela,IN (SELECT ...)aninhado pelo caminho da cascata, FK composta incluída) eviolations(), que lista toda FK para tabela do fecho que não éCASCADEnemSET NULLjustificado emretained(e entrada deretainedvelha, com ação errada ou em colunaNOT NULL). Tabela nova com FK em cascata entra na exportação sem configuração.tempest_fastapi_sdk.testing.assert_subject_graph_valid(graph)é o guard de CI pronto.SubjectObjectStorage(client, prefix=...)guarda os objetos do titular em<prefix>/<id>/<nome>(nome que sairia do prefixo éValueError) comput,presign,names,deleteedelete_all, que apaga o prefixo em lote e levantaSubjectErasureErrorcom as chaves recusadas. Medido contra MinIO real (tests/privacy/test_storage_live.py): 1203 objetos apagados em duas requisiçõesDeleteObjects(1000 + 203), o titular77e o8intactos. Receita:docs/recipes/subject-data.AsyncMinIOClient.remove_objects(keys, *, bucket=None)— remoção em lote viaMinio.remove_objects(umDeleteObjectspor 1000 chaves), chaves duplicadas colapsadas, lista vazia não faz requisição. Devolvelist[ObjectDeleteError](key,code,message) com o que o storage recusou; lista vazia é sucesso.ObjectDeleteErrorexportado no topo e emtempest_fastapi_sdk.storage.EnvironmentSettings— guard de produção que derruba o boot (#446). Mixin comENV: Literal["development", "test", "production"](defaultdevelopment). ComENV=production, a construção do settings levantaValidationErrorlistando, numa mensagem só e pelo nome (nunca pelo valor), todo campo de desenvolvimento dos mixins compostos:SERVER_DEBUG/SERVER_RELOADligados,DATABASE_URLSQLite,JWT_SECRETigual ao default declarado emJWTSettings.model_fields(não a um literal copiado),"*"emCORS_ORIGINS,TOKEN_SECRETvazio,TASKIQ_BROKER_URLvazio (broker em memória), chavesminioadmindo storage,STORAGE_SECURE=falseeSTORAGE_PUBLIC_SECURE=falseexplícito. Cada mixin declara as próprias regras num hook cooperativo novo,BaseAppSettings.production_violations(); o serviço soma regra ou dispensa uma (SQLite num nó único) sobrescrevendo e chamandosuper(). Emdevelopment/testnada muda. Receita:docs/recipes/system-checks, seção "Guard de produção". Testes:tests/settings/test_environment.py.ServerSettings.TRUSTED_IP_HEADER(#455). O header de borda queget_client_ip(trusted_header=),RateLimitMiddleware,AccessLogMiddlewareeHoneypotBanMiddleware(trusted_ip_header=) recebem, modelado uma vez e lido do ambiente. DefaultNone(peer TCP); normalizado para minúsculas; vazio viraNone.x-forwarded-foreforwarded(qualquer caixa) falham na validação com o motivo na mensagem: o proxy acrescenta a esses headers, então o conteúdo é o que o cliente mandou. Os middlewares eget_client_ipcontinuam recebendo o parâmetro explícito. ConstanteSPOOFABLE_IP_HEADERSexportada. Receita:docs/recipes/security, "Um setting para todos". Testes:tests/settings/test_trusted_ip_header.py(rate limit e access log com o mesmo valor veem o IP do header).StorageSettings— o mixin de storage S3 com nome de S3 (#457). CamposSTORAGE_ENDPOINT,STORAGE_ACCESS_KEY,STORAGE_SECRET_KEY,STORAGE_SECURE,STORAGE_REGION,STORAGE_DEFAULT_BUCKET,STORAGE_PUBLIC_ENDPOINT,STORAGE_PUBLIC_SECURE(mais osSTORAGE_ACCEL_*que já existiam) estorage_kwargs(). Cada campo lê o nome antigoMINIO_*como fallback viavalidation_alias=AliasChoices(new, old)— no ambiente e no.env.minio_kwargs()continua e devolve o mesmo dicionário. Testes:tests/settings/test_storage_settings.py.BaseAppSettings.DEPRECATED_ENV_ALIASES+reject_conflicting_env_aliases. Mapa nome antigo → nome novo, coletado pelo MRO. Osettings_customise_sourcesdoBaseAppSettings(novo; mantém a ordem padrão do pydantic-settings) recusa comSettingsErrorquando os dois nomes de um par estão definidos com valores diferentes, entre ambiente e.envsomados — sem a checagem oAliasChoicesficaria com o nome novo em silêncio. Mesmo valor nos dois é aceito (janela de migração). A mensagem nomeia os pares, nunca os valores.Settingsque sobrescrevesettings_customise_sourcesperde a checagem, a menos que chame a função.-
run_alembic_enveDEFAULT_REVISION_HOOKS(#451). O corpo doenv.pyque otempest db initgera mudou-se para o SDK: o arquivo gerado passa a ter três instruções (import derun_alembic_env, import doBaseModel, a chamada). URL, hooks de revisão,render_enum_types, batch mode, caminhos offline/online e a conexão entregue emconfig.attributes["connection"]continuam iguais, agora num lugar que recebe correção no upgrade do pacote. Hook próprio entra porprocess_revision_directives=compose_hooks(*DEFAULT_REVISION_HOOKS, meu_hook). Receita:docs/recipes/migrations, seção "Oenv.pygerado". -
op.drop_enum_type(DropEnumTypeOp) e o hookdrop_enum_types_on_downgrade(#454). A operação emiteDROP TYPE IF EXISTSno PostgreSQL e não faz nada nos outros bancos. O hook, ligado noenv.pygerado, acrescenta o drop no fim do downgrade de cada tabela nova, para todoENUMnativo que nenhuma tabela remanescente da metadata usa — tipo compartilhado só sai com a última tabela. Medido no PostgreSQL 16 (Alembic 1.19.1, SQLAlchemy 2.0.52): antes,upgrade→downgrade→upgradefalhava comtype "user_role_enum" already exists; agora passa (tests/db/test_alembic_env.py, marcadodocker). -
expected_sha256emload_sklearn_artifacteedge_pipeline_from_pickle(#440). O chamador pina o SHA-256 que a release do modelo registrou, e a carga recusa comArtifactDigestMismatchError(nova, subclasse deValueError, exportada emtempest_fastapi_sdk.modelops) quando o arquivo diverge — antes dojoblib.load, então um pickle trocado não executa. Antes, o digest só era calculado depois do unpickle e ia para o manifesto; conferir contra um valor conhecido exigia o chamador fazer a comparação por fora, com o código do arquivo já rodado. O arquivo é aberto uma vez: o hash é calculado pelo handle e o unpickle lê do mesmo handle, então os bytes conferidos são os que rodam. Formato aceito: 64 dígitos hex em qualquer caixa, prefixosha256:opcional, espaço nas pontas ignorado; qualquer outra coisa éValueErrorantes de abrir o arquivo. O teste usa um pickle cujo unpickle cria um arquivo-marcador: com hash divergente o marcador não existe, com hash certo existe (controle). Receita:docs/recipes/modelops, seção "Pinar o hash da release". -
AssetPolicy.for_render()— cópia rasa com as mesmas regras erefusalsvazia, sem rodar o__post_init__de novo:allow_dirsmantém os caminhos resolvidos na construção, então symlink trocado depois não amplia a política e diretório removido depois não faz a renderização levantarValueError. É o coletor por chamada que a docstring debuild_url_fetchersempre pediu e que o renderizador agora usa. make_logs_router(dependencies=...)— logs atrás da auth do próprio serviço (#426). Antes o router só aceitava o segredo compartilhado doX-Token, então quem protege o painel com Bearer JWT de admin não tinha como usá-lo. As dependências valem paraGETeDELETE; cada item é o callable ou umDepends(...)/Security(...)pronto, que é usado como veio (embrulhar de novo dáAssertionError: A parameter-less dependency must have a callable dependencyna construção — medido no FastAPI 0.141.1). Relação com oX-Token: as duas peças somam. Sódependenciessubstitui o header (nenhumX-Tokené lido nem aparece no OpenAPI);token_secretjunto exige os dois; nenhuma das duas continua levantandoValueError, edependencies=[]não conta como gate.read_log_entries(...) -> LogReadResulteclear_log_files(...) -> list[str](#426), exportadas no topo do pacote. São a leitura (teto por arquivo, flagtruncated, filtrosq/start/end, do mais novo pro mais antigo) e o truncate no lugar que o router fazia em funções privadas — quem precisava de outro contrato de resposta reescrevia as duas e perdia o teto de memória. Síncronas; numa rotaasync, rode comasyncio.to_thread. Omake_logs_routere a página de logs do admin viraram casca sobre elas (a página do admin tinha uma terceira cópia do filtro e da ordenação).read_xlsx_async,read_xlsx_as_asynceread_xlsx_sheets_async(#432) emtempest_fastapi_sdk.spreadsheet: os mesmos argumentos, retorno e erros dos leitores síncronos, rodando numa thread (asyncio.to_thread) atrás de um semáforo. Semsemaphore=, o limite éDEFAULT_MAX_CONCURRENT_XLSX_READS(4) leituras simultâneas por event loop — o semáforo é criado no primeiro uso de cada loop, porque umasyncio.Semaphorede módulo, disputado em doisasyncio.run, levantaRuntimeError: ... is bound to a different event loop(reproduzido). Medido numa aba de 99 999 linhas (8 colunas, 4 MB):read_xlsxchamado dentro de uma corrotina levou 5,4 s e um ticker de 10 ms rodou 1 vez no lugar de ~540; comread_xlsx_asynco mesmo ticker rodou 473 vezes. Quatro leituras simultâneas levaram 25,0 s contra 21,6 s de quatro em série: o parse segura o GIL, então o semáforo limita memória e o pool de threads, não acelera.confirm_pix_payment— o webhook avisa, a API confirma (#434).tempest_fastapi_sdk.integrations.payment.confirm_pix_payment(provider, charge_id, *, reference, amount_cents)relê a cobrança comprovider.get_pix_chargee devolvePixPaymentConfirmation, cujooutcome(PixConfirmationOutcome) diz qual conferência decidiu:REFERENCE_MISMATCH(a cobrança é de outro pedido),NOT_PAID(qualquer status que nãoPAID),AMOUNT_MISMATCH(paga com outro valor) ouPAID;.paidéTruesó no último. Erro do provedor na releitura sobe em vez de virar "não pago". O helper não escreve nada — a liberação única continua comtempest_fastapi_sdk.wallet.claim_once.make_mercado_pago_webhook_dependency(#437) — a terceira fábrica de webhook, no formato demake_stripe_webhook_dependencyemake_openpix_webhook_dependency. Lêdata.idda query string (MERCADO_PAGO_DATA_ID_QUERY, novo) ex-signature/x-request-iddos headers, rodaverify_signaturee devolve umMercadoPagoWebhookEvent; assinatura ausente ou inválida,tsfora detolerance_seconds, ou umdata.id/x-request-idque a assinatura cobria e o request não trouxe levantamUnauthorizedException— medido comTestClient: 401{"detail": "Invalid Mercado Pago webhook signature", "code": "UNAUTHORIZED", "details": {}}, e o handler não roda.data_idsai da query, não do corpo, porque o manifesto assinado não cobre o corpo. Tópico que o SDK não nomeia viraMercadoPagoEvent.UNKNOWN; corpo que não é JSON não derruba a entrega. A receitamercado-pago.mddeixa a função que terminava emboole mostra a rota inteira com a fábrica.
Changed¶
-
Breaking: chave desconhecida em
filterslevantaUnknownFilterKeyExceptionem vez de ser ignorada (#465). Chave que não é atributo SQL do model ({"usr_id": 1234}) ou sufixo que não é operador ({"user_id__bogus": 1}) era descartada em silêncio, e com só a chave errada no dictlist/countdevolviam todas as linhas ebulk_update/delete_manyalteravam a tabela inteira (3/3 linhas, fixado pela versão anterior detests/db/test_filters_contract.py). Agora a checagem roda embuild_filter_condition, antes de qualquer statement, e vale para todo método que recebefilters—list,count,exists,first,get,get_or_none,paginate,cursor_paginate,bulk_update,update_returning,delete_many—, para oQ(inclusive~Q(usr_id=1), que antes negava nada) e paraBaseService,BaseControllereTenantScopedRepository, que só repassam. Medido em SQLite em memória:delete_many({"usr_id": 1})levanta e as 3 linhas continuam lá;bulk_updateeupdate_returningnão alteram nenhuma. A exceção novaUnknownFilterKeyException(exportada no topo) é subclasse deValidationException: 422,code="UNKNOWN_FILTER_KEY"(catálogo PT-BR/EN),details={"filter": <chave>}— sem a lista de colunas do model, que numa querystring seria o mapa do que sondar. Atributo Python sem leitura SQL (método,@property,metadata) também é recusado; coluna,column_propertye a expressão de classe dehybrid_propertyresolvem. Novos emtempest_fastapi_sdk.db.expressions:resolve_filter_key(a checagem isolada) eFILTER_OPERATORS(os sufixos aceitos). Quem dependia do ignorar: schema de filtro com campo que não é coluna (search,include_archived) repassado inteiro porget_conditions()passa a receber 422 — tire o campo do dict antes de repassar. No admin,list_filter,search_fieldse osfiltersde cadaLenssão conferidos na construção doAdminModel: typo viraValueErrorno boot, nomeando a opção, em vez de 422 no primeiro acesso à listagem. Receita:docs/recipes/database, seção "Chave desconhecida é recusada". -
JobStore.reclaim_stale()devolveReclaimedJobs, nãoint(#459). Mudança de comportamento. Os ids requeued e os que falharam por orçamento (.requeued,.failed) são o que permite reenviar o job ao worker: a linha voltava paraQUEUED, mas a mensagem tinha morrido com o worker e nada no SDK a reenviava, então o job ficavaQUEUEDpara sempre. As duas atualizações continuam semSELECTantes (agoraUPDATE ... RETURNING id, que exige SQLite 3.35+ ou PostgreSQL).len()e verdade continuam valendo (if await store.reclaim_stale():); quem compara com número (== 1) ou soma o retorno quebra — use.total. POST /auth/loginpassa a responder429depois de 5 senhas erradas para o mesmo e-mail em 15 minutos (#456).make_auth_routerganhoulogin_throttle, no molde domfa_throttle: cada tentativa reserva uma unidade antes de a senha ser conferida, a sexta leva429 TOO_MANY_REQUESTScomRetry-Aftermesmo com a senha certa, e o login certo antes do limite zera a contagem. O default é umAttemptThrottlesobreInMemoryThrottleBackend(5 por 900 s, por processo); a chave é o SHA-256 do e-mail normalizado, gasta igual para e-mail sem conta, e odetailé o mesmo texto nos dois casos — o 429 não revela quais e-mails existem. Vale também para o login por cookie. Pode quebrar consumidor: teste ou script que erra a senha mais de 5 vezes seguidas para o mesmo e-mail num router só passa a ver 429 — passelogin_throttle=Falsepara desligar, ou umAttemptThrottlepróprio (sobre Redis, com mais de um worker)./auth/me,/auth/refreshe as demais rotas não mudam.BaseAppSettingsligahide_input_in_errors=True(#446). NumBaseSettingso input de um erro de validação é o ambiente inteiro: um campo obrigatório faltando imprimiainput_value={'JWT_SECRET': ...}no traceback do boot, e um valor malformado (uma URL com senha) era ecoado. Agora a mensagem traz só campo e motivo. Quem dependia do input no erro sobrescrevehide_input_in_errorsno própriomodel_config.- O scaffold do
tempest newcompõeEnvironmentSettingse o.env.exampletrazENV=developmente um# TRUSTED_IP_HEADER=x-real-ipcomentado. Ocreate_appgerado não monta rate limit nem access log, então não há onde repassar o header. -
tempest docker-compose/tempest new --extras minioescrevemSTORAGE_*no bloco do app e no.env.example(asMINIO_ROOT_*do container continuam).tempest storageprocurastorage_kwargs()etempest doctorlêSTORAGE_*(aceitandoMINIO_ENDPOINTno ambiente); projeto ainda emMinIOSettingscontinua funcionando. Atenção: quem regenerar só o compose de um projeto cujo.envainda tem umMINIO_ENDPOINTdiferente doSTORAGE_ENDPOINTgerado tem o boot recusado pela checagem de nomes conflitantes — remova o nome antigo. -
A
AssetPolicypassada emPdfRenderer(assets=...)não tem mais a listarefusalslida nem limpa pelo renderizador. Antes, toda renderização esvaziava essa lista no início e no fim, então lerpolicy.refusalsdepois derender_*devolvia[]em qualquer caso (medido: uma nota posta pelo chamador antes da renderização sumia). As recusas de uma renderização continuam chegando porAssetRefused.details["refused"](modo estrito) e pelo aviso em log (strict_assets=False), agora sem misturar pedidos. -
Os quatro workflows instalam com
uv sync --locked, e o release rodauv lock --checkantes do build: umuv.lockfora dopyproject.tomlderruba o run em vez de ser re-resolvido contra o PyPI do dia. pypa/gh-action-pypi-publisheastral-sh/setup-uvfixadas por SHA com a tag em comentário (dc37677…=v1.14.2,caf0cab…=v3.2.4, ambas derreferenciadas da tag anotada para o commit). O job que publica temid-token: write, e a referência anterior era o branchrelease/v1..github/dependabot.ymlnovo atualiza as actions semanalmente (SHA e comentário).[build-system]declarahatchling>=1.32.4,<2. O piso é a versão que construiu a 0.308.0 hoje; antes, cada build resolvia o hatchling mais novo.-
Guard novo
tests/test_supply_chain_guard.py:--lockedem todouv syncde workflow, as duas actions por SHA com tag,uv lock --checkemake auditantes douv buildno release,schedule:noaudit.ymle faixa no hatchling — cada checagem alimentada também com o texto exato que estava namainantes do #436. -
MercadoPagoWebhookEvent.eventpassa deAny | NoneparaMercadoPagoEvent | None, o tipo que o docstring já declarava. -
test_session/test_databasevirarammake_test_session/make_test_database(#450). O pytest coleta como teste toda função importada num módulo de teste cujo nome comece comtest, entãofrom tempest_fastapi_sdk.testing import test_sessiondava à suíte do consumidor um item fantasma que "passava" e emitiaPytestReturnNotNoneWarning— falha de verdade sob-W error(medido:1 failed, 1 passed). A própria suíte do SDK coletava os dois fantasmas emtests/testing/test_database.py. Os nomes antigos continuam importáveis como alias deprecado: cada chamada emiteDeprecationWarninge os dois carregam__test__ = False, então não são mais coletados. O teste roda um pytest de verdade num subprocess, com-W error, sobre um módulo que importa os quatro nomes, e falha (2 failed, 1 passed) quando o__test__ = Falseé removido. Quem rodafilterwarnings = ["error"]e chama o nome antigo passa a ver oDeprecationWarningcomo erro — troque o import.
Deprecated¶
MinIOSettings→StorageSettings(#457). Continua importável como subclasse deStorageSettings; listar como base direta (ou instanciar) emiteDeprecationWarningapontando para a linha do consumidor, e os atributossettings.MINIO_*continuam respondendo (propriedades de leitura). As variáveisMINIO_*continuam valendo nas duas classes. Comfilterwarnings = ["error"]o warning vira erro na definição da classe: troque a base paraStorageSettings(as variáveis de ambiente não mudam).
Fixed¶
- Readiness: checks em paralelo, com timeout, e log sem a mensagem da
exceção (#447).
make_health_routerrodava os checks em série e sem limite — uma dependência pendurada segurava a readiness inteira até o probe desistir, e as seguintes nem rodavam — e logavastr(exc), que em driver de banco ou broker costuma trazer a DSN com usuário e senha. Agora os checks rodam comasyncio.gather, cada um sobasyncio.wait_forcom o novotimeout=(default3.0s;Nonedesliga; zero ou negativo éValueError); timeout conta como falha só daquele check. O log leva só o nome e o tipo (Health check 'database' raised ConnectionRefusedError,Health check 'search' timed out after 0.5s). Medido: um check comsleep(60)etimeout=0.5responde503com o outro checkTrue, num processo de 1,7 s. Testes: o check pendurado falha sozinho; dois checks que esperam um pelo outro só passam em paralelo; comcaplog, a senha de uma DSN na mensagem não aparece. O valor devolvido por um check agora passa porbool()antes de entrar no payload. Receitahttp.mdganhou a seção "Liveness e readiness". MessageBroker.health_check/AsyncQueueManager.health_checkfazem opingreal do FastStream (#448). Antes devolviamself._started, com a docstring dizendo que o FastStream não tinha ping — ele tem (BrokerUsecase.ping(timeout), implementado por RabbitMQ, Redis, Kafka, Confluent e NATS; mesma assinatura no piso0.7.5e na0.7.7, a mais nova no PyPI hoje). Agora aceitamtimeout: float = 2.0(abaixo dos 3 s do router), devolvemFalseantes doconnect()sem tocar a rede e, depois, o resultado doping. Medido com RabbitMQ 3 em container:Trueconectado, e depois dodocker stopFalseem 2,00 s, com a readiness respondendo503 {"queue": false}— antes ficavaTrue.JSONFormatterre-renderizavaexc_infopor cima do traceback já redigido (#445). O campoexceptionagora sai derecord.exc_textquando um passo anterior já o renderizou — a precedência dologging.Formatterda stdlib — e deexc_infosó no resto. Antes, um filtro de handler que redigia oexc_textera ignorado: o e-mail da mensagem da exceção voltava para o JSON. Quem já setaexc_textà mão passa a vê-lo no lugar do traceback re-renderizado.TaskQueue.from_settings(Settings())e@tq.task(...)passam nomypy --strict(#449).TaskIQSettingsLikedeclarava os campos como atributo simples, que numProtocolé gravável; oBaseAppSettingséfrozen=True, então o mypy recusava oSettingsque os próprios mixins montam (expected settable variable, got read-only attribute) e o caminho documentado exigiatype: ignore. Os campos viraram@property, o que aceita o settings congelado e um objeto simples de teste.TaskQueue.taskdevolviaAny, e sob--strict(disallow_untyped_decorators)@tq.task(name=...)erauntyped-decorator; agora tem overloads e devolveTask[P, R], preservando parâmetros e retorno.on_startup/on_shutdowntinham o mesmoAnye devolvem o próprio handler tipado. Medido com mypy (config do repo,--strict), pyright e basedpyright sobre o mesmo snippet: zero erro no caminho da receita, eawait add.run("x", 1)vira erro dearg-type(comAny, passava). Guard:tests/tasks/test_queue_typing.py, que no código antigo falha com os três erros.-
env.pygerado limpo sob ruff ealembic.inicomprepend_sys_path(#451). O arquivo da 0.308.0 falhava emruff check --select E,W,F,I(I001duas vezes,E402no import de metadata) e trazia 21 linhas de comentário inline. O novo não importaalembic— o diretórioalembic/faz o default do ruff (src = [".", "src"]) tratar o pacote como first-party e o scaffold dotempest newcomo third-party, então nenhuma ordem fixa passava nos dois — e sai sem erro nas duas configurações, sem comentário. Oalembic.inigerado ganhouprepend_sys_path = .(oalembicpuro, sem o CLI dotempest, importa o pacote do serviço) epath_separator = os(sem ele o Alembic 1.19.1 emiteDeprecationWarning). Guard:tests/test_alembic_env_lint_guard.py. Projeto existente: oenv.pyantigo continua funcionando; para receber as correções, troque o conteúdo pelas três instruções e acrescente as duas linhas ao[alembic]do ini. -
CHECK de enum duplicado no autogenerate (#453). Sob SQLAlchemy 2.1.4 + Alembic 1.20.0, o
create_tablegerado trazia doisCheckConstraintpor coluna enum, os dois renomeados pela convention parack_<tabela>_<enum>, e o PostgreSQL 16 recusava o segundo (check constraint ... already exists).render_enum_typespassa a omitir oCHECKligado ao tipo (_type_bound); osa.Enum(..., create_constraint=True)da coluna o recria no SQLite, que fica com exatamente umCHECKcom o nome da convention (medido em 2.0.52 e 2.1.4).CheckConstraintdeclarado em__table_args__continua renderizado.tests/db/test_alembic_env_sqlalchemy_matrix.py(marcadonetwork) reroda os testes de enum sob 2.1.4/1.20.0 — no lock (2.0.52) o próprio Alembic já filtrava. -
PdfRenderer: cada renderização relata só as próprias recusas de asset (#435). A instância guardava umaAssetPolicysó e todas as renderizações em paralelo (atémax_concurrent, padrão 4) liam e limpavam a mesma listarefusals. Reproduzido com intercalação forçada de duas renderizações na mesma instância: oAssetRefused.details["refused"]de B trazia a URL de A (http://a.test/logo.png?token=secret-of-a— o token junto), e o de A caía no fallbackError fetching "..."; comstrict_assets=Falsesaía uma linha de aviso só, a de B listando as duas, e a recusa de A não era registrada em lugar nenhum. Agora cada renderização coleta numa cópia própria da política. -
make_prometheus_router: o exemplo de proteção derrubava o app ou punha o segredo na URL (#439). A docstring sugeriadependencies=[Depends(require_x_token)], que o router envolvia de novo emDependse o FastAPI recusava na construção (AssertionError: A parameter-less dependency must have a callable dependency); a forma "corrigida",dependencies=[require_x_token], montava, mas o FastAPI liasecretetokenda query string — todo scrape dava422, e só passava com?secret=...&token=...na URL. Agora: - item de
dependencies=que já éDepends(...)/Security(...)é anexado como está, sem envolver de novo; require_x_token(cru ou dentro deDepends) é recusado na construção comTypeErrorapontando paramake_token_dependency;- a docstring e a receita
recipes/metrics(PT + EN) montam a rota comdependencies=[make_token_dependency(metrics_token)]—401sem o headerX-Token,200com ele —, dizem que o default é sem autenticação, avisam que segredo vazio desliga a checagem, e oprometheus.ymlmanda o header comhttp_headers(medido no Prometheusv3.1.0:up == 1com o bloco,401eup == 0sem). -
Guard:
tests/api/test_prometheus_auth.pyexecuta ocreate_appda receita como publicado e faz scrape com e sem header, e falha se a doc ou a docstring voltarem a sugerirDepends(require_x_token)/dependencies=[require_x_token]. -
Os leitores async de Google Sheets não parseiam mais no event loop (#432).
read_google_sheet/read_google_sheet_asparseiam o CSV porasyncio.to_thread,read_google_sheet_asvalida as linhas também numa thread, eread_google_sheet_xlsxlê a pasta porread_xlsx_sheets_async. Antes, os três seguravam o loop do worker durante todo o parse. -
A receita de planilhas usava o leitor síncrono numa rota
async def.docs/recipes/spreadsheets.md/.en.mdpassam a usarread_xlsx_as_asyncno endpoint de importação e no script do Google, com um!!! warningexplicando por quê. Guard novo:tests/test_spreadsheet_event_loop_guard.pytroca o parser de cada corrotina por uma sonda que só uma task do loop libera, e prova que dispara comto_threadsubstituído por chamada direta (6 de 6 casos pegos). -
A receita do protocolo de Pix liberava o pedido só pelo webhook (#434). O
settlededocs/recipes/pix-protocol.md(e.en.md) marcava o pedido como pago direto do evento, sem reler a cobrança, sem conferir valor e sem idempotência — o contrário do que a receita da OpenPix exige, com uma assinatura RSA-1024 sem janela de validade. Agora o passo 3 mostra oOrderModelcompaid_ate o repositório comclaim_once, osettlerelê comconfirm_pix_paymentpeloprovider_charge_idguardado, e o teste do passo 7 roda sobre SQLite e mostra a reentrega respondendo{"settled": null}(medido num serviço montado com os blocos da página). A receita da OpenPix aponta para o mesmo caminho. -
O console SQL do admin ganhou estilo (#438). O
sql_shell.htmlusava uma famíliatempest-admin-sql*sem nenhuma regra noadmin.css, então o editor, o formulário e a grade de resultado caíam no estilo cru do browser. Agora o formulário é um card com as bordas e sombra do painel, o editor é monoespaçado com foco destacado, a política vira etiquetas, erro e aviso usam o padrão de alerta do painel, e a grade de resultado usa otempest-admin-table-wrap+tempest-admin-list__tabledas listagens, com cabeçalho fixo e rolagem própria (horizontal no celular, sem rolar a página). Um teste confere que toda classe do template tem regra no CSS.
Security¶
- Gate de CVE nas dependências travadas (#436).
make auditexporta ouv.lockcommitado (uv export --locked --all-extras --no-dev) e rodapip-audit2.10.1 com--strict; qualquer advisory conhecido derruba o alvo. Roda no workflow novoDependency audit(push namain, PR e diariamente às 07:00 UTC, porque advisory novo chega sem ninguém dar push), no job de build dorelease-pypi.ymlantes douv build, e nomake release. O relatório JSON de cada execução fica arquivado como artefato do run. Medido localmente: ~22 s a frio, ~1–4 s com cache. - Lock atualizado para sair de 31 advisories com correção publicada.
O primeiro
pip-auditsobre o lock achou 45 advisories em 10 pacotes. Subiram no lock (não no piso dopyproject.toml):pyjwt2.13.0 → 2.15.1 (14 advisories),pypdf6.16.2 → 6.20.0 (8),urllib32.7.0 → 2.8.0 (3),weasyprint69.0 → 70.0 (2),multidict6.7.1 → 6.9.1,oauthlib3.3.1 → 4.0.0,werkzeug3.1.8 → 3.1.9 eaccelerate1.14.0 → 1.15.0 (1 cada). - 14 entradas ignoradas — 10 IDs distintos, porque o pip-audit lista alguns
IDs do
transformersmais de uma vez —, com motivo noMakefile.chromadb1.5.9 (a mais nova no PyPI em 2026-10-09, sem versão corrigida): os quatro advisories miram o servidor HTTP do Chroma e oSimpleRBACAuthorizationProvider, e o SDK só abre oPersistentClient/EphemeralClientembarcado.transformers4.57.6: toda correção é 5.x, e otransformers<5do[genai-audio](coqui-tts 0.27.5 ainda não importa no 5.x) segura o lock; quem instala[genai]sem[genai-audio]resolve 5.x. - O painel admin não carrega mais script de terceiro (#433). O
base.htmlbuscavahttps://unpkg.com/htmx.org@2.0.3semintegrityem toda página do admin — login, dashboard, CRUD, console SQL —, na mesma origem doPOST /admin/sql. Omake_admin_routeragora monta omake_htmx_routerdo próprio SDK em{prefix}/_ssr/htmx.js, que serve ossr/_static/htmx.min.jsempacotado (HTMX 2.0.10) byte a byte e sem exigir sessão, porque a página de login também o carrega; o<script>segue oprefix(/backoffice/_ssr/htmx.jscomprefix="/backoffice"). Não exige o extra[ssr]: medido num interpretador novo comtempestwebbloqueado, omake_admin_routerimporta e nenhum módulotempestwebé carregado.tests/admin/test_local_assets.pyfalha se qualquersrc/href/actiondo HTML renderizado (login, dashboard, console SQL com resultado) apontar para outro host — com a linha antiga de volta nobase.html, 5 testes falham. CSP continua sendo passo à parte: o admin não enviaContent-Security-Policye ainda tem script inline (num browser comscript-src 'self', o HTMX local carrega e o<script>inline da list view é bloqueado).
Documentation¶
- Todo parâmetro
filters: dict[str, Any]diz o que espera (#443). As docstrings doBaseRepository,BaseService,BaseController,TenantScopedRepositorye doLensdo admin descreviam o argumento como "Filter conditions", sem dizer que é nome de coluna → valor. Agora cada uma traz o exemplo{"user_id": 1234, "is_active": True}, diz que os pares são ligados porAND, que lista viraIN,NoneviraIS NULLe<coluna>__<op>aplica operador, e aponta para a lista completa na classe. Medido numa tabela SQLite em memória e fixado emtests/db/test_filters_contract.py: igualdade em coluna de texto respeita caixa ({"status": "OPEN"}não casa"open"). A medição também mostrou que chave que não é coluna era ignorada em silêncio — é o defeito que a entrada Breaking de### Changed(#465) corrige: agora ela levantaUnknownFilterKeyException. - O teto
transformers<5do[genai-audio]aparece com o porquê e o custo (#441).README.mdedocs/installation.md/.en.mddiziamtransformers<5sem explicar. Agora dizem que o teto é intencional (ocoqui-tts0.27.5 importaisin_mps_friendly, que some da transformers 5.1.0 em diante; remedido em 2026-10-09 com a 5.19.0, ofrom TTS.api import TTSainda falha comImportError) e mostram o que ele rebaixa, resolvido comuv pip compile(Python 3.12): com[genai-audio],huggingface-hub0.36.2 etokenizers0.22.2; com[genai-audio,genai-image], tambémdiffusers0.39.0; com[genai-image]sozinho,diffusers0.41.0 ehuggingface-hub1.33.0. O comentário dopyproject.tomldizia que só o lock do repositório era afetado; a medição mostra que quem instala os dois extras juntos recebe o mesmodiffusers0.39.0, e o comentário foi corrigido.
[0.308.0] — 2026-10-08¶
O CLI tempest sai do runtime: tempest-cli, typer e click viram o
extra [cli], e o pacote base deixa de levar ruff para produção.
Changed¶
- Breaking para quem usa o comando
tempest: a CLI virou o extra[cli].tempest-cli>=0.4.0,typer>=0.27.2eclick>=8.5.0saem dedependenciese vão para[project.optional-dependencies] cli(e para o[all]). Otempest-cliexigeruff>=0.8.0em runtime, então todo serviço que adotava o SDK carregava um formatador como dependência de produção — e um projeto que fixaruff<0.8no grupo dev não resolvia o lock (uv lock:tempest-cli>=0.4.0 depends on ruff>=0.8.0).typereclickvão junto porque nada fora detempest_fastapi_sdk/cli/os importa (medido comgrepno pacote). Medido comuv pip install --dry-runnuma venv vazia: o pacote sem extras passa de 34 para 25 pacotes, semruff,tempest-cli,typer,clickerich. A biblioteca não muda:import tempest_fastapi_sdk,import tempest_fastapi_sdk.clietempest_fastapi_sdk.openapiimportam sem o extra. - O script
tempestcontinua registrado, agora emtempest_fastapi_sdk.cli.entrypoint:main, que só usa a stdlib: sem o extra, imprimeerror: missing tempest_cli, typer, click.seguido da instrução (uv add --dev "tempest-fastapi-sdk[cli]"/uv tool install "tempest-fastapi-sdk[cli]") e sai com código 2, sem traceback (medido numa venv limpa com a wheel instalada sem extras). tempest_fastapi_sdk.clire-exportaapp,TempestConfig,TypingStrictness,DEFAULT_TYPING_STRICTNESSeload_tempest_configde forma preguiçosa; sem o extra, ler um deles — ou importartempest_fastapi_sdk.cli.main,.config,.lint,.pr_prompt— levantaImportErrorcom a mesma instrução.generate_integration(run_format=True)sem o extra devolveformatted=False, como já fazia semruff: a busca doruffé dotempest-cli, e o código gerado já sai formatado.tempest newpassa a escrevertempest-fastapi-sdk[cli]>=<versão>no grupodevdo projeto gerado, que oDockerfile(uv sync --no-dev) não instala.- Guard novo:
tests/test_cli_extra_guard.py(CLI fora do base, nenhum import do extra em nível de módulo no que o base carrega). Passo a passo emdocs/migration.md(seção0.308.0).
[0.307.0] — 2026-10-07¶
Geocoding com endereço estruturado: NominatimBackend.reverse e geocode
devolvem cidade, estado, UF e país separados em GeocodeResult.address, sem
parse do display_name.
Added¶
GeocodeAddress— endereço estruturado no resultado doNominatimBackend:geocodeereversepassam a pediraddressdetails=1e a devolverGeocodeResult.addresscomcity,state,state_code,country,country_codeepostcode(todos opcionais;None, sem exceção, quando a resposta não traz objeto de endereço).cityé a primeira entrecity,town,villageemunicipalitypresente na resposta — ordem medida em payloads reais, porque um mesmo ponto em São José da Tenda/PI traztown,villageehamletjuntos;state_codeé a parte depois do-deISO3166-2-lvl4("BR-PI"→"PI") ecountry_codevem maiusculado ("br"→"BR"), como o servidor o escreve. Retrocompatível: quem não lê o campo não muda nada.
Documentation¶
- Receita de geocoding ganha seção de endereço estruturado
(
docs/recipes/geo.md+.en.md) com exemplo executável e saída medida de um payload registrado donominatim.openstreetmap.org.
[0.306.0] — 2026-10-06¶
Release de segurança. Downloads saem com nosniff, CSP e CORP e só vão
inline para tipo seguro; make_logs_router recusa segredo vazio e o serviço
gerado só monta /logs com segredo; as rotas do admin que confiavam só no
cookie passam a reler o principal, e os POSTs do console SQL e do cancel de
task conferem CSRF; o MFA — em /auth/mfa/verify e no /admin/mfa — ganha
limite de tentativas por conta e aceita cada código uma vez. Três mudanças
pedem ação na atualização (coluna totp_last_step, segredo do /logs,
download inline), com passo a passo em docs/migration.md (seções
0.306.0). Advisories: GHSA-2qc2-pcgx-589h, GHSA-233q-cpq4-mwx3,
GHSA-427r-m66r-6332, GHSA-5wpj-fr23-7p37.
Added¶
InMemoryThrottleBackend—ThrottleBackendsem Redis, por processo, com o mesmo contrato doredis-py(ttldevolve-2para chave ausente e-1sem TTL) eclock=injetável para teste semsleep. Com mais de um worker cada processo conta sozinho; ali o backend continua sendo o Redis. Exportado no topo do pacote e emtempest_fastapi_sdk.utils.make_auth_router(mfa_throttle=...)— limite de códigos errados emPOST /auth/mfa/verify, por conta (f"mfa:{user_id}"), não pormfa_token: um login novo não devolve o orçamento. O padrão éMFA_THROTTLE_MAX_ATTEMPTS(5) porMFA_THROTTLE_WINDOW_SECONDS(900), emtempest_fastapi_sdk.auth.router, sobre umInMemoryThrottleBackend. Esgotado, a rota responde429TOO_MANY_REQUESTScomRetry-Afterantes de olhar o código; acerto zera a contagem. Cada tentativa reserva sua vaga antes da verificação, então uma rajada simultânea não passa do limite.UserAuthService.mfa_verifyganhou o parâmetrothrottle=correspondente.make_admin_router(mfa_throttle=...)eAdminAuthBackend.claim_mfa_step. O desafioPOST /admin/mfado painel ganha o mesmo limite do/auth/mfa/verify(5 códigos errados por 900 s, chave por principal,429comRetry-Afterantes de olhar o código) e passa a aceitar cada código uma vez:UserModelAuthBackend.claim_mfa_stepgrava o passo emtotp_last_stepcom o mesmoUPDATEcondicional do service. O default declaim_mfa_stepna base delega averify_mfa, então backend customizado continua funcionando sem mudança.TOTPHelper.matching_step(secret, code, *, window=1) -> int | None— devolve o passo de 30 segundos de onde o código saiu (comparação comhmac.compare_digest);verifypassa a delegar a ele.
Changed¶
-
Headers de segurança nos downloads, e
inlinesó para tipo seguro.DownloadUtils.download/file_response/stream, os mesmos métodos doFileStoreUtilseAsyncMinIOClient.download_responsepassam a sair comDEFAULT_STATIC_SECURITY_HEADERS(X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox,Cross-Origin-Resource-Policy: same-site), com semânticasetdefault: um header de mesmo nome emheaders=vence.as_attachment=Falsesó produzinlinepara tipo na constante novaINLINE_SAFE_MEDIA_TYPES(imagem raster,application/pdf,text/plain, áudio e vídeo comuns); qualquer outro tipo, HTML e SVG incluídos, sai comoattachment.build_content_dispositionganhamedia_type=e aplica a mesma regra — sem ele,as_attachment=Falsedevolveattachment.accel_redirect_responsepassa a teras_attachment=Truepor padrão, e no modo redirectinlineexigemedia_type=(o objeto não é consultado). Os headers de segurança do modo redirect vão nolocationinterno do nginx (add_header ... always): medido no nginx 1.22.1, 1.27.5 e 1.29.8, o que o app põe na resposta vazia não chega ao cliente. A receita de storage traz o bloco.DEFAULT_STATIC_SECURITY_HEADERScontinua importável detempest_fastapi_sdk.api.static. Passo a passo emdocs/migration.md(0.306.0). -
make_logs_routerrecusa segredo vazio, e o serviço gerado só monta/logscom segredo.make_logs_router(token_secret="")(ou só espaço) levantaValueErrorna construção; para montar sem autenticação numa execução local, passe o novoallow_unauthenticated: bool = FalsecomoTrue. Com segredo, a flag não tem efeito. O espaço em volta detoken_secreté removido antes da checagem e da comparação. Osrc/api/app.pygerado pelotempest newmonta o router só quandosettings.TOKEN_SECRETnão está vazio, e os próximos passos impressos pelotempest newpassam a incluiruv run tempest secrets initeuv run tempest check-configantes de subir o serviço. Até a 0.305.0 um segredo vazio desligava a checagem doX-TokenemGETeDELETE /logs. Migração emdocs/migration.md. -
Código TOTP de uso único.
MFAMixinganhou a colunatotp_last_step(nullable).mfa_confirm,mfa_verify,mfa_disableerequest_email_recoverygravam o passo aceito numUPDATEcondicional e recusam código do mesmo passo ou anterior; o código doconfirmnão serve mais para o primeiro login.mfa_enrollemfa_disablezeram a coluna. Exige migration para quem usaMFAMixin— passo a passo emdocs/migration.md(seção0.306.0).
Fixed¶
- Admin: toda rota autenticada relê o principal, e os POSTs do console SQL
e do cancelamento de task conferem CSRF. O cookie de sessão prova que um
login aconteceu, não que a conta ainda pode entrar; quem decide isso é o
AdminAuthBackend.load_principal, relido a cada request. Quatro rotas dependiam só do cookie —GET /admin/logs/export,GETePOST /admin/sqlePOST /admin/tasks/{job_id}/cancel—, então uma conta apagada, desativada ou semis_admindepois do login continuava atendida nelas até o cookie expirar (8hpor default). Agora as quatro relêem o principal antes de qualquer outra coisa e respondem303para/admin/login, como as demais: o statement não chega aoSqlShellService(nenhuma entrada no auditor) e a linha da task não muda.POST /admin/sqlePOST /admin/tasks/{job_id}/cancelpassam a exigir o campocsrf_tokenda sessão, como os outros POSTs do painel: sem o campo é422, com token errado é403; os formulários dos templates já mandam o campo. Quem posta nessas duas rotas fora do formulário do painel precisa incluir o token. O console SQL passa também a renderizar o cabeçalho com o usuário e o logout a partir do principal relido, como o painel de tasks. Guard:tests/test_admin_principal_guard.pypercorre oAPIRouterdemake_admin_routere falha em toda rota que depende de_require_sessionsem chamar_resolve_principal— rodado contra o router anterior, lista exatamente as quatro.
[0.305.0] — 2026-10-05¶
Auth que o produto não precisa mais sobrescrever: token de uso único gasto
numa instrução só (dois resgates do mesmo link não passam mais), coluna
obrigatória do produto preenchida no signup, códigos de recusa configuráveis
com AuthExceptions e código por violação de senha, conta desativada recusada
mesmo com token ainda válido (e revelada ao dono que acertou a senha, se
ligado). Mais a rota pronta para o webhook da zap-api. Três mudanças de
comportamento — token de conta desativada, troca de senha com usuário removido
e signup gravando os campos do schema que são coluna — com passo a passo em
docs/migration.md (seções 0.305.0).
Added¶
- Rota pronta para o webhook da zap-api (#420).
make_zap_webhook_router(*, secret=None, verify=None, on_inbound=None, on_status=None, path="/webhooks/zap", tags=None, include_in_schema=True) -> APIRoutermonta oPOSTinteiro — verificação da assinatura, despacho porevente a resposta200— e devolve umAPIRouterpara oinclude_router. É a rota que a receitarecipes/zap-inboundescrevia à mão no passo 1, com o mesmo despacho e as mesmas recusas; a resposta passa a ser oZapWebhookAckSchema({"ok": true, "event": ...}).secretmonta a dependênciamake_zap_webhook_dependency(ouverifytraz a sua), e sem nenhum dos dois a construção levantaValueError— o gateway assina só quando o webhook foi registrado com--secret, então a rota não pode existir aceitando entrega sem assinatura.
O on_inbound recebe o ZapInboundMessage de message.received e o
on_status o ZapStatusCallback dos quatro eventos de status, já
validados. Exceção do handler sobe: a resposta vira 500 e o gateway
re-POSTa os mesmos bytes com backoff (CALLBACK_MAX_ATTEMPTS, 3 por
default) — que é o certo para falha passageira. Evento sem handler, evento
que o gateway inventar depois e corpo que não casa com o modelo do evento
são 200 com o nome do evento de volta (ZapWebhookAckSchema,
{"ok": true, "event": "message.received"}) e registro em debug — um
422 nesses casos só geraria reentrega. A resposta continua
tipada no OpenAPI, e include_in_schema=False a esconde sem desmontar.
Os handlers são callables simples: assinatura é
Callable[[ZapInboundMessage], Awaitable[None]] e nada mais. Dependência
do FastAPI no handler não é aceita porque a docstring que o autor do
gateway lê é a do gateway — onInbound(message) — e a forma de
AgentDependency, com os parâmetros de resolução resolvidos na chamada,
não cabe nela; quem precisar injeta o que falta na própria função, que a
rota chama como qualquer outro callable.
Os testes ficam em tests/integrations/messaging/zap/test_router.py (e
três casos novos no drift test, para a metade lazy continuar lazy), e a
receita recipes/zap-inbound (PT e EN) passa a abrir com a rota pronta e
mantém a rota manual num bloco colapsado, "por baixo dos panos".
-
AuthExceptions: a classe de cada recusa doUserAuthService(#425).UserAuthService(..., exceptions=AuthExceptions(...))troca a classe levantada eminvalid_credentials,account_inactive,email_taken,password_too_short/password_too_long/password_too_weakeinvalid_token(links de uso único). Cada campo é tipado com a classe default, então a do produto herda dela e o status não muda; muda a classe e ocode. Medido pelo router: login com senha errada sai401 ERROR_USER_INVALID_CREDENTIALS, e-mail repetido409 USER_ALREADY_EXISTS, senha de 73 bytes422 PASSWORD_TOO_LONG, com uma verificação bcrypt por login recusado nos três ramos. -
Código por violação de senha (#425).
PasswordViolationCode(PASSWORD_TOO_SHORT,PASSWORD_TOO_LONG,PASSWORD_TOO_WEAK) emPasswordPolicyViolation.code; oUserAuthServicelevanta com esse valor comomessage_key, e o catálogo default traduz os três em pt-BR e en-US ("A senha pode ter no máximo 72 bytes"). -
AUTH_REVEAL_INACTIVE_ACCOUNT(#424). Desligado (default), conta desativada continua no401genérico. Ligado, a senha certa numa conta desativada responde403ACCOUNT_INACTIVE(AccountInactiveException, ouAuthExceptions.account_inactive); senha errada e e-mail desconhecido continuam401. A senha é verificada antes deis_activenos dois modos. -
signup(fields=...)preenche coluna do produto antes do insert (#423).UserAuthService.signupaceitafields: Mapping[str, Any], aplicado no model antes doflush— então uma colunaNOT NULLsem default (ophonedo transport) deixa de exigir reescrever osignupinteiro. Chave que não é coluna e as protegidas (SIGNUP_PROTECTED_FIELDS:id,email,hashed_password,is_active,is_admin) levantamValueErrorantes de qualquer escrita. ColunaNOT NULLesquecida viraValueErrorcom o nome dela, nãoIntegrityErrorcru. Violação de unicidade no insert viraConflictException(409) comdetails={"columns": [...], "constraint": ...}efield; medido no SQLite, sem catálogo: telefone duplicado responde{"detail":"phone already in use","code":"CONFLICT","details":{"columns":["phone"],"constraint":null},"field":"phone"}(comdefault_message_catalog()odetailsai traduzido).
Changed¶
-
Token de conta desativada deixa de autenticar (#421).
UserAuthService.current_user_dependencyganharequire_active: bool = Trueeinactive_exception: depois de carregar o usuário, conta inativa responde403ACCOUNT_INACTIVE(oAuthExceptions.account_inactivedo service, ou a classe passada); comsoft=True, devolveNone. Até a 0.304.0 a dependência devolvia a linha sem olharis_active, e um token emitido antes da desativação autenticava pelo access TTL inteiro. As rotas autenticadas domake_auth_router(/auth/me, troca de senha e de e-mail, MFA, passkeys) passam a recusar do mesmo jeito.require_active=Falserestaura o comportamento antigo. Medido: token emitido, conta desativada,GET /me→403; comrequire_active=False→200. Orefresh_tokensjá recusava conta inativa com403, mas com ocodegenéricoFORBIDDEN; agora usa o mesmoACCOUNT_INACTIVE(ou a classe deAuthExceptions.account_inactive), como o login, a dependência e as rotas do router. -
Troca de senha com usuário removido é
InvalidTokenException(#425).confirm_password_resetlevantavaNotFoundException(404); agora éInvalidTokenException(401), comoactivateeconfirm_email_change.PasswordPolicyViolationganhou o campo obrigatóriocode. Passo a passo emdocs/migration.md(0.305.0). -
make_auth_router(signup_schema=...)grava os campos que são coluna (#423). Os campos que osignup_schemaacrescenta àSignupSchemae que também são coluna do user model (fora das protegidas) vão parasignup(fields=...), antes do insert. Até a 0.304.0 eram descartados se oon_signupnão os copiasse; agora o hook só precisa existir para o que não é coluna. Quem tinha um campo de schema com nome de coluna que não queria gravado como veio precisa renomeá-lo ou sobrescrevê-lo noon_signup(que continua rodando depois, na mesma transação). Um campois_adminno schema continua sem efeito: protegido.
Fixed¶
- Token de uso único é gasto numa instrução só (#422).
_consume_token(ativação, troca de senha, troca de e-mail) lia a linha, conferiaused_at IS NULLe gravavaused_atdepois — dois resgates simultâneos do mesmo link liam antes de qualquer escrita e passavam os dois. Agora é umUPDATE ... WHERE token_hash = :h AND purpose = :p AND used_at IS NULL AND expires_at > now RETURNING; quando não volta linha, umSELECTsó escolhe a mensagem (not recognized/already used/expired), sem decidir o resgate. Medido com oitoconfirm_password_resetconcorrentes no mesmo token, em sessões separadas: no PostgreSQL 16 o código antigo deixava as oito passarem, e no SQLite (arquivo) as perdedoras saíam comoOperationalError: database is locked, nãoInvalidTokenException; agora são um sucesso e seteInvalidTokenExceptionnos dois.RETURNINGexige PostgreSQL ou SQLite 3.35+.
[0.304.0] — 2026-10-04¶
Leitura de planilha .xlsx (arquivo, upload e a pasta inteira do Google
Sheets) com limites contra zip bomb, carteira com saldo atômico e saque Pix,
agente por requisição no router pronto, e duas mudanças de default que
quebram compatibilidade: o SQLite passa a conferir chave estrangeira e a
exceção crua de ferramenta não chega mais ao modelo. Os leitores do Google
Sheets passam a ter teto de download e de linhas por padrão. Passo a passo
em docs/migration.md (seções 0.304.0).
Changed¶
-
O leitor CSV do Google Sheets tem limite por padrão (#413).
read_google_sheet/read_google_sheet_asrecusam export acima de 10 MiB ou com mais de 100 000 linhas de dado comSpreadsheetTooLargeError(413); até a 0.303.2 liam a aba inteira, sem teto. Medido: um export falso de 100 001 linhas levanta comdetails["limit"] == "rows", e commax_rows=Nonedevolve as 100 001.max_rows=None/max_bytes=Nonedesligam. -
SQLite agora confere chave estrangeira (#395). Todo engine SQLite que o
AsyncDatabaseManagere ocreate_test_enginemontam rodaPRAGMA foreign_keys=ONnum listener doconnect— antes, nenhum rodava, e três coisas passavam no SQLite e falham no PostgreSQL: filho apontando para pai inexistente era aceito,ON DELETE CASCADEdeixava o filho órfão, eadd_all([filho, pai])semrelationship()passava. Medido no SQLAlchemy 2.0.52 e 2.1.3 (aiosqlite 0.22.1, SQLite 3.47.1): os três agora levantamIntegrityError/ apagam o filho, em arquivo e em:memory:(o cache compartilhado do manager e oStaticPooldo engine de teste). O pragma fica noconnectporque o SQLite o ignora dentro de transação, e oBEGINexplícito dos savepoints já abriu uma. create_test_engineaplica também oenable_sqlite_savepoints. Os dois caminhos passam por um configurador interno só,_configure_sqlite_engine; antes o engine de teste não aplicava nem o savepoint, então umbegin_nested()que saía limpo comitava a transação de fora no teste e não no manager.
Migração (guia completo em docs/migration.md):
- Teste que grava filho sem pai passa a falhar — é o objetivo: falharia
no PostgreSQL. Semeie o pai antes (e dê
flush()depois dele quando os models não têmrelationship()). A própria suíte do SDK tinha 112 testes assim (user_id=uuid4()sem linha emusers); foram corrigidos semeando o usuário, não desligando a FK. - Opt-out, pelos três caminhos:
AsyncDatabaseManager(..., sqlite_foreign_keys=False),DATABASE_SQLITE_FOREIGN_KEYS=false(viaDatabaseSettings.database_kwargs()),create_test_engine(..., foreign_keys=False). Ignorado em qualquer backend que não seja SQLite. - Banco SQLite existente com órfão abre e lê normalmente; ache as linhas
com
PRAGMA foreign_key_check. Medido no SQLite 3.47.1: com a FK ligada,UPDATEde outra coluna do órfão eDELETEdele passam; o que falha é gravar uma chave sem pai (INSERT, ouUPDATEda coluna FK para um pai inexistente). -
Conexão compartilhada com o Alembic: quem entrega a conexão de um
AsyncDatabaseManageraoenv.py(config.attributes["connection"]) agora entrega uma conexão com FK ligada, e o batch mode do Alembic que recria a tabela pai apaga os filhos comON DELETE CASCADEsem erro (medido com Alembic 1.19.1: 3 linhas → 0). Oenv.pygerado a partir desta versão recusa essa conexão; oenv.pyque já está no seu repositório não — migre comAsyncDatabaseManager(url, sqlite_foreign_keys=False)e regenere oenv.py.AlembicHelpere a CLI abrem engine próprio, sem FK, e não são afetados. -
A mensagem de
GoogleSheetAccessError(GOOGLE_SHEET_UNAVAILABLE) deixa de dizer "as CSV" — a mesma exceção cobre agora o caminho.xlsx. Ocodee o status não mudam. -
Erro cru de ferramenta não chega mais ao modelo (#406). Exceção de ferramenta que não é
AgentToolErrorvira a observaçãotool failed: <Tipo>; antes o modelo lia o texto inteiro, e oHTTPStatusErrorde umraise_for_status()levava a URL com?apikey=s3cr3tpara a conversa (medido comhttpx.MockTransportrespondendo401, peloScriptedBackend.messages_seen). O traço continua<Tipo>: the tool failed (details withheld)e o log continua com a exceção inteira;AgentToolErrorcontinua chegando como foi escrito. O mesmo knobexpose_tool_errors=Truedevolve o texto — agora ao modelo e ao traço, com as formas óbvias de credencial mascaradas (parâmetro comkey/token/secret/passwordno nome, valor deAuthorization, senha de URL); a máscara é defesa adicional, não garantia. Quebra quem dependia do texto cru na observação: traduza paraAgentToolErroro que o modelo precisa ler — guia de migração.
Added¶
- Enums para o webhook da zap-api (#417).
ZapOutboundKind(text,image,video,audio,document,reaction, portado doOutboundKinddo gateway) dá nome aos valores deZapStatusCallback.kind, eZapJidServer(os dez servidores doJidServerdo Baileys 7.0.0-rc14) aos defrom_. O campokindcontinuastr— um tipo de envio novo no gateway não pode virar entregaevent=None—, e os dois sãoStrEnum, então quem compara com string não muda nada. ZapInboundMessage.from_server— property que lê o servidor do JID emfrom_e devolve o membro deZapJidServer(ouNonesem@ou com servidor desconhecido). Separa pessoa, grupo, story de status e canal semendswith("@g.us"). Não é campo: omodel_dumpcontinua com a forma do fio.-
Docstrings de
ZapWebhookEvent,ZapInboundMediaType,ZapInboundMessage,ZapStatusCallbackeZapWebhookDeliveryreescritas comAttributes:por membro eExamples:conferidos compython -m doctest; a receitarecipes/zap-inboundvirou tutorial em quatro passos (mínimo, enums, camada model → repository → service → controller → router, teste), com cada exemplo executado e a saída colada. -
BaseRepository.update_returning(filters, values, *, returning, where=None): umUPDATE ... WHERE ... RETURNINGcondicional que devolve as colunas pedidas de cada linha atualizada ([]quando a condição não casa), com os mesmos filtros delist/bulk_updatee valoresFcalculados pelo banco. Obulk_updatepassa a dividir o mesmo caminho de erro (_execute_update), eupdate_returninglevanta obulk_update_conflict_exceptionda instância. OWalletServiceagora move o saldo por ele. Vindo do #410. -
Guards
test_wallet_concurrency_guardetest_wallet_update_shape_guard(do #410, adaptados à API do #409): corridas com intercalação forçada em SQLite e PostgreSQL, com controle que mostra o read-modify-write perdendo 50/50; e a forma "umUPDATE ... WHEREdecide" fixada em todo método que move saldo. -
tempest_fastapi_sdk.wallet— carteira, extrato, retenção e saque Pix em centavos inteiros (#400). O saldo fica na linha do usuário do app (WalletBalanceMixin, oubalance_attribute=para uma coluna que já existe) e cada movimento vira uma linha de extrato (BaseWalletEntryModel/make_wallet_entry_model, com a FK do usuárioRESTRICTpor padrão: apagar o usuário não apaga o histórico financeiro).WalletServicefaz todo movimento de saldo num únicoUPDATE ... SET saldo = saldo + :delta RETURNING, na mesma transação da linha de extrato:credit(hold=),debit_available(a retenção está dentro doWHEREdo débito, então dois saques simultâneos não consomem dinheiro retido),reverse,withdraw(debita, paga, e devolve só na recusa definitiva; falha ambígua mantém o débito com logCRITICAL),balanceestatement. Maisclaim_once(liquidação que roda uma vez),openpix_fee_cents/split_netem basis points e o router opcionalmake_wallet_router. Medido com 50 tarefas simultâneas contra Postgres real (tests/wallet/test_wallet_live.py, marcadordocker): os créditos somam todos, o saque paga uma vez, a retenção não é consumida; o teste de controle mostra a escrita ingênua perdendo crédito no mesmo cenário. Receita: "Carteira e saque Pix". -
Contrato de saque Pix em
integrations.payment.PayoutProvider,PayoutRequest,PayoutResultePayoutStatusemintegrations.payment.base;OpenPixPayoutProvideremintegrations.payment.adapters(cria e aprova numa chamada só, comautoApprove: true; HTTP 4xx e pagamentoDENIED/FAILEDviramPayoutRejectedException, o resto propaga como incerto);FakePayoutProvideremtesting.fakes. O adapter recusa no construtor umHTTPClientque refaz requisição: medido com a política padrão, umPOSTrespondido500sai três vezes, e num saque o retry pode pagar duas vezes. A chave usa oPixKeyTypeque o SDK já tinha. -
Exceções
InsufficientBalanceException(409),PayoutRejectedException(502) ePayoutUncertainException(502), com mensagens PT-BR e EN-US no catálogo padrão. -
Leitura de
.xlsx:read_xlsx,read_xlsx_aseread_xlsx_sheetsemtempest_fastapi_sdk.spreadsheet(extra[spreadsheet], sem dependência nova). Leembytes, caminho ou arquivo binário (oUploadFile.fileserve), uma aba por nome ou posição ou todas pelo nome, cada linha umdictpelo cabeçalho e cada célula com o tipo que o arquivo guarda (XlsxCellValue: número,datetime,time,bool,str,None). As regras de linha são as do leitor de CSV, agora num helper só: cabeçalho na linha 1, linha em branco pulada sem alterar a numeração,omit_blank. Erros tipados, todos422:InvalidSpreadsheetError(SPREADSHEET_INVALID, arquivo que não é.xlsx),SheetNotFoundError(SPREADSHEET_SHEET_NOT_FOUND, com as abas existentes emdetails["available"]) eSpreadsheetRowError(SPREADSHEET_ROW_INVALID,details["row"]+details["sheet"]).GoogleSheetRowErrorpassa a ser subclasse deSpreadsheetRowError. -
Pasta inteira do Google Sheets numa requisição:
download_google_sheet_xlsx(bytes do.xlsx, sem extra) eread_google_sheet_xlsx(todas as abas pelo nome; confere o extra antes do download). Mesmas garantias do caminho CSV: redirect seguido por requisição, cliente injetado nunca fechado, e resposta que não é o media type do.xlsxviraGoogleSheetAccessError. Medido em 2026-10-04 com planilhas públicas: o export.xlsxresponde307e depois o media type do.xlsx; ID inexistente responde404text/html; comgidna URL o export devolve só aquela aba (por isso ogiddo link é descartado); o Google grava o resultado de toda fórmula. -
HardwareInfo.ram_cgroup_limited(bool, defaultFalse):Truequando os campos de RAM vêm do limite de memória do cgroup (o--memoryde um container) e não do host. Nenhum campo existente muda de tipo. Com ele, oCapacityReport.reasontermina com "RAM is limited by the container's cgroup to 512 MiB.", e otempest model hardwareimprimeram total : 0.5 GB (cgroup limit). - Agente por requisição no router pronto (#407):
make_agent_routeraceita, no lugar doAgent, uma dependência FastAPI que devolve oAgentda requisição (AgentDependency, re-exportado emtempest_fastapi_sdk.agents)./rune/run/streama resolvem a cada requisição;GET /runse o download de artefato nunca a chamam. A dependência pode receber oRequestou declararDependspróprios — inclusive a mesma função deowner=, que o FastAPI resolve uma vez só.make_agent_router(agent)continua servindo a mesma instância. É como um prompt que depende de quem chama (facts_prompt) usa o router sem endpoint próprio.Agent.run(..., system_prompt=...)ficou de fora: tocariarun/stream/run_structurede a composição do bloco de skills e dorun_structured, para economizar ~3,3 µs de construção de agente. -
ScriptedBackend.messages_seen(#407): a conversa inteira recebida em cada chamada (list[list[dict[str, Any]]]), como cópia profunda tirada na hora — o agente continua acrescentando na mesma lista, e uma referência mostraria voltas futuras dentro de chamadas antigas. É como se afirma o que uma observação de ferramenta pôs na frente do modelo. -
enable_sqlite_foreign_keys(engine), emtempest_fastapi_sdk.dbe na raiz: o listener doconnectpara quem monta engine SQLite à mão, irmão deenable_sqlite_savepoints/enable_sqlite_wal. AsyncDatabaseManager(sqlite_foreign_keys=True),DatabaseSettings.DATABASE_SQLITE_FOREIGN_KEYS(defaultTrue, entra nodatabase_kwargs()) ecreate_test_engine(foreign_keys=True).require_sqlite_foreign_keys_off(connection): levantaRuntimeErrorquando a conexão é SQLite comPRAGMA foreign_keys=ON, lendo o pragma pelo cursor do driver (não abre transação). Oenv.py.templatechama antes de migrar numa conexão entregue.- Guard
tests/test_engine_configuration_guard.py: todocreate_async_engine/create_engine/async_engine_from_configdo pacote passa pelo_configure_sqlite_engineou está numa allowlist com motivo (hoje, só os engines de migration).
Fixed¶
-
create_paymentcomautoApprove: truelevantava depois de enviar o Pix. A resposta documentada pela própria OpenPix para esse caso traz"status": "APPROVED", e o enum fechado dePayment.status(CREATED,FAILED,CONFIRMED,DENIED) recusava o valor comInput should be 'CREATED', 'FAILED', 'CONFIRMED' or 'DENIED': o dinheiro saía e o chamador recebia umValidationError. O overlay agora levanta o enum como já fazia comCharge.status, então o campo éPaymentStatus | str | NoneePaymentStatuscontinua igual. Os cinco exemplos de resposta dePOST /api/v1/paymentvalidam, fixados emtests/integrations/payment/openpix/test_overlay.py. -
SQLite:
COMMITrecusado deixava a conexão do pool presa noBEGIN(#411). Quando o SQLite recusa oCOMMITcomdatabase is locked, a transação continua aberta no driver, mas o SQLAlchemy já marcou a dele como inativa: fechar aConnectionnão dáROLLBACKe avisa o pool que a transação já foi resetada, então o pool também não dá. Com oBEGINexplícito doenable_sqlite_savepoints, o próximo usuário daquela conexão falhava comcannot start a transaction within a transaction. Pegava o helpertransaction()e aConnectiondo Core; omanager.transaction()/get_session_context()já davarollback()na sessão e escapava. Oenable_sqlite_savepointsganha um listener deresetdo pool que dáROLLBACKquando o driver ainda estáin_transaction— vale para oAsyncDatabaseManagere para ocreate_test_engine, que passam por ele. Medido com aiosqlite 0.22.1 e SQLite 3.47.1, journaldelete: oCOMMITforçado a falhar (outra conexão segurando leitura,busy_timeoutde 0,1 s) deixava a conexão suja sempre, no SQLAlchemy 2.0.52 e no 2.1.3, e limpa com o fix. Na corrida de dois read-modify-write no mesmo arquivo (2.0.52), o primeiroCOMMITfoi recusado em 8 a 11 de 200 tentativas por execução, combusy_timeoutpadrão, 0,1 s ou 30 s, e toda recusa sujava a conexão; com o fix, 6 de 200 recusados e nenhuma suja. Em WAL (o padrão do manager) leitor não bloqueiaCOMMIT, e a mesma corrida recusou 0 de 200. - Em container com limite de memória o planejador via a RAM do host
(#398). O
/proc/meminfoe opsutil.virtual_memory()mostram o host, então numdocker run --memory=512mem host de 62 GB ocan_run(num_params=500_000_000)respondiafits=Truepara um load de 2,5 GB emfloat32. Agora, no Linux, oprobe_hardware()lê o limite do cgroup do processo (memory.maxno v2,memory.limit_in_bytesno v1, pelo/proc/self/cgroup+/proc/self/mountinfo, subindo pelos pais) e, quando ele fica abaixo da RAM do host, usa o limite como total e o menor entre o disponível do host e o do cgroup. Medido com a wheel do branch empython:3.12-slim(WSL2, Docker 29.6.1, cgroup v2): com--memory=512m,ram_total_bytes=536870912,can_rundáfits=Falseemfloat32e orecommenddesce paraint4(~0,4 GB); igual com opsutil7.2.2 instalado; sem--memory(memory.max=max), o total continua sendo os 67 430 916 096 bytes do host. -
O disponível no cgroup desconta o page cache que o kernel recupera. A conta é
limite - (uso - (file - shmem)). Medido alocando de 8 em 8 MiB até o OOM killer, com--memory-swap=512m: com 300 MiB de page cache (lido uma ou três vezes),limite - usoprevia ~183 MiB e o processo alocou 504 MiB, e a conta acima previa ~483 MiB; com os 300 MiB em/dev/shm, o processo alocou 200 MiB e a conta previa ~191 MiB (ofileinteiro teria previsto ~491 MiB). Pela wheel, oprobe_hardware()reportou 417 MiB livres e o processo alocou 432 MiB (3 de 3). Swap não entra. O cgroup v1 é coberto só por teste com arquivos falsos: não havia host v1 para medir. -
README: a tabela de
tempest_fastapi_sdk.testingdocumentavacreate_test_engine(url=..., **engine_kwargs)etest_database(url=...); as assinaturas reais usamdatabase_urle não aceitam**engine_kwargs. -
Leitor de
.xlsxcom limite de tamanho (#404): um.xlsxé um ZIP, e o leitor do #403 entregava qualquer coisa aoopenpyxl. Medido: uma zip bomb de 1,7 MB (umasheet1.xmlde 505 MB) levava oread_xlsxa 2 278 MB de RSS e 151 s; com a memória limitada a 2 GB,MemoryErrordepois de 113 s. Agoraread_xlsx,read_xlsx_as,read_xlsx_sheetseread_google_sheet_xlsxaceitam três limites keyword-only, ligados por padrão:max_uncompressed_bytes(100 MiB) emax_compression_ratio(100, por parte de pelo menos 1 MiB), conferidos no diretório central do ZIP antes de abrir — a mesma bomb é recusada em 0,1 s —, emax_rows(100 000 por aba, linha em branco não conta), conferido durante a leitura, sem guardar nada depois da linha recusada.download_google_sheet_xlsxeread_google_sheet_xlsxganhammax_bytes(32 MiB): o corpo do export é lido em streaming e a transferência para ao passar. Passou de qualquer limite:SpreadsheetTooLargeError(SPREADSHEET_TOO_LARGE,413, subclasse deFileTooLargeException, i18n PT/EN), com o limite emdetails["limit"]— nunca truncamento.Nonedesliga um limite; valor zero ou negativo levantaValueError. Os defaults são constantes públicas (DEFAULT_XLSX_MAX_ROWS,DEFAULT_XLSX_MAX_UNCOMPRESSED_BYTES,DEFAULT_XLSX_MAX_COMPRESSION_RATIO,DEFAULT_GOOGLE_SHEET_MAX_DOWNLOAD_BYTES) e a docstring de cada uma traz a conta: ~834 bytes de RSS e ~78 µs por linha numa aba de 8 colunas (200 000 e 1 000 000 de linhas, uma execução cada), razão de 7,2 a 14,7 em arquivo legítimo contra 294,5 na bomb. - XML truncado no
.xlsxdeixou de virar500: uma parte cortada no meio (por exemplo, diretório central declarando menos bytes do que a parte tem, com o CRC ajustado) fazia o parser levantarParseError/XMLSyntaxErrordurante a leitura da aba, fora dotry. Agora viraInvalidSpreadsheetError(422). O caso com CRC não ajustado já viravaInvalidSpreadsheetError: ozipfilepara no tamanho declarado e confere o CRC ali (medido no CPython 3.11 a 3.14). - Leitor CSV do Google Sheets com limite de tamanho (#413): o
read_google_sheet/read_google_sheet_aslia o corpo inteiro do export comresponse.content, decodificava numstre só então montava alist[dict], sem teto nenhum. Agora os dois aceitammax_bytesemax_rowskeyword-only, ligados por padrão: o download passa pelo mesmo streaming do.xlsx(umContent-Lengthacima do teto é recusado sem ler o corpo; sem ele, a transferência para no primeiro pedaço além), e as linhas são contadas durante o parse, que lê direto dos bytes baixados — na linhamax_rows + 1a leitura para, e o resto do corpo nunca é decodificado. Passou de um limite: o mesmoSpreadsheetTooLargeError(413), comdetails["sheet"]None(o CSV não traz o nome da aba); emread_google_sheet_aso limite vem antes de qualquer validação.Nonedesliga; zero ou negativo levantaValueErrorantes da requisição. Omax_rowsusa oDEFAULT_XLSX_MAX_ROWS(100 000); o download ganha constante própria,DEFAULT_GOOGLE_CSV_MAX_DOWNLOAD_BYTES(10 MiB), menor que a do.xlsxporque o CSV não é comprimido. Medido no CPython 3.11 (pico de RSS acima da base, três execuções por arquivo): uma aba de 8 colunas custa ~94 bytes de CSV e ~1 014 bytes de memória por linha (~11 bytes por byte), então 100 000 linhas (9 212 287 bytes) cabem e o limite de linhas dispara primeiro; o pior caso, células de dois caracteres, custa ~31,5 bytes por byte — 10 300 127 bytes chegaram a 309,4 MB. A maior aba da planilha pública de 16 abas tem 18 577 bytes; as 16, lidas com os defaults, deram 2 580 linhas sem recusa. O parse deixou de passar peloio.StringIO, que guarda uma cópia de umas cinco vezes o tamanho do texto (+475 MB para 95 MiB de ASCII): com o limite de linhas ligado, um export de 95 MB chegava a 632 MB acima da base por esse caminho, e chega a 179 MB lendo dos bytes.
[0.303.2] — 2026-10-04¶
Três defeitos achados ao medir agente e planejador em CPU: resposta vazia
do modelo deixa de contar como sucesso, a geração do Ollama não refaz mais
ReadTimeout (que custava três timeouts), e o planejador de hardware para
de tratar "RAM não medida" como "zero bytes livres" — no Linux, mede pelo
/proc/meminfo mesmo sem psutil.
Added¶
-
RetryPolicy.retry_on_read_timeout(defaultTrue): comFalse, oHTTPClientdeixa de refazerReadTimeoutemrequest()e na abertura destream(), e continua refazendoConnectErrore osretry_statuses. Quem usaHTTPClientdireto não vê mudança. -
Flags de "medido" no planejador de hardware. Campos novos, todos
boolcom defaultTrue(sem quebra de tipo — os campos de bytes continuamint):HardwareInfo.ram_measured,HardwareInfo.disk_measured(odisk_free_bytestambém virava0quando oshutil.disk_usagelevantavaOSError) eCapacityReport.memory_measured. Em GPU o veredito segue medido pela VRAM; só a sugestão de offload para CPU, que depende da RAM, vira "a RAM não foi medida" em vez de "o host é pequeno demais".
Fixed¶
-
Geração mais longa que o timeout custava três timeouts. O
HTTPClientqueOllamaGenerator,OllamaEmbeddereOpenAICompatGeneratorcriam refaziaReadTimeoutaté 3 vezes, mas o Ollama aborta a geração quando o cliente desiste (cada tentativa aparece no log do 0.30.11 como um500de exatamente o timeout), então nenhuma tentativa terminava. Medido comqwen2.5:3bem CPU (options={"num_gpu": 0}) etimeout=10: 31,54 s até a exceção, com 3 requisições no daemon (mediana, N=3); agora 10,01 s, com 1 (mediana, N=5). Com otimeout=120padrão, a conta passa de ~6 min para ~2 min. Esses clientes passam a usarRetryPolicy(retry_on_read_timeout=False)quandoretry_policy=não é dado; uma política explícita é usada como veio, e umhttp_client=injetado mantém a dele. -
Resposta vazia do modelo não termina mais como sucesso. Uma mensagem sem texto (ou só com espaço em branco) e sem
tool_callsviravaStopReason.COMPLETED, e a execução saía comsucceeded=Trueeoutput == "". Agora termina no novoStopReason.EMPTY_RESPONSE("empty_response"), comsucceeded=False— vale pararun,stream, a delegação (agent_toolmarca[stopped: empty_response]), orun_until(..., until=succeeded)(que passa a repetir a rodada) e orun_structured(parse_error="the run produced no answer", sem a chamada de extração). O agente não repete a pergunta sozinho. Medido comOllamaGenerator("qwen2.5:0.5b", options={"num_gpu": 0})no Ollama 0.30.11, meta que pede uma ferramenta e a resposta: antes, 58 de 200 execuções saíamcompletedcomoutputvazio; depois, 73 de 200 terminaramempty_responsee 0 de 200 saíramsucceededvazias (a taxa oscilou entre 25% e 40% de um lote para outro). Comrun_until(..., until=succeeded, max_rounds=3), 58 de 60 laços terminaram aceitos. -
RAM desconhecida virava "sem memória livre". Sem
psutil(que vem no[metrics], não no[genai]), oprobe_hardware()reportavaram_available_bytes=0e o planejador lia isso como zero byte livre. Medido na 0.303.1 numa venv limpa só com o pacote base, máquina de 62 GB,CUDA_VISIBLE_DEVICES=:recommend(num_params=500_000_000)devolviafits=Falseemint4,headroom_pct=-100.0e "use a smaller model or add memory". - No Linux a RAM é medida sem
psutil, lendoMemTotal/MemAvailabledo/proc/meminfo(o arquivo que o própriopsutillê). Na mesma venv limpa, orecommend(num_params=500_000_000)agora dáfits=Trueemfloat32; o total lido bateu com ofree -b(67 430 916 096 bytes) e, no mesmo processo, os dois bytes bateram exatamente com opsutil7.2.2. Kernel semMemAvailable(ou com0) conta como não medido, em vez de aproximar porMemFree + Buffers + Cached. Limitação medida e documentada: em container com--memory=512m, o/proc/meminfo(e opsutil) mostram a RAM do host, e o planejador dizfits=Truepara um load de 2,5 GB. - Quando nenhuma fonte responde (Windows/macOS sem
psutil, ou Linux com o/proc/meminfoilegível), o relatório diz "não medido" em vez de "não cabe":memory_measured=False,dtypena precisão nativa (sem descer a escada de quantização),headroom_pct=0.0, e uma sugestão que diz por plataforma como medir ("on darwin measuring RAM requires psutil (pip install 'tempest-fastapi-sdk[metrics]')").fitscontinuaFalsequando nada foi verificado, para umif report.fits:seguir conservador. tempest model hardwareimprime a RAM lida do/proc/meminfono Linux sempsutil, eram total : unknown (<motivo da plataforma>)em vez de0.0 GBquando nenhuma fonte responde.
[0.303.1] — 2026-10-04¶
O planejador de hardware passa a dimensionar CPU na precisão que o gerador
de fato carrega (float32), e o offload quantizado que ele sugere fica na
CPU mesmo com GPU visível.
Changed¶
can_run(dtype=...)agora tem defaultNone, que dimensiona na precisão que oTextGenerator(dtype="auto")carrega no device escolhido:bfloat16em CUDA/MPS (igual a antes),float32em CPU (antesbfloat16).dtype=explícito continua valendo.recommend()tenta a precisão nativa →int8→int4; em GPU a ordem é a mesma de antes. OCapacityReportnão mudou de forma; muda odtype/estimated_bytesque ele traz para CPU.- A
suggestionde quantizar em CPU acrescenta que o bitsandbytes em CPU decodifica mais devagar quefloat32e aponta oOllamaGenerator(GGUF) como o caminho de CPU. Medido no Qwen2.5-3B:generate()de até 16 tokens em ~9 s com int8/int4 contra ~3 s emfloat32.
Fixed¶
- O planejador subdimensionava CPU pela metade. Sem
dtype=,can_run/recommendestimavam CPU embfloat16enquanto o gerador carregafloat32: para o Qwen2.5-0.5B a estimativa era 1,24 GB e o RSS medido após oload()foi 2,13 GB (transformers 4.57.6) / 2,12 GB (5.18.0); para o 3B, 7,71 GB contra 12,51 / 12,48 GB. Agora a estimativa é 2,47 GB / 15,43 GB. - A
suggestionem CPU não propõe mais "Quantize to bfloat16" (não é quantização, e oTextGeneratorrecusaquantization="bfloat16"). TextGenerator(device="cpu", quantization="int8"|"int4")carregava na GPU quando havia CUDA: o caminho quantizado passava sempredevice_map="auto", e oacceleratepunha os pesos emcuda:0enquantogen.devicedizia"cpu"— o offload que ocan_runsugere era irrealizável. Agora usadevice_map="cpu"quando o device é CPU (medido com CUDA visível, Qwen2.5-0.5B, transformers 4.57.6 + bitsandbytes 0.50.2: todos os parâmetros emcpuem 6/6 loads, N=3 por int8/int4; o código anterior pôs o int4 emcuda:0).- O exemplo de
recommend()emgenai-examplesliarec.dtype.value, que levantaAttributeError: oCapacityReportguarda o valor do enum (use_enum_values), entãorec.dtypejá é a string.
[0.303.0] — 2026-10-03¶
Auditoria da documentação contra o código entregue: 150 afirmações da doc
conferidas com execução, e as que o código não sustentava viraram fix de
código (quando o defeito era do SDK) ou de prosa. Três são de segurança:
POST /auth/mfa/enroll desligava o MFA com só o access token, o login
revelava pelo tempo (e, no admin, pela mensagem) quais contas existem, e o
console SQL do admin classificava como leitura três formas de escrita.
Entram junto: leitura de planilha pública do Google Sheets,
max_concurrent= e defaults de geração nos geradores, make_session_dependency
com fábrica de SessionAuth (e tipado Session com required=True), o
compose gerado com a API sob o profile prod, media types de Office fixos
e o tempest new --extras que agora soma aos obrigatórios admin e auth.
Leia antes de atualizar:
POST /auth/mfa/enrollsobre conta com MFA ativo agora responde 409MFA_ALREADY_ENROLLED. Para girar o segredo:/auth/mfa/disable(senha + código) e depoisenroll.- O login do admin com senha errada responde
"Invalid credentials"para qualquer conta;"Account disabled"e"This account is not authorized for /admin"só aparecem com a senha certa. BR_CURRENCY_FORMAT,BR_PERCENT_FORMAT,BR_DATE_FORMATeBR_DATETIME_FORMATmudam de valor (ganham[$-416]).make_websocket_routerpassa a aceitar o handshake antes de fechar com4401: o cliente real vê o close code em vez deHTTP 403/1006.@cachedem método deixaself/clsfora da chave default: instâncias da mesma classe passam a dividir a entrada.UploadUtilstrata allowlist vazia emax_size_bytes=0como "sem restrição".OutboxRelayrecusa na construção umpublishque não aceitapublish(event).changes_sincedevolve oserver_time5 segundos atrás do início da query: linha alterada nessa janela volta no pull seguinte, então o cliente aplica os itens por upsert noid.watermark_lag=timedelta(0)restaura o marco antigo.
Security¶
POST /auth/mfa/enrollnão desliga mais o MFA com só o access token. Um segundo enroll sobre conta com MFA ativo apagava os recovery codes e zeravatotp_enabled_at, sem pedir senha nem código. AgoraUserAuthService.mfa_enrolllevantaMFAAlreadyEnrolledException(409,MFA_ALREADY_ENROLLED) e não muda nada. Enroll sobre um enrollment pendente (sem confirm) continua permitido.- Login não denuncia mais e-mail inexistente pelo tempo.
UserAuthService.logineSessionAuth.authenticaterespondiam em ~0,5 ms para e-mail desconhecido contra ~154 ms para senha errada (bcrypt custo 12, mediana de 21). As três recusas (e-mail inexistente, senha errada, conta inativa) agora pagam um bcrypt. - Login do admin não revela mais quem é admin nem quem está desativado.
UserModelAuthBackend.authenticatechecavais_activeeis_adminantes da senha: qualquer senha bastava para ler, pela mensagem, se o e-mail existia e que tipo de conta era. Agora a senha é verificada primeiro (e o e-mail inexistente paga um bcrypt fictício); com senha errada as quatro situações respondem"Invalid credentials"em ~154 ms (mediana de 11). - Console SQL do admin: leitura por fora, escrita por dentro.
analyze_sqlclassificava pelo nó do topo, então três formas de escrita saíamreade passavam por um console read-only: CTE que modifica dados (WITH gone AS (DELETE … RETURNING *) SELECT …),EXPLAIN ANALYZEdo MySQL (que executa o statement) ePRAGMAdo SQLite com valor (journal_mode = DELETE,writable_schema = ON) ou que age (optimize). Agora o statement leva a capacidade mais privilegiada que contém, e a regra deWHEREobrigatório olha a escrita.
Added¶
-
Leitura de planilha pública do Google Sheets:
read_google_sheet,read_google_sheet_as,google_sheet_export_url,GoogleSheetAccessErroreGoogleSheetRowErroremtempest_fastapi_sdk.spreadsheet. Aceita o link que o Google mostra no Compartilhar (/edit?usp=sharing,?gid=ou#gid=, sem/edit,/u/<n>/, ou só o ID) e lê uma aba pelo/export?format=csv. Não precisa de extra: roda só comhttpxecsv. Medido em 2026-10-03 contra uma planilha pública: o export responde307para*.googleusercontent.com(umhttpx.AsyncClientsemfollow_redirectsdevolve esse307; o leitor segue o redirect por requisição, inclusive em cliente injetado, que nunca é fechado); ID inexistente responde404text/htmlegidinválido400text/html— toda resposta que não étext/csvde sucesso viraGoogleSheetAccessError(502,GOOGLE_SHEET_UNAVAILABLE). Planilha privada não foi medida.read_google_sheet_asvalida cada linha num modelo Pydantic; a falha viraGoogleSheetRowError(422,GOOGLE_SHEET_ROW_INVALID) comdetails["row"]igual ao número da linha na planilha (cabeçalho = 1). Célula vazia conta como ausente (omit_blank=True); linha em branco é pulada e planilha sem dados devolve[]. Marcador novo de testenetwork(fora do run default;make test-network). -
max_concurrent=emTextGeneratoreVisionTextGenerator. Limita quantas decodificações a instância roda ao mesmo tempo; as excedentes esperam numa fila. Em CPU uma decodificação já usa todo o pool intra-op do torch, então N execuções de agente simultâneas viravam N threads disputando os mesmos núcleos. O limite é um pool de N threads próprias do gerador, e não um semáforo na frente doasyncio.to_thread: o OpenMP do torch mantém um time de workers por thread chamadora, e o semáforo sobre o executor padrão serializava e ainda deixava cada chamada mais lenta (18,5 tokens/s com quatro chamadas, contra 22,8 sem limite no mesmo processo). Medido comQwen/Qwen2.5-0.5B-Instructem CPU (WSL2, i9-13900F, 12 CPUs lógicas visíveis, torch 2.14 com 6 threads intra-op), 128 tokens forçados, greedy, mediana de 5 rodadas: com quatro chamadas simultâneas,max_concurrent=1levou a vazão de 22,4 para 26,9 tokens/s e a latência mediana de 22,73 s para 11,91 s; uma chamada sozinha não muda (4,56 s contra 4,57 s). Cancelar na fila tira o pedido sem rodar; cancelar durante a decodificação continua acionando ostop_evente só libera a vaga quando a thread termina. Cobregenerate,chat,chat_with_tools,generate_structured,chat_structuredestream. O default continuaNone(sem limite): o ganho foi medido só em CPU, e mudar o default mudaria a concorrência de quem roda em GPU. Sem knob de threads:torch.set_num_threadsé global ao processo. -
docker-compose.yamlgerado sobe a API sob o profileprod(#384).tempest newetempest generate --dockeremitem um serviçoapicomprofiles: ["prod"],build: .,env_file: .enve umenvironment:que reescreve todo host que o.env.exampleaponta paralocalhost(DATABASE_URL→postgres,REDIS_URL→redis,RABBITMQ_URL/TASKIQ_BROKER_URL→rabbitmq,MINIO_ENDPOINT→minio:9000) só para os extras escolhidos, maisSERVER_HOST=0.0.0.0/SERVER_PORT;depends_onemservice_healthypor serviço de extra (eservice_completed_successfullynominio-bootstrap) e healthcheck em/health/readinessviaurllib.docker compose up -dcontinua subindo só a infra. Medido com Docker 29.6.1 / Compose v5.3.0 e--extras auth,admin,cache,tasks:--profile prod up -d --build --waitdeixa aapihealthy, a readiness responde200com{"database": true}, a conexão aparece nopg_stat_activitydo Postgres vinda do IP do container e não existe/app/app.db. A mesma imagem com só--env-file .envrespondia200sobre um SQLite criado dentro do container.generate()ganhouport=keyword-only (default8000). -
XLSX_MEDIA_TYPE,DOCX_MEDIA_TYPE,PPTX_MEDIA_TYPEeguess_media_type(filename)emtempest_fastapi_sdk.utils(e no topo);XLSX_MEDIA_TYPEtambém emtempest_fastapi_sdk.spreadsheet. A receita de planilhas deixa de mandar copiar a string do media type. -
make_session_dependency(session_auth=<fábrica>)(#381). Além de umSessionAuth,session_auth=aceita umSessionAuthFactory((request) -> SessionAuth), chamado por request e só quando o middleware não resolveu a sessão. A dependency fica declarável no nível do módulo num serviço que monta oSessionAutha partir de settings atrás de@lru_cache, sem guardá-la emapp.statenem reescrever adaptador. E ganha overloads: comrequired=True(default) o tipo éCallable[[Request], Awaitable[Session]], então oif session is Noneque só servia para estreitar o tipo sai do consumidor; comrequired=FalsecontinuaSession | None. -
Defaults de geração no gerador:
config=emTextGeneratoreOllamaGenerator,num_ctx=eoptions=noOllamaGenerator. O laço doAgentchamachat_with_tools(messages, tools)sem opção nenhuma, então um agente rodava sempre nos defaults do backend — no Ollama, inclusive a janela de contexto, que corta o prompt sem erro. Medido no Ollama 0.30.11 comministral-3:14be uma ferramenta devolvendo ~11,5 mil tokens: semnum_ctx, o daemon processou 2 051 tokens e o agente respondeu errado comstop_reason=completed(3 de 3 execuções); comnum_ctx=32768, processou os 11 551 e respondeu certo (3 de 3). Ordem, da mais fraca para a mais forte:optionscruas,configdo gerador,configda chamada (campo a campo),**kwargsda chamada. Os defaults entram na chave dogeneration_cache. MFAAlreadyEnrolledException(tempest_fastapi_sdk.auth).PasswordUtils.dummy_verify(plain): uma verificação bcrypt contra um hash descartável, para o ramo "conta não existe" custar o mesmo que uma senha errada.instrument_sqlalchemy_engine(engine)(tempest_fastapi_sdk.apie topo do pacote): instrumenta o engine que só existe depois doconnect(); as queries saem como filhas do span da request.Embedder(query_prefix=..., passage_prefix=...)eEmbedder.embed_query/Embedder.embed_passages.Retriever(eHybridRetriever) eChatMemoryos usam quando o embedder os tem, entãomultilingual-e5recebequery:/passage:sem prefixo escrito à mão.embed()continua sem prefixo.changes_since(..., watermark_lag=...)e a constanteDEFAULT_SYNC_WATERMARK_LAG(timedelta(seconds=5)).
Changed¶
-
Portas da infra do compose gerado publicadas só em
127.0.0.1(#384):"127.0.0.1:5432:5432"e equivalentes para Redis, RabbitMQ, MinIO e MailHog. O processo no host continua alcançando a infra; nada de fora da máquina alcança. A porta da API segue publicada em todas as interfaces. -
asyncpg>=0.30.0é dependência dopyproject.tomlscaffoldado (#384), não mais uma linha comentada: a imagem do profileprodusapostgresql+asyncpg://e instala só o que opyproject.tomldeclara.
Fixed¶
-
tempest new --extras cache,tasksgerava um projeto que não subia. O--extrassubstituía o defaultauth,admin, mas osrc/api/app.pygerado sempre monta o painel admin: opyproject.tomlsaía comtempest-fastapi-sdk[cache,tasks]e o app quebrava no import comImportError: Admin requires the [admin] extra(reproduzido numa venv limpa com só os extras pinados;--extras ""quebrava igual). Sem[auth]o app sobe, mas o primeiro login do admin levantaPasswordUtils requires the [auth] extra. Agoraadmineauth(REQUIRED_EXTRASemtempest_fastapi_sdk/cli/new.py) entram sempre e o--extrassoma a eles, sem duplicata e em ordem alfabética:--extras cache,tasksfixa[admin,auth,cache,tasks]. O default da opção passou de"auth,admin"para""— o resultado é o mesmo conjunto. Otempest generatenão muda: ele lê os extras dopyproject.tomlexistente e não reescreve a dependência do SDK (#389). -
Download de
.xlsx/.docx/.pptxsaíaapplication/octet-streamem container slim.DownloadUtils.file_response,DownloadUtils.streameAsyncMinIOClient.download_responseadivinhavam o tipo commimetypes.guess_type, cuja tabela embutida não tem.xlsx,.docx,.pptx,.odt,.odsnem.ogg(Python 3.11 a 3.13); ele só os conhece pelo/etc/mime.typesdo host. Medido napython:3.13-slim— base do Dockerfile dotempest new—, que não tem o arquivo:Nonepara os seis, e o download caía no fallback. Agora o palpite passa porguess_media_type, que consulta uma tabela do SDK antes domimetypes. -
Cancelar uma chamada do modelo local para a decodificação.
TextGenerator(generate,chat,chat_with_tools,generate_structured,chat_structured) eVisionTextGenerator(generate,chat) rodam numa thread, e cancelar a corotina só abandonava a espera: a thread seguia gerando atémax_new_tokens, segurando os núcleos. O laço doAgentnunca passastop_event, então todo timeout doAgentBudgetdeixava um worker decodificando para ninguém. Agora toda chamada roda sob um evento (ostop_eventdo caller ou um privado) que é acionado no cancelamento. Medido comQwen/Qwen2.5-0.5B-Instructem CPU, 300 tokens forçados: por umAgentcomAgentBudget(max_seconds=1.0), a thread seguia decodificando de 9,3 a 10,8 s depois de o run voltartimeout(em medições diferentes); agora termina em 0,01 a 0,02 s. -
TextGenerator.chat_with_toolsaceitastop_event=, como os outros métodos. tempest new --extras queue(etempest generate --src) geravasrc/queue/__init__.pyimportandoAsyncBrokerManagerdo topo do pacote, que nunca o exportou: o projeto davaImportErrorno primeiro import. O template usaAsyncQueueManagerdetempest_fastapi_sdk.queue, e um teste importa cada módulo gerado por extra. Otasks/jobs.pygerado registra com@tq.task, como otasks/__init__.pyjá ensinava.make_spa_router:HEADno fallback respondia 405; agora responde comoGET, sem corpo.UploadUtils(eFileStoreUtils) trataallowed_extensions=set(),allowed_mimetypes=set()emax_size_bytes=0como "sem restrição", o contrato que oUploadSettingsdocumenta. Antes,UploadUtils(**settings.upload_kwargs())com os defaults do mixin recusava todo upload com 415.@cached: a chave default deixa de fora oself/clsquando a função decorada é método.str(self)carregava o endereço de memória, então um método num service construído por request nunca acertava o cache.key_buildercustomizado continua recebendo os args completos.Agent: o motivo de bloqueio da moderação liaverdict.labels, campo que oModerationResultnão tem, e saía sempre(policy). Agora lêcategories—RuleModerator(..., category="toxicity")produzblocked by moderation (toxicity).make_websocket_routeraceita o handshake antes de fechar com4401(token ausente ou inválido). Fechar antes doacceptviravaHTTP 403no uvicorn e1006no browser; oTestClientescondia a diferença.setup_tracingemiteRuntimeWarningquando chamado depois de a app montar a pilha de middleware (no lifespan), caso em que nenhuma request vira span. Com provider já instalado, passa a instrumentarsqlalchemy_engineem vez de ignorá-lo.make_admin_router: card dedashboard_cardscujocomputelevanta é registrado comlogger.exceptionem vez de sumir sem rastro.BR_PERCENT_FORMAT,BR_DATE_FORMATeBR_DATETIME_FORMATganham[$-416]e passam a não depender do locale de quem abre a planilha (LibreOffice 7.4: sem ele,30.00%em en-US e14.08.2026em de-DE).BR_CURRENCY_FORMATpassa de[$R$-416] #,##0.00para[$-416]"R$ "#,##0.00: a forma antiga não fixava os separadores (en-US liaR$ 1,234.56). Medido no LibreOffice, não no Excel.OutboxRelayrecusa na construção, comTypeError, umpublishque não aceita ser chamado comopublish(event)— oMessageBroker.publish(channel, message)cru era aceito e cada evento terminavafailedsem ter sido publicado.export_sklearn_to_compact(eedge_pipeline(compact=True)): o limiar de split vira float32 arredondando para baixo. O arredondamento para o mais próximo fazia uma floresta semmax_depthser recusada pela própria verificação nos dados de treino. O leitor do browser (tempest-react-sdk) já comparaMath.fround(x) <= thresholde bate com o valor novo.BaseRepository.changes_sincenão perde mais a escrita cuja transação atravessa o pull. Oupdated_até carimbado noflush, então uma escrita que faziaflushantes do pull e commitava depois ficava comupdated_atanterior aoserver_timee fora de todo pull incremental. O marco agora saiwatermark_lagatrás (defaultDEFAULT_SYNC_WATERMARK_LAG, 5 s); medido com duas sessões num SQLite em arquivo, a linha que se perdia aparece no pull seguinte.edge_pipeline(compact=True, verify_samples=<ndarray>)não levanta mais "truth value of an array is ambiguous", efeature_namescom contagem errada levantaValueErrorcom as duas contagens (antes,IndexError).
Documentation¶
migrationganha as seções 0.302.0 (constraint composta, comlegacy_constraint_renamesmedido) e 0.301.0; PT e EN voltam a contar a mesma história sobre dump defloat.- Exemplos que quebravam quem copiava:
tempest newcom hífen (recusado), prefixo duplicado da auth na receita React SPA, CORS que ficava*notempestweb-frontend,@cachedque nunca gravava,outboxcom argumentos invertidos, fábrica do chat sem anexos/reações,drainde graceful shutdown que nunca emitia503, tracing no lifespan, exemplos demodelopsque não rodavam,Settingsdo WebAuthn sem banco. - Números e status medidos: 401 (não 400) para token inválido, 13 rotas no
make_auth_router, cinco rotas OAuth, 93 receitas, 684 classes de schema da OpenPix, tabela completa de env vars. httpxvem no pacote base; o extra[http]é opcional.offline-sync: a janela do marco medida, com o que ela não cobre (transação mais longa que ela, skew entre réplicas maior, SQL à mão).
[0.302.0] — 2026-10-01¶
Arquivo privado servido pelo backend (#361, #362, #363): download_response
com Range, X-Accel-Redirect para o nginx e URL assinada para a rota do
próprio app. O zap ganha o lado de entrada (#374), e o Redis passa a ser o
relógio do rate limit e das quotas (#339). Correções: parse_integrity_error
com SQLAlchemy 2.1 (#367), SessionAuth.from_credentials sem o extra [auth]
(#373) e o Modo E do auth subindo com [auth,email] (#324). Extra novo
[tests] (#328).
Leia a migração do #335 antes de atualizar: constraint composta (uq,
ix, fk com mais de uma coluna) muda de nome. Banco criado antes precisa
de uma revisão com RENAME, ou de name= explícito na constraint para
manter o nome antigo.
Utils: URL curta assinada para rota do próprio app (#363)¶
Arquivo privado servido pelo backend precisa de uma URL que o navegador abra
sozinho: <img>, <video> e link de download não mandam Authorization. A
URL presignada aponta para o bucket; esta aponta para a rota do app.
sign_path(path, *, secret, expires_in, purpose, now=None) -> strdevolve o path com percent-encoding seguido de?expires=&signature=. A assinatura éHMAC-SHA256sobre"<expires>\n<path>", com a chave derivada do segredo e dopurpose(HMAC-SHA256(secret, "tempest-fastapi-sdk.signed-url.v1\0" + purpose)), em base64 URL-safe sem padding. Só stdlib, sem extra novo.verify_path(path, *, expires, signature, secret, purpose, now=None)compara comhmac.compare_digest, confere a assinatura antes da validade e levantaInvalidSignedURLException(403,SIGNED_URL_INVALID) ouExpiredSignedURLException(403,SIGNED_URL_EXPIRED).403, e não401, porque URL assinada é capacidade e não login, como no S3.make_signed_path_dependency(*, secret, purpose)emtempest_fastapi_sdk.api.dependencies, ao lado dos outrosmake_*_dependency: lêexpires/signatureda query, declara os dois no OpenAPI e verifica contrarequest.scope["path"]. Parâmetro ausente ouexpiresnão numérico (inclusive com mais dígitos do que oint()do Python aceita) é403, nunca422nem500. Segredo oupurposevazio éValueErrorna construção.- Regra de encoding: o path assinado é o decodificado, o mesmo que a rota
vê.
%2Fe/chegam à rota como o mesmo path e valem com a mesma assinatura (medido no Starlette 0.46.0 e 1.6.0). SIGNED_URL_EXPIRES_PARAM/SIGNED_URL_SIGNATURE_PARAMexportados, e os doiscodenovos no catálogo i18n (pt-BR e en-US).- Receita de storage mostra
sign_pathno mapper e a dependency na rota comdownload_response(..., request=). Medido contra MinIO em container (tests/storage/test_signed_url_live.py):200e206comRangepela URL assinada,403sem nenhumstat_objectna URL adulterada, e o mesmo par comserve_objectem modoX-Accel-Redirect.
WhatsApp: receber mensagem e status do zap-api (#374)¶
O integrations.messaging.zap ganha o lado de entrada, escrito à mão
em webhooks.py e portado do código do gateway (src/utils/signature.ts,
src/services/webhook.service.ts, src/services/callback.service.ts),
já que a spec não declara webhooks nem callbacks.
make_zap_webhook_dependency(*, secret=None, verifier=None, error_message=...)verificax-zap-signature: sha256=<hex>sobre o corpo cru comhmac.compare_digeste devolve umZapWebhookDelivery(event_name,event,inbound,status,payload,body). Corpo alterado, secret errado, header ausente e prefixosha256=ausente sãoUnauthorizedException(401). Evento desconhecido, corpo que não bate com o modelo e corpo não-JSON voltam comevent=Noneem vez de500. Construir sem secret (ou com secret vazio) éValueError, porque o gateway entrega sem assinatura quando o webhook não tem secret.ZapInboundMessage(message.received) eZapStatusCallback(message.{sent,delivered,read,failed},status: AcceptedResponseStatus), com os nomes do fio emvalidation_alias/serialization_alias. Otextda mensagem recebida não passa pelostr_strip_whitespacedoBaseSchema.ZapWebhookEvent,ZapInboundMediaType,webhook_verifier(secret)e as constantesZAP_WEBHOOK_SIGNATURE_HEADER,ZAP_WEBHOOK_SIGNATURE_PREFIXeZAP_INBOUND_EVENT, cada uma com teste fixando o valor portado.is_forward_transition(current, new):queued < sending < sent < delivered < read,failedterminal e só sobrequeued/sending/sent(oOUTRANKED_BYdo gateway). O gateway protege a própria linha mas manda o callback mesmo assim, então umdeliveredatrasado depois dereadchega ao consumidor.- Os nomes novos resolvem de forma lazy como os gerados: importar o
namespace continua sem carregar
schemas. - Receita nova, bilíngue: Receber mensagens do WhatsApp (zap-api).
Fixed¶
- A docstring do pacote
zapmostravaassert accepted.status is AcceptedResponseStatus.QUEUED, que é sempre falso: oBaseSchemaguarda o valor do enum. Agora usa==.
Storage: X-Accel-Redirect para o nginx entregar o arquivo (#362)¶
O backend autoriza e o nginx entrega: a rota responde vazia com
X-Accel-Redirect, e o nginx busca o objeto no bucket pela rede interna. O
bucket continua privado e nenhum byte passa pelo processo Python.
AsyncMinIOClient.accel_redirect_response(key, *, bucket=None, internal_prefix=None, expires=timedelta(minutes=5), filename=None, media_type=None, as_attachment=False, cache_control=None, headers=None)presigna contra oendpointinterno — nunca opublic_endpoint— e montaX-Accel-Redirect: <prefixo><bucket>/<key>?X-Amz-....Content-Disposition,Content-TypeeCache-Controlvão assinados na URL como os overridesresponse-*do S3, porque no nginx 1.27.5 oContent-Typedo app perde para o do bucket e umContent-Dispositionnos dois chega duplicado (medido).AsyncMinIOClient.serve_object(key, *, request=None, ...)escolhe entredownload_responseeaccel_redirect_responsepelo novoaccel_redirect=do construtor, então a mesma rota alterna proxy e redirect só por configuração.accel_prefix=(default"/_bucket/") precisa começar com/—ValueErrorcaso contrário.MinIOSettingsganhaSTORAGE_ACCEL_REDIRECT(defaultfalse) eSTORAGE_ACCEL_PREFIX(default/_bucket/), mapeados porminio_kwargs().- Receita de storage com o bloco nginx completo, o porquê do
Hoste da barra final doproxy_pass, e quando escolher cada modo. Medido com nginx + MinIO em container (tests/storage/test_accel_redirect_live.py, nginx 1.22.1, 1.27.5 e 1.29.8):200pela rota,206com oRangerepassado,304,404em/_bucket/...direto,403 SignatureDoesNotMatchcom oHost $hostherdado e400 InvalidBucketNamesem a barra final.
Storage: download_response com Range e revalidação (#361)¶
O AsyncMinIOClient.download_response() respondia sempre 200 com o objeto
inteiro: <video>/<audio> não avançavam, download interrompido recomeçava
do zero e imagem era baixada de novo a cada visita.
download_response(key, *, request=None, cache_control=None, ...). Com orequest:Range: bytes=a-b,bytes=a-ebytes=-n→206comContent-Range, lendo só o trecho no bucket; range que começa no fim do objeto ou depois →416comContent-Range: bytes */<tamanho>;If-None-Matchbatendo com o ETag, ouIf-Modified-Sincenão mais antigo que o objeto →304sem corpo e sem ler o objeto; vários trechos,Rangeinválido ouIf-Rangeque não bate mais →200inteiro. Toda resposta levaAccept-Ranges: bytes,ETagentre aspas eLast-Modified. Semrequesto status continua sempre200— medido contra MinIO em container (tests/storage/test_download_live.py) e num Chromium, onde um MP4 de 60 s pulou para os 50 s com206.stream_object(key, *, offset=0, length=None)repassa o trecho aoMinio.get_object. A leitura inteira continua chamandoget_object(bucket, key)sem argumento novo, então dublê de teste da chamada antiga segue funcionando.DownloadUtils.download/FileStoreUtils.downloadganhamrequest=ecache_control=, repassados no modo MinIO (o modo local já respondiaRangepeloFileResponse).- O
ResponseCacheMiddlewarepassa a usar o mesmo comparador deIf-None-Match(módulo privadoutils._http_cache), sem mudança de comportamento.
Rate limit e quotas: o relógio é o do Redis (#339)¶
RedisQuotaStoreeRedisRateLimitStoreleem o tempo do servidor. O script Lua chamaTIMEem vez de receber otime.time()da réplica. Medido contra Redis 4 e 7 reais, com dois clientes: um cliente uma hora adiantado passava num bucket e numa janela que o outro tinha acabado de esgotar, e um cliente uma hora atrasado gravava timestamps que deixavam o cliente no horário passar. Com oTIMEdo servidor, os oito casos são negados (tests/api/test_quota_redis_live.py,make test-docker). O script ligaredis.replicate_commands()antes de escrever: sem isso o Redis 4.0.14 recusa a escrita depois doTIME, e comfail_open=True(default) um limite de 1 requisição deixou passar 10 de 10. Nenhuma assinatura muda; o layout deARGVdos scripts perde o timestamp.MemoryQuotaStore(clock=...): callable keyword-only que devolve segundos, defaulttime.monotonic. Deixa recarga e esgotamento determinísticos em teste.- Teste intermitente corrigido.
test_bucket_absorbs_burst_then_rejects[redis]usava 100 req/s comburst=3— um token de volta a cada 10 ms. Sob carga (24 processos ocupando 12 CPUs), em N=1000 execuções por caso: 267 falhas no[redis], 3 no[memory]e 3 notest_bucket_refills_over_time[redis]; depois da correção, 0 em 1000 nos quatro. Os testes de store rodam num relógio que só anda quando o teste manda (injetado noMemoryQuotaStore, fixado nofakeredis), com regra de 1 token por hora.
Nome de constraint composta leva todas as colunas (#335)¶
NAMING_CONVENTION nomeava unique, índice e foreign key só pela primeira
coluna. UniqueConstraint("title") e UniqueConstraint("title",
"release_year") viravam ambas uq_books_title: o PostgreSQL 16 recusa o
CREATE TABLE com relation "uq_books_title" already exists, o SQLite
aceita as duas — e é por isso que o defeito passava no banco de teste. Com
dois uniques com o mesmo nome, o constraint que o parse_integrity_error
devolve no PostgreSQL deixava de identificar a regra. Reproduzido contra
PostgreSQL 16 em container e SQLite via aiosqlite
(tests/db/test_naming_live.py).
Migração — leia antes de atualizar¶
- Constraint composta muda de nome.
uq,ixefkcom mais de uma coluna passam a levar todas no nome:uq_books_title→uq_books_title_release_year,ix_books_author→ix_books_author_books_title,fk_books_tenant_id_authors→fk_books_tenant_id_author_id_authors. Nome de uma coluna não muda (uq_books_title,ix_books_title,fk_books_author_id_authors, com e sem schema — fixado por teste).pkecknão mudam. - Banco criado antes precisa de uma revisão com
RENAME. Medido contra PostgreSQL 16 e Alembic 1.19.1: o--autogenerateacusa as uniques e os índices compostos comodrop+createe não vê a FK composta renomeada. Essedrop+createfalha quando uma FK depende da unique (cannot drop constraint ... because other objects depend on it). Gere osRENAMEcomlegacy_constraint_renames(BaseModel.metadata)e cole numa revisão vazia; depois dela ocompare_metadatadevolve[]. Passo a passo na receita Banco de dados → Migrar constraints compostas da convenção antiga. - O próprio SDK tem duas. O model de
make_user_oauth_account_modelrenomeiauq_<tabela>_provider→uq_<tabela>_provider_subjecteuq_<tabela>_user_id→uq_<tabela>_user_id_provider; com o model importado,legacy_constraint_renamesas inclui. - Para adiar, dê à constraint composta o nome que ela já tem no banco
(
name="uq_books_title"): nome explícito vence a convenção.
Changed¶
NAMING_CONVENTIONusacolumn_0_N_name/column_0_N_labelemuq,ixefk. Para uma coluna, esses tokens renderizam o mesmo quecolumn_0_*(medido no SQLAlchemy 2.0.52, piso declarado e versão do lock, e no 2.1.1). Nome acima de 63 caracteres é encurtado pelo SQLAlchemy no DDL do PostgreSQL — 55 caracteres,_e 4 hex do MD5 do nome completo —, igual em toda execução e sem drift no autogenerate depois (medido).
Added¶
legacy_constraint_renames(metadata, *, legacy=LEGACY_NAMING_CONVENTION)lista toda constraint composta nomeada pela convenção cujo nome antigo era outro — FK incluída —, comoConstraintRename(kind, table, schema, columns, old_name, new_name). Constraint comname=explícito fica de fora. Os nomes saem do próprio SQLAlchemy, por tabela de rascunho sob cada convenção.ConstraintRename.statement(dialect)escreve oALTER TABLE ... RENAME CONSTRAINT(unique, FK) ouALTER INDEX ... RENAME TO(índice) do PostgreSQL, com o encurtamento de 63 caracteres e a citação do dialeto;inverse()dá o dodowngrade(). Fora do PostgreSQL levantaValueError— o SQLite não renomeia constraint, e a migração em batch do autogenerate funciona lá (medido).LEGACY_NAMING_CONVENTION(a convenção anterior) eConstraintKind.
Auth só com o backend: Modo E sobe com [auth,email] e a receita mostra o ciclo inteiro (#324)¶
A issue pedia a doc de como cadastrar, ativar e logar tendo só o backend.
Rodar esse fluxo numa venv limpa achou dois defeitos no Modo E
(AUTH_BACKEND_LINKS=true), o que existe exatamente para quem não tem
frontend:
- Com
[auth,email], a combinação que a receita manda instalar, omake_auth_routerlevantavaRuntimeError: Form data requires "python-multipart" to be installedna construção: o form HTML de reset declarava os campos comForm(...). - Sem Jinja2 (
[auth]sozinho), o router subia e o primeiro clique no link de ativação consumia o token, ativava a conta e respondia 500 (ModuleNotFoundError: No module named 'jinja2'), porque a página renderiza depois do commit. O usuário via erro, o link já estava gasto, e o login dava 200.
Fixed¶
POST /auth/password-reset/{token}sempython-multipart: o form lê o corpoapplication/x-www-form-urlencoded(o que um<form method="post">envia) comurllib.parse.parse_qs. Corpomultipart/form-data(template próprio comenctype) continua passando pelo parser do Starlette.AUTH_BACKEND_LINKS=truesem Jinja2 falha no boot:make_auth_routerlevantaRuntimeErrorcitandotempest-fastapi-sdk[auth,email], em vez do 500 no clique.
Changed¶
- Campo faltando no form de reset HTML re-renderiza o form com 400 e o
token intacto, como já fazia senha curta ou divergente. Antes respondia
422 em JSON (a validação do
Form(...)), numa página que o browser abriu.
Docs¶
- Receita
auth-flow, seção nova Só o backend: do cadastro à rota protegida (PT-BR + EN): app completo num arquivo, o ciclo medido comcurlpelos dois caminhos (JSON comPOST /auth/activate/{token}, e linkGETdo Modo E), Swagger, app mobile por deep link e o mesmo ciclo num teste comFakeEmailUtils. A tabela de settings diz para onde oAUTH_ACTIVATION_URL_TEMPLATEaponta em cada modo.
Added¶
-
Extra
[tests]:pytest,pytest-asyncioepytest-xdist(#328). O que a suíte de um serviço e otempest test --fastprecisam, numuv add --dev "tempest-fastapi-sdk[tests]". Pisos iguais aos do grupo de dev deste repo (pytest>=9.1.1,pytest-asyncio>=1.4.0,pytest-xdist>=3.8.0) e nenhum teto próprio. Os tetos herdados, lidos dorequires-distde cada um: opytest-asyncio1.4.0 exigepytest<10,>=8.4, e opytest9.1.1 exigepluggy<2; opytest-xdist3.8.0 (execnet>=2.1,pytest>=7.0.0) não traz teto. Opytest<10não é custo novo — todo serviço daqui já depende dopytest-asyncio—, só passou a estar escrito. Fica fora do[all]: é ferramenta de teste, não helper de aplicação. -
Documentação do
tempest test --fast/tempest check --fast. A receita de testes ganhou a seção A suíte em paralelo: quando usar, o que a mensagem de pytest-xdist ausente diz, e o caminho de conferência de teste que só falha em paralelo (rodá-lo sozinho, em série, antes de tratar como regressão). A flag é dotempest-cli0.4.0, e o piso do SDK sobe paratempest-cli>=0.4.0. Medido na suíte deste repo (10 218 testes, máquina de 6 núcleos físicos / 12 threads, uma execução de cada, com otempest-clida branch instalado em modo editável): 2127 s em série, 411 s com--fast(auto→ 6 workers, porque opsutilestá no ambiente).
Fixed¶
parse_integrity_errorperdia as colunas no PostgreSQL com SQLAlchemy 2.1 (#367). O pacote declarasqlalchemy[asyncio]>=2.0.52sem teto, e no 2.1.1 ostr(error.orig)do asyncpg traz só a primeira frase, sem a linhaDETAIL:de onde saíam as colunas: toda unique voltavacolumns=(), e o resumo dodescribe_database_error/redact_database_errorsperdia ocolumns=. Agora a função lê primeiro o diagnóstico estruturado que a exceção do driver carrega (sqlstate,detail,constraint_name,table_name,column_name), percorrendoorige__cause__, e cai para o texto só no que falta. Os campos são lidos por nome de atributo: oasyncpgnão é importado e o SDK continua sem exigir[postgres]. Medido contra Postgres 16 comasyncpg0.31.0, no SQLAlchemy 2.0.52 (o piso) e no 2.1.1:tests/db/test_integrity_live.pytinha 3 falhas no 2.1.1 (test_unique,test_unique_composite,test_redaction_drops_the_detail_value) e passa inteiro nas duas versões. SQLite não muda: não expõe esses atributos e segue pelo texto.- Unique no PostgreSQL agora nomeia a tabela. O
table_namedo driver éusersnas duas versões; antes a frase não dizia, etableficavaNone. O resumo de log ganha o campo:unique violation; constraint=users_email_key; table=users; columns=email. Mensagem solta, sem a exceção do driver atrás, continua comtable=None. - Violação com a frase traduzida (
lc_messages) é classificada pelosqlstate(23505,23503,23502,23514). Medido comlc_messages=de_DE.utf8nas duas versões: antes voltavaunknown; agora voltauniquecom constraint e tabela, e comcolumns=(), porque oDETAILtambém vem traduzido. - Guard de drift:
tests/db/test_integrity_sqlalchemy_matrix.py(marcadodocker) reroda o módulo live, SQLite incluído, sob o piso declarado nopyproject.tomle sob o 2.1.1. Contra o código antigo, o caso2.1.1falha com as 3 falhas acima e o do piso passa. SessionAuth.from_credentials(...)exigia o extra[auth]sem usá-lo (#373). O__init__construíaPasswordUtils()sempre, e ele levantaImportErrorsem bcrypt — então o modoauthenticator=, que compara comhmac.compare_digestsobre SHA-256 e nunca verifica hash, não subia num serviço instalado só com[ssr]. AgoraSessionAuth.passwordsé uma propriedade: no modoauthenticator=oPasswordUtilssó é construído se alguém ler o atributo; no modouser_model=continua construído no__init__, para a falta do extra derrubar o boot e não o primeiro login.passwords=injetado e atribuição a.passwordsseguem como antes. Medido numa venv com.[ssr](sembcryptnemjwtimportáveis):from_credentials+login_with_credentials+resolvelevantavamImportError: PasswordUtils requires the [auth] extrae agora completam.
[0.301.0] — 2026-09-27¶
As pontes que o painel administrativo do tempest-bucket escrevia à mão
sobem para o SDK (#340–#354): formulário com campo oculto, upload e
dependency tipada; CSS com URL versionada, fonte de sistema, Shell de largura
total e DataTable com widget na célula; confirmação de ação destrutiva sem
JS inline, flash message, redirect-back contra open redirect e página de erro
para rota HTML; sessão sem tabela de usuário e dependency de rota HTML com
303; e, no banco, dependency de sessão para manager lazy, create_tables
com metadata própria e uma base de controller sem CRUD. A validação em
browser da release achou um defeito antigo, corrigido aqui: o DataTable
alargava a página inteira no celular (#360).
Migração — leia antes de atualizar¶
Cada item aponta para o detalhe na seção da área.
- CSS:
make_css_routerecss_responsemudam oCache-Controldefault depublic, max-age=3600parano-cacheno path sem versão — linkesheet.url(path)para o cache longo. O reset doStyleSheetganhoufont-familynobody. Detalhe emui.css,ShelleDataTable. DataTable: o<table>agora sai dentro de<div class="tui-table-scroll">; seletor próprio que mirava.tui-card__body > tableprecisa incluir o wrapper.- Formulários:
help_textpresente sempre vence (vazio suprime a dica em vez de cair nadescription), e campoUploadFiledeixou de sair como<input type="text">. Detalhe emui.forms. html_response(page)semtitle=usapage.document_title()em vez de levantarValueError. Detalhe em SSR.- Sessões:
make_session_dependency(...)devolve dependencyasync,SessionAuth.user_modelpode serNone,SESSION_COOKIE_SAMESITEéLiterale o logout apaga o cookie com os atributos do settings. Detalhe em Sessões.
ui.forms — campo oculto, upload, dependency e ajuda sob controle (#340, #345, #347, #351)¶
Added¶
- Campo oculto de verdade (#340).
json_schema_extra={"ui": {"control": "hidden"}}renderiza só<input type="hidden" name value>— sem label, ajuda nem wrapper, e sem atributos de validação —, qualquer que seja o tipo ou omax_length(umstrcommax_length=1024não vira mais<textarea>). O valor atravessa oparse_forme é validado pelo schema; como o campo não mostra nada, o erro dele sobe para a lista de erros do formulário prefixado com o label (Position: Input should be greater than or equal to 0).Controlganhou"hidden". {"ui": {"omit": True}}tira o campo do formulário. É o nome novo do que{"hidden": True}sempre fez;hidden: Truenão mudou de semântica e continua omitindo o campo — ele não gera campo oculto.form_dependency(Schema, *, include=(), exclude=(), extra=None, error_message=None)(#345) devolve a dependency do FastAPI que chama oparse_form, então a rota recebeAnnotated[FormResult[Schema], Depends(form_dependency(Schema))]sem declararRequest.result.unwrap()sai tipado comoSchemano mypy--strict, e a rota não ganharequestBodynem parâmetro no OpenAPI.- Upload de arquivo (#347). Campo anotado como
fastapi.UploadFile(oulist[UploadFile], ou marcado com{"ui": {"control": "file"}}) renderiza<input type="file">, comaccept/multiplevindos do blocoui, e o formulário ganhaenctype="multipart/form-data"sozinho (umenctypeemattrs=vence). Oparse_formentrega o próprioUploadFileno modelo; controle de arquivo vazio conta como chave ausente; no re-render com erro o arquivo volta vazio, com a mensagem, e os outros campos mantêm o valor.Controlganhou"file",FieldSpecganhouaccepteFormSpecganhou a propriedademultipart. describe=emform_for,form_spec_forefields_for(#351).describe=Falsedesliga o fallback da dica para adescriptiondo campo.
Changed¶
help_textpresente sempre vence (#351).{"ui": {"help_text": ""}},NoneouFalsesuprimem o<small>(e oaria-describedby); antes oorcaía nadescriptionde novo. Só quem passavahelp_textvazio esperando ver adescriptionpercebe a diferença.- Campo
UploadFilenum schema de formulário deixou de sair como<input type="text">. O JSON schema dele não temformat: binary, então a recusa antiga nunca disparava paraUploadFile; agora ele gera o controle de arquivo.bytescontinua levantandoUnsupportedFieldError, com mensagem que aponta paraUploadFile.
ui.css, Shell e DataTable — URL versionada, fonte, largura e célula com widget (#341, #350, #346)¶
Três contornos que o painel do tempest-bucket escrevia à mão sobem para a
camada ui: a folha servida com max-age=3600 num path fixo deixava o
browser até uma hora no CSS antigo depois do deploy (#341); o texto saía
na fonte padrão do browser e o Shell prendia o <main> em 72rem sem
opção (#350); e o DataTable só aceitava texto na célula, então link,
botão e form por linha viravam tabela escrita com Stack (#346).
Migração¶
make_css_routerecss_responsemudam oCache-Controldefault depublic, max-age=3600parano-cacheno path sem versão. O browser passa a revalidar peloETag(resposta304, sem corpo) antes de usar a cópia. Para o cache longo, linkesheet.url(path)— ver abaixo. Quem passavacache_control=explícito não muda nada.- O reset do
StyleSheetganhoufont-familynobody. Serviço que já definia a família numa regra própria continua vencendo quando a regra vem depois do reset (é o caso doextra=doapp_stylesheet).
Added¶
StyleSheet.url(path="/static/app.css")eStyleSheet.version()(#341): a URL que a página linka,"/static/app.css?v=<versão>", com a versão = 12 primeiros hex do SHA-256 do CSS renderizado (o mesmo hash doETag). Path sem/inicial ou com query levantaValueError. Teste em subprocesso (PYTHONHASHSEED1 e 2) confirma a mesma URL em dois interpretadores e no processo do teste; mudar uma declaração muda a URL.make_css_router(..., versioned_cache_control=...): requisição com?v=igual à versão servida recebepublic, max-age=31536000, immutable(default), inclusive no304; path sem?v=ou com versão diferente recebecache_control(no-cache). Versão diferente nunca saiimmutable, para uma réplica antiga num deploy em rolagem não fixar o CSS velho sob a URL nova.ThemeTokens(font_family_body=...),ThemeTokens.font_family()eSYSTEM_FONT_STACK(tempest_fastapi_sdk.ui.css, #350): o:rootemite--t-font-family-body(default: pilhasystem-ui, …, sans-serif, sem web font) e o reset aplicafont-family: var(--t-font-family-body)nobody; semtheme, o reset aplica a pilha literal.Shell(width="contained" | "full")+ShellWidtheComponentClasses.shell_main_full(tui-shell__main--full, #350):"full"acrescenta a classe no<main>, e a regra zeramax-width,marginepadding."contained"(default) mantém o72remcentralizado. Valor fora dos dois éValidationError.TableColumn(name, header=, render=, align=, class_name=)+CellAligneCellRenderer(tempest_fastapi_sdk.ui.components, #346):DataTable.columnsaceitastr | TableColumnmisturados.renderrecebe a linha e devolve widget, lista de widgets oustr(texto escapado;Noneviranone_text); widgets passam pelo renderizador, sem HTML cru.align(left/center/right) aplicaComponentClasses.table_align(tui-table__cell--<align>) no<th>e em cada<td>;rightligatabular-nums.class_namevai no<th>e em cada<td>.alignfora dos três ourendernão chamável levantamValueErrorna construção.DataTable.column_specs()devolve as colunas comoTableColumn;resolved_columns()continua devolvendo os nomes.
Fixed¶
DataTablelargo não alarga mais a página no celular. O<table>sai dentro de<div class="tui-table-scroll" tabindex="0">(novaComponentClasses.table_scroll), comoverflow-x: autoemin-width: 0; comcaption=o wrapper virarole="region"rotulado pela legenda. Antes não havia wrapper nenhum: a 390px, uma tabela de 849px levava o documento a 890px descrollWidth; agora o documento fica em 390px e a tabela rola no contêiner (medido no Chromium). Quem mirava.tui-card__body > tablena própria folha precisa incluir o wrapper no seletor.
Changed¶
- Scaffold do
tempest generate/tempest new --extras "ssr":src/ui/styles.pyexportaCSS_URL = STYLESHEET.url(CSS_PATH), eapi/routers/web.pylinkaCSS_URLem vez deCSS_PATH. OCLAUDE.mdgerado manda linkarCSS_URLe usarTableColumnpara ação por linha.
SSR — head na página-base, confirmação sem JS inline, flash e redirect-back (#348, #349, #352)¶
Added¶
Page.stylesheets,Page.headePage.title_suffix(ClassVar, herdados) ePage.document_title().html_response(page)usa esses valores quandostylesheets=/head=/title=não são passados; um argumento explícito substitui o da página (#352).form_for(..., confirm="...")etempest_fastapi_sdk.ssr.confirm()emitemdata-confirm;make_htmx_router()passa a servir também/_ssr/confirm.js, ehtml_response(confirm=None)o inclui quando o documento usa o atributo ou quandohtmx=True. O texto é lido do atributo comgetAttribute, nunca interpolado em script; sem JS o form continua submetendo (#348). Validado em Chromium (Playwright): cancelar não submete, a pergunta do botão de submit vale no lugar da do form,<a data-confirm>também pergunta.- Flash messages:
FlashMiddleware(cookieHttpOnly, assinado com HMAC-SHA256, lido uma vez, expira emmax_age),flash(),get_flashes(),flash_enabled(), e os componentesFlashMessage/FlashMessagesemui.components(#349). redirect_back(request, fallback=..., allowed_prefix=...)eback_url(): seguem oReferersó para o mesmo host e um path sob o prefixo, e emitemLocationrelativo; recusam outro host, user info,Referersem host,//host, esquema que não éhttp/https, path fora do prefixo,..(inclusive%2e%2e), barra codificada e\(#349).register_html_error_handlers(app, prefixes=..., tags=..., error_page=..., fallback=...): embrulha os handlers deAppExceptioneHTTPException; rota HTML respondeErrorPagenoGET/HEADe flash +303nos demais métodos; o resto mantém o JSON. Novoui.pages.ErrorPage(title_template,from_error) (#349).- Receita nova: Ações SSR (confirmação, flash, redirect-back).
Changed¶
html_response(page)semtitle=agora usapage.document_title()em vez de levantarValueError; o erro continua para widget que não éPage.stylesheetseheadpassaram a aceitarNone(o novo padrão).
Sessões — login sem tabela de usuário, rota HTML sem middleware e cookie por mapper (#343, #344)¶
O painel do tempest-bucket não conseguiu usar tempest_fastapi_sdk.sessions:
SessionAuth exigia user_model= (o serviço tem uma credencial root no
ambiente, nenhuma tabela), make_session_dependency só respondia 401 (rota
HTML precisa de 303 para o login) e só funcionava com o SessionMiddleware,
um BaseHTTPMiddleware no caminho de todo byte do upload/download. E a rota de
login repetia sete settings no set_cookie, com SESSION_COOKIE_SAMESITE
anotado str, que o mypy recusa contra o Literal do Starlette.
Migração¶
make_session_dependency(...)devolve uma dependênciaasync. Quem só usa emDepends(...)não muda nada; quem chamava o resolver direto (teste) precisa deawait.SessionAuth.user_modelétype[BaseUserModel] | None. Código tipado que lê o atributo precisa estreitar.SESSION_COOKIE_SAMESITEéLiteral["lax", "strict", "none"]. Nenhum valor que passava antes é recusado agora — medido com pydantic 2.13.5:Lax/LAX/lax;já falhavam nopatternantigo e continuam falhando (agoraliteral_error), elaxcom espaço continua aparado e aceito. O que muda é o tipo: umSettingsque sobrescreve o campo comostrpassa a ser override incompatível no mypy — sobrescreva com oLiteralou remova o override.- O logout do
make_session_routerapaga o cookie com os atributos do settings. Antes oSet-Cookiede remoção saía sempre comSecureeHttpOnly, qualquer que fosseSESSION_COOKIE_SECURE/_HTTPONLY(medido:...; HttpOnly; Max-Age=0; Path=/; SameSite=lax; Secure).
Added¶
SessionAuth.from_credentials(username, password, *, store, settings, user_id=None)— sessão sem tabela de usuário, sobre umStaticCredentialAuthenticator: as duas metades são sempre comparadas comhmac.compare_digestsobre o SHA-256 (32 contra 32 bytes, sem vazar tamanho nem quebrar em não-ASCII) e combinadas com&, então usuário errado custa o mesmo que senha errada e a mensagem é uma só. AceitaSecretStr; credencial vazia levantaValueErrorna construção. O dono da sessão éuuid5(STATIC_CREDENTIAL_NAMESPACE, username)por padrão.- Protocolo
SessionAuthenticator(async def authenticate(self, username, password, /) -> UUID) eSessionAuth(authenticator=...)no lugar deuser_model=— exatamente um dos dois, senãoValueError.authenticate_credentials(...)elogin_with_credentials(...)são o passo de login desse modo;authenticate(session, ...)num serviço semuser_modellevantaRuntimeError, emake_session_routerrecusa esse serviço na montagem. make_session_dependency(session_auth=..., on_missing=...)—session_auth=resolve o cookie na própria dependência, sem middleware (e reaproveita a sessão quando o middleware já rodou);on_missing=troca o401por outra exceção.redirect_to("/login")é o handler de rota HTML:303comLocation, mantido pelo handler do FastAPI e peloregister_exception_handlers.SessionSettings.session_cookie_kwargs()/session_cookie_delete_kwargs()—response.set_cookie(value=token, **settings.session_cookie_kwargs())eresponse.delete_cookie(**settings.session_cookie_delete_kwargs()), comkey,path,domain,secure,httponlyesamesitesaindo dos mesmos campos. Tipados comoTypedDict(SessionCookieKwargs,SessionCookieDeleteKwargs), então o mypy confere o splat.make_session_routerpassou a usá-los.
Docs¶
- A receita de sessões ganha a seção Login sem tabela de usuário (exemplo
completo de painel com form, página protegida e logout, com a saída
medida) e o aviso do custo do
SessionMiddlewarecom streaming — medido com Starlette 1.6.0: 256 MiB em pedaços de 64 KiB levaram ~4 ms sem o middleware e ~64 ms com ele (mediana de 7,sendvazio). A frase que dizia que sem middleware a dependência levantavaAttributeErrorestava errada: ela responde401.
Banco e controllers — sessão para manager lazy, metadata própria e controller de orquestração (#342, #353, #354)¶
Três pontes do tempest-bucket: a dependency que embrulhava
get_session_context() commitava depois da resposta sem o nome avisar
(issue #342), um controller que orquestra três services não tinha base sem
CRUD (issue #353) e create_tables() não criava nada para uma
DeclarativeBase própria (issue #354).
Added¶
session_dependency_for(get_db)(issue #342): dependency de sessão por request para um manager construído sob demanda (@lru_cache def get_db()). Mesma semântica desession_dependency— sem commit no sucesso, sessão fechada no fim do request, também quando o endpoint levanta — eget_dbsó é chamado no request, nunca no import. Abre a sessão direto em vez de iterar o gerador do manager, então não sobra gerador interno para o GC fechar (oaclosingdo contorno do consumidor deixa de ser necessário).AsyncDatabaseManager.transaction()(issue #342): o mesmo context manager deget_session_context()sob um nome que diz que ele commita. Não confundir comtransaction(session)dedb.transaction, que agrupa escritas numa sessão que já existe.Controller(issue #353): raiz da camada de controllers, sem método nenhum, para controller que orquestra vários services sem CRUD de recurso único. Fixa só a convenção (services injetados pelo__init__, nenhum acesso a banco), sem checagem em runtime.BaseControllerpassa a herdar dela; nada muda para quem já a usa.create_tables(metadata=None)/drop_tables(metadata=None)(issue #354): aceitam ametadatade umaDeclarativeBaseprópria; o default continuaBaseModel.metadata.
Changed¶
- Docstring e receita de
get_session_context()(issue #342) dizem na primeira linha que ele commita na saída e alertam contra usá-lo numa dependency de request: medido no FastAPI 0.141.1,http.response.startehttp.response.bodysaem antes doCOMMITdo teardown. session_dependencypassa a abrir a sessão porget_session(); o comportamento é o mesmo.- Receitas
databaseearchitecture(PT/EN), README e referência cobrem as três peças.
[0.300.0] — 2026-09-26¶
Auditoria dos módulos de IA — agents, genai (texto, RAG, áudio, imagem,
moderação), chat, modelops e vision — com cada defeito reproduzido antes
do conserto e um teste que falha no código antigo, mais os follow-ups que a
própria auditoria abriu (#315–#323, #332). Os temas: dados de um usuário que
outro alcançava (chat, memória do pipeline, runs do router de agentes); SSRF
e DNS rebinding na extração de conteúdo; request sem teto que segurava o
worker ou a GPU; trabalho síncrono no event loop; loaders que carregavam o
modelo N vezes e descarregavam no meio de uma chamada; e promessas de
docstring e receita que o código não cumpria.
Migração — leia antes de atualizar¶
Esta release quebra compatibilidade em vários pontos. Cada item aponta para o detalhe na seção da área.
- Schema do chat (migre o banco antes de atualizar o pacote).
BaseMessageAttachmentModelganhou a colunauploader_id; sem a migração,POSTeGETde mensagens respondem500— inclusive mensagem sem anexo (medido no SQLite). Migration em Chat. make_ai_chat_router:user_idsaiu do corpo (é ignorado), o dono da memória vem decurrent_user_id=, pipeline commemory=sem ele levantaValueErrorna montagem, ehistorycomrolefora deuser/assistantresponde422.ContentExtractor: intranet precisa deallow_private_networks=True; página acima de 5 MiB precisa demax_response_bytes=maior; HTTPS por proxy HTTP passa a falhar fechado (failed=True).- Limites novos respondem
422emmake_genai_router(GenAIRequestLimits),make_vision_router,make_chat_router(paginação e tetos de schema) ePOST /predictdomodelops(10 000 linhas);POST /imagerecusanum_images > 1. - Router de agentes: a rota de artefato mudou de
/runs/{index}/...para/runs/{run_id}/...;ChatBackend/ToolCallingBackendficaram posicionais e sem**kwargs; erro inesperado de ferramenta não aparece mais no traço (Agent(expose_tool_errors=True)restaura). - Chat: quem não enxerga a mensagem recebe
404onde antes recebia403. - RAG:
add/indexsubstituem por fonte — passe todos os chunks de uma fonte na mesma chamada. - Métricas:
genai_requests_totalegenai_request_secondsganharam o rótulostatus; query que compara a série inteira precisa somar ou filtrar por ele. modelops:/modeldevolve só o nome do arquivo empath(expose_model_path=Truerestaura);[modelops]passa a trazernumpy.AlembicHelper: o método síncrono que roda oenv.py, chamado com um event loop rodando, levantaRuntimeError— use o par_async.RedisFactStore: fato gravado antes sobsubject=""ousubject="_"fica na chave antiga{prefix}:_.- Helper privado
genai/_lifecycle.pymudou parautils/_lifecycle.py(sem shim; só afeta quem importava o módulo privado).
Agentes (#312)¶
Auditoria do tempest_fastapi_sdk.agents. Os tetos do AgentBudget só
eram conferidos no topo do laço, então uma ferramenta que dormia 3 s com
max_seconds=0.5 rodava os 3 s, e uma volta pedindo 50 chamadas com
max_steps=5, max_tool_calls=2 executava as 50. O router HTTP endereçava
execuções pela posição no histórico e mostrava as execuções de todo mundo a
todo mundo; o traço servido por ele carregava o texto cru de qualquer
exceção de ferramenta.
Security¶
- Execuções do router têm dono.
make_agent_router(..., owner=dep)recebe uma dependência FastAPI que devolve quem chama; cada execução é marcada (AgentRun.owner,AgentContext.owner, herdado pela delegação),GET /runslista só as de quem chama e o artefato de outra pessoa responde404, igual a uma execução inexistente. Semowner=o comportamento continua o de antes (todo mundo vê tudo), agora documentado como aceitável só com um único principal. - Exceção inesperada de ferramenta não vaza mais pelo traço. O modelo
continua lendo o texto inteiro, mas o
AgentStep.error— o que o router, o SSE e oDbAgentRunSinkexpõem — guarda só o tipo (RuntimeError: the tool failed (details withheld)), e a exceção vai para o logtempest_fastapi_sdk.agents.agent. Medido com um DSN com senha na mensagem: a senha não aparece emrun.model_dump_json()nem na resposta dePOST /run.AgentToolErrorcontinua gravado como escrito;Agent(expose_tool_errors=True)volta ao texto inteiro. - Moderador que levanta falha fechado. Antes a exceção derrubava o
run(); agora a execução terminaBLOCKEDcomblocked: moderation unavailable (<Tipo>). - A resposta estruturada passa pela moderação. O
outputde uma execução encerrada pelofinal_answeré o JSON da resposta, e é ele que o moderador confere;run_structuredsó devolvedatade execuçãoCOMPLETED.
Fixed¶
max_secondscorta a chamada travada. Chamada de modelo roda sobasyncio.timeoutdo tempo restante; chamada de ferramenta, sob esse tempo mais uma folga de 0,25 s dividida pordepth + 1, para o sub-agente — que vigia o mesmo prazo — parar sozinho e devolver o traço antes de o pai cortar. Medido: ferramenta de 3 s commax_seconds=0.5termina em 0,75 s,stop_reason=timeout. UmaTimeoutErrorlevantada pela própria ferramenta continua sendo erro comum de ferramenta.- Os tetos valem dentro de uma volta.
max_steps,max_tool_callse o prazo são conferidos antes de cada chamada; medido: 50 chamadas commax_steps=5, max_tool_calls=2executam 2 e param emmax_tool_calls. argumentscomo string JSON (formato OpenAI de vLLM/TGI) é lido; JSON inválido ou que não é objeto vira erro de ferramenta em vez de{}silencioso.- Mensagem
role: toollevatool_call_idenamequando a chamada trouxeid; semido formato fica igual ao de antes. final_answerencerra a execução — o modelo não é consultado de novo, como a receita já dizia. Resposta que não valida vira erro de ferramenta legível (invalid answer for final_answer: headline: Field required).run_structurednão perde as skills. O agente é copiado (copy.copy) em vez de reconstruído com um subconjunto dos argumentos; oload_skillanunciava ferramentas que a cópia não tinha.- Skill carregada não vaza entre pai e filho. O controle saiu do
statecompartilhado paraAgentContext.opened_skills, por contexto; a resposta estruturada, paraAgentContext.answer. - Router: execução endereçada pelo
run_idestável (/runs/{run_id}/artifacts/{name}), e{name:path}serve artefato de sub-agente (illustrator/bike.pngdava404). agent_toolpassa ao filho o nome do agente que delegou emcontext.parent(antes, o avô ou"agent").refineaprova só com a resposta inteira igual aAPPROVED(antes,startswith) e só de worker que terminou; tentativa cortada nem vai ao crítico.run_untilmantém o prazo herdado docontextquando ele é mais cedo; a passada de extração dorun_structuredherda o prazo da execução e o moderador do agente.schema_ofnão entra em recursão infinita com model auto-referente: a referência cíclica fica$refe só essas definições ficam em$defs.RedisFactStorenão junta maisNone,""e"_"no mesmo hash.Nonee subjects comuns mantêm a chave de antes (dado existente continua acessível);"","_"e o que começa com~:s:vão para"{prefix}:~:s:<percent-encoded>". Fato gravado antes sobsubject=""ousubject="_"está na chave antiga"{prefix}:_".- Artefato não é sobrescrito. Nome já ocupado por um retorno de
ferramenta vira
<nome>-1.<ext>(o texto para o modelo avisa); nas ferramentas prontas, nome escolhido pelo modelo que já existe é recusado comAgentToolError, e o nome padrão pula os ocupados. AgentRun.tool_callsinclui as delegações (StepKind.AGENT).
Changed¶
ChatBackend/ToolCallingBackenddescrevem só o que oAgentfaz: parâmetros posicionais (/), sem**kwargsobrigatório e mensagenslist[dict[str, Any]]. Entram emTHIRD_PARTY_CLIENT_PROTOCOLSdotest_protocol_shape_guard.- Rota de artefato mudou de
/runs/{index}/...para/runs/{run_id}/...;POST /rundevolverun_ide o eventodonedo SSE passa a carregar{"run_id": ...}(antes vazio).
Added¶
ToolResult.final,AgentRun.run_id,AgentRun.owner,AgentContext.agent/run_id/owner/opened_skills/answer,AgentContext.unique_artifact_name/claim_artifact_name,InMemoryAgentRunSink.get(run_id),OwnerDependencye o keywordAgent(expose_tool_errors=...).
GenAI: fronteira de confiança e SSRF (#309, #315)¶
Seis defeitos de fronteira de confiança no GenAI. O ContentExtractor
seguia redirect para qualquer lugar — um 302 para
http://169.254.169.254/latest/meta-data/ era buscado e o texto extraído
(reproduzido com httpx.MockTransport); o AIChatPipeline.stream() não
moderava a resposta que o respond() moderava e a docstring prometia; o
make_ai_chat_router aceitava role: "system" no history e tirava o
user_id do corpo, então quem soubesse o id de outra pessoa recebia os
memory_hits dela; e o RuleModerator nunca casava "$hit" nem "c++" e
caía com espaço de largura zero ou letra fullwidth.
Security¶
ContentExtractor.extractbloqueia SSRF por padrão. Sóhttp/https; o host (IP literal ou todo endereço que o hostname resolve) precisa ser público — loopback, privado, link-local (o endpoint de metadata incluso), CGNAT, reservado e multicast são recusados, e IPv4 mapeado em IPv6 (::ffff:127.0.0.1) é julgado pelo IPv4 que carrega; redirect é seguido à mão com a mesma checagem em cada salto, atémax_redirects=(default 5); o corpo é lido em stream e a busca desiste passando demax_response_bytes=(default 5 MiB). Recusa voltafailed=True, como toda falha.allow_private_networks=Truedesliga só a checagem de endereço, para intranet;resolver=injeta o DNS. O DNS rebinding que a primeira versão deste guard deixava aberto foi fechado na mesma release (#315, mais abaixo nesta seção).make_ai_chat_routernão confia no corpo.AIChatTurnSchema.roleéLiteral["user", "assistant"](um turnosystemresponde422); oAIChatRequestSchemaperdeu o campouser_id— o dono da memória vem do novocurrent_user_id=(dependência FastAPI), edependencies=aplica auth/rate limit a toda rota. Pipeline commemory=semcurrent_user_id=levantaValueErrorna montagem.AIChatPipeline.stream()modera a resposta. Novostream_moderation=no construtor:"incremental"(default) checa o texto acumulado antes de mandar cada pedaço e troca o resto porblocked_messagena primeira flag — pedaço já enviado não volta, mas o que completa o termo nunca sai;"buffered"gera tudo, modera uma vez e manda inteiro ou bloqueia. Resposta bloqueada não é indexada.- O
historypassa pelo moderador, emrespondestream, como a mensagem nova. RuleModeratornormaliza antes de casar: NFKC, remove caracteres de categoriaCf(largura zero, bidi) e casefold, nos termos e no texto; a fronteira de palavra virou lookaround ((?<!\w)…(?!\w)), então termo com pontuação na borda casa. Homóglifo de outro alfabeto continua fora.
Changed¶
respond/streamaceitamuser_id=None, que pula a memória do turno (nem busca, nem indexa) — é o que o router passa semcurrent_user_id.- O
trafilaturaroda emasyncio.to_threadnoContentExtractor, fora do event loop.
Fixed¶
- Falha de indexação na memória é logada (
WARNINGcom traceback no loggertempest_fastapi_sdk.genai.pipeline) em vez de engolida em silêncio; a resposta continua saindo.
Migração¶
- Cliente HTTP que manda
user_idno corpo não quebra — o campo é ignorado. - Pipeline com
memory=montado no router precisa demake_ai_chat_router(pipeline, current_user_id=...). historycomrolefora deuser/assistantpassa a responder422.ContentExtractorapontado para a intranet precisa deallow_private_networks=True; página acima de 5 MiB precisa demax_response_bytes=maior.
O guard de SSRF do ContentExtractor (entregue no #309) validava uma
resolução de DNS e conectava usando outra. Cada salto agora conecta no IP
que passou na checagem.
Security¶
ContentExtractor.extractfecha o DNS rebinding (#315). O guard de SSRF resolvia o host, validava e deixava ohttpxresolver de novo na conexão; um DNS que respondia IP público na checagem e127.0.0.1na conexão alcançava o endereço privado (reproduzido com um resolver fake atrás doAsyncHTTPTransportreal: a conexão ia para127.0.0.1). Agora cada salto, redirect incluso, conecta no IP validado: a URL leva o IP, e o headerHoste a extensãosni_hostnamemantêm o hostname, então o HTTPS segue validando o certificado contra o nome (medido com servidor TLS local: certificado do nome aceito, de outro nome recusado). Oresolver=passa a ser chamado uma vez por salto, e o primeiro endereço que ele devolve é o conectado.- Conexão do pool aberta para outro hostname no mesmo IP não é
reaproveitada em HTTPS. Como o pool indexa por IP, a resposta que chega
numa sessão TLS negociada para outro nome é descartada sem ler o corpo (o
que fecha a conexão) e o salto é refeito numa conexão nova, até
MAX_TLS_NAME_RETRIES(3) vezes.
Changed¶
- HTTPS por proxy HTTP volta
failed=TruenoContentExtractor. O httpcore 1.0.9 abre o túnel para o IP fixado e não manda o SNI, então a verificação do certificado falha (IP address mismatch, medido com proxy CONNECT local). Atrás de proxy quem resolve o DNS é o proxy;allow_private_networks=Truedesliga a fixação junto com a checagem de endereço.
GenAI: limites de request (#307)¶
Os routers de IA não tinham limite de request nenhum. O /generate
aceitava um prompt de 2 MB com max_new_tokens=10**9, o /embed aceitava
100 000 textos, o /image validava width=100000 e
num_images=10**6 — e renderizava o lote inteiro para devolver só a
primeira imagem —, o /rag aceitava top_k=-5, e /transcribe e as rotas
de visão liam o upload inteiro com await file.read(). Cada um é um jeito
de um único cliente segurar o worker.
Potencialmente breaking: requests que passavam agora recebem 422.
Os tetos default estão abaixo; quem precisa de mais passa
make_genai_router(limits=GenAIRequestLimits(...)) ou
make_vision_router(max_upload_bytes=..., max_image_pixels=...).
Added¶
GenAIRequestLimits(tempest_fastapi_sdk.genai) emake_genai_router(limits=...): tetos por request conferidos antes de o modelo rodar —max_prompt_chars(32_000: prompt do/generate, soma das mensagens do/chat, query do/rag, cada texto do/embed, prompt e negative prompt do/image),max_chat_messages(100),max_new_tokens(4096, só o valor que o cliente manda),max_embed_texts(256),max_top_k(50),max_tts_chars(5_000),max_image_side(2048),max_image_steps(100) emax_upload_bytes(25 MiB). Estouro é422, e comregister_exception_handlerso corpo trazdetails={"field": ..., "limit": ...}.read_upload_capped(upload, *, max_bytes, label="upload")eUPLOAD_READ_CHUNK_BYTES(tempest_fastapi_sdk.utils, reexportados na raiz): o leitor em blocos que omake_voice_routertinha como helper privado, agora público e usado por/transcribe, pelomake_voice_routere pelomake_vision_router. Para de ler no bloco que cruza o teto e levantaValidationException(422), a mesma resposta que omake_voice_routersempre deu.make_vision_router(max_upload_bytes=..., max_image_pixels=...)e as constantesDEFAULT_MAX_IMAGE_UPLOAD_BYTES(20 MiB) eDEFAULT_MAX_IMAGE_PIXELS(50 MP). O teto de pixels lê o header comPIL.Image.open, sem decodificar: medido com Pillow 12.3.0 eort-vision-sdk0.8.0, um PNG em branco de 9400 x 9400 com 10 804 bytes decodificava num array RGB de 265 080 000 bytes sem disparar o aviso de decompression bomb do Pillow (88,36 MP fica abaixo dos 89,48 MP dele).max_image_pixels=Nonedesliga a checagem.
Changed¶
ImageGenerationConfigganha teto absoluto no schema:widtheheight<= 4096,steps<= 500,num_images<= 16. Vale para a classe usada direto, não só pela rota.RagRequestSchema.top_kexige>= 1.POST /imagerecusaconfig.num_imagesacima de1com422em vez de renderizar o lote na GPU e devolver só a primeira imagem.
Fixed¶
InMemoryVectorStore.search(top_k=-3)devolvia 7 de 10 chunks (o slicescored[:-3]corta os piores do fim).top_k <= 0agora devolve[], o contrato que oChromaVectorStore.searchjá tinha. O mesmo vale paraPgVectorStore.search(que mandavaLIMITnegativo ao Postgres),HybridRetriever.searche o ranqueamento doReranker.- Rotas de visão devolviam
500para bytes que não são imagem: oImageLoadErrordoort-vision-sdkescapava sem tratamento (medido:500 INTERNAL_SERVER_ERRORantes,422 VALIDATION_ERRORdepois, com o decoder real doort-vision-sdk). GET /modelsmontava o relatório no event loop:probe=Truelê NVML etorch.cuda, chamadas síncronas que travavam todo request concorrente. Agora roda emasyncio.to_thread.
GenAI: ciclo de vida dos modelos (#314, #318, #320, #321, #332)¶
O ciclo de vida dos loaders self-hosted de genai tinha dois defeitos
repetidos em todas as classes, e o stream() do TextGenerator travava o
event loop. Três primeiras chamadas simultâneas a TextGenerator.generate
rodavam o from_pretrained três vezes (e às vezes falhavam com
NotImplementedError: Cannot copy out of meta tensor); o relógio de
ociosidade só andava no fim da chamada, então unload_if_idle soltava os
pesos no meio de uma geração longa, que morria em 'NoneType' object has no
attribute 'decode'.
Fixed¶
- Um build por cold start, e nada de unload no meio de uma chamada.
TextGenerator,Embedder,ImageGenerator,VisionTextGenerator,Reranker,ClassifierModerator,OnnxEmbedder,SpeechToText,TextToSpeecheSpeakerDiarizerpassam por um helper privado comum (utils/_lifecycle.py): lock de load (primeiras chamadas concorrentes esperam um build só) e contador de chamadas em andamento. Durante uma chamadaseconds_idlelê0.0eunload_if_idle()devolveFalse; umunload()explícito — inclusive o de uma evicção doModelRegistry— espera a última chamada terminar. Oassert engine is not NonedoSpeakerDiarizervirouRuntimeError, e as passadas do modo"auto"rodam num bloco só, sem janela para o engine sumir entre elas. - Handle guardado depois da evicção do
ModelRegistrynão recarrega mais fora domax_models(#320). A evicção marca o loader como despejado (ModelLifecycle.mark_evicted), e a próxima chamada no handle velho volta pelo registry: registra o modelo de novo na mesma chave, despeja o menos usado e espera as chamadas em andamento do despejado terminarem antes de carregar. A mesma espera vale para o primeiro build de um modelo novo doget(). Commax_models=1, guardarA, pedirBe usarAde novo dava dois modelos residentes; agora são três loads, dois unloads e pico de um residente, inclusive com oito chamadas concorrentes no handle velho e com threads alternando dois handles guardados. Exceção documentada: chamada feita de dentro da chamada de outro modelo não espera (evita deadlock), e o teto é excedido até a de fora acabar. Objeto de terceiro que só temunload()segue como antes. TextGenerator.stream()não trava mais o event loop. Load, tokenização e espera entre tokens rodam numa worker thread, e os pedaços chegam ao loop porcall_soon_threadsafe. Fechar o iterador (aclose(),break, cliente que desconectou) seta um stop event checado a cada token e volta sem esperar a thread. Medido comQwen/Qwen2.5-0.5B-Instructnuma RTX 4070 Ti SUPER: o primeiro stream travava o loop 4190 ms, agora de 164 a 193 ms (três execuções); fechar depois de 5 de 200 tokens bloqueava 3532 ms, agora ~0,01 ms, com a thread encerrada 28 ms depois.ImageGenerator.generate/editcarregam fora do loop. Oload(), o build do pipeline image-to-image e a decodificação/conversão da imagem de entrada doeditrodavam na thread do loop; agora rodam na worker thread, junto do render.probe_hardwarenão cria mais contexto CUDA quando há NVML. Nome e memória de cada GPU vêm dopynvml(extra[metrics]); o fallbacktorch.cuda.mem_get_infoinicializa CUDA no processo — medido em +209 MiB de VRAM numa RTX 4070 Ti SUPER. O NVML só é usado quando não pode discordar dos índices do CUDA (semCUDA_VISIBLE_DEVICESe mesma contagem). As docstrings deruntime_reporte da rota/models, que diziam "lê NVML" sem ressalva, agora nomeiam o fallback.TextToSpeechnão vaza mais.wavtemporário quando a síntese falha: o arquivo domkstempé removido numfinally.OnnxEmbedderusa o pad id do próprio tokenizer. Oenable_padding()sem argumentos padroniza com id 0, que no RoBERTa/XLM-R é<s>; agora o padding exportado notokenizer.jsoné mantido (com comprimento dinâmico) ou o<pad>/[PAD]é lido do vocabulário. A saída do grafo é escolhida pelo nome (sentence_embedding/pooler_outputjá pooled, depoislast_hidden_state) em vez deoutputs[0], e uma saída 2-D é usada como veio.VoiceEmbeddereFaceRecognizerentram no mesmo ciclo de vida. As duas classes tinham os dois defeitos corrigidos acima: três primeiras chamadas simultâneas construíam o extrator de voz duas vezes (omax_concurrent=2default deixava duas passarem) e os modelos de rosto três vezes, eFaceRecognizer.unload_if_idle()devolviaTrue— soltando os modelos — com umrecognize()em andamento; umunload()no meio de umVoiceEmbedder.embed()soltava o extrator na hora. Agora é um build só,unload_if_idle()devolveFalsedurante a chamada eunload()espera a última terminar. Osassertde modelo carregado viraramRuntimeError. O helper saiu degenai/_lifecycle.pyparautils/_lifecycle.py(continua privado): importar degenaipuxava 39 módulos degenaipara dentro defaces, que não depende dele.- A
seeddoTextGeneratorvale sob concorrência (#321). Omodel.generatenão aceitatorch.Generatorpor chamada (generate(..., generator=g)levantaValueErrorno transformers 4.57.6 e no 5.17.0), e otransformers.set_seedresemeava o RNG do processo: duas chamadas concorrentes com a mesma seed divergiam da execução serial (5 de 5 pares,Qwen/Qwen2.5-0.5B-Instructna CPU e na GPU) e toda chamada sem seed virava determinística por tabela. Na amostragem simples (do_sample=True,num_beams=1, sem modelo assistente) um logits processor privado sorteia o token com umtorch.Generatorda chamada; agora 0 de 5 pares divergem, e sem concorrência a mesma seed dá o mesmo texto de antes. Custo só nas chamadas com seed: o passo de escolha do token vai de 1,07 para 3,08 ms na RTX 4070 Ti SUPER e de 7,3 para 13,3 ms na CPU (vocabulário de 151 936), de 4 a 6% de throughput na GPU e 12% na CPU, medidos numa máquina carregada. Beam sampling, decodificação assistida/prompt lookup e DoLa continuam noset_seedglobal. VisionTextGeneratorhonra aseed. A doGenerationConfigera descartada em silêncio, eseed=por chamada fazia omodel.generatelevantarValueError; agora segue a mesma regra doTextGenerator.VisionTextGeneratorhonrastopestop_event(#332). OstopdoGenerationConfigera descartado em silêncio (a geração passava da string de parada), estop=oustop_event=por chamada fazia omodel.generatelevantarValueError: The following model_kwargs are not used by the model. Agora o VLM usa os mesmos helpers doTextGenerator(_resolve_controle_apply_stop_strings, promovidos a funções de módulo emgenai/text.py):stopvirastop_stringscontra o tokenizer do processor e a string de parada fica no texto, como noTextGenerator;stop_eventencerra no próximo token e levantaGenerationStoppedError.tests/genai/test_vision_text_config.pypercorreGenerationConfig.model_fields(no config e por chamada) e compara as keywords degenerate/chatcom as doTextGenerator: campo novo que o VLM nem aplica nem recusa derruba o teste.
Added¶
OnnxEmbedder(pooling="mean" | "cls")—"cls"para modelos da família BGE, treinados no token[CLS]. Default"mean", o comportamento anterior.OnnxEmbedder(idle_unload_seconds=)+seconds_idle/unload_if_idle(), que oSHIPPED.mdjá prometia: oModelRegistry.unload_idle()agora libera a sessão ONNX também.TextToSpeech(idle_unload_seconds=)+seconds_idle/unload_if_idle(), no mesmo contrato dos outros loaders.VoiceEmbedder(idle_unload_seconds=)+unload_if_idle(), no mesmo contrato; oModelRegistry.unload_idle()passa a liberar o extrator de voz.
GenAI: backends, stream e cache (#313)¶
Auditoria dos backends de genai e do stream do HTTPClient: um timeout no
meio do stream reenviava o POST e repetia o texto já entregue, o Ollama
devolvia "" para um corpo de erro, o cliente OpenAI mandava campos que o
formato OpenAI não define, os caches em memória cresciam sem limite e o
BatchScheduler podia deixar um submit esperando para sempre.
Fixed¶
HTTPClient.streamnão reemite depois da primeira linha. Oexcept ReadTimeoutenvolvia oyield, então um timeout entre dois chunks refazia o POST e o chamador recebia["Hello ", "Hello ", "world"]de dois POSTs. O retry agora cobre só a abertura, como a docstring já prometia; depois da primeira linha a falha propaga.OllamaGenerator/OllamaEmbedderlevantamOllamaErrorpara um corpo{"error": "..."}—generatenum200 {"error": "boom"}devolvia"", e uma linha de erro no stream terminava a geração em silêncio (o router emitiadone). Vale paragenerate,chat,generate_structured,chat_structured,chat_with_tools,streameembed.OpenAICompatGeneratornão manda campo só do HuggingFace.do_sample,top_kerepetition_penaltyiam crus no corpo; agora são descartados (do_sample=Falsesemtemperatureviratemperature=0), emodel/messages/streampassados como keyword levantamTypeErrorem vez de sobrescrever o corpo, como a docstring de_bodyjá afirmava.- Chave do cache de geração separa operação e identidade dos pesos.
generate(json.dumps(msgs))echat(msgs)caíam na mesma chave; e doisTextGeneratordo mesmomodel_idemrevisionouquantizationdiferente liam o cache um do outro.make_generation_key/cached_generateganhamoperation=eidentity=(keyword-only); umgeneratesem nenhum dos dois mantém o digest anterior, então um Redis quente continua quente. BatchSchedulerresolve todo future em todo caminho. Handler que levantavaCancelledErrordeixava osubmitpendurado; handler que devolvia valor semlen(None) matava o worker comTypeErrorfora dotry. Agora o lote falha comRuntimeErrore o worker segue; oCancelledErrordo próprio handler cancela só o lote; cancelar o worker cancela o lote e a fila.build_prefix_allowed_tokens_fnnão refaz o índice do vocabulário a cada chamada. OTokenEnforcerTokenizerDatafica em cache por tokenizer (WeakKeyDictionary, refeito selen(tokenizer)mudar). Medido no tokenizer doQwen/Qwen2.5-0.5B-Instruct(151 665 ids): 2,1 s de CPU na primeira chamada, 0,03 s nas seguintes, com os mesmos tokens permitidos.truncate_messagesnão separatool_callsdos resultados. Um turnoassistantcomtool_callse os turnostoolque o respondem saem juntos, então o histórico nunca começa com um resultado órfão.
Added¶
OllamaError(tempest_fastapi_sdk.genai), com.modele.detail.InMemoryGenerationCache(max_entries=1024)eInMemoryEmbeddingCache(max_entries=1024): os dois viram LRU (a leitura conta como uso);Nonedesliga o limite. Antes, 1001 prompts deixavam 1001 entradas.OpenAICompatGenerator(forward_params=...): nomes do HuggingFace que o provedor aceita como estão (o servidor do vLLM documentatop_kerepetition_penalty).OllamaGenerator.chatcom cache e métricas, como oTextGenerator.chat: cacheia chamada determinística (chavechat) e registraop="chat"com os tokens do Ollama.
Changed¶
GenAIMetrics: rótulostatus("ok"/"error") emgenai_requests_totalegenai_request_seconds. Antes uma chamada que levantava contava como sucesso. O nome das métricas não muda, mas o conjunto de rótulos sim: seletor{model, op}continua casando, porém cada série vira duas — query que compara a série inteira, ouget_sample_valuecom o conjunto exato de rótulos, precisa somar porstatusou filtrarstatus="ok".GenAIMetrics.recordganhastatus=keyword-only (default"ok").
GenAI: moderação por classificador (#316)¶
O ClassifierModerator pontuava modelo multi-rótulo com softmax e só lia os
primeiros 512 tokens (#316): com o unitary/toxic-bert, que a receita
recomenda, insult nunca passava do limiar ao lado de toxic, e um insulto
atrás de uns 650 tokens inócuos pontuava toxic em 0,001.
Fixed¶
ClassifierModeratorusa sigmoid em modelo multi-rótulo.activation="auto"(default) lêconfig.problem_type: sigmoid para"multi_label_classification"ou saída única, softmax nos demais. Medido com ounitary/toxic-bert(revisão4d6c22e), os seis rótulos emflagged_labels, sobre um conjunto fixo de 10 frases ofensivas e 5 limpas: com sigmoid, 31 rótulos reportados nas 10 ofensivas e 0 das 5 limpas sinalizadas; com softmax, 10 rótulos (sótoxic) e 1 limpa sinalizada (toxic0,556).ClassifierModeratornão trunca mais. O texto é classificado em janelas com sobreposição e cada rótulo fica com o maior score entre elas. O default é curto (window_tokens=64,window_overlap=16) porque o modelo dilui uma frase curta em texto benigno: sobre 40 trechos de 1801 a 3030 tokens com uma frase ofensiva inserida, janelas de 512 pegaram 1 de 40 e janelas de 64 pegaram 36 de 40, com 0 de 40 trechos limpos sinalizados em ambas. Texto de até 62 tokens continua numa passada só; texto maior custa mais passadas (a tabela está na receita).
Added¶
ClassifierModerator(activation=, window_tokens=, window_overlap=), keyword-only.activation="sigmoid" | "softmax"força a ativação para checkpoint cuja config não declara oproblem_type;window_tokens=Noneusa o contexto inteiro do modelo, e qualquer valor é limitado a ele.
RAG, memória de chat, hub e diarização (#311, #319)¶
Auditoria de RAG, memória de chat, hub e diarização. O defeito de raiz era a
chave do chunk: source#index (e source::index no Chroma) não é única,
porque o chunk_text recomeça o index em 0 a cada chamada — dois lotes da
mesma fonte se sobrescreviam em silêncio.
Changed¶
- Todo store do SDK e o
HybridRetrieversubstituem por fonte.InMemoryVectorStore.add,PgVectorStore.add,ChromaVectorStore.addeHybridRetriever.indexapagam o que já estava indexado para assources do lote e gravam o lote; reindexar um documento editado não deixa a versão antiga nem uma cauda dela. Passe todos os chunks de uma fonte na mesma chamada. Chunks idênticos (mesmosource,indexetext) no lote são gravados uma vez. ChromaVectorStorepassa a usar como id o SHA-256 de(source, index, text). Linhas gravadas com o id antigo (source::index) são removidas na primeira reindexação da fonte, porque a limpeza filtra pelo metadadosource. O upsert vem antes da limpeza: sem transação no Chroma, um upsert que falha deixa a versão anterior, não uma fonte vazia.ChatMemorygrava ocreated_atconvertido para UTC e umcreated_at_ts(segundos epoch) no metadado.
Fixed¶
HybridRetrieverdevolvia texto de outro chunk. Doisindex()da mesma fonte sobrescreviam o mapa por chave enquanto o BM25 guardava os dois lotes, entãosearch("alpha")voltava textos debeta, e reindexar duplicava o corpus esparso. A chave agora é(source, index, text), e linhas que umVectorStorede terceiro (só-acrescenta) ainda devolve são descartadas antes da fusão.ChromaVectorStore.addperdia linhas em silêncio: dois chunks com mesmosourceeindexe textos diferentes no mesmo lote viravam um só; o mesmo chunk duas vezes no lote levantavaDuplicateIDErrorno Chroma real.- BM25 sem sobreposição invertia o ranking denso. Sem termo em comum
todos os scores são 0, e a lista "ordenada" era a ordem de inserção, somada
no RRF. Só entram na fusão os chunks que compartilham ao menos um token com
a query (teste por token, não
score > 0, porque o BM25 dá score negativo a termo presente na maioria dos documentos de um corpus pequeno). reciprocal_rank_fusioncontava um id repetido na mesma lista mais de uma vez; agora só a primeira (melhor) posição conta, e as repetições continuam ocupando posição.ChatMemory.searchpedia exatamentetop_kao Chroma e reordenava só esses, então uma mensagem recente fora do top-K cru nunca subia — a promessa da classe. Novocandidate_multiplier(keyword-only, padrão4,ValueErrorabaixo de 1): o Chroma é consultado portop_k * candidate_multipliere o re-rank escolhetop_k.- A cota do
ChatMemorydespejava a mensagem errada entre fusos: ordenava as strings ISO, que põem10:00+05:00depois de08:00+00:00. Ordena pelo instante UTC (created_at_ts, com fallback para o ISO das linhas antigas). A contagem lê só os ids; o metadado só é buscado quando a cota estoura. download_model(check_disk=True)somava o repositório inteiro, ignorandoallow_patterns/ignore_patternse o cache: um repo de 28 GB filtrado para 14 GB de safetensors, com 20 GB livres, recusava com "needs ~30.8 GB". Agora conta só os arquivos que os padrões selecionam (pelahuggingface_hub.utils.filter_repo_objects, a mesma dosnapshot_download) e que ainda não estão no cache para a revisão — o mesmo caso pede ~15,4 GB.model_disk_bytesganhaallow_patternseignore_patternskeyword-only.- Os modelos de diarização não eram verificados:
SEGMENTATION_MODELeEMBEDDING_MODELtinhamsha256="", e oensure_modelspulava a checagem que a docstring prometia. Os digests estão fixados (medidos comcurl -L+sha256sumsobre os assets do release do k2-fsa; o GitHub não publica digest para eles), e o download usa timeout de socket de 60 s — ourlopensem timeout travava o primeiroload()para sempre numa conexão muda.
Security¶
PgVectorStorevalida o nome da tabela, que é interpolado em DDL e DML: fora denomeouschema.nomesem aspas ([A-Za-z_][A-Za-z0-9_]*, até 63 caracteres cada) éValueErrorno construtor, assim comodimque não seja inteiro positivo. O store também cria um índice B-tree emsource, grava o lote numexecutemanye ganhou um teste contrapgvector/pgvector:pg16(make test-docker).
Índice aproximado no PgVectorStore (#319). Toda busca era varredura
sequencial com <=>, e o índice HNSW/IVFFlat ficava como passo manual.
Added¶
PgVectorStore.ensure_schema(ann_index="hnsw" | "ivfflat", m=, ef_construction=, lists=)(keyword-only) cria<table>_embedding_idxcomvector_cosine_ops, a operator class do<=>que a busca usa. ParâmetroNonefica fora doWITH(vale o default do pgvector); parâmetro do outro método, ou semann_index, levantaValueErrorantes de qualquer SQL."hnsw"num pgvector abaixo de 0.5.0 levantaRuntimeErrorcom a versão encontrada (medido contraankane/pgvector:v0.4.4; o IVFFlat segue funcionando lá). Um índice com esse nome que já existe com outro método ou outros parâmetros levantaValueErrorpedindoDROP INDEX— nunca é reconstruído em silêncio. NovoPgVectorStore.ann_index_namee o tipoAnnIndex(tempest_fastapi_sdk.genai.rag).PgVectorStore.search(ef_search=, probes=)(keyword-only) aplicahnsw.ef_search/ivfflat.probesviaset_config(..., true)— oSET LOCALem forma de função, com valor bindado —, válidos só na transação da busca: a sessão seguinte no mesmo pool volta a ler40e1. Medida de latência e recall@10 a 100 000 vetores na receita de genai.- Teste
@pytest.mark.dockercontrapgvector/pgvector:pg16confirma que o índice existe e que oEXPLAINdo statement que osearchenvia (capturado no engine) usaIndex Scannele. O container do teste ganhou nome e porta por processo, então dois checkouts rodandomake test-dockerao mesmo tempo não derrubam o banco um do outro.
Chat (#310, #317)¶
O chat tratava o id de uma mensagem como capability: toda rota que
recebe só /messages/{id} carregava a linha pelo id e seguia, sem olhar
a conversa dela. O /stream segurava uma sessão de banco pela vida da
conexão e continuava entregando depois que a pessoa saía, e o histórico
aceitava page_size=0.
Security¶
react/unreactexigem enxergar a mensagem. Antes, qualquer usuário autenticado que soubesse um id recebia200com aMessageResponseSchemainteira (body, anexos,storage_key), e a reação era gravada e publicada no SSE de uma conversa em que ele nunca esteve. Agora o usuário precisa ser participante ativo da conversa da mensagem, e a mensagem não pode ser anterior aohistory_fromdele. Fora disso a resposta é o not-found do próprio repositório — mesma classe, mesma mensagem, corpo HTTP idêntico ao de um id inexistente —, para o id não virar oráculo de existência.forwardconfere a original. O encaminhamento copiava body, payload e as linhas de anexo de qualquer mensagem para uma conversa do chamador; agora é leitura da original e segue a mesma regra.edit_message/revoke_messageexigem participação ativa, além de ser o remetente: quem saiu da conversa não reescreve nem apaga mais o que disse lá. Quem não enxerga a mensagem recebe404, não o403"only the sender", que confirmava a existência.- Responder citando mensagem anterior ao
history_fromé404: o stub da citação carregava o trecho do backlog que o recém-chegado não recebeu. - O
/streamfecha quando a participação acaba.leaveeremove_participantpublicamparticipant.removed(PARTICIPANT_REMOVED_EVENT, com oParticipantResponseSchemade quem saiu); o stream de quem ele nomeia entrega o evento e termina, e a reconexão leva403. Saída feita fora doChatServiceé pega pela releitura da participação a cadamembership_recheck_seconds(MEMBERSHIP_RECHECK_SECONDS,30.0;Nonedesliga), numa sessão curta.
Fixed¶
- O
/streamnão segura mais sessão de banco. O endpoint recebia o serviço porDepends, e a sessão comyieldficava aberta — conexão do pool emprestada, transação começada — enquanto o cliente estivesse conectado: N abas ociosas esgotavam o pool. A checagem de participante agora roda numa sessão própria, fechada antes do primeiro byte (medido com um contador de sessões abertas:1antes,0depois, com o stream aberto). revoke_messagesó devolve chave que ninguém mais referencia. Encaminhar aponta a cópia para a mesmastorage_key, então revogar a original mandava o chamador apagar o arquivo que a cópia ainda renderizava. A chave sai agora na revogação que remove a última linha de anexo que a cita (comostorage_keyouthumbnail_key).- Paginação do histórico.
page_size=0era500(ZeroDivisionError),page_size=-1viravaLIMIT -1— que o SQLite lê como "sem limite": 25 de 25 mensagens numa página — epage=0respondia200. O router exigepage >= 1e1 <= page_size <= max_page_size(422fora disso), eChatService.list_messageslevantaValidationExceptionpara os mesmos valores.
Added¶
make_chat_router(max_page_size=..., membership_recheck_seconds=...), keyword-only, com defaultsMESSAGES_PAGE_SIZE_MAX(100) eMEMBERSHIP_RECHECK_SECONDS(30.0).- Tetos nos schemas,
422quando passados:bodyde postar e editar (MESSAGE_BODY_MAX_LENGTH, 65 536 caracteres),attachment_ids(MESSAGE_ATTACHMENTS_MAX, 32),ForwardSchema.conversation_ids(FORWARD_TARGETS_MAX, 20),participant_ids(PARTICIPANT_IDS_MAX, 256), etitle/descriptionno tamanho da coluna (255 / 512), que o schema não conferia.
Changed¶
- Quem não enxerga a mensagem recebe
404emPATCH/DELETE /messages/{id}onde antes recebia403; o remetente que ainda é participante continua recebendo403para mensagem alheia.
A linha de anexo continuava sem registrar quem fez o upload; a correção,
que exige coluna nova e migração, está na seção do uploader_id abaixo.
O anexo do chat não registrava quem fez o upload, então qualquer participante que soubesse o id de um anexo ainda não reivindicado — vazado num log, numa URL compartilhada — o prendia à própria mensagem (#317).
Added¶
BaseMessageAttachmentModel.uploader_id(nullable, indexado, sem FK) eChatService.add_attachment(uploader_id, *, storage_key, ...), que grava a linha do upload já com o autor e devolve oAttachmentResponseSchemacujoido autor posta. Encaminhar copia ouploader_idda linha original.
Security¶
- Só quem enviou o arquivo o reivindica.
post_messagerecusa umattachment_idscujouploader_idnão é o remetente com o mesmo404de um id inexistente (unknown or already claimed attachment, mesmofield), então a resposta não confirma que o arquivo existe — e o anexo continua livre para o autor postar. Linha comuploader_idNULL(gravada antes da coluna, ou por um upload que não a preenche) segue a regra antiga e é reivindicável por qualquer remetente: é a janela de transição, que fecha quando não sobra linhaNULLsem mensagem.
Migração¶
Breaking de schema: migre o banco antes de atualizar o pacote. A coluna
mora na tabela abstrata, então toda tabela de anexo concreta a herda e todo
SELECT nela passa a nomeá-la. Sem a migração quebra toda rota que monta
mensagem — inclusive mensagem de texto sem anexo, porque a resposta lê os
anexos. Medido com SQLite, código novo sobre a tabela antiga:
POST /api/chat/conversations/{id}/messages -> 500
GET /api/chat/conversations/{id}/messages -> 500
GET /api/chat/conversations -> 200
sqlite3.OperationalError: no such column: message_attachments.uploader_id
A migração é o que o alembic revision --autogenerate gera sobre a tabela
antiga — uma coluna nullable e um índice, sem backfill:
op.add_column("message_attachments", sa.Column("uploader_id", sa.Uuid(), nullable=True))
op.create_index(op.f("ix_message_attachments_uploader_id"), "message_attachments", ["uploader_id"], unique=False)
O exemplo completo, com downgrade(), está na receita de chat (seção
Anexos). Quem grava a linha de upload à mão em vez de chamar
add_attachment precisa passar uploader_id= — sem ele, a linha nova
nasce NULL e continua reivindicável por qualquer um.
modelops (#308)¶
Auditoria do caminho de serving do modelops. O [modelops] não declarava
o numpy que o monitor e o leitor compacto importam; a inferência e o reload
rodavam no event loop; o lote não tinha teto; as rotas de operação não tinham
guard e expunham o caminho absoluto do modelo; o monitor guardava uma chave
por valor de regressor e comparava rótulo pelo texto; e o reload aquecia a
sessão depois de colocá-la no ar, engolindo a falha.
Fixed¶
[modelops]declaranumpy.baseline_from_samples,PredictionMonitorcom baseline,read_compactepredict_compactimportamnumpy, e o extra trazia sópsutil+nvidia-ml-py: medido numa venv limpa comtempest-fastapi-sdk[modelops]==0.299.0,baseline_from_sampleslevantavaModuleNotFoundError: No module named 'numpy'. Com a wheel desta branch, o mesmo install roda; sem o extra, o erro agora é umImportErrornomeando[modelops].POST /predicte o reload doPOST /model/syncsaem do event loop (asyncio.to_thread). Medido com um predictor que segura a thread por 1 s: na 0.299.0 o loop ficava parado 0,992 s; agora o atraso de agendamento fica abaixo de 1 ms.- O monitor tem memória limitada de verdade. Cada rótulo distinto virava
uma chave: 50 000 saídas de regressor davam 50 000 chaves e um relatório de
889 270 bytes de JSON. Agora no máximo
MAX_TRACKED_LABELS(64) chaves próprias, mais as classes da baseline (que nunca perdem a delas), e o resto emOTHER_LABEL("__other__"): 65 chaves, 1 350 bytes. A docstring do módulo, que já dizia "memória limitada", passa a ser verdade. - PSI de saída compara rótulo por valor. Baseline com alvo float
(
"0.0") contra classificador que responde inteiro ("0") dava PSI 26,24 (significant) sobre distribuições idênticas; agora 0,0 (stable). Baselines já salvas com chaves"0.0"são normalizadas na leitura. OnnxPredictor.reloadaquece antes de trocar. A docstring prometia "Warm the new session before it serves", mas o código trocava a sessão e aquecia depois, engolindo a exceção. Agora a inferência de aquecimento roda na sessão nova antes do swap; falhou,reloadlevantaRuntimeErrore o modelo anterior continua servindo.warm_up()público registra a falha em log em vez de engolir em silêncio.OnnxPredictor.predictlê sessão e descrição juntas. A largura era validada contraself.infofora do lock e a sessão lida depois: um reload no meio fazia a linha ir para o modelo novo e virarInvalidArgumentdo runtime (500) em vez doValueError(422).RegistryModelSource.syncé serializado por umasyncio.Lock: medido na 0.299.0, três syncs concorrentes faziam três downloads da mesma versão para o mesmo caminho; agora um download e um reload — e é o lock que deixa o reload rodar em thread sem que os três recarreguem. O download cai num.parte só é renomeado quando termina — antes, um download interrompido deixava o arquivo parcial no caminho final, e o próximo sync o tratava como versão em cache.
Added¶
make_prediction_router(max_rows=...), defaultDEFAULT_MAX_PREDICT_ROWS(10 000): lote maior vira422;Nonetira o limite; valor não positivo levantaValueErrorna construção.make_prediction_router(dependencies=..., admin_dependencies=...): o router não traz autenticação;admin_dependenciesprotege/model,/model/synce/monitordeixandoPOST /aberto, edependenciesprotege todas as rotas.RegistryModelSource(checksum_field="sha256"): quando a linha da registry tem esse atributo preenchido, o download é conferido por SHA-256 antes de ser cacheado ou carregado; arquivo divergente é apagado e o sync responde503. OArtifactVersionMixinnão declara a coluna — é opt-in pelo schema do consumidor, sem migration no SDK.Nonedesliga.PredictionMonitor(max_labels=...)e as constantesMAX_TRACKED_LABELS/OTHER_LABEL.
Changed¶
GET /modelePOST /model/syncdevolvem só o nome do arquivo empath(classifier.onnx), não o caminho absoluto do dispositivo.make_prediction_router(expose_model_path=True)restaura o comportamento anterior.POST /predictrecusa lote acima de 10 000 linhas por default, antes ilimitado.
Migrações: AlembicHelper async (#323)¶
AlembicHelper não tinha caminho para código async (#323). O env.py que o
SDK gera sobe o engine com asyncio.run(...), então helper.upgrade()
chamado de um lifespan do FastAPI morria dentro do Alembic com asyncio.run()
cannot be called from a running event loop e um RuntimeWarning: coroutine
'run_async_migrations' was never awaited; a docstring mandava usar
asyncio.to_thread, e cada serviço escrevia esse passo à mão. O check()
era pior: engolia o erro e devolvia False, relatando drift que nunca foi
medido.
Added¶
- Par
_asyncpara todo método que roda oenv.py:upgrade_async,safe_upgrade_async,downgrade_async,stamp_async,revision_async,check_async,current_async,has_existing_schema_async,adopt_async,sync_schema_async,squash_asyncepending_destructive_ops_async, com a mesma assinatura do síncrono (teste compara). Rodam o síncrono numa worker thread — sem loop próprio, então oasyncio.rundoenv.pyfunciona lá — e por isso funcionam com todoenv.pyjá gerado. Medido com 20 revisions em SQLite:upgrade_asynclevou de 0,10 a 0,14 s e um ticker de 5 ms no loop seguiu rodando (maior intervalo entre ticks de 7 a 26 ms, três execuções). Rodar no loop viaAsyncConnection.run_sync(receita de connection sharing do Alembic) foi descartado como default: contra oenv.pygerado até a 0.299.0 ele falha com o mesmo erro deasyncio.run. env.pygerado aceitaconfig.attributes["connection"]. Recebendo uma conexão (de dentro deAsyncConnection.run_sync), migra nela sem criar engine; executado de um loop sem conexão, levantaRuntimeErrorexplicando as duas saídas antes de criar a coroutine, em vez do erro deasyncio.runcom coroutine não aguardada. Vale para projeto que regenerar oenv.py; o existente continua funcionando com os métodos_async.
Changed¶
- Método síncrono que roda o
env.pylevanta com um loop rodando (upgrade,safe_upgrade,downgrade,stamp,revision,check,adopt,sync_schema,squash):RuntimeErrornomeando o par_async, antes de tocar no Alembic.revisionrecusa mesmo comautogenerate=False, porquerevision_environmentno ini faz esse caminho executar oenv.py.current()ehas_existing_schema()continuam funcionando no loop quando há driver síncrono; só o fallback só-async (asyncpgsempsycopg2) levanta, nomeandocurrent_async/has_existing_schema_async. Quem chamava o síncrono de código async com umenv.pypróprio semasyncio.runpassa a receber o erro — troque pelo_async. - Receitas
migrationsedatabase(PT/EN) usam os métodos_asyncno lifespan; oasyncio.to_threadescrito à mão saiu dos exemplos. O scaffold dotempest newnão chama o helper no lifespan, então não mudou.
[0.299.0] — 2026-09-25¶
Quatro pontes do alofans-api sobem para o SDK. O 500 de um
IntegrityError não tratado escrevia no log o valor que o cliente mandou,
porque o DETAIL do Postgres cita a linha (issue #296); o painel
/admin não tinha como esconder uma credencial que não é senha (issue
297); o VoucherDocument deixava a codificação do QR para cada serviço,¶
que calibrava às cegas um quadro que é do template (issue #300); e o nome da tabela de um model só se lia pelo dunder (issue #304).
Added¶
BaseModel.get_table_name()(issue #304): classmethod que devolve o nome da tabela mapeada, lido deinspect(cls).local_tablee não do__tablename__— cobre o nome derivado, o__tablename__explícito, o model declarado com__table__ = Table(...)e a herança de tabela única (a subclasse responde a tabela do pai). Tipadostr(medido com basedpyright). Model abstrato levantaNoInspectionAvailable; model mapeado sobre join/subquery,TypeError.AdminSite.get/require/unregisteraceitam a classe do model além da string do slug.AdminModel.get_slug,Inline.get_sluge odiscover_modelspassam a usarget_table_name().VoucherDocument(qr_content=...)(issue #300): o texto (URL de verificação, Pix BR Code) que o SDK codifica como QR, uma vez, na validação — conteúdo longo demais para qualquer versão éValidationError, não falha no render.qr_contenteqr_data_urijuntos levantamValidationError;qr_data_uricontinua aceito sozinho. A imagem vive num atributo privado exposto porqr_image, entãomodel_dump()valida de novo, e o cache é chaveado pelo conteúdo:model_copy(update={"qr_content": ...}), que não roda validator, recodifica em vez de imprimir o QR do documento de origem.tempest_fastapi_sdk.pdf.qr_data_uri(content, *, error, scale, border)e as constantesQR_ERROR_CORRECTION("h"),QR_SCALE(8) eQR_BORDER(2), calibradas para o quadro de26mmdovoucher.html. Medido comsegno1.6.6: um Pix BR Code de 197 caracteres sai na versão 15, módulo de0,32mm, e renderizado pelo WeasyPrint e rasterizado a 100 dpi decodifica nozxing-cpp.segno>=1.6.6no extra[pdf](e no[all]): Python puro, sem dependência de runtime e sem upper bound.AdminModel(exclude_fields=...)(issue #297): coluna que o painel nunca mostra nem aceita — para credencial que não é senha (endpoint/p256dh/authde Web Push,push_token,totp_secret).readonly_fieldstrancava o input e mostrava o valor no detail. A coluna sai da listagem, do detail, do form de create/edit (oPOSTque manda o campo é ignorado), do import e do export CSV/JSON, do cabeçalho ordenável, da tabela de inline do pai (mesmo com oInlinenomeando a coluna no própriolist_display), das linhas da linha do tempo de auditoria e do rótulo de FK (fk_label). Validado na construção: coluna inexistente, coluna que outra opção também nomeia (list_display,list_filter,search_fields,readonly_fields,upload_fields,autocomplete_fields,password_fields,identity_field,ordering,Lens) e colunaNOT NULLsem default comcan_create=TruelevantamValueError.BaseModel.__audit_redact__(ClassVar[frozenset[str]]) +AUDIT_REDACTED,DEFAULT_AUDIT_REDACT,audit_redacted_columnseredact_snapshot(tempest_fastapi_sdk.db.audit, re-exportados). As entradasfor_create/for_update/for_deletegravam"[redacted]"no lugar do valor das colunas listadas (e dehashed_password, sempre); no update o diff sai dos valores crus, então a rotação continua registrada. Nome que não é coluna levantaValueError.AdminModelcomaudit_model=recusaexclude_fieldsfora do__audit_redact__do model — esconder a linha do tempo não tirava o valor da tabela.AdminModel.hidden_field_names(): o conjunto único (hashed_password+password_fields+exclude_fields) que toda superfície de leitura do painel filtra.redact_database_errors,RedactedErroreExceptionRedactor(tempest_fastapi_sdk.api.redaction, re-exportados no topo). A função devolve o mesmo objeto quando a cadeia não temsqlalchemy.exc.DBAPIError; quando tem, devolve uma cópia deRedactedErrorque mantém todo frame e troca o texto do erro de banco porunique violation; constraint=...; columns=...; driver=...; database message withheld from the log. O texto do servidor sai para todoDBAPIError(umDataErrorcita a entrada recusada), o statement SQL também (umtext()com f-string carrega o literal), e a exceção do driver sai da cadeia. Percorre__cause__,__context__e membros deExceptionGroup, termina em cadeia cíclica e nunca muta o original.describe_database_erroreWITHHELD_NOTICE(tempest_fastapi_sdk.db.integrity, re-exportados no topo): o resumo em uma linha, público para oexcept IntegrityErrordo serviço.redact_exception=emregister_exception_handlers,make_unhandled_exception_handler,make_app_exception_handleremake_http_exception_handler. Defaultredact_database_errors;Noneloga a exceção como levantada.
Changed¶
voucher.htmldeclaraimage-rendering: pixelatedno QR: o WeasyPrint passa a gravar/Interpolate false(antes,true, que pede ao visualizador para suavizar a imagem).- CI:
timeout-minutes: 45no job epytest-timeout(timeout = 300por teste, grupodev), para um hang falhar em minutos em vez de segurar o runner. zxing-cppentra no grupodevcomo decoder independente dos testes do QR. Não é dependência de runtime.
Fixed¶
- O
lifespan(scheduler=True)não pendura mais no shutdown. O supervisor do lease só saía peloCancelledError, e uma dependência pode engoli-lo — no CPython 3.11 oasyncio.wait_fordevolve o resultado quando o futuro interno completa no mesmo tick do cancel, e ofakeredisespera cada resposta por ele. O supervisor continuava renovando e o exit esperava para sempre (o job 3.11 do CI ficou 2h20 parado emtest_scheduler_lease.py). Agora o loop checacurrent_task().cancelling()a cada volta: medido em 3.11, 1000 ciclos de lifespan com 156 cancels engolidos pela dependência e zero travamentos. -
O cancel do próprio shutdown não é mais engolido. O
suppress(CancelledError)em volta doawait supervisorabsorvia também um cancel dirigido a quem roda o exit (um shutdown com prazo, umwait_for), que então retornava como se tivesse terminado. -
A linha do tempo de auditoria do admin não mostra mais
hashed_passwordnempassword_fields. O detail renderizava o antes/depois do digest; agora filtra porhidden_field_names(). - A tabela de auditoria não grava mais o digest de
hashed_password. Entradas novas trazem"[redacted]"; as já gravadas não são reescritas. - O log de 5xx não carrega mais o
DETAILdo Postgres. Medido contra Postgres 16 comhide_parameters=True: antes, oexceptiondoJSONFormatterterminava emDETAIL: Key (cpf)=(123.456.789-00) already exists.; agora termina no resumo acima, sem o valor. Vale para o catch-all, paraAppException5xx levantadafromo erro de banco e paraHTTPException5xx. A resposta e o queon_server_errorrecebe não mudam. - O
warningde conflito doBaseRepositorytambém. Os dez caminhos que convertemIntegrityErroremConflictException(add,add_all,update,save_with_outbox, as variantes_audited, ...) logavamexc.orig, com oDETAIL; agora logamdescribe_database_error(exc).
[0.298.0] — 2026-09-24¶
order_by era str livre e o BaseRepository aceitava qualquer coluna
mapeada: numa listagem pública isso é um oráculo de ordenação — ordenar
por wallet ranqueia a base por saldo, e quem controla a própria linha
extrai o valor da vizinha por busca binária. A recusa ainda publicava
details["allowed"] com toda coluna do model, hashed_password
inclusive, e page_size não tinha teto (issue #295). Duas outras pontes do
alofans-api sobem junto: a paginação por raio, que o consumidor fazia em
memória, e a resolução de coordenada de endereço brasileiro.
Added¶
orderable_columnsemax_page_sizeemBasePaginationFilterSchema(ClassVar). Com o conjunto declarado,order_byfora dele levantaOrderByNotAllowedExceptionjá na validação do schema (422,code="ORDER_BY_NOT_ALLOWED",field="order_by",detailscomorder_byeallowed— só o conjunto declarado, ordenado). Declararmax_page_sizenuma subclasse reescreve ole=depage_size, então a recusa é oless_than_equaldo pydantic e o OpenAPI publicamaximumno parâmetro — medido comDepends()e comAnnotated[..., Query()].Noneremove o teto.CursorPaginationFilterSchemaganhaorderable_columnsemax_limit(defaultDEFAULT_MAX_CURSOR_LIMIT= 500, ole=500de sempre).BaseRepository.orderable_columns/max_page_size, como atributo de classe ou argumento do construtor (o construtor vence) — a segunda linha para quem chega sem o schema. Valem parapaginate,cursor_paginate,changes_sinceepaginate_nearby. Nome declarado que não é coluna mapeada levantaValueErrorna construção.max_page_sizedo repository tem defaultNone: o export do painel/adminpagina 5000 linhas por default.PageSizeTooLargeException(422,PAGE_SIZE_TOO_LARGE,field="page_size",detailscompage_sizeemax_page_size) eOrderByNotAllowedException, as duas subclasses deValidationException, com tradução PT-BR e EN no catálogo.DEFAULT_MAX_PAGE_SIZE(100) eDEFAULT_MAX_CURSOR_LIMIT(500).GeoRepositoryMixin.paginate_nearby— raio, ordenação por distância,COUNTeOFFSET/LIMITno banco, devolvendo o envelope de paginação do SDK com cada item umNearbyMatch(row, distance_km)(tupla nomeada). Linha com latitude ou longitudeNULLnão entra; empate desempata porid;extra_filters,whereequeryse somam ao raio. Rodado contra SQLite 3.47.1 e contrapostgres:16-alpinesem PostGIS (tests/geo/test_db_postgres_live.py,make test-docker).haversine_distance_sql(latitude, longitude, center)— a distância como expressão SQL, só comsin/cos/asin/sqrt. O termo é limitado a[0, 1]comCASE: em pares antípodas aleatórios ele saiu1.0000000000000002em cerca de 4% de 2 000 000 sorteios no PostgreSQL 16 (e também no SQLite), easinacima de 1 éNULLno SQLite eERROR: input is out of rangeno PostgreSQL.extract_cep(*texts)eCEP_PATTERN— o primeiro CEP escrito em texto livre, normalizado para00000-000.resolve_br_coordinate(*, geocoder, uf, address, city, complement, country)— CEP → endereço completo → centroide da UF.geocoder=Nonevai direto ao centroide; exceção do geocoder é logada emWARNINGe a cadeia segue; UF desconhecida devolveNone. Retry fica no geocoder injetado.
Changed¶
- A recusa de
order_bydo repository não lista mais as colunas do model. Semorderable_columns,detailsé só{"order_by": ...}; com ele,allowedé o conjunto declarado. Ocodepassou deVALIDATION_ERRORparaORDER_BY_NOT_ALLOWED—except ValidationExceptioncontinua pegando. page_sizetem teto de 100 por default emBasePaginationFilterSchemaeCompactPaginationFilterSchema. Cliente que pedia mais passa a levar 422; declaremax_page_size(ouNone) na subclasse.order_byvazio viraNoneemBasePaginationFilterSchema(antes chegava ao repository e respondia 422).
[0.297.1] — 2026-09-23¶
Fixed¶
- A instrução de instalação do
OIDCTokenVerifierpedia um extra que não acrescenta nada. OImportErrordo construtor, a receita de OAuth, a tabela de extras do README e a de instalação mandavam instalar[oidc,http]ou usar o[oidc]"junto com[http]". O[http]traz sóhttpx>=0.28.1, que o pacote base já declara com o mesmo piso. Medido numa venv vazia com a 0.297.0 do PyPI e só[oidc]: o caminho inteiro — busca do JWK Set peloHTTPClientdefault contra um servidor local e verificação de um token RS256 — passou. A mensagem agora dizpip install "tempest-fastapi-sdk[oidc]", e o teste fixa a instrução exata em vez de só o nome do extra.
[0.297.0] — 2026-09-23¶
O OIDCProvider resolvia a identidade pelo userinfo e a audiência pelo
tokeninfo_url: duas idas ao IdP por login, e nenhuma verificação da
assinatura do token. O serviço que queria verificar offline um token de
realm Keycloak escrevia isso à mão — e o que errava não era a criptografia,
era a qual status cada falha corresponde (issue #288).
O e-mail de ativação sai uma vez. Quando ele não chega, a conta ficava sem
saída nenhuma que não fosse UPDATE no banco.
Added¶
OIDCTokenVerifier(tempest_fastapi_sdk.api.oidc_verifier) — verificação offline de access token assinado por realm OIDC. Busca o JWK Set peloHTTPClientasync injetado, faz o parse comjwt.PyJWKSet.from_dicte guarda as chaves em memória porlifespan(default 300s). Umkiddesconhecido dispara no máximo uma atualização pormin_refresh_interval(default 60s), e requisições simultâneas esperam a mesma busca. OPyJWKClientficou de fora de propósito: medido no PyJWT 2.13.0 contra o JWK Set de um Keycloak 26.3.5, cincokidforjados custaram cinco buscas, e a busca dele éurllib.request.urlopen, bloqueante. Lista dealgfechada (noneé removido mesmo se listado;HS256enonesão recusados antes de qualquer busca),exp/iss/subsempre obrigatórios — o PyJWT só confereexpquando presente, e um token sem ele verificava —, chavesuse: "enc"descartadas, e audiência conferida sobreaud+azp+client_idjuntos.OIDCProvider(token_verifier=...). Com o verificador,verify_token_audienceefetch_userleem os claims verificados, sem chamartokeninfo_urlnemuserinfo_url. DefaultNonemantém o comportamento anterior. Medido contra um Keycloak 26.3.5 real (realm e clientmobile-apprecém-criados, grantpassword): o access token saiu com"aud": "account"e"azp": "mobile-app", verificou com uma única busca do JWK Set, efetch_userdevolveu e-mail,email_verified=Truee nome dos claims.OAuthProviderUnavailableException— subclasse deOAuthError, 502OAUTH_PROVIDER_UNAVAILABLE, com tradução PT-BR e EN. É o único caso do verificador em que repetir faz sentido: JWK Set inalcançável, não-2xx, não-JSON ou sem chave de assinatura.kidausente do conjunto lido, token que não é JWT,algfora da lista, assinatura/iss/expinválidos respondem 401OAUTH_TOKEN_REJECTED;aud/azpde outro client, 401OAUTH_TOKEN_AUDIENCE_MISMATCH.-
Extra
[oidc]—pyjwt>=2.13.0+cryptography>=50.0.1, que o PyJWT exige para RS256/EC. Numa venv vazia com a wheel e[oidc,http], um token RS256 verificou com PyJWT 2.14.0 e com o piso 2.13.0; só com[auth,http], o construtor levantaImportErrornomeando o[oidc]. O[all]já trazia os dois pacotes. -
POST /auth/activation/request— reenvio do link de ativação, sem credencial. Cadastro feito, e-mail perdido no filtro de spam:signupde novo responde 409 (e-mail já na tabela),loginresponde 401 (a conta é inativa) eemail-verify/request— cuja própria descrição se oferece para esse caso — exige bearer token, que só sai de um login que não acontece. A rota nova é o caminho de volta, e não pede autenticação por necessidade, não por descuido.
UserAuthService.request_activation(session, *, email) é o verbo por
trás dela. Endereço desconhecido, conta já ativa e reenvio legítimo
respondem o mesmo 202 com a mesma frase — a disciplina anti-enumeração
que o password-reset/request já tinha. Conta ativa não é remetida: o
link não autorizaria nada e o e-mail contaria a um estranho que aquele
endereço tem conta aqui. O token novo respeita
AUTH_ACTIVATION_TTL_SECONDS e, sob AUTH_SINGLE_ACTIVE_TOKEN (default),
queima o anterior.
Schemas ActivationRequestSchema e ActivationResendResponseSchema
exportados no topo. tests/auth/test_activation_resend.py fixa o
caminho inteiro — signup → login 401 → reenvio → ativação → login 200 —
e a indistinguibilidade das três respostas.
Fixed¶
- A introspection do Keycloak recusava todo token. A receita mandava
apontar o
tokeninfo_urldoOIDCProviderpara.../token/introspect, mas otokeninfo_urlé chamado comoGET ?access_token=. A RFC 7662 pedePOSTcom autenticação do client. Medido num Keycloak 26.3: oGETresponde 405 e a rota devolvia 401OAUTH_TOKEN_REJECTEDpara o token válido.OIDCProvider(introspection_url=...)faz oPOST token=comclient_id/client_secretno corpo (oclient_secret_postda troca do código). Contra o mesmo Keycloak: - token vivo do nosso client: aceito;
- token lixo (
200 {"active": false}): 401OAUTH_TOKEN_REJECTED; - token de outro client: 401
OAUTH_TOKEN_AUDIENCE_MISMATCH; - secret errada (o Keycloak responde 401): 502
OAUTH_ERROR, porque a credencial errada é a do serviço, não a de quem chamou; - porta fechada: 502
OAUTH_PROVIDER_UNAVAILABLE.
Passar tokeninfo_url e introspection_url juntos levanta ValueError.
A mensagem de OAUTH_PROVIDER_UNAVAILABLE ficou genérica ("Não foi
possível falar com o provedor de identidade"), porque agora cobre as duas
chamadas; details.reason diz qual falhou.
Changed¶
- A regra de audiência (
aud/azp/client_idcontraclient_id+extra_audiences) virou uma função de módulo usada pelo caminho detokeninfo_urle pelo verificador — a mesma regra nos dois, sem cópia. O método privado_BaseOAuthClient._assert_audiencesaiu.
[0.296.0] — 2026-09-22¶
O 500 de uma exceção não tratada saía fora do CORSMiddleware. O
navegador descartava a resposta, e o front via só TypeError: Failed to
fetch — sem status, sem code, sem X-Request-ID. O scaffold do
tempest new gerava exatamente essa pilha (#287).
Fixed¶
- O envelope de 500 passa pela pilha de middleware. O Starlette
executa o handler de
ExceptionnoServerErrorMiddleware, a camada mais externa, então o envelope nunca voltava porCORSMiddlewarenem porRequestIDMiddleware. Medido antes da correção, comRequestID+apply_cors+register_exception_handlersna ordem do template:500semAccess-Control-Allow-Origin, semX-Request-IDe comdetails: {}. Agoraregister_exception_handlersinstala também oErrorEnvelopeMiddlewarecomo o middleware de usuário mais interno (appendemuser_middleware, enquantoadd_middlewaresempre insere no índice 0), e o mesmo500sai com o header de CORS, com oX-Request-IDe comdetails.request_idigual ao header — em qualquer ordem das três chamadas, e por isso o template não precisou mudar. A camada não decide CORS: origem fora da allowlist continua sem header no 500. O handler deExceptioncontinua registrado para a exceção levantada por um middleware, que a camada interna não vê. Medido no Starlette 0.46.0 (o piso quefastapi>=0.141.1aceita) e no 1.6.0. - O compose do
[minio]volta a subir. A MinIO apagouminio/minioeminio/mcdo Docker Hub em 2026-09-11, então todotempest new --extras minioetempest generate --dockergerava um compose que falhava nodocker compose pullcompull access denied. As duas imagens agora vêm dequay.io/minio/*, com as mesmas tags (manifest list multi-arch,curldo healthcheck presente). É stopgap: o repositório upstream está arquivado e não recebe correção de segurança. Compose já gerado precisa trocar o prefixo à mão ou rodartempest generate --docker --forcede novo.
Added¶
ErrorEnvelopeMiddleware(e o aliasErrorEnvelopeHandler, emtempest_fastapi_sdk.api.middlewares), para quem monta a pilha à mão com um handler próprio.
Changed¶
register_exception_handlerslevantaRuntimeErrornuma aplicação que já montou a pilha (já serviu requisição ou subiu). Antes a chamada tardia registrava handlers que o Starlette nunca lia; agora falha em vez de parecer funcionar, e falha antes de registrar qualquer handler, então a aplicação recusada fica intocada. Chamar duas vezes instala a camada uma vez só, com o handler da segunda chamada.- A exceção continua sendo re-levantada depois do envelope, como o
ServerErrorMiddlewarejá fazia: o servidor ASGI segue logando, e umTestClientcom o defaultraise_server_exceptions=Truesegue levantando no teste do consumidor. Falha depois de a resposta começar (stream quebrado no meio) é re-levantada sem segunda resposta. - Stream quebrado no meio passa a chegar ao
on_server_error. O callback vai comoBackgroundTaskda resposta de erro, e uma resposta que nunca é enviada nunca roda a background: medido antes, a falha era logada e oon_server_errornão disparava nenhuma vez. Agora a camada interna roda a background da resposta que o handler construiu, e o callback dispara uma vez, com o log também uma vez.
[0.295.0] — 2026-09-19¶
A CLI sabia criar serviço e rodar os gates. Não sabia operar o
serviço que já existe: nada listava as rotas servidas, nada exportava o
contrato, nada conectava nas dependências, e mexer em flag, cache, fila,
sessão ou bucket era redis-cli, script descartável ou SQL na mão.
Added¶
-
tempest secrets generate|init|vapid. O grupo só sabiarotate.generateimprime N segredos (crus,KEY=valuecom--keys, ou JSON) e não escreve arquivo nenhum — é o que se pipa num secret manager;--countjunto de--keysé erro de uso, porque os dois dizem a mesma coisa.initpreenche só as chaves ausentes, vazias ou ainda com o placeholderchange-me-change-me-change-me-32que otempest newescreve (os valores que ocheck_secretsreporta comosecurity.W001/W004), mantém toda chave já configurada e portanto é idempotente;--forcesubstitui valor real e faz backup antes.vapidgera o par P-256 do Web Push no formato que oWebPushSettingse opywebpushleem — escalar de 32 bytes e ponto não-comprimido de 65, base64url sem padding — e recusa sobrescrever um par existente sem--force, porque trocar o par invalida toda subscrição ativa de browser. O formato não foi deduzido: a chave privada é lida de volta porpy_vapid.Vapid02e assina um header no teste. -
tempest routes. Lista toda rota efetiva, com router montado expandido. Lêfastapi.routing.iter_route_contexts— a mesma expansão que o gerador de OpenAPI percorre — porque desde o FastAPI 0.141.1 oinclude_routerguarda uma entrada_IncludedRoutere uma compreensão sobreapp.routesnão lista nenhum path do router incluído. Rota fora do schema aparece comSCHEMA=no, e a colunaGUARDSnomeia as dependências herdadas doinclude_router, não só as declaradas na rota. -
tempest openapi-export [--check]. Escreve o documento OpenAPI do próprio serviço (JSON, ou YAML com o extra[openapi]) sem subir nada.--checkcompara com o arquivo commitado e nomeia o que mudou (+ DELETE /x,- GET /y,~ /z changed), saindo 1 — "a spec mudou" não é mensagem revisável. -
tempest db check. Roda a comparação de autogenerate do Alembic e sai 1 listando as operações que uma revision conteria. Orevision --autogenerategrava o diff quando alguém pede; nada percebia quando ninguém pediu. -
tempest serveetempest shell. Oserveentrega ao uvicorn a string de import, nunca o objeto — o reloader reimporta no processo filho, e passar a instância transforma--reloadem nada, em silêncio.--reloadtem três estados, então--no-reloaddesliga de verdade umSERVER_RELOAD=true; junto de--workerssai 2. Oshellabre um REPL comsettings, cada model mapeado que o projeto define (aBaseModeldo SDK fica de fora),select,texte umasessionaberta; compila comast.PyCF_ALLOW_TOP_LEVEL_AWAITe roda a corrotina no loop em que a sessão foi aberta — um helperrun(coro)pareceria equivalente e levantariaMissingGreenletno primeiro lazy load. -
tempest doctor. Ocheck-configlê as settings e não abre socket; este conecta de verdade em banco, Redis, RabbitMQ, SMTP e MinIO, dobra o registry de checagens estáticas e sai 1 em qualquer falha, servindo de smoke test de deploy. Capacidade não configurada aparece comoskip, nuncaok— e default de mixin conta como não configurado (SMTP_HOST=localhost,MINIO_ENDPOINT=localhost:9000), comparado contramodel_fields[campo].default, a régua docheck_secrets. -
tempest user show|activate|deactivate|sessions|delete. Oshowimprime toda coluna mapeada com ohashed_passwordredigido.deactivaterevoga os refresh tokens na mesma transação que o flag, porqueis_active=Falsesozinho não encerra nada.sessionslista (ou revoga com--revoke, contando só o que aquela execução matou); usuário sem sessão sai 0.deleteapaga os refresh tokens antes da linha do usuário — a FK scaffoldada não temON DELETE CASCADE, então a ordem óbvia falharia citando uma constraint em vez da causa — e exige--yesfora de um TTY. -
tempest flagsetempest cache.flags list/get/enable/disablefalam peloRedisFeatureFlagBackend;getseparaunset(código 1, o serviço cai no default do chamador) de umoffguardado (código 0).cache ping/stats/flushusa oCacheInvalidatornas opções direcionadas;--alléFLUSHDBe exige--yes, porque o banco costuma guardar também sessão, contador de rate limit e as flags acima. -
tempest email testetempest storage. Oemail testmanda uma mensagem porEmailUtils(**settings.email_kwargs())e, na recusa, sai 1 com a resposta do servidor mais a linha que lembra o par que mais custa tempo: 587 quer STARTTLS (SMTP_USE_TLS), 465 quer TLS implícito (SMTP_USE_SSL). Ostorage check/ls/put/get/presign/rmdirige oAsyncMinIOClient(**settings.minio_kwargs()), endpoint público incluído — opresigndiz nostderrpara qual host assinou, porque URL assinada contraminio:9000é válida e inútil no browser. -
tempest queueetempest tasks.queue publishconecta, publica e fecha, nessa ordem, num processo só;queue handlerslista os canais consumidos, importando os módulos de handler antes, porque subscriber só existe no broker depois do import.tasks listimprime o nome que o TaskIQ registra (<módulo>:<função>), que não é o nome no arquivo e é o que orunrecebe;tasks runenfileira, e diz isso — quem executa é o worker. -
tempest integrations list|verify.listmostra os clientes que o SDK ships e se este projeto tem a credencial;verifyfaz a leitura autenticada mais barata de cada provedor (OpenPixGET /company, Mercado PagoGET /users/me), que não move dinheiro nem cria registro. Como isso é tabela mantida à mão sobre cliente gerado, um guard assere que cada método nomeado ainda existe e não exige argumento — ele já pagou por si: o campo éMERCADOPAGO_ACCESS_TOKEN, nãoMERCADO_PAGO_ACCESS_TOKEN. -
tempest agents tools|run.toolslista as ferramentas sem chamar modelo nenhum (zero token, funciona sem credencial de backend).runsegueAgentRun.succeededno código de saída, não "não levantou exceção": run cortado pelo orçamento ainda devolve texto, e tratar esse texto como resposta é o erro que o comando se recusa a cometer pelo chamador.
Changed¶
tempest secrets rotatepassou a usar o mesmo helper de backup dos comandos novos; comportamento inalterado (backup antes de reescrever,0600no arquivo e no.bak).tempest_fastapi_sdk/cli/project.pycentraliza a resolução de projeto usada pelos comandos novos: app FastAPI (por spec ou pelos lugares scaffoldados, reportando cada tentativa com a causa), instância de settings por tipo sobsrc/app, e URL de Redis (--redis-url>REDIS_URL> settings).
[0.294.0] — 2026-09-19¶
Um assistente local que roda dentro do WSL precisa agir na máquina que o
hospeda — ler um arquivo em C:\, rodar PowerShell, desligar o computador.
Isso vinha sendo um serviço separado, reescrito em cada projeto que precisava
dele, com a parte perigosa (sandbox de caminho, auth, gate do lado
destrutivo) reescrita junto.
Added¶
tempest_fastapi_sdk.hostbridge— controle do host, sem extra nenhum.HostBridgecobre energia (shutdown/restart/abort_shutdown/lock/logoff), identidade (host_info), arquivos (read_text/write_text/list_dir/delete), PDF (read_pdf), comando arbitrário (run_command) e o seletor nativo (pick_file). Tudo async, com o que toca disco ou processo fora do event loop. Configuração éHostBridgeConfig— um value object congelado — ouHostBridgeSettingsvindo do ambiente, viahostbridge_kwargs().
As duas decisões de segurança são estruturais, não avisos na doc.
allowed_base_paths começa vazio, negando todo caminho até alguém
configurar, e o caminho é resolvido antes de ser checado — checar antes
de resolver deixa .../../../etc/passwd passar por começar dentro de uma
base permitida. make_hostbridge_router exige a lista de dependências
de auth na assinatura e recusa uma vazia, e o lado que escreve (comando,
escrita, remoção, energia) só é montado com destructive=True.
Onde não existe host para alcançar — container Linux puro, nem
powershell.exe nem wslpath — a chamada levanta HostUnavailableError
(503, HOST_UNAVAILABLE) em vez de uma falha genérica, então o chamador
degrada em vez de reportar que a ação falhou. Cada código
(HOST_INVALID_PATH, HOST_FILE_NOT_FOUND, HOST_FILE_TOO_LARGE,
HOST_FILE_DECODE_FAILED, HOST_COMMAND_FAILED, HOST_COMMAND_TIMEOUT,
HOST_UNAVAILABLE) ships frase PT-BR e en-US no default_message_catalog.
-
read_pdf_pages+ o extra[pdf-layout]. O leitor que já existia (extract_pdf_text/extract_pdf_pages,[pdf-read]) entrega prosa; um documento com colunas sai intercalado linha a linha e uma tabela sai como fluxo de células.PdfExtractor.LAYOUTrodapdfplumber, que recupera a grade emPdfPage.tables, e o novo caminho aceita senha, distinguindo documento cifrado (PDF_DECRYPT_FAILED) de arquivo corrompido (PDF_EXTRACT_FAILED). Continua sem OCR: página escaneada volta comtext=""— a entradané sempre a páginan— em vez de sumir. -
HostBridgeSettings, mais um mixin de settings, comHOST_ALLOWED_BASE_PATHS,HOST_POWERSHELL_BINARY,HOST_CMD_BINARY,HOST_COMMAND_TIMEOUTeHOST_MAX_FILE_READ_BYTES.
Fixed¶
- Senha errada num PDF lido com
[pdf-layout]dizia "arquivo ilegível". Opdfplumberembrulha todo erro de parse num únicoPdfminerExceptionconstruído a partir do original, então oexcept PDFPasswordIncorrectnunca disparava e o caso — que o chamador resolve digitando a senha certa — saía comoPDF_EXTRACT_FAILED, mandando quem lê procurar um documento quebrado que não existia. A cadeia (argumentos,__cause__,__context__) agora é percorrida atrás da falha de senha real.
[0.293.0] — 2026-09-13¶
A CLI sabia criar usuário e sabia promovê-lo, mas não sabia trocar a senha dele — e a política que ela aplicava não era a do projeto.
Added¶
tempest user set-password --email <e>. Troca a senha de um usuário existente, localizado por email (case-insensitive), hasheando com oset_passworddo próprio model — o mesmo hash que o login verifica. Sai com código 1 eno user foundquando ninguém casa. Sem o comando, a única saída eraUPDATEmanual com o hash calculado à mão, ou apagar e recriar a conta.
A revogação de sessão é o default, não uma flag. Trocar o hash não
encerra sessão nenhuma: access token já emitido vale até expirar e
refresh token guardado no banco continua trocável — a janela que o reset
existe pra fechar. Então o comando resolve
src.db.models:UserRefreshTokenModel (o nome que o tempest new
scaffolda) e revoga os refresh tokens do usuário na mesma transação
que grava o hash novo. --refresh-token-model aponta uma tabela em
outro lugar (spec digitada que não resolve é erro com código 2, nunca
degrada em "não revoguei nada"); --keep-sessions opta por fora, e é
recusado com código 2 se vier junto de --refresh-token-model. Projeto
sem a tabela opt-in ouve, em stderr,
note: no refresh-token model at 'src.db.models:UserRefreshTokenModel', so no session was revoked. Pass --refresh-token-model if yours lives elsewhere.
A contagem impressa é de sessões que aquela execução matou: linha já
revogada não é recontada, então a segunda rodada imprime
Revoked 0 active refresh token(s).
revoke_user_refresh_tokens(session, model, *, user_id)eUserAuthService.revoke_user_sessions(session, *, user_id). O par administrativo dorevoke_refresh_token: logout-everywhere endereçado por id, pra quem não tem o token em mãos — um endpoint de "desconectar tudo", um agente de suporte trancando conta comprometida, e a CLI acima. Devolvem quantas linhas revogaram; o método devolve0quando o serviço não temrefresh_token_model(JWT stateless não tem linha pra derrubar).revoke_refresh_token(all_sessions=True)passou a chamar a mesma função em vez de repetir oUPDATE.
Changed¶
tempest user createpassou a validar a senha com a política do projeto. Antes o floor era umlen(password) < 8escrito na CLI; agora écheck_password_policycontra os três camposAUTH_PASSWORD_*dosrc.core.settingsdo projeto, com os defaults doPasswordPolicy(AuthSettings()medido: mínimo 12 caracteres, máximo 72 bytes, complexidade desligada) quando o projeto não compõeAuthSettings. É a mesma régua dosignupe do painel admin.
É mudança de comportamento: senha de 8 a 11 caracteres que a CLI
aceitava agora sai com código 2 e
error: password must be at least 12 characters. — que é a senha que o
signup já recusava, ou seja, uma conta que a CLI semeava e cujo dono
não conseguia trocar a senha depois.
Fixed¶
- Senha acima de 72 bytes virava traceback em vez de mensagem. Medido
antes da correção,
tempest user create --email long@example.com --password $(python -c 'print("a"*80)')saía comValueError('password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])')vindo direto doPasswordUtils.hash, sem tratamento. A política roda antes de qualquer escrita e sai com código 2 eerror: password must be at most 72 bytes.O teto é medido em bytes UTF-8, a unidade que o bcrypt conta: 40 caracteres acentuados são 80 bytes e caem no mesmo erro.
[0.292.3] — 2026-09-12¶
Três números que a documentação afirmava sem que ninguém pudesse reproduzi-los. Dois foram medidos e corrigidos; um deles trocou de causa. Nenhum comportamento muda.
Fixed¶
- A separação de faces era relatada com dois números diferentes, e
nenhum reproduzível.
docs/recipes/faces.mddizia mesma-pessoa mínimo 0,877 e diferentes máx 0,180;faces/recognizer.pydizia 0,904-0,960 contra 0,225;faces/models.pycomparava os packs com uma terceira série (0,920-0,971 / 0,208 / "15 ms versus 54 ms"). As listas de transformações também divergiam, e a foto de grupo por trás de qualquer uma nunca foi versionada — então escolher entre elas seria um cara ou coroa vestido de correção.
Os três passam a citar a mesma medição, agora reproduzível por
scripts/face_separation.py: seis retratos gerados por sdxl-turbo em
seeds fixas (1000 a 1005), a mesma face sob cinco transformações
(n=30) contra os 15 pares de faces diferentes. No pack padrão,
0,898-0,991 contra máx 0,285, folga 0,614; no buffalo_l,
0,932-0,992 contra 0,231, folga 0,701. Detecção a 23 ms contra 178 ms
por imagem na máquina que tirou os números.
Faces geradas em vez de fotografadas por dois motivos: não distribui
biometria de pessoa real, e coloca a entrada da medição dentro do
repositório. O custo entra na prosa: retrato gerado é frontal e bem
iluminado, ou seja, o extremo fácil da faixa — e o buffalo_l perdeu um
rosto no recorte apertado (n=29 contra n=30), que é exatamente o
caso para o qual o detector pesado existe.
- A recomendação de
system=creditava à separação um efeito que é de truncamento. A receita afirmava "0 itens com a instrução concatenada, 20 com ela no turnosystem". Medido contragpt-oss:20b(Ollama 0.30.11) sobre um edital de 24.086 caracteres com 20 itens plantados, três execuções por configuração:
num_ctx |
em system |
concatenada |
|---|---|---|
| 4096 (default) | 0, 6, 5 | erro, 2, 1 |
| 16384 | 20, 20, 20 | 20, 20, 20 |
Com a janela dimensionada para o documento, as duas formas acertam os
20. O que decide é num_ctx, cujo default de 4096 tokens trunca em
silêncio. A instrução em system degrada menos sob truncamento, porque
não disputa espaço com o documento — a recomendação continua de pé, mas
é a metade menor da história. A receita e a docstring de
chat_structured passam a dizer isso, com a tabela acima.
Added¶
scripts/face_separation.py— mede a separação do reconhecimento facial sobre um conjunto que o repositório contém, para os dois packs. Primeira execução baixasdxl-turboe o pack; as seguintes reusam o cache.
[0.292.2] — 2026-09-12¶
Duas docstrings que a auditoria de prosa pegou, e o guard que faltava para a classe delas. Nenhum comportamento muda.
Fixed¶
- O exemplo de
BatchSchedulerlevantavaTypeError. A docstring mandavareturn await embedder._embed_many(texts). O método é privado e síncrono, e o scheduler fazawait handler(batch), então o snippet documentado quebra na primeira chamada:TypeError: object list can't be used in 'await' expression. Agora usa oembedpúblico, que já é coroutine, e a docstring diz por que o privado não serve.
O mesmo emb._embed_many passado ao BatchScheduler já tinha sido
corrigido na v0.257.0, entre os 162 achados da primeira passada do
test_docs_type_guard sobre 1999 blocos de código. Ele sobreviveu em
dois lugares que aquele guard não lê: esta docstring e um bullet de
prosa escrito com crases em vez de cerca — corrigido no commit de docs
anterior a esta release.
- O piso do
onnxruntimeestava desatualizado emsklearn.py. A docstring dizia "The SDK's floor moved toonnxruntime>=1.28"; opyproject.tomldeclara>=1.29.0nos três extras desde o bump em lote. A constanteBINARY_TREE_FIXED_IN_ONNXRUNTIME = (1, 28)e a mensagem de aviso continuam intactas e corretas — 1.28.0 é onde o defeito do upstream foi corrigido, e é outra afirmação que a de piso.
Added¶
tests/test_docstring_example_guard.py— nenhuma linha>>>de docstring chama atributo privado do pacote. É a régua estreita que teria pegado o defeito acima: os guards de exemplo deste repo leem bloco markdown cercado, e a referência de API renderiza docstring, então o leitor que copia de lá estava fora de cobertura. Hoje há 139 docstrings com exemplo>>>no pacote e zero violações. Quatro testes fixam o guard: a forma que shippou falha, a forma corrigida passa, a prosa que apenas cita o nome privado não dispara (a própria correção a contém) edocstring-guard: skipisenta uma linha deliberada.
[0.292.1] — 2026-09-12¶
A v0.292.0 corrigiu o código e deixou quatro passagens ensinando o comportamento que ela removeu — uma delas o Recap da própria página que aquela release corrigiu. Nenhum comportamento muda aqui.
Fixed¶
-
Q.resolvedocumentava o descarte que a v0.292.0 tinha acabado de tirar. A docstring dizia que "conditions with aNonevalue or an unknown column are skipped (matching the repository's dict-filter behavior)" — a frase estava imediatamente acima do laço que chamabuild_filter_condition, ou seja, acima do código que passou a devolverIS NULL.TestQTreejá fixava o comportamento certo desde a v0.292.0, então a docstring vinha sendo contradita por um teste verde. Agora descreve o que a função faz:Nonenuma coluna simples éIS NULL, em__neéIS NOT NULL, e o que continua sendo pulado é coluna desconhecida, operador desconhecido eNonenum operador que não o expressa — este último comDroppedFilterWarning. -
A tabela "Filter dict conventions" do
README.mdensinava o defeito. A linha era| `{"col": None}` | filter is skipped (omit-when-None semantics) |. O CHANGELOG da v0.292.0 registra que "a própria documentação do SDK já ensinava o uso que o código descartava" e corrigiu a receita de banco nas duas línguas — o README, que é o que o PyPI renderiza, ficou para trás com a mesma frase. Agora a tabela trazIS NULL,IS NOT NULLe as duas pontas de__isnull, seguidas da nota sobreDroppedFilterWarninge da exceção deliberada do parstart_in/end_in.
As quatro linhas novas foram medidas antes de escritas, contra o
pacote instalado: {"col": None} → rows.left_at IS NULL,
{"col__ne": None} → IS NOT NULL, {"col__isnull": True} /
False → IS NULL / IS NOT NULL; e __gt, __between, __in e
__lte com None devolvem nenhuma condição, cada um com um
DroppedFilterWarning.
-
A receita de offline-sync dizia que
__necomNoneignora a condição.docs/recipes/offline-sync.mde.en.md, na mesma linha nas duas línguas, fechavam a seção de operadores com "um valorNoneignora a condição, igual a qualquer outro filtro". Errado em duas dimensões desde a v0.292.0:{"col__ne": None}écol IS NOT NULL, não um descarte; egt/gte/lt/ltecontinuam descartando, só que agora comDroppedFilterWarning, que a página não mencionava. A frase escapou da varredura da v0.292.0 e da primeira desta release porque as duas procuraram a redação do README (filter is skipped,omit-when-None) — esta é uma paráfrase, sem nenhuma das duas. -
O Recap da receita de banco dizia o contrário do corpo da página.
docs/recipes/database.mde.en.mdfecham com "filtro é um dict com convenção previsível, eNonepula em vez de virarIS NULLpor acidente" — na página cujas linhas 1083, 1198 e 1210 a v0.292.0 já havia corrigido para dizer queNoneéIS NULL. A contradição ficou dentro do mesmo arquivo, a oitocentas linhas de distância, nas duas línguas.
Tests¶
-
test_agent_docs_guardrecusava a citaçãoarquivo:linha. O guard confere que todo caminho entre crases existe no disco, e tratavadocs/recipes/database.md:1948como nome de arquivo — que de fato não existe.file_path:line_numberé a forma que oCLAUDE.mdraiz pede para citar uma passagem, então o guard obrigava a doc a ser menos precisa justamente onde a precisão importa: a lição desta release fala de uma frase específica numa página de duas mil linhas. Agora o sufixo:linha(e:início-fim) é removido antes do lookup, e só o arquivo precisa existir. Dois testes fixam os dois lados: um caminho morto com sufixo continua sendo reportado, com a citação como escrita, e um arquivo real com sufixo passa. -
O silêncio de
start_in/end_invirou asserção. O teste da v0.292.0 checava que o par comNonedevolve todas as linhas, não que ele faz isso sem avisar — e "in silence" é exatamente o que o README passou a afirmar.test_none_bound_says_nothingfixa a lista de warnings como vazia; trocada a chave porleft_at__gt, que avisa, o teste falha, então a asserção não é vacuosa.
[0.292.0] — 2026-09-12¶
{"col": None} deixa de sumir e passa a significar IS NULL.
Fixed¶
- Filtro
NonegeravaIS NULL, não o descarte da chave (#276).BaseRepository.list({"left_at": None})— ecount,exists,paginate,delete_many, tudo que passa por_apply_filters— descartava a chave. O filtro sumia e a query devolvia todas as linhas.
O modo de falha é o mais caro que existe: silencioso e na direção de
mais resultado. Um filtro que errasse para menos apareceria como
tela vazia no primeiro teste manual; esse aparece como dado a mais, que
é exatamente onde ninguém olha duas vezes. Medido no consumidor que
abriu a issue, {"left_at": None} — a grafia óbvia de "ainda é
membro" — casava também quem tinha saído do grupo, e o fan-out de
WebSocket construído a partir dela continuava entregando as
mensagens do grupo a essas pessoas. Sair não parava a entrega.
Agora None numa coluna simples é col IS NULL, e em __ne é
col IS NOT NULL. A correção fica em build_filter_condition, que o
Q também usa — Q(left_at=None) tinha o mesmo descarte silencioso.
Onde None não tem leitura nenhuma (__gt, __gte, __lt, __lte,
__between, __in, __like, …) a condição continua sendo descartada,
porque não há com o que comparar — mas agora emite
DroppedFilterWarning nomeando a chave e apontando
{"col__isnull": True}. O par start_in / end_in é a exceção
deliberada: são as duas pontas de uma faixa, então None ali continua
significando "sem limite deste lado", em silêncio.
A própria documentação do SDK já ensinava o uso que o código
descartava. A receita de banco mandava "esconder linhas
soft-deleted passando deleted_at=None" e, trinta linhas adiante,
escrevia a query crua num exemplo porque "_apply_filters pula
None". Quem seguiu a primeira frase listou linha apagada. As duas
passagens foram corrigidas, e o exemplo voltou a caber em uma linha.
Migração¶
Quem montava o dict à mão com chaves opcionais e contava com o descarte precisa omitir a chave:
O caminho canônico do SDK não muda: get_conditions() chama
to_dict(exclude_none=True), então um campo não preenchido do schema de
filtro nunca chegou — e continua não chegando — ao repositório.
[0.291.0] — 2026-09-11¶
O módulo de chat vira mensageiro, e as quatro armadilhas que o consumidor achou pelo caminho viram superfície.
Added¶
- O módulo
chatcobre o que uma thread entre pessoas precisa (#273). O que o SDK entregava eram três tabelas com sete colunas somadas, e o consumidor que precisava de um mensageiro reimplementava a camada inteira em volta delas — otempest-zapficou com 10 tabelas, 7 repositories, 4 services e 4 routers sobre o que o SDK nomeava.
Entram, nas tabelas abstratas: kind, client_id, reply_to_id,
forwarded_from_id, forward_score, edited_at, revoked_at e
payload na mensagem; role, joined_at, left_at, history_from,
muted_until, is_pinned, is_archived, last_read_at,
last_read_message_id e last_delivered_at no participante; kind,
description, avatar_path, created_by, last_message_at e
last_message_id na conversa. Mais duas tabelas novas,
BaseMessageAttachmentModel e BaseMessageReactionModel, e os enums
ConversationKind, ParticipantRole, MessageKind e SystemEvent.
O ChatService ganhou post_message com client_id / reply_to_id /
attachment_ids, edit_message, revoke_message, react,
unreact, forward, mark_read, mark_delivered, receipt_counts,
add_participants, remove_participant, leave, set_role,
update_conversation e set_preferences; o make_chat_router expõe
todos, com a guarda de participante que agora trata quem saiu como
não-participante.
O que a superfície decide, e por que não deve ser decidido de novo em cada serviço:
- recibo é marca d'água no participante. Uma linha por mensagem
por leitor é O(mensagens × participantes): num grupo de 200 pessoas
são 200 linhas por mensagem, e marcar a thread como lida é um bulk
insert. A marca d'água são 200 linhas no total e um
UPDATE, ereceipt_countsderiva "quem leu" delast_read_at >= message.created_at. - uma reação por pessoa, substituída ao reagir de novo. É a
UniqueConstraint(message_id, user_id); a constraint larga é o que transforma double-tap em duas reações. - apagar para todos limpa o
bodye mantém a linha, para a thread guardar a forma e as respostas continuarem tendo o que citar. As chaves de storage órfãs saem no retorno, porque o SDK não é dono do bucket. - o stub da citação morre com a mensagem citada. Sem o campo
revokedo texto apagado vaza pelo quote de quem respondeu — a mensagem some da thread e continua legível dentro de cada resposta. client_idtorna o reenvio idempotente, com unicidade por(sender_id, client_id): o retry devolve a linha gravada em vez de postar a segunda.- conversa direta é idempotente por par. Duas threads entre as
mesmas duas pessoas é um estado que o usuário não conserta pela UI, e
é o que acontece quando dois clientes abrem o mesmo chat ao mesmo
tempo. Sem
kinddeclarado, até duas pessoas viraDIRECTe mais que isso viraGROUP— o comportamento anterior continua válido. message_iddo anexo é nullable, que é o desenho do upload em duas etapas: a linha nasce quando os bytes chegam e a mensagem que a reivindica pode não existir por mais um minuto.NOT NULLali faz todo upload falhar com erro de integridade.- quem entra num grupo não herda o backlog (
history_fromcarimbado na entrada,share_history=Truepara entregar), e quem sai mantém a linha comleft_at, senão mensagem antiga perde o nome do remetente.
Uma página de histórico custa um número fixo de queries, não um por
mensagem: anexos, reações e as mensagens citadas saem em um IN cada.
Medido numa página de 20 mensagens, resolvendo linha a linha eram 42
statements; em lote são 4, e continua 4 quando a página cresce.
UtcDateTime— todo timestamp do SDK volta timezone-aware. Medido nesta suíte, mesma linha e mesmo processo: logo após ocommit()o default do Python deixacreated_ataware, e oSELECTseguinte, no SQLite, devolve a mesma coluna naive — entãodatetime.now(UTC) - row.created_atlevantaTypeError: can't subtract offset-naive and offset-aware datetimes. O PostgreSQL sempre devolveu aware, e é isso que tornava a divergência invisível: código escrito contra um engine quebra no outro, nunca nos dois. As 19 colunas de timestamp do SDK passam a usar o tipo novo; o DDL emitido é idêntico nos dois dialetos (TIMESTAMP WITH TIME ZONEno PostgreSQL,TIMESTAMPno SQLite), então não há migration para o consumidor.
O render_item do Alembic passa a renderizar a coluna como
sa.TIMESTAMP(timezone=True). Sem isso o autogenerate escrevia
tempest_fastapi_sdk.db.datetime_type.UtcDateTime(timezone=True) num
arquivo que nunca importa o pacote — medido, o tempest db squash
reportou F821 Undefined name \tempest_fastapi_sdk`na migration
gerada. É a mesma armadilha que oTempestEnum` já tinha, e o mesmo
hook resolve as duas.
O tipo declara python_type explicitamente, porque TypeDecorator
levanta NotImplementedError ali — e quem pergunta antes de decidir
como tratar a coluna degrada em silêncio: o form do admin cairia para
input de texto num datetime, e a coerção de cursor deixaria a string
ISO sem converter. Pelo mesmo motivo o impl é declarado como
instância (TIMESTAMP(timezone=True)), não como a classe: com a
classe, impl_instance.timezone responde False para um tipo que é
sempre aware.
-
UploadUtils(require_known_signature=False). Comverify_magic_bytes=True, assinatura desconhecida era recusada — e.txt/.csvnão têm assinatura nenhuma, então um chat não conseguia anexar texto. O modo novo recusa só quando a assinatura contradiz o tipo declarado:image/jpegsem assinatura de JPEG continua sendo recusado, porqueimage/jpegestá no conjunto novoSNIFFABLE_MIMETYPES. -
UploadUtils.save(..., hasher=...). Ocontent_validatorvê só o primeiro chunk — a doc dizia isso, mas não dizia que por isso ele é inútil para checksum: um SHA-256 acumulado lá dentro digere o primeiro chunk e parece um digest do arquivo. Ohasherrecebe todos os chunks enquanto o arquivo passa, sem segunda leitura do objeto.
Changed¶
tempest newescrevesrc/db/configs/names.py. A convenção já estava documentada na página de arquitetura como opcional, e o projeto nascia sem o arquivo — então cada serviço ou a reinventava ou escrevia__tablename__ = "users"e a string da FK duas vezes. O scaffold passa a trazer o módulo (só strings, sem import do projeto, para nunca entrar num ciclo), oUserModelgerado lêUSER_TABLE_NAMEde lá, e a receita de sete passos doCLAUDE.mddo projeto começa por adicionar o nome ali.
Cada bloco de código dessa receita passa a declarar o caminho do
arquivo na primeira linha (# src/db/models/product.py). O teste que
materializa a receita localizava os blocos por posição, então
acrescentar um passo reescrevia silenciosamente o arquivo errado no
projeto sob teste; agora ele lê o cabeçalho.
BaseSchemadocumenta o queuse_enum_values=Truefaz com enum, e um guard passa a varrer os schemas do pacote (tests/test_enum_schema_guard.py). O campo guarda o valor, não o membro: comStrEnuma igualdade sobrevive e só oisfalha, mas comEnumcomum até==éFalse— medido nos dois casos. As duas formas passam no type-checker, rodam sem erro e simplesmente nunca entram no branch. Por isso todo enum de schema no SDK éBaseStrEnum, e o guard recusa um que não seja.
[0.290.0] — 2026-09-11¶
A segunda página do cursor, o campo culpado no envelope, a frase em inglês no 422 em português, e o painel que não conseguia criar um usuário.
Fixed¶
cursor_paginatereidrata o valor do cursor no tipo da coluna (#274).encode_cursorserializa porjson.dumps(default=str), então umdatetimesaía da página 1 como string ISO e entrava noWHEREcomo texto. No PostgreSQL a segunda página respondia 500:
O SQLite escondia o defeito porque compara os dois sem reclamar — e o que
ele devolvia estava errado, não só destipado: medido nesta suíte, um
passeio de 7 linhas em páginas de 3 ordenado por created_at repetia r02
e perdia r05 com o código anterior
(tests/db/test_cursor_types.py::TestCursorWalkStillWorks). O valor agora
vira datetime, date, time, UUID, Decimal ou membro de enum antes
da comparação, seguindo column.type.python_type; coluna de texto e
TypeDecorator que não declara python_type ficam intocados. Para
datetime o naive/aware acompanha a coluna, porque o timestamptz do
PostgreSQL recusa naive sob sessão fora de UTC.
Envelopar o bind com type_=column.type não resolveria: o tipo do
SQLAlchemy marca o parâmetro, não converte o valor, e o asyncpg então recusa
uma str onde espera datetime. O guard compila a query no dialeto do
asyncpg e falha no ::VARCHAR (roda no make check, sem servidor); um
segundo guard, sob o marcador docker, percorre as páginas contra um
PostgreSQL de verdade.
Added¶
AppException.field— o envelope diz qual input é o culpado (#271). O 422 já respondia isso peloloc; um 409 ou um 401 sobre o mesmo input não tinha onde dizer, e o cliente re-derivava o mapeamento no call site ("CONFLICTnesse formulário significa o e-mail, porque é o único conflito possível"). Agora a exceção declarafield = "email"no corpo da classe (introspectável, como ocode) ou recebefield=no raise site, e o handler emite a chave.
Emitida só quando não é None, nunca como null: "field" in body
continua significando há um input culpado, sem caso especial no cliente.
error_responses() copia o valor para o exemplo do OpenAPI e
ErrorResponseSchema declara a chave como opcional.
Credencial inválida continua sem nomear campo, de propósito — nomear
email ali confirma que o endereço existe e nomear password confirma o
mesmo por omissão, o que é um oráculo de enumeração de conta. Está na
docstring, na receita e num teste.
ValidationValueError— os validadores BR localizam a frase inteira (#272). O pydantic arquiva tudo que um validador de campo levanta sob o tipo únicovalue_error, e o catálogo localiza por tipo — entãoCPFOrCNPJField,PhoneBRField,UFField, CEP e chave PIX chegavam ao usuário final comoValor inválido: invalid CPF/CNPJ, metade em cada idioma. O consumidor não tinha como consertar: a única chave que os casos compartilhavam eraVALIDATION.value_error, então traduzir significava casar substrings das frases em inglês do SDK.
Os dez sites do SDK passam a levantar ValidationValueError, que carrega um
código — INVALID_CPF, INVALID_CNPJ, INVALID_CPF_CNPJ, INVALID_CEP,
INVALID_PHONE_BR, INVALID_MOBILE_PHONE_BR, INVALID_PIX_KEY,
INVALID_UF, UNKNOWN_CITY — resolvido como VALIDATION.<código> nas duas
línguas do catálogo embutido. Aqui o inglês é carregado, ao contrário da
tabela do pydantic, porque o msg do upstream neste caso é
Value error, invalid CPF/CNPJ (medido no pydantic 2.13.5): prefixado e em
minúscula.
Compatível nas duas pontas: str(exc) continua sendo a frase em inglês de
antes, a classe é subclasse de ValueError (todo except ValueError segue
pegando), e um ValueError comum de um validador do consumidor continua
caindo em VALIDATION.value_error.
AdminModel(password_fields=...)— criar usuário pelo painel (#275). Nenhum modelo que herdaBaseUserModelpodia ser criado pelo admin:editable_field_namesexcluihashed_password(ninguém digita um digest bcrypt num<input type="text">), a coluna éNOT NULL, e o insert respondia 400 comConflict creating <Model>— que o operador lê como e-mail duplicado. O contorno de todo consumidor eracan_create=Falsemais um comentário.
A caixa agora aceita texto puro e o save grava o hash, pelo set_password
do próprio modelo quando ele existe (senão PasswordUtils). O rótulo perde
o prefixo hashed_ — validado em browser real, hashed_password renderiza
como Password, porque "Hashed Password" descreve a coluna e contradiz o
que a caixa aceita. No create o
campo é obrigatório enquanto a coluna for NOT NULL; no edit, vazio
significa não mexer e preenchido rotaciona, com o digest nunca
pré-preenchendo o input. A coluna fica fora do list_display — mesmo
declarado à mão —, do detail e do export CSV/JSON.
A política de senha é aplicada e devolve erro no campo, não
ConflictException: PasswordPolicy e check_password_policy são novos
símbolos públicos, extraídos de UserAuthService._enforce_password_policy
(que passou a usá-los, sem mudança de mensagem nem de details).
PasswordPolicy.from_settings(settings) lê os três AUTH_PASSWORD_* do
Settings do serviço, então o painel aplica a mesma regra que o signup.
password_fields com can_import=True levanta ValueError na construção
do AdminModel: hashear plaintext vindo de arquivo é decisão separada, e o
importador postaria toda linha sem senha nenhuma — falhar no boot é melhor
que falhar linha a linha em produção.
[0.289.0] — 2026-09-10¶
O campo de datetime do admin vira widget: lê e escreve no fuso do operador.
Added¶
AdminModel(display_timezone=...)— o painel passa a declarar em que fuso o operador lê e digita datetime, e as três telas concordam: listagem e detail convertem cada valor e mostram o offset junto (2026-06-15 20:00:00-03:00), o form pré-preenche a caixa com a hora local e escreveTimezone: <zona> — stored as UTC.embaixo dela, e o submit lê o valor como hora de parede naquele fuso e grava em UTC. DefaultNone: nada converte, que é o comportamento de sempre.
Sem isso o painel é um editor de coluna crua. O
<input type="datetime-local"> não carrega offset: medido nesta suíte,
parse_submission devolve datetime(2026, 6, 15, 20, 0) sem tzinfo para
a caixa preenchida com 2026-06-15T20:00
(test_without_a_zone_the_submission_stays_naive). O que acontece com esse
naive depois é do driver — no consumidor que motivou a mudança, medido em
homolog contra Postgres, o asyncpg o resolve no TZ do processo, então num
container em UTC o operador em Teresina digita 20:00 e a linha guarda
20:00Z, três horas à frente do evento
(And-All/alofans-api#114).
A página, em ambos os casos, não nomeava fuso nenhum.
A conversão fica nas duas pontas de propósito. Converter só na escrita passa
em todo teste sobre gravar e ainda assim move a linha: o form mostra a hora
UTC, o operador salva sem editar nada, e o valor exibido é relido como hora
local — cada save de um formulário intocado empurra a linha três horas. O
guard é um POST real de um form que ninguém editou
(tests/admin/test_display_timezone.py::TestTheRenderedPanel::test_saving_the_form_keeps_the_instant),
verificado por mutação: remover a conversão de qualquer um dos dois lados
derruba três testes.
Fuso desconhecido levanta ValueError na construção do AdminModel, não no
request que renderiza o form — a lição do cron_offset da v0.284.0, cuja
string morria dentro do loop do scheduler.
O export CSV/JSON continua em UTC: alimenta máquina, e timestamp que muda de significado entre a tela e o arquivo é pior que um que sempre significa a mesma coisa.
AdminSite(display_timezone=...) declara o mesmo fuso para todo model
registrado depois — o fuso é do painel, não de uma tabela —, e um
AdminModel que traz o seu próprio continua com ele.
Novos helpers públicos em tempest_fastapi_sdk.admin.forms:
to_display_timezone e from_display_timezone.
[0.288.0] — 2026-09-07¶
Um heartbeat que era invisível na docstring e um evento no fio.
Fixed¶
ServerSentEvent.encodenão escreve maisdata:num frame de controle (#270). O encoder emitia a linha de dados sempre, inclusive no frame que só carregacomment— então o batimento ocioso doEventStreamsaía como: keepalive\ndata: \n\ne chegava ao cliente como um evento real no nome defaultmessage, a cada 15 s numa conexão saudável.
data: vazio não é inerte, e é aí que a dedução errou: o parser do
navegador guarda o valor vazio mais um line feed, o passo de despacho
tira o feed e entrega "" como message. Medido no Chromium sobre
EventSource: três frames : keepalive\ndata: \n\n dispararam
onmessage três vezes com data === ""; três frames : keepalive\n\n
dispararam zero.
Agora um frame sem payload e sem event que carrega comment, id ou
retry sai sem linha de dados — : keepalive\n\n, id: 5\n\n,
retry: 3000\n\n —, que é a semântica do spec para os três: comentário é
ignorado, id só arma o Last-Event-ID, retry só ajusta a reconexão.
Frame que nomeia um event mantém a linha mesmo com corpo vazio
(event: NOTIFY\ndata: \n\n), porque aí despachar é a intenção; e
ServerSentEvent() cru continua despachando, então nada que já funcionava
muda de forma.
A docstring do próprio SDK afirmava a correção em dois lugares ("which the
browser never surfaces to JavaScript"), e um teste fixava o byte errado
com a justificativa deduzida do spec — o passo de despacho ignora buffer de
dados vazio, e o buffer não estava vazio. Guard: TestControlFrames em
tests/sse/test_event_stream.py, byte a byte, com o par do evento nomeado
para a correção não virar regressão.
[0.287.0] — 2026-09-06¶
Uma integração nova, e um defeito no gerador que ela expôs antes de existir.
Added¶
asyncapi/— cliente de WebSocket gerado de um documento AsyncAPI 3.0. O contraparte doopenapi/para a superfície que o OpenAPI não descreve: conexão que fica aberta, mensagem nos dois sentidos, servidor que fala sem ninguém pedir. Rota de socket virava um parágrafo de prosa, e prosa não gera cliente.
uv run tempest asyncapi-client http://127.0.0.1:3000/asyncapi.json \
--name zap --out src/integrations/zap_ws
Saem schemas.py, stream.py e o barrel. Os frames viram duas uniões
tagueadas — o que o cliente envia e o que recebe — então match sobre a
de entrada é exaustivo, e frame novo no documento vira erro de tipo em vez
de um else silencioso. Cada variante é reconhecida pelo discriminante que
o documento declara (enum de um valor só, que é como um literal
renderiza); tag desconhecida levanta com a tag e o corpo, em vez de um erro
de validação listando todas as variantes.
A direção é invertida uma vez, no parser. O action do AsyncAPI é
relativo a quem publicou o documento, o publicador é o servidor, e o que se
gera é o cliente — então receive lá é outbound aqui. Errar o sinal é
invisível: o cliente compila, passa no type-check e manda o que deveria
escutar. Por isso o loader recusa documento sem
x-tempest-perspective, em vez de assumir o caso comum. Documento do
tempest-express-sdk 0.32.0 carrega o campo.
Reuso maior do que o desenho previa: AsyncAPI guarda os payloads em
components.schemas, no mesmo lugar que o OpenAPI e no mesmo dialeto, então
o parser de schemas e o emit_schemas inteiro servem sem alteração. O
cliente gerado usa websockets, que o extra [websocket] já declara —
nenhuma dependência nova.
Primeira integração: integrations/messaging/zap_ws, gerada de
vendor/zap-asyncapi.yaml por scripts/regen_zap_ws.py, com SPEC_SHA256
pinado e drift test. Exercitada contra o gateway rodando: conecta, assina,
recebe o ack, e recusa no cliente uma sala que o pattern do documento
não aceita — o frame inválido não chega à rede.
Guards: tests/asyncapi/ (30, com o pacote gerado dirigido contra um
servidor WebSocket em processo) e
tests/integrations/messaging/zap_ws/test_generated_drift.py (10). Provado
disparando com o sinal da direção invertido no parser — 8 dos 30 falham,
incluindo os quatro que atravessam a rede. Receita: "Cliente de
WebSocket (AsyncAPI)".
integrations/messaging/zap— o gateway de WhatsApp da casa. 28 schemas e 27 operações, gerados devendor/zap-openapi.yamlporscripts/regen_zap.pye commitados, atrás do namespace novointegrations/messaging/.
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.messaging.zap import (
SendTextRequest,
ZapClient,
)
http: HTTPClient = HTTPClient(
base_url="http://127.0.0.1:3000",
default_headers={"x-api-key": "<sua chave>"},
)
client: ZapClient = ZapClient(http)
accepted = await client.send_text(
body=SendTextRequest(to="5511999999999", text="oi"),
idempotency_key="4f1c…",
)
Enviar não é entregar, e isso muda o desenho do serviço que consome:
um envio responde 202 com a linha enfileirada, e o status caminha
queued → sending → sent → delivered → read (ou failed) no webhook de
status do gateway. Esse webhook é descrito em prosa e não tem bloco
webhooks nem callbacks no documento — medido, 27 operações e zero
callbacks —, então não há o que gerar e o pacote não o modela.
Exercitado contra o gateway rodando, só nas rotas sem efeito colateral:
| Chamada | Resposta |
|---|---|
health() |
None (200) |
ready() |
HTTP 503 com o corpo de prontidão |
get_session_status() sem chave |
HTTP 401 {"error": "Invalid or missing API key"} |
Nenhuma mensagem foi enviada: a própria doc do gateway avisa que um
POST manda WhatsApp real para pessoa real.
Três rotas respondem um corpo que não é JSON — get_message_media
(application/octet-stream), get_session_qr_image (image/png) e
metrics (text/plain) — e chegam tipadas -> bytes, sem decodificar.
make zap-regen regenera offline; make zap-fetch relê o documento de
ZAP_OPENAPI_URL (default http://127.0.0.1:3000). Não existe URL
pública canônica para esta especificação — ela é nossa —, então o
arquivo vendorizado é a autoridade e o SPEC_SHA256 registra de quais
bytes este checkout gerou. Guard:
tests/integrations/messaging/zap/test_generated_drift.py.
Fixed¶
- Corpo
multipart/form-datagerava um método sem corpo nenhum. O gerador modelava JSON eapplication/x-www-form-urlencoded, e reportava multipart como não suportado. Reportar não bastava: o método saía assim mesmo, sem argumento de corpo algum. Ele type-checkava, aparecia no autocomplete, e toda chamada que conseguia fazer era um400— as quatro rotas de upload da zap exigemtoefile, e oupload_imagegerado não mandava nenhum dos dois.
Multipart não é outro objeto de corpo, é outra call shape: os parts que
a spec marca format: binary saem em files, os escalares em data.
Os campos agora são achatados em argumentos nomeados em vez de embrulhados
num modelo que a call site teria de desmontar de novo:
accepted = await client.upload_image(
file=Path("nota.png").read_bytes(),
to="5511999999999",
caption="sua nota",
)
Desbloqueou 6 métodos: as 4 rotas de upload da zap, mais um do OpenPix e
um do Mercado Pago que estavam no mesmo estado. Guard:
tests/openapi/test_multipart_bodies.py, provado disparando com o ramo
de multipart removido do parser — 6 dos 8 casos falham.
HTTPClientnão sabia enviar corpo binário.request()estream()aceitavam sójsonedata, então nenhum cliente gerado podia fazer upload nem mandar um corpo bruto. Ganharamfiles(multipart, em qualquer das formas que o httpx aceita) econtent(bytes crus).
Corpo que não pode ser reenviado não é mais repetido. O retry
reemite os mesmos argumentos, e um stream em content ou files já foi
drenado na primeira tentativa — a segunda subiria um corpo truncado que o
servidor não distingue de um arquivo curto. Passando bytes, os retries
continuam; passando um stream, a requisição vai uma vez só. json e
data não entram na conta: o httpx os re-serializa a cada tentativa.
- Corpo de sucesso que não é JSON era descartado, não entregue.
O gerador modela
application/jsone mais nada. Todo o resto tomava a mesma saída de um corpo vazio: o método saía tipado-> None, fazia a request, checava o status e devolvia nada. Não é lacuna de tipagem — é um método que gasta um round trip e joga o payload fora.
Agora esse corpo é entregue como bytes, sem decodificar. A
especificação não carrega charset, e adivinhar um corrompe em silêncio;
bytes é lossless e o caller decodifica com o que sabe.
Shippou em três integrações ao mesmo tempo. 10 métodos deixam de perder o corpo:
| Integração | Métodos |
|---|---|
| OpenPix | get_invoice_pdf, get_invoice_xml, get_receipt |
| Mercado Pago | download_claim_file, export_subscriptions, download_release_report, download_settlement_report |
| zap | get_message_media, get_session_qr_image, metrics |
Sobreviveu tanto tempo porque cada caso tinha outra rota para os mesmos
bytes — o QR duplica get_session_qr, as métricas são alvo de scrape —,
e a docstring do próprio gerador se apoiava nisso para chamar o
comportamento de inofensivo. GET /message/{messageId}/media não tem
outra rota: o gateway baixa a mídia enquanto a mensagem está em memória e
o WhatsApp não a serve de novo.
Um 204 de verdade continua -> None. A linha Returns: do caminho sem
corpo dizia "answers 200 with no JSON body" tanto para ele quanto para o
PNG, o que era falso na metade dos casos; agora o caminho vazio diz "no
body" e o caminho raw nomeia os media types declarados. Guard:
tests/openapi/test_raw_responses.py, provado disparando com o retorno
do parser revertido — 5 dos 11 casos falham.
- O gerador escolhia entre dois corpos de sucesso em silêncio.
Um método de client devolve uma anotação, então só um status 2xx pode ser
modelado, e
_build_responseescolhe o menor. Isso é de graça quando os outros carregam a mesma forma, e não é quando não carregam: o caller recebe um método anotado para um corpo que a API pode nunca mandar.
Até aqui a escolha era calada. O gerador avisava sobre resposta que
não conseguia modelar de jeito nenhum (text/plain, image/png) e
calava sobre uma que modelava errado.
Achado gerando o client da zap-api numa versão do documento em que as 6
rotas de envio declaravam 200 com {"success", "to"} e 202 com
{"id", "status", "deduped"}. Reproduzido:
As formas são comparadas com a prosa removida (description, example,
examples, title), porque specs divergem no texto onde a estrutura é
idêntica. Medido nas duas specs que o SDK já ships — 125 operações do
OpenPix e 143 do Mercado Pago — a nota dispara zero vezes; as
operações delas que declaram 200 e 201 diferem só na redação de um
description aninhado.
Nenhuma integração entregue muda de forma: a escolha do menor 2xx
continua a mesma, só deixou de ser calada. Guard:
tests/openapi/test_divergent_success.py.
[0.286.0] — 2026-09-05¶
Três issues do consumidor. A do meio começou como um bug de cache e acabou sendo dois middlewares, cinco assinaturas e um nome de argumento que queria dizer duas coisas.
Fixed¶
- Middleware que drena o corpo não pendura mais um stream.
(
#269)ResponseCacheMiddlewarecalculava o ETag lendobody_iteratoraté o fim. Numtext/event-streamo corpo só acaba quando o cliente desconecta, então o middleware nunca voltava, nenhum header chegava ao proxy, e a rota respondia504noproxy_read_timeout. Derrubou o stream de notificação inteiro daalofans-api.
O relato cobria metade. IdempotencyMiddleware drena igual, para
poder reproduzir a resposta num retry, e não tinha lista de isenção
nenhuma — então nem o workaround existia. Reproduzido em processo,
com asyncio.wait_for:
| Middleware | Antes | Agora |
|---|---|---|
ResponseCacheMiddleware em GET /api/sse/stream |
TimeoutError |
responde, sem ETag |
IdempotencyMiddleware em POST /generate/stream com Idempotency-Key |
TimeoutError |
responde, sem armazenar |
Os dois caminhos são rotas que o próprio SDK monta:
make_chat_router serve GET /conversations/{id}/stream, e
make_genai_router serve POST /generate/stream.
A correção óbvia não funcionaria. _StreamingResponse, que é o que
BaseHTTPMiddleware entrega ao dispatch, não carrega media_type —
medido no caminho ASGI real, starlette 1.6.0, ele é None para toda
resposta, SSE inclusive. Um teste escrito como
response.media_type == "text/event-stream" é um ramo que nunca roda.
O sinal que existe é o header Content-Type, mais a ausência de
Content-Length.
Changed¶
exempt_pathsé igualdade nos cinco middlewares que o aceitam. (#269) Era igualdade emResponseCacheMiddleware,RateLimitMiddlewareeGracefulShutdownMiddleware, e prefixo emAccessLogMiddlewareeHoneypotBanMiddleware. Mesmo nome de argumento, dois comportamentos — e a divergência estava documentada num arquivo só, invisível dos outros quatro. Um serviço que isentava sua rota SSE dos dois passava a mesma tupla para ambos e ganhava isenção num e silêncio no outro.
Prefixo agora tem nome próprio, exempt_prefixes, disponível nos cinco.
A implementação é uma só (PathExemption), e um guard estrutural recusa
um sexto middleware que reimplemente a checagem por conta própria.
Alargar tudo para prefixo foi recusado de propósito: em
RateLimitMiddleware uma isenção é um buraco no rate limit, e
exempt_paths=("/health",) passaria a isentar /health-admin também.
Estreitar falha para o lado seguro; alargar não.
!!! danger "Migração"
Só AccessLogMiddleware e HoneypotBanMiddleware mudam. Quem
passava exempt_paths=("/api/sse",) a um deles contando com
/api/sse/stream renomeia para exempt_prefixes.
Added¶
fuse_detect_classifyeDetectClassify. (#268) Um detector a 640 e um classificador a 224, fundidos num.onnxsó. Entre os estágios existe um recorte dinâmico, entãoonnx.compose.merge_modelssozinho não fecha — a ponte éRoiAlign.
São duas superfícies, não uma, e ficam em dois lugares:
| Símbolo | Onde | Extra | Quando |
|---|---|---|---|
fuse_detect_classify |
tempest_fastapi_sdk.modelops |
[modelops-compose] |
build |
DetectClassify, DetectClassifyResults, DetectClassifySchema, to_detect_classify_schemas |
tempest_fastapi_sdk.vision |
[vision] |
runtime |
Medido contra ort-vision-sdk 0.8.0 — o piso que declaramos —
instalado sem o extra [compose]: DetectClassify importa, e
ort_vision_sdk.compose levanta ImportError: No module named 'onnx'.
É essa divisão que põe os dois atrás de extras diferentes: o serviço em
produção nunca importa onnx.
Nada novo entra na resolução de ninguém. O SDK já declarava
ort-vision-sdk>=0.8.0 no [vision] e onnx>=1.22.0 no
[modelops-onnx]; o extra [compose] do upstream acrescenta
exatamente onnx>=1.16.0, e ort-vision-sdk não tem upper bound
nenhum.
fuse_detect_classify é re-export lazy, não wrapper, e a razão foi
medida: a lista de argumentos nomeados não é fixa. São 18 no
ort-vision-sdk 0.8.0 — o piso que declaramos — e 19 na 0.9.0, que é
o que uma instalação nova resolve; normalization entrou entre as
duas. Um wrapper escrito contra o piso teria engolido esse argumento
em silêncio, e nenhum teste reportaria.
Achado pela instalação limpa da wheel, não pelo lock: no .venv do
worktree a resolução dava 0.8.0 e a contagem fechava em 18.
- A anotação
x-mp-sdk-coveragedo Mercado Pago entra como terceira opinião. (#259)spec3.sdk.yaml— a variante anotada do documento do provedor — marca, por operação, quais SDKs oficiais a implementam. Medido em 2026-09-05: 142 operações anotadas, 44 listandopython, contra as 65 chamadas queOFFICIAL_SDK_CALLSguarda lidas à mão do sdist; 39 coincidem.
E não pode substituir a lista lida à mão, que era a proposta. Cinco
das 44 operações que a anotação marca python não têm ponto de chamada
nenhum em mercadopago 3.5.0 — que é o release mais recente no
PyPI, então não é artefato de pin velho:
DELETE /v1/customers/{}/delete
GET /preapproval/export
GET /v1/payment_methods/installments
PUT /v1/chargebacks/{}
PUT /v1/payments/{}/cancellations
O caso legível é o do chargeback: chargeback.py chama
GET /v1/chargebacks/search e GET /v1/chargebacks/{id}, e nenhum
PUT. A anotação é evidência sobre a intenção do provedor; o sdist
é evidência sobre o código que ships. Onde discordam, o código
ganha — mesma regra que já valia contra o spec3.yaml.
As cinco ficam registradas em SDK_COVERAGE_DISAGREEMENTS, com motivo,
e make mercadopago-diff reporta tanto uma entrada nova quanto uma que
o provedor tenha corrigido.
[0.285.0] — 2026-09-05¶
As duas pendências que a 0.284.0 deixou registradas, mais um defeito num guard que apareceu ao consertá-las.
Added¶
register_exception_handlers(envelope_client_errors=True)põe todo 4xx no envelope. A 0.284.0 cobriu o 422 e deixou o resto. Medido, com catálogo instalado eAccept-Language: pt-BR, e espionando o handler que o SDK registra:HTTPException4xx cru, o 401 doHTTPBearer, rota inexistente e método errado todos passam por ele — quatro categorias, seis casos medidos na tabela abaixo — e respondiam o corpo nu do Starlette, semcodee em inglês.
| Caminho | Antes | Agora |
|---|---|---|
HTTPException(404, "pedido 42 não existe") |
{"detail": "pedido 42 não existe"} |
mesma mensagem + code: NOT_FOUND |
HTTPException(403) |
{"detail": "Forbidden"} |
Acesso negado + code: FORBIDDEN |
HTTPException(409) |
{"detail": "Conflict"} |
Conflito de recurso + code: CONFLICT |
Depends(HTTPBearer()) sem token |
{"detail": "Not authenticated"} |
mesma mensagem + code: UNAUTHORIZED |
| rota inexistente | {"detail": "Not Found"} |
Recurso não encontrado + code: NOT_FOUND |
| método errado | {"detail": "Method Not Allowed"} |
mesma mensagem + code: HTTP_405 |
Mensagem que o caller escreveu não é traduzida. O Starlette preenche
detail com a phrase do status quando não recebe uma, então os dois casos
são distinguíveis, e trocar a mensagem específica pelo genérico do catálogo
destruiria a mais informativa das duas.
O mapa status→code é declarado, não derivado: medido, treze classes de
AppException carregam code de 401 e quatro de 404. Status fora do mapa
responde HTTP_<status> e mantém a phrase — 400 e 405 estão nesse grupo.
Dois flags e não um renomeado: envelope_client_errors é o superconjunto
(liga o 422 também), e envelope_validation_errors segue sendo o opt-in
estreito, então quem ligou o segundo na 0.284.0 não muda de comportamento ao
atualizar.
Fixed¶
- Artefato de validação em browser sai do sdist.
.playwright-mcp/estava rastreado: 14 arquivos de console e page snapshot de 2026-08-01. Medido no tarball publicado da 0.284.0:tar -tzf | grep -c playwright→14, somando 2 623 bytes. Os mesmos 14 arquivos e os mesmos 2 623 bytes estão nos sdists da 0.250.0 e da 0.270.0. O wheel nunca os levou; o sdist leva o repo inteiro menos umexclude, e oexcludenomeava só.claude.
Mesma classe do defeito que o .claude/ já tinha causado (12 938 bytes em
4 arquivos, no sdist da 0.235.0). Aquele
conserto nomeou um diretório em vez de enunciar a regra, então não preveniu
este — daí o guard novo (abaixo) enunciar a regra.
-
make_http_exception_handlernão tinha parâmetrocatalog. Introduzido na 0.284.0: um serviço que configuravaMessageCatalogainda respondia{"detail": "Forbidden"}em inglês, porque o handler deHTTPExceptionnão tinha por onde receber o catálogo. Agora recebecatalogedefault_locale. -
A docstring do
register_exception_handlersdescrevia cinco dos oito parâmetros.logger,on_server_erroreenvelope_validation_errorsentraram na 0.284.0 sem entrada emArgs:. Os quatro estão documentados. -
O guard de tipo da documentação reportava zero achados quando o mypy pintava a saída.
FORCE_COLORestá setado em muitos terminais e imagens de CI; o mypy o honra, e oERROR_REdo guard casa": error: "como texto puro. Com cor, nada parseia.
A direção da vacuidade é o que torna isso grave: 230 dos 233 casos do arquivo
afirmam a ausência de achado, e ausência é exatamente o que uma saída
não-parseável produz. Medido com FORCE_COLOR=3: 3 failed, 230 passed — os
três são os test_guard_fires_*, o controle negativo, e são a única razão
pela qual isso foi percebido.
--no-color-output na invocação, mais um teste que fixa o flag setando
FORCE_COLOR e exigindo achado. O guard consertado pegou na hora um defeito
na doc desta release: um bloco de exemplo sem os imports, contra a regra de
exemplo completo.
Guards¶
tests/test_sdist_payload.py(novo). O wheel tem payload fixado desde sempre; o sdist não tinha nada. Constrói um sdist em diretório temporário e exige que toda entrada com ponto na raiz esteja numa allowlist com motivo. Provado que reportaria.playwright-mcpe.claude.test_i18n_coverage_guardpassa a exigir que todo code de_STATUS_ERROR_CODEStenha entrada no catálogo, nas duas línguas — code mapeado sem tradução deixaria a phrase inglesa no lugar sem nada reclamar.
Nenhuma quebra: envelope_client_errors=False é o default, e uma classe de
teste fixa que o corpo de cada caminho segue idêntico com ele desligado.
[0.284.0] — 2026-09-03¶
Quatro issues abertas por um consumidor (alofans-api), todas medidas contra a
0.283.1. Duas eram defeito de comportamento, duas eram superfície faltando — e
duas das quatro descreviam a consequência errada, o que mudou o desenho.
Fixed¶
- O
cron_offsetque a receita ensina parava o agendamento do processo inteiro.CronOffset.BRASILIAé a string"-03:00", e o TaskIQ lêcron_offsetstring como chave IANA (taskiq/cli/scheduler/run.py:102→now.astimezone(ZoneInfo(offset))). O valor que a doc ensina levantavaZoneInfoNotFoundErrordentro do tick, e o loop guarda o tick comexcept CronValueErrorapenas — então a exceção escapava e encerrava owhile True. Medido, duas tasks* * * * *, uma com offset e uma sem:
loop_done=True exc="ZoneInfoNotFoundError: 'No time zone found with key
-03:00'" health=False fired=[]
fired=[] inclui a task sem offset: uma declaração como a receita
ensina silenciava todas as tasks do processo. O loop morria 0,363 s depois
do startup. Depois: loop_done=False exc=None health=True.
normalize_cron_offset converte as formas numéricas (CronOffset,
"±HH:MM") para o timedelta que o TaskIQ de fato aplica, e resolve
qualquer outra string como chave IANA na declaração. Offset fixo também
não depende de tz database — medido, sem um, até ZoneInfo("UTC") levanta.
Aplicado nos cinco caminhos que podem carregar offset, não só no que doeu.
A issue relatava "o painel mostra o horário errado". Estava certa sobre o painel e errada sobre o runtime: a task não disparava.
-
A linha do painel de tasks carregava uma projeção de duas chaves.
TaskPanelService.schedule()liacroneintervale descartava o resto. Medido, com as tasks declaradas como a receita ensina: o offset era descartado, um gatilhotime(disparo único) aparecia comoon demand, e numa task com dois crons um deles desaparecia.TaskTriggerpassa a carregar a entrada como o registro a declara, eScheduledTask.triggersé a tupla delas. -
O result backend do Redis gravava sem expiração, e
from_settingsnão sabia dizer não.results=settings.TASKIQ_RESULT_BACKEND_URL or Trueé sempre truthy, e passar a opção pelo**optionscolidia (TypeError: TaskQueue.redis() got multiple values for keyword argument 'results'). Medido contra Redis 8.2.9, com os defaults dotaskiq-redis: chavetask-abc-123(o id pelado),TTL -1, 144 bytes. Uma task a cada minuto deixa 1440 chaves/dia sem teto, e num Redisallkeys-lruisso evicta as chaves de quem está usando o Redis para trabalhar.
E não havia escape hatch: TaskQueue.redis(url, result_ex_time=3600) é
aceito na construção — o RedisStreamBroker engole chave desconhecida nos
**connection_kwargs — e explode depois, no connect(), com
TypeError: AbstractConnection.__init__() got an unexpected keyword argument
'result_ex_time'.
- Esquema de broker inválido volta a levantar
ValueError. Introduzido e corrigido dentro desta release: ler campo de settings antes do dispatch de esquema fazia uma URL com typo responderAttributeErrorsobre um campo que o caller nunca mencionou.
Added¶
-
TaskQueueganharesult_ttl_secondseresult_prefix, aplicados nos dois transportes (redis()e_attach_redis_results). Default86400— lido decelery.app.defaults.NAMESPACES["result"]["expires"]na celery 5.6.3, que étimedelta(days=1)— e prefixotempest:results, que torna as chaves varríveis porSCAN. Medido depois:tempest:results:abc-123comTTL 86400. -
TaskIQSettingsganhaTASKIQ_STORE_RESULTSeTASKIQ_RESULT_TTL_SECONDS.TASKIQ_STORE_RESULTS=Falsedeixa oDummyResultBackendno lugar nos dois transportes — a configuração de um serviço só de cron, onde nenhum chamador espera retorno. Também acerta a assimetria:TASKIQ_RESULT_BACKEND_URLvazio significava "sem resultado" no RabbitMQ e "resultado na URL do broker" no Redis. -
from_settingsaceitaresults,result_ttl_secondseresult_prefix, comNone= derivar das settings, então valor explícito ganha em vez de colidir. -
TaskTriggereScheduledTask.triggersno painel de admin, mais a colunaSchedule(que substituiCron+Interval) com uma linha por gatilho e o fuso ao lado da expressão. Validado em browser real, 1280px e 400px. -
register_exception_handlers(logger=...)roteia o registro dos handlers para o logger do serviço. A issue afirmava que o marcador do500.lognão era aplicado — é:handlers.pyo seta nos três caminhos de 5xx, e medido comLogUtils(scope="root"), o default, o arquivo recebe a linha. O furo real é de roteamento: comscope="logger"o logger do SDK fica fora da árvore configurada e o registro não chega a nenhum arquivo (medido, 0 e 0; comlogger=, 1 e 1).
O tipo é logging.Logger, e não o Protocol RetryLogger que a issue
sugeria: ele tem dois membros e nenhum extra=/exc_info=, e os handlers
passam os dois. Medido, nenhum tipo único cobre os dois candidatos —
LogUtils.error(extra=...) guarda um campo chamado extra, e
logging.Logger.error(request_id=...) levanta TypeError.
-
register_exception_handlers(on_server_error=...)chama um callback(request, exc)comoBackgroundTaskdepois de a resposta estar montada. Dispara nos três caminhos que produzem 5xx, não só no catch-all, e não dispara em 4xx. Exceção dentro do callback é logada e engolida: medido sem isso, a exceção do notificador propaga pela pilha ASGI e passa a ser o que o servidor relata em vez do erro original. -
register_exception_handlers(envelope_validation_errors=True)registra uma quarta forma,RequestValidationError, respondendo{"detail", "code": "VALIDATION_ERROR", "details": {"errors": [...]}}com as mensagens saindo doMessageCatalogpor tipo de erro do pydantic — 104 chaves, o conjunto exato depydantic_core.ErrorType. Campo novo num schema nasce traduzido, porque a tabela não é por campo.
O 422 default devolve o valor submetido. Medido: um campo password que
reprova em min_length põe a senha no corpo da resposta, e SecretStr
não protege, porque a validação roda antes de o wrapper existir. O
envelope carrega loc e type e nunca input.
Ligar reescreve também o componente HTTPValidationError do schema, para
doc e runtime não discordarem na resposta que cliente gerado mais modela.
Changed¶
-
Resultado de task guardado no Redis passa a expirar em 24 h. Antes era eterno. Quem lê resultado dias depois da execução para de achar; o opt-out é
TASKIQ_RESULT_TTL_SECONDS=0ouresult_ttl_seconds=0. -
A chave do resultado ganha o prefixo
tempest:results, então resultado gravado antes do deploy não é legível depois (janela de minutos, dentro do TTL). Vai junto do TTL de propósito: fazer depois seria uma segunda quebra no mesmo keyspace. -
O rótulo do
cron_offsetpassa a carregartimedeltaonde o input era"±HH:MM", e string que não resolve como fuso é recusada na declaração comValueErrorem vez de matar o loop do scheduler depois. -
TaskIQSettingsLikeganha dois membros obrigatórios (TASKIQ_STORE_RESULTS,TASKIQ_RESULT_TTL_SECONDS). Quem compõe o mixin — o caminho documentado e o que otempest newgera — não sente nada; um stand-in escrito à mão precisa carregá-los. -
A coluna
Cron+Intervaldo painel de tasks vira uma colunaSchedule. ColunaTimezoneprópria foi recusada: seria vazia em quase toda linha e alargaria uma tabela que ainda precisa caber em 400px.
Nada muda com os defaults em register_exception_handlers:
envelope_validation_errors=False preserva os caminhos medidos, e os 13
testes existentes de handlers.py passam sem alteração.
[0.283.1] — 2026-09-02¶
A v0.283.0 não chegou ao PyPI: o gate da CI parou no Type check e o
Publish to PyPI foi pulado. Esta versão é essa correção, mais o defeito de
CI que a deixou passar batido.
Fixed¶
librosaentra no override do mypy, como onumpyjá estava. O bump de pisos trouxe um segundo pacote que resolve por split de Python:librosa1.0.0 exige Python>=3.12e a 0.11.0 exige>=3.8, então o lock carrega as duas e só o split 3.12+ pega a nova — que usa a sintaxetype X = ..., recusada pelo mypy enquanto ele mirapython_version = "3.11":
O numpy já carregava um override idêntico, escrito pelo mesmo motivo. A
lição que faltava é sobre o método: make check local não é evidência
sobre a CI quando uma dependência resolve por split. O gate rodou verde
aqui no 3.11, onde o lock dá librosa 0.11.0, e falhou na CI no 3.13.
Verificado agora nos três: mypy, ruff e a suíte completa (7733 testes)
passam em 3.11, 3.12 e 3.13.
- A matriz da CI rodava três vezes o mesmo Python.
.python-versionfixa3.13e ouv synchonra o arquivo, entãouv python install 3.11seguido deuv syncconstruía uma venv 3.13 — nos três jobs. A evidência está no run da v0.283.0: o job chamado "Python 3.11" reportou o erro dentro de.venv/lib/python3.13/site-packages/librosa/beat.py. Lint, type check e testes eram, na prática, um job só; apenas os passos de smoke eram honestos, porque passam--pythonexplícito.
UV_PYTHON: ${{ matrix.python-version }} no env: do job resolve, porque
a variável sobrepõe o .python-version. Consequência: a escolha de piso
pelo menor das versões do lock, feita na v0.283.0, estava certa sem que
a CI tivesse verificado — medido agora, 3.12 e 3.13 resolvem numpy 2.5.2 e
só o 3.11 pega a 2.4.6, que é o piso declarado.
[0.283.0] — 2026-09-02¶
O lock foi para as versões mais recentes que a resolução alcança, e os 81 pisos declarados subiram para o que foi medido. Sessenta e cinco deles propagam para quem consome o SDK.
⚠️ Isto obriga o consumidor a subir dependência¶
Piso declarado (>=) num pacote publicado não é preferência nossa: ele entra
na resolução de todo serviço que instala o SDK. Sessenta e cinco pisos subiram,
então um serviço que ainda estava em numpy 1.x, redis 5.x ou torch 2.2
não resolve mais contra a 0.283.0 sem subir também.
Os saltos que mais doem, porque atravessam major: numpy 1.24 → 2.4.6,
redis 5.0 → 8.1.0, chromadb 0.5 → 1.5.9, accelerate 0.33 → 1.14,
pillow 10.0 → 12.3, torch 2.2 → 2.14. Atualize o ambiente antes do merge.
Nada de superfície pública mudou nesta release — o que muda é o que ela exige em volta.
Changed¶
Pisos que propagam para o consumidor (65):
| Pacote | De | Para |
|---|---|---|
accelerate |
>=0.33.0 |
>=1.14.0 |
aiofiles |
>=24.1.0 |
>=25.1.0 |
aiosmtplib |
>=4.0.0 |
>=5.1.2 |
aiosqlite |
>=0.20.0 |
>=0.22.1 |
alembic |
>=1.18.4 |
>=1.19.1 |
asyncpg |
>=0.30.0 |
>=0.31.0 |
bitsandbytes |
>=0.43.0 |
>=0.50.2 |
chromadb |
>=0.5.0 |
>=1.5.9 |
click |
>=8.0.0 |
>=8.5.0 |
coqui-tts |
>=0.24.0 |
>=0.27.5 |
cryptography |
>=50.0.0 |
>=50.0.1 |
diffusers |
>=0.31.0 |
>=0.39.0 |
email-validator |
>=2.0.0 |
>=2.3.0 |
faster-whisper |
>=1.0.0 |
>=1.2.1 |
faststream[rabbit] |
>=0.7.1 |
>=0.7.5 |
fido2 |
>=2.0.0 |
>=2.2.1 |
firebase-admin |
>=7.0.0 |
>=7.5.0 |
httpx |
>=0.28.0 |
>=0.28.1 |
huggingface-hub |
>=0.24.0 |
>=0.36.2 |
jinja2 |
>=3.1.0 |
>=3.1.6 |
lm-format-enforcer |
>=0.10.0 |
>=0.11.3 |
minio |
>=7.2.0 |
>=7.2.20 |
numpy |
>=1.24.0 |
>=2.4.6 |
nvidia-ml-py |
>=12.560.30 |
>=13.610.43 |
onnx |
>=1.16.0 |
>=1.22.0 |
onnxruntime |
>=1.28.0 |
>=1.29.0 |
opentelemetry-exporter-otlp-proto-grpc |
>=1.27.0 |
>=1.44.0 |
opentelemetry-instrumentation-fastapi |
>=0.48b0 |
>=0.65b0 |
opentelemetry-instrumentation-httpx |
>=0.48b0 |
>=0.65b0 |
opentelemetry-instrumentation-sqlalchemy |
>=0.48b0 |
>=0.65b0 |
opentelemetry-sdk |
>=1.27.0 |
>=1.44.0 |
ort-vision-sdk |
>=0.4.0 |
>=0.8.0 |
pgvector |
>=0.3.0 |
>=0.5.0 |
pillow |
>=10.0.0 |
>=12.3.0 |
prometheus-client |
>=0.20.0 |
>=0.26.0 |
psutil |
>=6.0.0 |
>=7.2.2 |
pydantic |
>=2.13.4 |
>=2.13.5 |
pydantic-settings |
>=2.14.2 |
>=2.15.0 |
pyjwt |
>=2.12.1 |
>=2.13.0 |
pymupdf |
>=1.24.0 |
>=1.28.2 |
pyotp |
>=2.9.0 |
>=2.10.0 |
pypdf |
>=5.0.0 |
>=6.16.2 |
python-multipart |
>=0.0.12 |
>=0.0.32 |
pywebpush |
>=2.0.0 |
>=2.5.0 |
pyyaml |
>=6.0 |
>=6.0.3 |
redis |
>=5.0.0 |
>=8.1.0 |
safetensors |
>=0.4.0 |
>=0.8.0 |
sherpa-onnx |
>=1.12.0 |
>=1.13.7 |
skl2onnx |
>=1.16.0 |
>=1.20.0 |
sqlalchemy[asyncio] |
>=2.0.49 |
>=2.0.52 |
sqlglot |
>=25.0.0 |
>=30.17.0 |
taskiq |
>=0.11.0 |
>=0.12.6 |
taskiq-aio-pika |
>=0.4.0 |
>=0.6.0 |
taskiq-redis |
>=1.0.3 |
>=1.2.3 |
tempestweb |
>=0.67.0 |
>=0.127.0 |
tokenizers |
>=0.20.0 |
>=0.22.2 |
torch |
>=2.2.0 |
>=2.14.0 |
torchaudio |
>=2.2.0 |
>=2.11.0 |
torchcodec |
>=0.8.0 |
>=0.16.0 |
torchvision |
>=0.17.0 |
>=0.29.0 |
trafilatura |
>=1.12.0 |
>=2.2.0 |
transformers |
>=4.44.0 |
>=4.57.6 |
typer |
>=0.12.0 |
>=0.27.2 |
weasyprint |
>=66.0 |
>=69.0 |
websockets |
>=13.0 |
>=17.1 |
Pisos de desenvolvimento, que não propagam (16): fakeredis >=2.20.0 → >=2.37.1, lupa >=2.0 → >=2.8, mkdocs >=1.6.0 → >=1.6.1, mkdocs-include-markdown-plugin >=7.0.0 → >=7.3.0, mkdocs-material >=9.5.0 → >=9.7.7, mkdocs-static-i18n >=1.2 → >=1.3.1, mkdocstrings[python] >=0.27.0 → >=1.0.6, mypy >=1.13.0 → >=2.3.1, onnxscript >=0.1.0 → >=0.7.1, pymdown-extensions >=11.0.1 → >=11.0.2, pytest >=8.3.3 → >=9.1.1, pytest-asyncio >=0.24.0 → >=1.4.0, pytest-cov >=6.0.0 → >=7.1.0, ruff >=0.8.0 → >=0.16.5, types-openpyxl >=3.1.5 → >=3.1.5.20260827, uvicorn >=0.30.0 → >=0.52.4
Fixed¶
- O upgrade do
typerexpôs umtry/except ImportErrorque engolia dois outros símbolos.typer._click.exceptionsdeixou de exportarAbortna 0.27.2, mas ainda exportaUsageErroreNoArgsIsHelpError— e os três eram importados no mesmo bloco. Medido em typer 0.27.2 com click 8.5.0:
typer._click.exceptions.Abort: False
typer._click.exceptions.UsageError: True
typer._click.exceptions.NoArgsIsHelpError: True
Então o bloco todo caía no fallback e perdia o UsageError vendorizado e o
NoArgsIsHelpError com ele, em silêncio, num Typer que ainda os tinha — o
FullHelpTyperGroup deixaria de imprimir o help completo. Abort passou a
vir do typer público, e typer.Abort não é a mesma classe que
click.exceptions.Abort, então as duas são capturadas.
-
O contrato do
tempest-coredocumentava um defeito que o upstream corrigiu.test_core_form_widgets_do_not_render_usable_htmlafirmava queDropdowneTextAreasaem como<div>vazio. Natempest-core0.18.0 as tags estão certas —<input>,<textarea>e um<select>carregando os seus<option>. O que decide continua valendo e ficou como o teste assere agora: nenhum dos três renderizaname, e controle sem nome submete corpo vazio. A receita de forms repetia a afirmação antiga nas duas línguas e foi corrigida junto;Form()segue saindo<div></div>, medido, e agora está fixado no mesmo teste. -
Doc afirmando piso que mudou.
docs/ssr.*edocs/fullstack-web.*diziam "o piso étempestweb>=0.67.0", juntando duas coisas: a versão em que a capacidade nasceu (0.67.0, ainda verdade) e o piso declarado (hoje>=0.127.0). Separadas.docs/recipes/genai.*citavatorch>=2.2/torchcodec>=0.8, que subiram.
Notas de medição¶
transformers<5no[genai-audio]continua load-bearing. Re-medido em 2026-09-02 com o coqui-tts mais recente (0.27.5), transformers 5.16.1 e torch 2.14.0:import TTS.apiainda morre comImportError: cannot import name 'isin_mps_friendly' from 'transformers.pytorch_utils'.
O que o bound custa, rastreado no mesmo dia: transformers 4.57.6 declara
huggingface-hub<1.0 e tokenizers<=0.23.0, o que segura este lock em hub
0.36.2 e tokenizers 0.22.2 — e diffusers 0.40.0 exige huggingface-hub>=1.23.0,
então [genai-image] fica em 0.39.0. Um bound, quatro pacotes. Só o
nosso lock é afetado: quem instala [genai-image] sem [genai-audio]
resolve diffusers 0.40.0 normalmente. Registrado no comentário do
pyproject.toml.
-
Piso lido do lock não é piso satisfazível. O lock carrega uma versão por split de Python, e a mais nova pertence ao split mais novo: medido,
numpy2.5.2 exige Python>=3.12enumpy2.4.6 exige>=3.11. Elevar para a mais nova deixou orequires-python = ">=3.11"insatisfazível e ouv lockrecusou, nomeando a causa. O piso é o menor das versões que o lock carrega. Três pacotes têm split aqui:numpy,scipyelibrosa. -
fastapifica em>=0.141.1porque é a última publicada.
[0.282.0] — 2026-09-02¶
O scheduler periódico passa a rodar dentro do lifespan do FastAPI, com garantia de que uma réplica agenda — e as quatro peças que um serviço reescrevia para chegar até ali saíram junto. Issue #263.
Added¶
tq.lifespan(scheduler=True)— abre o broker, sobe o scheduler e derruba os dois, plugável direto emFastAPI(lifespan=...). Isso remove o segundo processo de um deploy pequeno, e traz a falha que otaskiq schedulerstandalone nunca tinha: com N réplicas, cada agendamento dispara N vezes, sem nada levantar e sem nada no log. Não é trade-off com duas respostas razoáveis, então não é aviso: uma réplica segura um lease e roda o loop, as outras ficam de prontidão e assumem quando o lease lapsa.
Três lifespans abertos contra o mesmo lease, com os loops contados nos dois modos:
scheduler=True loops correndo: 1 replica-1=1 replica-2=0 replica-3=0
scheduler='unlocked' loops correndo: 3 replica-1=1 replica-2=1 replica-3=1
A propriedade atravessa processo, então é medida atravessando:
tests/tasks/test_scheduler_lease.py fixa a primeira contagem parametrizada
em 2, 3 e 5 réplicas, fixa o 3 do modo "unlocked" — que é o número que o
modo guardado existe para evitar — e tem um caso que espera o líder sair e
confirma que a de prontidão assume dentro de um TTL.
Sem lease derivável, scheduler=True recusa no startup com a mensagem
que nomeia as três saídas, em vez de agendar N vezes. scheduler="unlocked"
existe para réplica única e tem que ser digitado; scheduler=False segue o
default e mantém o comportamento anterior de lifespan().
-
SchedulerLock+RedisSchedulerLock— o contrato do lease são três chamadas (acquire/renew/release) e não conhece transporte, então coordenação própria (etcd, advisory lock no Postgres) entra sem o SDK saber. A implementação usa o lock que oredis-pyjá ships, que resolve as duas partes que um lock à mão erra: o acquire é umSET NX PXsó, e renew e release são scripts Lua que conferem a posse do token antes de tocar a chave. Renovar lease de outro é o bug que transforma eleição de líder em dois líderes. Nenhuma dependência nova —redisjá vem no[cache]. -
TaskQueue.from_settings(settings)— decide as três coisas que o bloco reescrito em cada serviço decidia: qual transporte a URL significa (redis://→ Redis Streams,amqp://→ RabbitMQ), se resultado é guardado, e o que uma URL vazia faz (broker in-memory, para o dev box e a suíte sem broker continuarem registrando@tq.task). Esquema desconhecido levantaValueErrornomeando o esquema, em vez de cair para memória — URL com typo em ambiente deployado viraria fila que parece saudável enquanto nada é enfileirado. -
Extra
[tasks-redis]—taskiq-redisnão estava declarado em extra nenhum, só na lista de override do mypy, entãoTaskQueue.redis()era superfície documentada cuja dependência ninguém instalava e que a suíte nunca exercitou. Piso medido, não deduzido:RedisStreamBrokernão existe na 1.0.0, então o piso é>=1.0.3— e as duas pontas do range trazem bounds deredisdiferentes (<6,>=5na 1.0.3,<9,>=8na 1.2.3), ambos confinados ao extra. O lock foi exercitado nas duas.
Fixed¶
TaskQueue.redis()deixava o result backend no dummy. Medido contra a 0.281.0 publicada:
Então .enqueue seguido de leitura de resultado não devolvia nada pela
fachada, e o serviço voltava a montar o broker na mão. Agora o default
guarda na URL do broker; results="redis://..." manda pra outro lugar e
results=False restaura o comportamento anterior.
-
TASKIQ_RESULT_BACKEND_URLnão tinha consumidor. O campo existia noTaskIQSettingse nenhum código do pacote o lia —grepno pacote devolvia só o próprio mixin.from_settingspassa a lê-lo, inclusive no caminho AMQP, onde otaskiq-aio-pikanão ships result backend nenhum e o resultado precisa morar em outro lugar. -
O scaffold contradizia a própria docstring. O
src/tasks/__init__.pygerado afirmava "connect/disconnect run from the app lifespan" e osrc/api/app.pygerado pelo mesmo scaffold não tinha nenhuma menção a task manager ou broker. O layer também usava o legadoAsyncTaskBrokerManager+AioPikaBrokercru +os.environ.get, ignorando oTaskIQSettingsque a regra de settings manda compor e fixando RabbitMQ. Agora usaTaskQueue.from_settings, expõebroker/schedulerjá ligados aos nomes que a CLI do TaskIQ resolve, e mostra o lifespan que o leitor precisa colar — em vez de afirmar que ele já existe. OSettingsscaffoldado passou a comporTaskIQSettings, sem o quefrom_settings(settings)levantavaAttributeErrorno projeto gerado.
Os dois defeitos apareceram ao rodar o scaffold, não ao lê-lo: o
primeiro era um SyntaxError de """ aninhado na docstring do módulo, o
segundo o mixin ausente.
Changed¶
- A docstring de
start_schedulerpara de recomendar o processo separado como a única saída e nomeia o que ela realmente faz: o loop cru, uma vez por processo que a chamar.lifespan(scheduler=True)é o caminho com garantia.
[0.281.0] — 2026-09-02¶
Três defeitos que a adoção da 0.280.0 no alofans-api encontrou, todos na
mesma fronteira: uma peça que o SDK entrega e outra do SDK que não a aceitava.
Cada um foi reproduzido contra a 0.280.0 publicada antes de qualquer linha ser
escrita, e cada teste novo foi rodado contra o código como shippou — 35 falhas
no LogUtils, 12 na resolução da CLI.
Fixed¶
exc_infoera parâmetro deerrore de mais nenhum nível (#260). Nos outros quatro o kwarg caía no**fields, viravaextra=, e ologgingrecusa sobrescrever atributo reservado doLogRecord— dentro domakeRecord, que roda depois da checagem de nível. Medido na 0.280.0:
nível ERROR, chamada warning(exc_info=...): NÃO levanta
nível DEBUG, mesma chamada -> KeyError: "Attempt to overwrite 'exc_info'
in LogRecord"
É a forma mais desagradável de bug: logger.debug("...", exc_info="auto")
num serviço rodando em INFO fica dormente até alguém subir a verbosidade —
ou seja, detona durante o incidente, no minuto em que o log é a única
ferramenta que sobrou.
debug/info/warning/error/critical agora aceitam
exc_info: bool | Literal["auto"], e qualquer campo que sombreie
atributo do LogRecord (stack_info, msg, args, levelname, name,
asctime, entre outros) levanta TypeError nomeando a chave, independente
do nível. O conjunto de nomes é lido de um LogRecord de verdade, não
digitado à mão, então acompanha o interpretador: medido em 22 nomes no
3.11 e 23 no 3.12/3.13 — a diferença é taskName, que o 3.11 não tem. Os seis métodos passaram a funilar
num _emit privado, porque a assimetria nasceu de seis quase-cópias
driftando.
Isto muda comportamento de propósito: um serviço que hoje passa nome
reservado num nível filtrado não levanta nada; a partir daqui levanta
TypeError na primeira execução da linha. Era esse o pedido — falha
determinística em vez de bomba-relógio.
async_retry(logger=)recusava oLogUtilsque o próprio SDK entrega (#262). O parâmetro eralogging.Logger | None, eLogUtilsnão é umlogging.Logger— embrulha um. O call site natural rodava, porque as formas batem, e falhava só no type check comArgument "logger" to "async_retry" has incompatible type "LogUtils"; expected "Logger | None". Quem não roda type-checker nunca descobria; quem roda escrevialogger=logger.logger.
Agora o parâmetro é RetryLogger | None. Os parâmetros obrigatórios do
protocolo são posicionais-only porque logging.Logger chama o primeiro de
msg e LogUtils chama de message: mypy aceita as duas grafias,
basedpyright recusa a divergência de nome — a regra da v0.263.0. Medido:
o exemplo da receita passa nos dois checkers, e volta a falhar nos dois se a
anotação for reestreitada, porque tests/test_docs_type_guard.py roda mypy
sobre ele.
- A CLI
dbexigia o nomesettings, ignorava o.enve engolia a causa (#261)._resolve_database_urlimportavasrc.core.settings.settings; um serviço que chamasse a instância deconfiglevantavaImportError, que oexcept Exception: return Noneapagava. A mensagem resultante — "no database URL. Pass --database-url, set DATABASE_URL, or run inside a project with src/core/settings.py" — apontava para duas condições que o projeto já satisfazia, então quem lia concluía que o bug era outro. Pegavaseed,upgrade,current,backup: a família toda.
Três mudanças: a instância é achada por tipo (qualquer
pydantic_settings.BaseSettings, com os nomes settings e config
tentados primeiro), sob src ou app; o .env do projeto é lido
quando o ambiente não tem nada, que é onde o valor mora em dev; e cada
tentativa que falha reporta o motivo no stderr — mas só quando a resolução
inteira falha, para o caminho feliz não virar ruído.
Added¶
RetryLogger— oProtocolcomwarning/errorqueasync_retrypassou a exigir. Exportado no topo, sem extra:utils/retry.pyimporta só stdlib.
[0.280.0] — 2026-09-01¶
Seis buracos de superfície que o alofans-api vinha tapando por fora, achados
ao auditar quais pontes locais dele o SDK já tornava desnecessárias. Cinco eram
"o SDK escreve isto, mas não deixa você alcançá-lo"; o sexto é o defeito que
LogUtils(__name__) tinha desde sempre.
Changed¶
LogUtilsconfigura o logger raiz, não o logger nomeado.LogUtils(__name__)é como a classe se lê e como ela é usada — uma linha por módulo. Até aqui o construtor chamavaconfigure_logging(logger_name=name), que põepropagate = Falsenaquele logger e lhe dá handlers próprios: medido em 7 handlers por logger (1 stdout + 6 de arquivo), um serviço com 27 módulos abria 27 stdout e 162 de arquivo, todos nos mesmos seis caminhos, comRotatingFileHandlerdisputando o rollover de cada um — registro perdido e backups.1/.2embaralhados. Agora o construtor configura o raiz, uma vez por processo, e liga o nome por propagação. Medido: cinco instâncias → 7 handlers no raiz, zero em cada logger de módulo.
Duas consequências visíveis, ambas intencionais: logger de biblioteca
(httpx, sqlalchemy) passa a cair nos seus arquivos, que é o que
configurar o raiz significa; e a primeira instância decide nível e
formato, porque as seguintes não reconfiguram nada. scope="logger"
restaura a forma anterior para quem isola um subsistema de propósito.
RetryPolicymudou deutils/http_client.pyparautils/retry.py. A curva de backoff não é uma ideia de HTTP. O import antigo continua funcionando (from tempest_fastapi_sdk.utils.http_client import RetryPolicy), e o export de topo não muda.
Added¶
-
async_retry— aplica umRetryPolicya qualquer corrotina. O SDK tinha a matemática da espera e nenhum jeito de esperar: todo serviço reescrevia o laço, e cada um decidia sozinho as duas coisas fáceis de errar — quais exceções consomem tentativa (umTypeErrorretentado é o mesmo bug, mais tarde) e o que sobe quando o budget acaba (engolir a última esconde a falha permanente). A última exceção propaga, easyncio.CancelledErrorpassa intocada porque deriva deBaseException. -
LogUtils.error_500()— escreve no500.logque o próprio SDK mantém. O marcadorHTTP_500_MARKERe o filtro do arquivo sempre foram do SDK, mas só os handlers de exceção dele os usavam: um serviço reportando falha grave própria precisava importar a constante decore.logging. O registro também cai noerror.log, e o traceback vai junto quando há um sendo tratado. -
exc_info="auto"emLogUtils.error()— anexa o traceback só quando há exceção sendo tratada.exception()exige estar dentro de umexcept;exc_info=Truefora de um escreveNoneType: None. O default segueFalse. -
reinitialize_logging()— re-habilita todo logger e limpa o latch de configuração.fileConfig()desabilita, por padrão, todo logger existente, e oenv.pyque este SDK ships chama exatamente isso: uma migration silenciava os loggers da aplicação pelo resto do processo, com os registros descartados antes de qualquer handler ver. -
configure_root_once()— o ponto de entrada idempotente que oLogUtilsusa, exposto para quem configura logging no bootstrap. -
DELETE /logsnomake_logs_router, atrás do mesmo gateX-Token, eresolve_log_files()público. O router escreve e lêlogs/; limpar era o terceiro verbo, e todo consumidor recopiava o mapa de nível para nome de arquivo. Trunca in-place em vez de remover, porque os handlers seguram descritor aberto em cada caminho.allaqui inclui o500.log— ao ler não inclui, para não listar o mesmo registro duas vezes. -
LOG_MAX_BYTESeLOG_BACKUP_COUNTnoLogSettings, maislogging_kwargs().configure_loggingaceitava os dois desde que a rotação existe e o mixin não modelava nenhum, então o knob que decide quanto disco os logs ocupam num host de longa duração era o único que o operador não podia setar. -
parse_integrity_error(),IntegrityFailure,IntegrityViolation— lê um erro de integridade de volta na constraint que o recusou, para responder409nomeando o campo em vez de um conflito genérico. Os dois dialetos suportados dizem as mesmas cinco coisas de formas diferentes, e a diferença não é cosmética: o Postgres nomeia a constraint e lista as colunas numDETAIL:; o SQLite nomeiatabela.colunae, para chave estrangeira, nada. Todo padrão foi lido de erro real — Postgres 16 em container, SQLite viaaiosqlite—, etests/db/test_integrity_live.py(marcadodocker) os reproduz contra servidor vivo, para uma mudança de wording falhar aqui em vez de no handler de um consumidor. Lêerror.orig, nãostr(error): este último anexa[SQL: <statement>], e um valor contendoUNIQUE constraint failed: x.yseria parseado como nome de coluna.
Fixed¶
- Os treze codes
OAUTH_*não tinham tradução. O catálogo tempt-BRcomo locale default, então um code sem entrada não caía num texto neutro — caía namessageda própria exceção, escrita em inglês. Eram 24 codes com classe e 11 traduzidos; agora são 24/24 nas duas línguas. Guard novo (tests/test_i18n_coverage_guard.py) recusa o décimo quarto.
[0.279.0] — 2026-09-01¶
A metade que faltava da v0.278.0: a audiência extra existia no client, mas não
no Settings.
Added¶
OAUTH_GOOGLE_EXTRA_AUDIENCESnoOAuthSettings, repassado porgoogle_kwargs(). Sem ele, adotar oPOST /auth/oauth/google/tokennum app mobile exigia declarar o campo à mão em cada serviço — exatamente o que os mixins de settings existem para evitar. O Google emite um client id por plataforma, então o token do app Android carrega o id do Android emaud, e sem esses ids listados todo login nativo é recusado como token de outra aplicação.
[0.278.0] — 2026-09-01¶
Duas metades de auth que todo serviço reescrevia por fora do SDK, e a falha de segurança que uma delas carregava em toda reimplementação conhecida.
Added¶
-
signup_schema=eon_signup=nomake_auth_router. Quase nenhum produto tem uma conta que é só e-mail, senha e nome — tem o telefone, o documento, a flag que separa cliente de produtor. A saída até aqui era desligarPOST /auth/signupe escrever a rota na mão, que então reconstruía a política de senha, o 409 de duplicata, o ramo de ativação e a emissão do par JWT. Agora o corpo é uma subclasse deSignupSchema(visível no/openapi.json, não só no handler) e o hook escreve as colunas novas dentro da transação do insert, antes do commit: hook que levanta desfaz a conta junto, em vez de deixar uma linha com e-mail e senha e nenhum dos campos que a tornam usável — invisível no produto, mas ocupando o e-mailUNIQUEe respondendo ao reset de senha. Umsignup_schemaque não herda deSignupSchemafalha na montagem do router, não no primeiro request. -
POST /auth/oauth/{provider}/token— login social para cliente sem navegador. O app nativo roda o SDK do provedor no dispositivo e termina com um access token na mão; não há navegador para o/login→ consentimento →/callback. A rota nova recebe esse token no corpo (nunca no path: URL atravessa log de acesso, histórico eReferer) e devolve a mesma sessão do callback — mesmos claims, mesma família de rotação, mesmo/auth/logout. Só monta comAUTH_OAUTH_ENABLED, junto das outras quatro.
Security¶
- A audiência do token passou a ser conferida, e é o que torna a rota acima
segura. O
userinfode um provedor responde de quem é o token, nunca para qual aplicação ele foi emitido — e no fluxo token-in-hand quem apresenta o token é quem está chamando. Sem a checagem, o ataque é de três passos e nenhuma senha: o atacante publica um app qualquer, pede consentimentoemail profile, a vítima aceita, e o token resultante — que descreve a vítima — vira sessão da vítima neste serviço. Toda reimplementação local desse endpoint que conhecemos parava nouserinfo.
A rota agora pergunta ao provedor para qual client_id o token foi emitido,
antes de ler qualquer linha: GoogleOAuthClient usa o tokeninfo
(comparando aud e azp), GitHubOAuthClient usa
POST /applications/{client_id}/token com Basic auth, e OIDCProvider usa o
endpoint de introspection passado em tokeninfo_url=. Token de outro app é
401 OAUTH_TOKEN_AUDIENCE_MISMATCH; token recusado pelo provedor é
401 OAUTH_TOKEN_REJECTED.
App mobile tem um client_id por plataforma — o Google emite um para o
backend, um para o Android, um para o iOS —, então extra_audiences= no
construtor do client lista os ids das outras plataformas do mesmo
projeto. Client sem nenhum id configurado responde 501 em vez de comparar
contra string vazia, que ou recusaria tudo ou casaria com um provedor que
ecoa claim vazio.
Fail closed: um client registrado que não implementa
verify_token_audience faz a rota responder 501
OAUTH_AUDIENCE_UNVERIFIABLE sem sequer buscar o perfil. Aceitar seria
exatamente o furo. O fluxo de redirect desse mesmo client continua
funcionando — lá o token foi trocado por este serviço, a partir de um code
que este serviço pediu.
Changed¶
OAuthClientnão ganhou método novo: a checagem mora no protocolo separadoOAuthAudienceVerifier, então client escrito contra versão anterior segue satisfazendo o contrato do fluxo de redirect. Ele só não serve para o token-in-hand, e é recusado explicitamente em vez de silenciosamente confiado.OIDCProvider.__init__aceitatokeninfo_url=(defaultNone).
Docs¶
recipes/auth-flowganha Conta que nasce com mais que e-mail e senha, com o parsignup_schema+on_signupe o!!! dangerexplicando por que o hook roda antes do commit.recipes/oauthpassa de quatro para cinco rotas e ganha Cliente sem navegador: o fluxo token-in-hand, com o ataque de substituição de token escrito passo a passo e a tabela de como cada client confere a audiência. As duas páginas nas duas línguas.
[0.277.0] — 2026-08-30¶
Uma auditoria de documentação, feita para ver se tinha ficado algo pendente. Nove verificações mecânicas vieram limpas — 44/44 extras, 42/42 linhas de roadmap, 43/43 comandos de CLI, 24/24 links do site, todas as contagens de operação. Os três achados foram os que nenhum guard podia pegar, e dois deles apontam para código do próprio SDK.
Fixed¶
AccessLogMiddlewaredeixa de depender da ordem de registro. A v0.275.0 documentou a ordem como um!!! danger: registrado por fora doRequestIDMiddleware, toda linha saía semrequest_ide nada avisava. A regra deste repositório diz que aviso sobre passo mecânico com uma resposta certa é código não escrito — e ela pegou o SDK. Medido:
AccessLogMiddleware por dentro do RequestIDMiddleware context var: setado
header: ainda não escrito
AccessLogMiddleware por fora do RequestIDMiddleware context var: limpo
header: presente
O RequestIDMiddleware é um BaseHTTPMiddleware: carimba o header
depois que a app retorna, então o wrapper de send de um middleware
interno já rodou; e limpa o context var ao desenrolar, então um externo não
acha nada lá. As duas fontes são complementares, e o middleware passou a ler
as duas — o wrapper de send que ele já tinha para capturar o status agora
captura o header também. request_id_header= acompanha um
RequestIDMiddleware(header_name=...) customizado.
Sem RequestIDMiddleware na pilha, nenhum campo é inventado.
make_spa_routerrecusa uma política que apaga a própria página. PassarDEFAULT_STATIC_SECURITY_HEADERS— que foi o default deste router até a v0.251.0, e é destinado a arquivo que o serviço não confia — apontadefault-src 'none'; sandboxpara a SPA compilada. O bundle e a folha de estilo da própria página são bloqueados, e o único sintoma é documento em branco com a causa no console do browser.
Isso estava documentado como !!! danger, com repro medido em browser real,
e a função aceitava o dict sem olhar. Agora levanta ValueError nomeando a
saída. A checagem é sobre a forma da política — sandbox sem
allow-scripts, ou default-src 'none' sem script-src — então um
equivalente escrito à mão é recusado igual, e uma política que funciona
passa.
Changed¶
make_spa_routerganhouallow_blocking_headers: bool = False. A v0.251.0 decidiu de propósito quesecurity_headersé override cru que o router não policia, e um teste fixava isso. A checagem acima reverteria essa decisão em silêncio, então ela vem com opt-out explícito, no padrão que osafe_upgrade(force=True)já usa aqui: o override continua possível, mas precisa ser deliberado. É o que separa "eu quis isso" de "copiei um snippet antigo".
Docs¶
- Três frases sobreviveram à correção da v0.276.0 e afirmavam ainda que o
Mercado Pago não publica OpenAPI:
scripts/mercadopago_overlay.py,tests/integrations/payment/mercado_pago/test_overlay.pye o checklist devendor/PROVENANCE.md. Corrigidas. A lição que a v0.276.0 escreveu — "grepo assunto no repositório inteiro antes de acreditar na frase à sua frente" — falhou em sua primeira aplicação, que foi a si mesma. - O
!!! dangerde ordem de registro emlogging.mdvirou!!! infodizendo que a ordem não importa, com a medição das duas fontes. O!!! dangerdoreact-spa.mdcontinua — ele explica por que a política quebra —, mas agora diz que o SDK recusa, e como pedir o override.
[0.276.0] — 2026-08-30¶
Uma issue que pedia para achar a origem de um documento vendorizado. A origem estava escrita no repositório desde o dia em que o arquivo entrou — no docstring do módulo que o gera, trinta linhas acima da frase que a negava.
Fixed¶
vendor/mercadopago-openapi.yamltem upstream, e sempre teve. (#228) Quatro lugares afirmavam que o documento não tinha de onde ser refrescado e que ninguém sabia como fora montado:vendor/PROVENANCE.md,vendor/mercadopago-evidence.md,scripts/mercadopago_diff.pye dois docstrings descripts/regen_mercado_pago.py. O docstring do módulo desse mesmo arquivo dizia o contrário, e estava certo.
A afirmação errada saiu de uma sonda que adivinhou o nome do arquivo. As três sondas registradas continuam válidas; a conclusão tirada delas, não:
404 api.mercadopago.com/openapi.json
404 api.mercadopago.com/openapi
404 raw.githubusercontent.com/mercadopago/openapi/main/openapi.yaml
200 raw.githubusercontent.com/mercadopago/openapi/main/spec3.yaml
github.com/mercadopago/openapi é público, Apache-2.0, criado em
2026-05-20, e se descreve como "MercadoPago's OpenAPI Specification". O
arquivo se chama spec3.yaml. "A org não tem repositório de especificação"
nunca foi medido — foi inferido de um 404 sobre outro nome.
O arquivo vendorizado é aquele spec3.yaml, byte a byte:
vendorizado 260935 bytes sha256 893ec14bfd912dd3…
commit 73bc0e49 260935 bytes sha256 893ec14bfd912dd3…
main (2026-08-30) 260935 bytes sha256 893ec14bfd912dd3…
Consequência para o SPEC_SHA256: ele passou a ser afirmação sobre eles,
não sobre nós. O docstring dizia explicitamente o oposto — "this digest is a
claim about us" — e essa distinção era a justificativa inteira de por que o
guard do Mercado Pago era mais fraco que o da OpenPix. Não era.
Added¶
-
make mercadopago-fetch. Rebaixavendor/mercadopago-openapi.yamldo repositório do provedor, espelhandomake openpix-fetch. Medido: rebaixa os mesmos bytes e a regeneração seguinte é no-op, então um refresh futuro vira um diff só do que eles mudaram — que é a propriedade que o overlay existe para preservar. -
Guard de procedência —
tests/integrations/payment/mercado_pago/test_provenance.py. Afirma o fato positivo, não a ausência da frase errada: a URL que o fetcher usa está registrada noPROVENANCE.md, o digest bate, o alvomercadopago-fetchexiste noMakefilee aponta para o arquivo certo, e as duas línguas da receita nomeiam o repositório upstream e o comando de refresh. Grepar a redação antiga seria pior: as notas de correção a citam de propósito, e guard que proíbe citar um erro torna o erro mais difícil de explicar.
Changed¶
- A receita do Mercado Pago para de dizer que o provedor não publica
OpenAPI, nas duas línguas, e passa a separar as duas perguntas que estavam
fundidas: "o documento mudou?" (rebaixar responde) e "esta operação
existe?" (rebaixar não responde). A segunda continua com o SDK oficial como
autoridade, porque — medido em 2026-08-30 — o documento do provedor omite
as sete operações que o SDK do próprio provedor chama. É por isso que a
#226
não fecha junto: o upstream não modela as sete, então
dict[str, Any]continua sendo a forma honesta até haver credencial de sandbox.
Internal¶
vendor/PROVENANCE.mdganhou a tabela das variantes que o upstream publica, medidas no mesmo commit:spec3.yaml(109 paths / 143 operações, a vendorizada),spec3.reference.yamlespec3.sdk.yaml(108 / 142 cada). A raiz é vendorizada por ser o superconjunto — a operação a mais éPUT /checkout/preferences/{id}/expire.spec3.sdk.yamlanotax-mp-sdk-coveragepor operação, dizendo quais SDKs oficiais cobrem cada uma. Cruzar isso com oOFFICIAL_SDK_CALLSmantido à mão é trabalho separado, registrado em issue própria.
[0.275.0] — 2026-08-30¶
Quatro issues abertas por consumidores, todas da mesma família: um serviço
reescrevendo, à mão, um passo que sempre tem a mesma resposta certa — e
errando. Duas viram middleware, uma vira superfície no AlembicHelper, uma é
uma linha de dependência que fazia todo projeto novo nascer avisando.
O guard que a última delas trouxe achou um quinto defeito que ninguém tinha visto: uma rota do SDK que devolve 500 no lugar de 422 para todo consumidor que trata warning como erro.
Added¶
AccessLogMiddleware— uma linha estruturada por request. (#256) O SDK amarrava o id de correlação e formatava JSON, mas nada emitia a linha que faz omake_logs_routervaler a leitura. Cada consumidor reescrevia os mesmos ~60 linhas de ASGI puro e errava um subconjunto diferente de três detalhes: não pode serBaseHTTPMiddleware(o status sai dohttp.response.startvia wrapper desend, e o caminho de exceção fica intocado); exceção que escapa não tem status, e é justamente o request que mais precisa de linha; e o path pode carregar segredo, que recusar no handler não desloga.
Emite http_method, http_path, http_query, http_status, duration_ms
e client_ip por extra=, então o JSONFormatter grava chaves de verdade e
o GET /logs filtra por elas. 5xx — renderizado ou escapado — sai em
ERROR; o resto no level configurado. exempt_paths casa por prefixo,
para o SSE não virar "request de uma hora"; redact reescreve path e query
antes do registro existir.
A ordem de registro decide se a linha carrega request_id, e isso foi
medido, não deduzido: o RequestIDMiddleware limpa o context var ao
desenrolar, então o access log só vê o id quando roda por dentro dele —
adicionado primeiro, já que o Starlette aplica o último add_middleware
como o mais externo. Na ordem invertida toda linha sai sem id e nada avisa
(tests/api/test_access_log.py::TestAccessLogRequestIdOrdering).
HoneypotBanMiddleware+DEFAULT_HONEYPOT_PATTERNS— ban de scanner. (#257) ORateLimitMiddlewareresponde essa chave passou de N requests em W segundos; um scanner que sonda doze caminhos uma vez cada e vai embora nunca o dispara. A entrega real aqui não é o middleware — é a lista de 39 assinaturas curadas (dotfile, recon de PHP, scanner de CMS, extensão de backup/dump, sonda de credencial), portada doBadActorMiddlewareque roda em produção noalofans-api, casada contra path e query string. Vindo do SDK, o consumidor pega assinatura nova com um bump em vez de manter regex na mão.
Duas decisões ficam dentro em vez de na doc: falha aberta (todo acesso ao
store é guardado; store que levanta vira WARNING e o request passa, porque
blocklist que não alcança o Redis não pode transformar queda de cache em
queda de API) e o IP vem de um header que só o edge escreve — atrás de
nginx o X-Forwarded-For é acrescentado ao que o cliente mandou, então lê-lo
direto bane o IP errado e pode ser induzido a banir um usuário real.
MemoryBanStore e RedisBanStore implementam o protocolo BanStore. A
resposta é um 403 que não conta nada: scanner que aprende que está banido
aprende a rodar de IP. /.well-known/ fica fora da lista de propósito — ACME
e security.txt moram lá.
AlembicHelper.sync_schema()/adopt()/base_revision()/has_existing_schema()— o bootstrap de schema do primeiro boot. (#249) Um serviço perdeu um dia com o chat inteiro fora do ar: um banco criado antes de o projeto ter migrations foi carimbado emheadno boot, sem que nenhuma migration rodasse.create_tables()écreate_all, ou sejaCREATE TABLE IF NOT EXISTS— no-op silencioso em tabela existente —, e ostamp("head")seguinte registrou como aplicadas revisions que nunca rodaram. O resultado é o pior estado possível: schema velho, comalembic current,upgradeehistorytodos respondendo que está em dia, até uma query estourarno such columnsemanas depois.
sync_schema() distingue os três estados e devolve qual caminho tomou
(SchemaSyncOutcome): banco vazio roda a árvore inteira; banco que precede o
Alembic é carimbado na base e só então sobe; banco já sob o Alembic só
sobe. O defeito está reproduzido ponta a ponta em
tests/db/test_schema_sync.py::TestTheReportedDefect, junto do caminho de
reparo (stamp("base") e sync_schema()) para quem já está no estado ruim.
Nova receita bilíngue Migrations,
que cobre como o schema nasce, adoção, reparo e onde create_tables()
continua legítimo — com a razão técnica, não só a proibição.
Changed¶
- O template do
tempest newpinahttpx2>=2.12.0no grupodev, no lugar dehttpx. (#251) O Starlette 1.x liga ostarlette.testclientaohttpx2quando ele está instalado, e cai nohttpxavisando quando não está. O aviso parece cosmético e não é:StarletteDeprecationWarningherda deUserWarning, não deDeprecationWarning, então um projeto comfilterwarnings = ["error"]quebra no import, antes de coletar teste, e o silenciador óbvio (ignore::DeprecationWarning) não casa nada. Como o smoke test do scaffold abre comfrom fastapi.testclient import TestClient, todo serviço novo nascia avisando.
Medido com fastapi 0.141.1 / starlette 1.6.0 / httpx 0.28.1 /
httpx2 2.12.0: com o httpx2 instalado o import é silencioso e o
TestClient responde 200 por cima dele. O grupo dev deste repo ganhou o
mesmo pin, mais filterwarnings = ["error::starlette.exceptions.StarletteDeprecationWarning"]
no pytest, para o aviso voltar como falha de coleta em vez de ruído.
-
Piso do
fastapide>=0.118.0para>=0.141.1. (#251) Um piso 23 minors atrás do que a CI exercita deixava a resolução de qualquer consumidor escolher um FastAPI que nada aqui testa. Isto não silencia o aviso acima — os dois itens da issue são independentes. -
AlembicHelper.stampdocumenta o modo de falha. A docstring dizia "useful when importing an existing schema into Alembic for the first time" sem dizer em qual revision, comrevision: str = "head"na assinatura logo abaixo — convidando ao erro em vez de evitá-lo. Agora carrega o.. danger::e aponta paraadopt()/sync_schema().
Fixed¶
make_serving_routerdevolvia 500 no lugar do 422 para quem trata warning como erro. Achado pelofilterwarningsnovo, não por relato: otempest_fastapi_sdk/modelops/router.pymontava a rejeição comstatus.HTTP_422_UNPROCESSABLE_ENTITY, que o Starlette 1.x depreciou. Ler esse nome emiteStarletteDeprecationWarning— que éUserWarning—, então num consumidor comfilterwarnings = ["error"]o aviso é levantado enquanto a resposta está sendo montada, e o 422 que a rota promete vira 500. Reproduzido emtests/modelops/test_serving.py::TestRouter::test_the_wrong_width_is_a_422_not_a_500etests/modelops/test_monitoring.py::TestMonitoredRouter::test_a_rejected_request_is_not_counted.
O nome novo (HTTP_422_UNPROCESSABLE_CONTENT) não resolve: medido, ele
não existe no starlette 0.46.0, que é o piso que fastapi>=0.141.1 aceita.
Nenhuma das duas constantes cobre a faixa suportada, então a rota passou a
escrever o número, com o motivo registrado numa constante nomeada.
Internal¶
AlembicHelper.current()passou a compartilhar o caminho de leitura comhas_existing_schema()(_read/_read_via_async), em vez de duplicar a lógica de engine sync com fallback assíncrono.- Guard novo
tests/test_testclient_httpx2_guard.py: os doispyproject(este e o do template) pinamhttpx2, e as duas afirmações de comportamento da issue são re-executadas em vez de confiadas.
[0.274.0] — 2026-08-30¶
Quatro defeitos achados pela mesma migração real — um serviço adotando a v0.273.0 — e todos da mesma família: o SDK tem a capacidade, mas a porta por onde todo mundo entra não a expõe. O consumidor então reimplementa o pedaço inteiro para alcançá-la.
Security¶
request_password_resetnão invalidava os links anteriores. (#253) Cada chamada só empilhava linha emuser_tokens, então todos os tokens de reset não usados e não expirados ficavam válidos ao mesmo tempo.
O que isso quebra é a reação correta do usuário. Um atacante dispara o
reset para a vítima; a vítima recebe um e-mail de recuperação que não pediu,
desconfia, e por conta própria pede um reset e conclui. Isso não fechava a
janela — o link do atacante continuava valendo até
AUTH_PASSWORD_RESET_TTL_SECONDS, então um token vazado por qualquer via
lateral (log de proxy, extensão de browser, e-mail encaminhado, dispositivo
compartilhado) ainda resetava a senha depois do incidente parecer
resolvido. Medido:
AUTH_SINGLE_ACTIVE_TOKEN=True -> link do atacante: recusado (InvalidTokenException)
AUTH_SINGLE_ACTIVE_TOKEN=False -> link do atacante: AINDA FUNCIONA
A correção vive dentro do _issue_token, não no request_password_reset,
então cobre todos os propósitos de uma vez: ativação, reset,
troca de e-mail e verificação de e-mail. Uma troca de e-mail pendente para um
endereço que o atacante controla tem exatamente a mesma forma. O escopo é
estreito nas duas direções — só o mesmo propósito e só o mesmo usuário:
pedir um reset não derruba uma troca de e-mail pendente. A linha é marcada
used_at em vez de apagada, então a trilha de auditoria fica.
É mudança de comportamento, com AUTH_SINGLE_ACTIVE_TOKEN (default
True) restaurando o anterior. Ver o guia de migração.
Added¶
make_bearer_token_dependency/make_jwt_user_dependencyrepassamstrictelegacy_claims. (#252) Otoken_type_allowedganhou os dois parâmetros e a docstring descreve exatamente quem precisa deles — um serviço cujos tokens legados declaravam o tipo numa claim própria (type,token_type). Esse token não temtypnem o marcador de fallback do SDK, então o default permissivo o classifica como "desconhecido" e deixa passar. Medido, com um refresh token legado:
default (strict=False) -> HTTP 200
strict=True -> HTTP 401
strict=True, legacy_claims=('type',) -> HTTP 401
o access legado, mesmas opções -> HTTP 200
A primeira linha é o buraco: todo refresh token que aquele serviço já
emitiu autoriza qualquer rota pelo resto da vida dele. A capacidade existia;
o único jeito de alcançá-la era não usar as factories. Agora as três portas
repassam — as duas factories, UserAuthService.current_user_dependency, e o
make_auth_router por token_strict= / token_legacy_claims=, que guarda
/auth/me, /auth/password-change e o resto das rotas autenticadas.
- Os guards de autorização ganham seam de exceção, e
make_flag_guard. (#254)require_active/require_adminlevantavamForbiddenExceptionfixa, em inglês, comcode="FORBIDDEN". O próprio SDK tratacodecomo contrato — oregister_exception_handlersserializa{"detail", "code", "details"}para o cliente ramificar nele, e oAppExceptionavisa comGenericCodeWarningquando uma subclasse herda um genérico. Um serviço que levou isso a sério declaraUserIsNotAdminError(code="USER_IS_NOT_ADMIN")e não podia adotarrequire_admin, porque adotá-lo devolveriaFORBIDDENem toda rota de admin e quebraria os clientes. Medido:
require_admin default -> 403 'FORBIDDEN'
require_admin exception= -> 403 'USER_IS_NOT_ADMIN'
make_flag_guard('is_producer') -> 403 'USER_IS_NOT_ADMIN'
Cada guard agora aceita exception= (a própria recusa) e unauthenticated=
(o caso None que ele delega), nas funções livres e nos espelhos
estáticos do UserAuthService. make_flag_guard(attr, exception=...)
generaliza para as flags que o SDK não modela — is_producer, is_staff —
que o consumidor escrevia à mão de qualquer jeito. O atributo é lido com
getattr(user, attr, False), então um model sem a coluna é recusado com 403
em vez de estourar AttributeError num 500. Defaults inalterados.
- Dez exceções tipadas para as recusas do login social.
(#255)
login_with_oauthnegava por motivos opostos com a mesma classe, o mesmocode("CONFLICT") e as mesmas chaves emdetails: "e-mail já cadastrado, entre e ligue o provedor pelas configurações" tem um próximo passo para a pessoa; "o provedor não verificou este e-mail" — a barreira que impede quem registrou uma identidade carregando o e-mail da vítima de assumir a conta — não tem nenhum, por design. Um cliente que mostrasse "entre e ligue" no segundo caso estaria mandando a pessoa fazer algo que não pode funcionar. Distinguir pormessageé frágil por definição, e o SDK já localiza o resto do fluxo de auth.
Agora cada recusa do caminho tem code próprio — não só as duas da issue:
OAUTH_PROVIDER_NOT_CONFIGURED, OAUTH_PROVIDER_DENIED,
OAUTH_STATE_MISMATCH, OAUTH_CODE_MISSING, OAUTH_EMAIL_MISSING,
OAUTH_EMAIL_TAKEN, OAUTH_EMAIL_UNVERIFIED,
OAUTH_REGISTRATION_DISABLED, OAUTH_ACCOUNT_INACTIVE,
OAUTH_ACCOUNT_NOT_LINKED. Cada classe herda da exceção que aquele ponto já
levantava, então except ConflictException continua pegando exatamente o que
pegava.
AUTH_SINGLE_ACTIVE_TOKENnoAuthSettings(defaultTrue).FlagGuardeGuardExceptionexportados, para tipar um guard próprio sem redeclarar a forma.
Changed¶
@requires: documentado que um guard que trafega schema Pydantic precisa retornarNone. O_accept_resultdecide "valor de usuário" porisinstancecontra a base declarativa, então devolver o schema validado parece o contrato de narrowing que os guards de ORM usam, mas o decorator não reconhece — ele avisa comGuardContractWarninge mantém o usuário original. Era descoberta por warning; agora está na docstring do módulo.
[0.273.0] — 2026-08-30¶
Added¶
- Login social entra no
make_auth_router: o SDK deixa de parar no protocolo e passa a entregar a sessão. (#250)api/oauth.pysempre entregou a dança OAuth2 —GoogleOAuthClient,GitHubOAuthClient,OIDCProvider, terminando numOAuthUser. O que vinha depois, que é onde mora o login de verdade, era do consumidor: rota,state, modelo, find-or-create e emissão de token, escritos à mão em cada serviço. Medido na v0.272.0, com todos os grupos condicionais ligados:
Agora, com AUTH_OAUTH_ENABLED=true e pelo menos um client registrado:
rotas com tudo ligado: 29 (27 paths distintos)
paths de OAuth / social: ['/auth/oauth/accounts', '/auth/oauth/accounts/unlink',
'/auth/oauth/{provider}/callback',
'/auth/oauth/{provider}/login']
com AUTH_OAUTH_ENABLED=false: 25 rotas, oauth=[]
O defeito que isso fecha é o token. A receita antiga terminava em
self.tokens.encode({"sub": str(user.id)}), e o access token do flow bundled
carrega mais coisa. Os dois lado a lado, mesmo JWTUtils:
receita antiga claims=['exp', 'iat', 'sub'] lax=True strict=False
callback bundled claims=['email', 'exp', 'iat', 'sub', 'typ'] lax=True strict=True
O token do login social só passava pela janela de compatibilidade do
token_type_allowed — o ramo return not strict, que existe para não
deslogar sessão emitida antes do claim typ. Um serviço que nunca teve token
legado e liga strict=True (a postura correta para ele) derrubava todo login
com Google, e nenhum teste via, porque o caminho social não passava por
código do SDK. Agora passa: o callback emite por issue_token_pair, então
herda typ, refresh token opaco, rotação, detecção de reuso por família e
POST /auth/logout.
As quatro rotas:
| Método | Rota | O que faz |
|---|---|---|
GET |
/auth/oauth/{provider}/login |
Sorteia o state, grava no cookie HttpOnly e redireciona 302 ao provedor |
GET |
/auth/oauth/{provider}/callback |
Confere o state, troca o code, resolve a identidade e devolve o par JWT |
GET |
/auth/oauth/accounts |
Autenticado. Lista os provedores ligados |
POST |
/auth/oauth/accounts/unlink |
Autenticado. Desliga um provedor |
As três regras de segurança que a receita carregava em !!! danger e que
nenhum guard alcançava — porque o SDK não era dono de nenhuma linha daquele
caminho — viraram código exercitado, uma classe de teste cada:
- o
stateé comparado comhmac.compare_digestcontra um cookieHttpOnlyque carrega a chave do provedor junto do valor aleatório, então umstatesorteado para o Google não vale no callback do GitHub; - ligar identidade nova a conta existente por e-mail exige
AUTH_OAUTH_LINK_BY_VERIFIED_EMAIL=trueeemail_verified is True—None(o provedor não disse nada) não é um sim, que é exatamente o caso possível do GitHub; -
a identidade é chaveada em
(provider, subject)comUNIQUEcomposto, declarado no próprio modelo abstrato, então nem um mapeamento escrito à mão ships sem ele. -
OAuthSettings— mixin de credenciais comgoogle_kwargs(),github_kwargs()eoauth_redirect_uri(provider, prefix=...). O redirect URI não é declarado: ele é derivado deOAUTH_REDIRECT_BASE_URL+ prefixo do router + chave do provedor, porque é totalmente determinado pelos três e precisa casar caractere a caractere com o que está no console. -
BaseUserOAuthAccountModel+make_user_oauth_account_model— tabela de identidades ligadas, espelhandomake_user_refresh_token_model. Tabela separada (e não duas colunas noUserModel) é o que permite a mesma pessoa ter Google e GitHub ligados, e é onde ficam os doisUNIQUE. -
NameMixin— colunanameopt-in para o user model, espelhando oMFAMixin. Declarada type-only noBaseUserModel, vira coluna real só para quem mistura. LigarAUTH_OAUTH_ENABLEDsem ela é recusado na construção do router, no mesmo idioma deAUTH_MFA_ENABLEDsemrecovery_code_model. -
generate_password— gerador de senha que satisfaz a política por construção, usado pelo callback ao criar conta (hashed_passwordcontinuaNOT NULL, sem migration e sem ramo "usuário sem senha"). Sortear de um alfabeto plano e torcer é o defeito que ele evita; medido contra_enforce_password_policycomAUTH_PASSWORD_REQUIRE_COMPLEXITY=truee o piso default de 12 caracteres, 200 000 amostras cada:
token_urlsafe(16) 106012/200000 rejeitados (53.01%)
token_urlsafe(32) 53076/200000 rejeitados (26.54%)
token_hex(32) 200000/200000 rejeitados (100.00%)
Um quarto dos logins com Google falharia de forma intermitente, com um 422 vindo de dentro do callback sobre uma senha que o usuário nunca digitou.
-
OAuthClient—Protocolpúblico com os três métodos que o router chama, para registrar um provedor que o SDK não ships (ou um stub de teste) sem herdar de_BaseOAuthClient, que continua privado. -
UserAuthService.login_with_oauth/.list_oauth_accounts/.unlink_oauth_account, mais o parâmetrooauth_account_model=no construtor eoauth_clients=nomake_auth_router. -
AUTH_OAUTH_ENABLED,AUTH_OAUTH_STATE_COOKIE_NAME,AUTH_OAUTH_STATE_TTL_SECONDS,AUTH_OAUTH_LINK_BY_VERIFIED_EMAIL,AUTH_OAUTH_ALLOW_ACCOUNT_CREATIONnoAuthSettings. O último tem defaultNone, que herdaAUTH_SIGNUP_ENABLED: o callback cria conta, então fechar o cadastro público (v0.272.0) fecha essa porta junto, em vez de deixar um segundo caminho mais silencioso. -
AUTH_DEFAULT_DISPLAY_NAME/default_display_name— placeholder de nome localizado ("Você"/"You") para quando o provedor não devolvename, resolvido pelo mesmoresolve_localeque o resto do fluxo de auth usa.
Changed¶
docs/recipes/oauth.md/.en.mdreescritas. A receita ensinava a escrever as duas rotas, o find-or-create e a emissão do token à mão; agora ensina o router bundled primeiro, e o caminho manual continua documentado numa seção própria — com o aviso de que, indo por ali, as três regras de segurança voltam a ser do leitor.docs/recipes/auth-flow.md/.en.mdganham as quatro rotas na tabela de endpoints e o "Grupo 10 — Login social" na tabela de settings.
Notes¶
Nada quebra: os cinco campos novos de AuthSettings têm default que preserva o
comportamento atual (AUTH_OAUTH_ENABLED=false), os dois parâmetros novos são
keyword-only com default None, e api/oauth.py não se move nem muda de
assinatura. Por isso não há seção de migração para esta release.
[0.272.0] — 2026-08-30¶
Fixed¶
check_debugecheck_databaseliam um campo que nenhum mixin do SDK declara. (#246) Os dois faziamgetattr(context, "DEBUG", ...), e o mixinServerSettingschama o campo deSERVER_DEBUG. O erro não era simétrico:check_debugficava permanentemente calado, echeck_databaseavisava ao contrário — a condiçãonot getattr(context, "DEBUG", False)é sempreTruequando o campo não existe, então a isenção de debug nunca valia e a mensagem "while DEBUG is off" aparecia justamente com o debug ligado.
Medido num serviço compondo os mixins, antes:
SERVER_DEBUG = True | hasattr DEBUG = False
check_debug -> []
check_database -> ['DATABASE_URL points at SQLite while DEBUG is off.']
Depois:
debug on -> check_debug ['deployment.I001']
debug on -> check_database []
debug off -> check_debug []
debug off -> check_database ['DATABASE_URL points at SQLite while debug is off.']
O helper _debug_enabled resolve por precedência de nome — SERVER_DEBUG
primeiro, DEBUG depois — então quem declarou o campo na mão continua
coberto. deployment.I001 passou a nomear o campo que de fato carregou o
flag.
check_secretsaprovava o placeholder que o próprio SDK ships. (#247)MIN_SECRET_LENGTHé 32 e o default deJWTSettings.JWT_SECRETé"change-me-change-me-change-me-32"— exatos 32 caracteres. Não era vazio (security.W001) nem curto (security.W002), então o único valor garantidamente errado era o único que o check aprovava: um deploy que esqueceu de definirJWT_SECRETassinava token com um segredo publicado no código-fonte, e otempest check-configdizia que estava tudo bem.
Novo security.W004 compara o valor com model_fields[nome].default, lido
da classe — nunca com uma cópia da string — então a checagem acompanha uma
troca futura do placeholder em vez de comparar com um valor que o SDK parou
de usar. Medido, num serviço sem nenhuma env var definida:
$ tempest check-config --settings probe.settings:settings
WARNING: (security.W004) JWT_SECRET still holds the default declared by its settings field — a deployment that never set it signs tokens with a value that lives in source control.
HINT: Generate one with `tempest secrets rotate`.
WARNING: (database.W001) DATABASE_URL points at SQLite while debug is off.
HINT: Use PostgreSQL in production; SQLite is for development.
2 message(s), 0 at/above ERROR.
Segredo vazio continua saindo como security.W001, que é a mensagem mais
específica; cada segredo rende no máximo uma mensagem.
Added¶
AUTH_SIGNUP_ENABLED/allow_signup=— fechar o cadastro público. (#248)make_auth_routermontava o conjunto inteiro de rotas. Num sistema fechado — conta criada só por administrador —POST /auth/signupé exatamente o buraco que não se quer aberto, e não havia parâmetro para não montá-lo.
AuthSettings ganhou AUTH_SIGNUP_ENABLED (default True), e
make_auth_router ganhou allow_signup: bool | None = None, que
sobrescreve a setting nos dois sentidos — o mesmo par que token_delivery
já formava com AUTH_TOKEN_DELIVERY. A rota passa pelo _mount_if que os
outros grupos condicionais já usam, então some da aplicação e do
/openapi.json; o workaround que sobrava (filtrar router.routes por
string de path depois de montar) corrigia só o primeiro.
Medido: o router sai com 12 rotas ligado e 11 desligado, e a diferença é
exatamente /auth/signup. A ativação continua montada de propósito —
conta criada por administrador também precisa consumir o token de
/auth/activate/{token}.
Changed¶
- A suíte dos checks passou a exercitar os mixins reais. Todo teste de
tests/checks/test_checks.pyalimentava os checks com um attribute bag escrito à mão, que carregava umDEBUGque os mixins nunca definem — é por isso que o #246 shippou com a suíte verde. A classeTestBuiltinsAgainstTheSdkMixinscompõeServerSettings+DatabaseSettings+JWTSettingsde verdade. Rodada contra obuiltins.pyda v0.271.0, ela falha em 4 testes — os dois defeitos, nas duas direções de cada um.
[0.271.0] — 2026-08-29¶
Added¶
- O documento vendorizado do Mercado Pago ganhou guard de digest.
(#228)
vendor/PROVENANCE.mdjá dizia que "mudança nele é edição nossa, e precisa de justificativa" — mas isso era só prosa. Nenhum teste comparava o digest do arquivo do Mercado Pago;SPEC_SHA256e o guard existiam apenas do lado da OpenPix.
Medido: acrescentar uma linha de comentário ao YAML vendorizado deixava a suíte inteira passar, porque o teste de drift regenera a partir do arquivo editado e a saída fica consistente com ele —
# com um comentário acrescentado, antes do guard
14 passed
# com o guard
FAILED ...::test_the_vendored_document_is_the_one_that_was_justified
1 failed, 13 passed
scripts/regen_mercado_pago.py ganhou SPEC_SHA256 e spec_digest(),
espelhando a OpenPix, e o drift test do Mercado Pago compara os dois.
O que ele não faz está escrito junto, porque a diferença é o ponto: o
digest da OpenPix é de bytes que o provedor serviu, então sustenta uma
afirmação sobre eles. O Mercado Pago não publica especificação
(medido: api.mercadopago.com/openapi{,.json} respondem 404), então
este digest sustenta uma afirmação sobre nós — que o documento não
mudou desde que alguém justificou o conteúdo. As 82 operações que o SDK
oficial não toca continuam sem segunda fonte, e três delas responderam
404 quando sondadas. Achar a origem continua aberto na #228.
Fixed¶
vendor/PROVENANCE.mdcontava operações com o rótulo errado. A linha diziaOperações (paths) | 109, misturando duas medidas diferentes. São 109 paths e 143 operações (path × verbo) — os dois números agora aparecem separados, cada um com o próprio rótulo.
[0.270.0] — 2026-08-29¶
Added¶
OpenPixPixProviderpassou a ser exercitado sobre HTTP de verdade. (#245) A classe que a receita manda o consumidor usar nunca tinha visto um byte de JSON: os testes de adapter existentes usam umStubOpenPixClientque devolveCreateChargeResponse(...)construído em Python, então a camada JSON→modelo inteira — alias, enum, tipo declarado de cada campo — não rodava. Foi por isso que a #238 shippou passando por 12 testes verdes — a contagem que opytest --collect-onlyreporta para aquele módulo; a issue estimou 13.
tests/integrations/payment/adapters/test_openpix_adapter_wire.py põe um
httpx.MockTransport sob o OpenPixClient real e dirige o provider como
um serviço o usa: bytes saindo, bytes voltando — 43 testes. Os corpos
não são inventados: o da cobrança é o capturado do sandbox em 2026-08-29,
e o da entrega é o exemplo de request-body do próprio documento. Fixture
derivada da especificação não enxerga uma especificação que mente.
Dois testes vão além e montam rota, porque status code é fato sobre um
serviço e não sobre uma função: a metade de webhook assina os bytes crus e
deixa make_openpix_webhook_dependency verificar e decodificar antes de o
parse_webhook ver um objeto; a metade de leitura põe get_pix_charge
atrás de register_exception_handlers, então o que se assere é a resposta
que o consumidor recebe.
Cada teste nasceu xfail(strict=True) nomeando a issue dona da correção.
As seis fechadas aqui tiraram os marcadores junto — que é para isso que o
strict serve: passar inesperadamente é falhar, então nenhum deles podia
ficar para trás em silêncio.
A suíte com stub continua: ela é rápida e cobre mapeamento. O que ela não alcança é a fronteira, e as seis correções abaixo estavam todas lá.
PaymentStatus.UNKNOWN. Estado canônico para o que esta versão do SDK não classifica, irmão doPixEventType.UNKNOWNque já existia.
Fixed¶
- Webhook com
valuenão-numérico deixou de viraramount_cents=0. (#239) Uma cobrança liquidada cujovaluenão fosseint/floatchegava ao serviço como paga, valendo zero, sem exceção e sem log. Medido na v0.268.0:
O guard era isinstance(value, (int, float)) com else 0 — mais
estreito que a função que ele protegia. to_cents aceita str de
propósito, e a docstring dela diz que o uso documentado é um payload cru
de webhook; o documento da Woovi tipa value como string em dois
schemas, então "1990" é um corpo que ela pode legitimamente mandar.
Agora to_cents decide, e a recusa dela é tratada: um valor ilegível
levanta nomeando a cobrança, em vez de responder "zero" para "não sei".
Zero é um número que o serviço credita.
- Bloco
customerdeixou de violar ooneOfda própria especificação. (#240)create_pix_chargecom umPixPayerque só tinhanamemontava um bloco que nenhuma variante aceita:
CustomerPayload.oneOf[0] required = ['name', 'taxID']
CustomerPayload.oneOf[1] required = ['name', 'email']
CustomerPayload.oneOf[2] required = ['name', 'phone']
name é necessário nas três e suficiente em nenhuma. O modelo gerado não
podia recusar — o emissor registra que funde oneOf num modelo só — e
todo campo de PixPayer é opcional. Payer que não alcança nenhuma
variante agora é omitido, como já acontecia com payer sem nome.
- Estado que o enum não conhece deixou de virar 500 na API e
PENDINGsilencioso no webhook. (#241) Umstatusfora do enum gerado se comportava de dois jeitos opostos, e nenhum era o que a docstring doSTATUS_MAPafirmava. Medido na v0.268.0:
API (get_pix_charge, status 'CANCELLED'):
ValidationError: charge.status
Input should be 'ACTIVE', 'COMPLETED' or 'EXPIRED'
-> o app responde 500 {"detail":"Internal server error"}
webhook (mesmo status):
type=charge_paid status=pending provider_status='CANCELLED'
Os dois caminhos agora respondem PaymentStatus.UNKNOWN preservando
provider_status. E a leitura da API parou de recusar: o documento
declara três valores em Charge.status e deixa WebhookCharge.status
como string livre, então a Woovi não se comprometeu com a lista no objeto
que entrega.
A correção é um lift, não uma deleção, e a diferença é o desenho
inteiro: remover o enum no lugar apaga a classe ChargeStatus gerada —
medido, ela some do módulo e do __all__ — e ela é superfície pública,
exportada, chaveando o STATUS_MAP, ensinada pela receita e citada no
guia de migração como classe que não mudou. Os valores passaram a ser
um componente próprio, e a propriedade virou um anyOf dele com string:
Charge.status: ChargeStatus | str | None. Valor conhecido continua
chegando como membro do enum.
União foi rejeitada para expiresIn na v0.269.0 e é certa aqui porque as
duas não têm a mesma forma. int | str obriga todo consumidor a um
int() defensivo sem saber o que recebe; ChargeStatus | str é a união
de um enum de str com str — é string dos dois lados, == funciona
dos dois lados, e a união acrescenta informação em vez de ambiguidade.
A docstring do STATUS_MAP estava invertida para o caminho de API e foi
corrigida.
- Identificador com caractere reservado parou de endereçar outro
recurso.
(#242)
O id entrava no path sem escape, e com
#a chamada mudava de alvo. Medido na v0.268.0:
'order-1042' -> /api/v1/charge/order-1042
'order/1042' -> /api/v1/charge/order/1042
'order#42' -> /api/v1/charge/order
O fragmento é descartado pelo cliente HTTP, então order#42 virava uma
operação sobre order — e em cancel_pix_charge isso é um DELETE no
lugar errado. O correlationID é escolhido pelo consumidor e
PixChargeRequest.reference só valida min_length=1.
A correção entra pelo emissor (openapi/emit_client.py), não editando
código gerado: todo parâmetro de path é interpolado por um
_path_param(value) novo, que faz quote(str(_param(value)), safe="").
Vale para todas as rotas de todos os clientes gerados de uma vez — 58
interpolações na OpenPix e 108 no Mercado Pago. Medido depois:
PixCharge.rawpassou a ter uma grafia só, a do fio. (#243) Orawmudava de grafia conforme o caminho, e o exemplo da própria receita devolviaNone. Medido na v0.268.0, mesmo corpo, dois caminhos:
API raw['paymentLinkUrl'] -> None
API raw['payment_link_url'] -> https://openpix.com.br/pay/pl_1
webhook raw['paymentLinkUrl'] -> https://openpix.com.br/pay/pl_1
O caminho de API fazia model_dump(mode="json") sem by_alias, então
campo declarado saía em snake_case; o webhook devolvia o dict cru, em
camelCase. Pior, o caminho de API produzia uma mistura: campo não
declarado, que extra="allow" preserva, mantinha a grafia do fio
enquanto os vizinhos declarados não. Agora os dois usam by_alias=True.
Ver Migração — é mudança observável para quem já lê
raw com snake_case.
-
Dois guards de doc paravam de funcionar quando existia um
git worktree.tests/test_agent_docs_guard.pyetests/test_docs_api_guard.pyvarriam.claude/comrglob("*.md"), e um worktree criado em.claude/worktrees/é um checkout inteiro do repositório, virtualenv incluído. Os dois passavam a checar oCHANGELOG.md, oREADME.mde odocs/daquela cópia como se fossem os deste repo — e o segundo chegava a parsear.venv/.../typeshed/.../README.mdde terceiro. Medido: 238 falhas com três worktrees em disco, todas em caminho que existe relativo à raiz do worktree e não a esta. Os dois agora pulamworktrees/. -
Dois campos de dinheiro que o overlay não alcançava. (#244) A v0.269.0 fechou a classe da #238 até onde o overlay chegava, e dois casos ficaram registrados no guard como conhecidos.
_retypeendereçacomponents.schemas.<Nome>.properties.<prop>, e nenhum dos dois mora ali:
paths./api/v1/dispute/{id}.get.responses.200.content
.application/json.schema.properties.dispute
paths./api/v1/webhook.post.callbacks.receivedPix
.{$request.body#/webhook.url}.post.requestBody...properties.pix
MISTYPED_POINTERS é a tabela irmã, endereçada por JSON pointer
(RFC 6901). dispute.value quebrava hoje — get_dispute era um dos sete
métodos que não liam resposta real, e a correção da v0.269.0 parava nos
schemas de componente. Os três callbacks receivedPix* não quebram nada
ainda, porque o gerador não emite modelo para callbacks (medido: zero
classes Callback nos schemas gerados); foram corrigidos assim mesmo,
para o dia em que os modelos de callback existirem não ser o dia da
descoberta.
[0.269.0] — 2026-08-29¶
Fixed¶
- Toda resposta de cobrança da OpenPix voltou a validar.
(#238)
Com o
splitsda v0.265.0 corrigido, oPOST /api/v1/chargepassava — e a resposta era rejeitada um andar adiante:
pydantic_core._pydantic_core.ValidationError: 1 validation error for CreateChargeResponse
charge.expiresIn
Input should be a valid string [type=string_type, input_value=3600, input_type=int]
A Woovi devolve expiresIn inteiro; o documento declara string. O
codegen estava certo em relação à especificação, e a especificação errada
em relação à API.
O que torna a correção uma leitura e não um palpite: o documento se contradiz sobre esse campo em três lugares, medido —
Charge.expiresIn -> "string"
ChargePayload.expiresIn -> "number"
WebhookCharge.expiresIn -> "integer"
WebhookCharge é o mesmo objeto de cobrança entregue por webhook, e ali o
documento já diz integer.
A correção entra pela camada de overlay que já existia
(scripts/openpix_overlay.py), numa tabela nova MISTYPED_PROPERTIES —
irmã da CHARGE_RESPONSE_PROPERTIES, que cobre campo ausente, para o
caso de campo declarado errado. Ela se aposenta sozinha: _retype
pula a propriedade já declarada com o tipo certo, então no dia em que a
Woovi corrigir o documento a linha some do log de regeração.
Rejeitado: int | str em união — empurraria a ambiguidade para todo
consumidor, que passaria a precisar de int(charge.expires_in) defensivo
sem saber se algum dia recebe texto.
- E
expiresInnão estava sozinho: mais dois campos de dinheiro. (#238) Varrer o documento atrás da mesma forma achouPixQrCode.valueeWithdrawTransaction.value, os doisstringem resposta contranumberno schema irmão — ePixQrCodecontraPixQrCodePayloadé o mesmo objeto indo e voltando. Sete dos 106 métodos do cliente não liam uma resposta real:get_static_qr_code,list_static_qr_codes,create_static_qr_code,withdraw_from_account,get_dispute,get_transactionelist_transactions.
Dois casos reais ficam de fora e estão registrados, não esquecidos: o
dispute.value inline de GET /api/v1/dispute/{id} e o pix.value dos
três callbacks receivedPix*, que exigem override por JSON pointer.
Changed¶
Charge.expires_inpassa destr | Noneparaint | None. (#238) Antes da correção nenhuma resposta chegava a ser construída, então o tipo antigo nunca entregou um valor a ninguém — mas anotação de consumidor que o mencione precisa mudar. Guia de migração tem a linha.
Added¶
-
tests/integrations/payment/openpix/test_spec_type_conflicts.py. Varre o documento corrigido e falha quando um nome de propriedade passa a serstringnum schema e numérico noutro. Conflito que não é defeito precisa de uma entrada escrita dizendo por quê, e entrada que deixou de conflitar também falha — para a tabela não virar silenciador que ninguém lê. -
tests/integrations/payment/openpix/test_real_payload_shapes.py. O corpo capturado da API, não construído a partir do documento — que é por que nada acusava: fixture e modelo saíam da mesma especificação errada e concordavam entre si. Mais o guard que impede uma regeração de repor o tipo errado em silêncio, e o par de testes que fixa a contradição de três tipos, para uma correção upstream aparecer como teste falhando em vez de surpresa. Alimentados com o código da v0.268.0, falham com oValidationErrorexato do relato.
[0.268.0] — 2026-08-29¶
Added¶
- Painel de tasks no admin.
(#234)
As duas metades já produziam o dado e nenhuma tinha tela:
task_inventorydevolvia uma lista que nada renderizava, e uma linha deJobStoresó era alcançável escrevendo a própria página.
make_admin_router(..., tasks=TaskPanelService(queue=tq, job_store=jobs))
monta GET {prefix}/tasks (execuções filtráveis + agenda declarada),
GET {prefix}/tasks/{job_id} (progresso, estágio, tentativas, erro) e
POST {prefix}/tasks/{job_id}/cancel, mais o item "Tasks" na barra
lateral — o mesmo formato opcional do console SQL.
Qualquer uma das metades serve: só queue= mostra a agenda, só
job_store= mostra as execuções, e a seção sem fonte não é renderizada
em vez de aparecer vazia. Nenhuma das duas levanta ValueError na
construção.
O painel não mostra profundidade de fila — a TaskIQ não expõe, o que já estava registrado para o painel de dead-letter. A página diz isso em vez de deixar o leitor supor.
Não há coluna "próxima execução", e a docstring registra por quê: o
pycron que chega com a TaskIQ expõe is_now/has_been e nenhum
next(), então calcular exigiria varrer minuto a minuto a cada render
(até ~44 mil iterações para um cron mensal) ou uma dependência nova.
make_job_admin_model. Simétrico aomake_dead_letter_admin_model: registra a tabela de jobs noAdminSitee a listagem ganha paginação, filtro, ordenação e export. Create e edit desligados — uma linha de job é registro do que um worker fez, e um operador digitando uma nova descreve trabalho que ninguém vai executar.
Fixed¶
-
Três defeitos que só o browser pegou (validação em Playwright antes de reportar concluído, como manda a regra de mudança visual):
-
As páginas novas renderizavam sem barra lateral e sem header — o
_rendernão injeta o principal, cada view passa; as três rotas passam agora. - Uma task com intervalo aparecia como
on demandna coluna Cron. Cron vazio virou—, eon demandsobrou só para quem não tem agenda nenhuma. - A barra de progresso ficava invisível nas linhas listradas: o trilho
usava
--tempest-bg-row-alt, exatamente a cor com que a tabela listra. Passou a--tempest-border.
[0.267.0] — 2026-08-29¶
Changed¶
tempest dbreporta condição operacional em uma linha, não em vinte frames. (#233)CommandErroré como o Alembic diz que o banco não está no estado que o comando precisa — migration pendente, histórico bifurcado, revisão apagada. Nada disso é bug, e tudo isso chegava nopretty_exceptionsdo Typer: ~20 frames de alembic, asyncio, greenlet e doenv.pydo próprio projeto, com o caminho deste pacote no topo, então a falha lia como defeito do SDK.
Medido, num projeto de verdade contra Alembic 1.18.4 + SQLite, repetindo o passo a passo do relato (gerar revisão, não aplicar, gerar de novo):
$ tempest db revision -m two --autogenerate
error: the database is behind head — a migration is pending. Run `tempest db upgrade`, then try again.
(TEMPEST_DEBUG=1 for the full traceback)
$ echo $?
1
Quatro condições ganham conselho acionável; qualquer outra mensagem sai
verbatim, para uma mensagem nova do Alembic degradar para as palavras
dele em vez de para uma dica errada. Os substrings foram lidos da fonte
instalada (autogenerate/api.py:601, script/base.py:214,222,236), não
lembrados.
Vale para os seis comandos que chamam o helper — revision, upgrade,
downgrade, current, stamp e history —, não só o do relato.
TEMPEST_DEBUG=1 re-levanta intocado, medido: o traceback completo volta e
o exit code segue 1.
Escopo: só a CLI. AlembicHelper continua propagando CommandError,
porque uso programático quer a exceção tipada, não SystemExit.
A mensagem sai em inglês, como as outras nove da CLI. A issue pedia PT-BR; misturar as duas línguas na mesma superfície é pior que traduzir tudo, e traduzir tudo não é o que ela pede.
Added¶
tests/cli/test_db_alembic_errors.py. As quatro condições conhecidas, o fallback verbatim, oTEMPEST_DEBUG, os seis comandos, e — sem double — o repro do relato contra Alembic de verdade. Alimentados com o código da v0.266.0, 6 dos 8 falham (o doTEMPEST_DEBUGpassa lá de propósito: sem o wrapper, oCommandErrorsempre propagava).
[0.266.0] — 2026-08-29¶
Fixed¶
- As páginas HTML do auth pararam de responder 500 sob o default do
SQLAlchemy.
(#237)
async_sessionmakerusaexpire_on_commit=Truepor default. As páginas de sucesso renderizam depois do commit que consome o token, então o primeirouser.<coluna>do template disparava um refresh — IO fora do greenlet, em contexto async:
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here. Was IO attempted in an unexpected place?
Quem passa o session_factory do AsyncDatabaseManager nunca viu:
db/connection.py:326 já constrói com expire_on_commit=False. Quem
escreve o próprio provider — o session_factory=get_db que a própria
receita mostra — recebia 500 num fluxo que já tinha dado certo.
A recarga é condicional: inspect(user).expired responde sem tocar no
banco, então o SELECT extra só acontece para quem de fato expirou.
Quatro rotas: ativação, o ramo de sucesso do reset de senha, confirmação de
troca de e-mail e verificação. O password_reset_form (GET) não commita, e
as páginas de erro não leem coluna nenhuma.
Added¶
tests/auth/test_expire_on_commit.py. Monta a factory do jeito default do SQLAlchemy — toda outra fixture da suíte usaexpire_on_commit=False, e é por isso que nada acusou. Alimentado com o código da v0.265.0, os quatro testes falham comMissingGreenlet.
[0.265.0] — 2026-08-29¶
Fixed¶
create_pix_chargevolta a abrir cobrança: o corpo parou de mandar"splits": []. (#236) Toda chamada da OpenPix falhava com 400. O corpo que o SDK montava, lido do transporte no teste novo:
{"correlationID": "probe-0001", "value": 1190, "additionalInfo": [], "splits": []}
→ 400 {"error": "O array de split precisa ter ao menos um item"}
O mesmo corpo sem as duas chaves é aceito. Duas decisões certas isoladamente
se anulavam: o codegen materializava array opcional como
default_factory=list, e o _dump gerado só sabia descartar None — []
não é None, então ia para o fio sempre. O cliente afirmava algo que o
chamador nunca afirmou, e para a Woovi "sem splits" e "com lista vazia de
splits" são fatos diferentes.
Duas correções, uma por causa:
- O
_dumpgerado ganhaexclude_unset=True. Medido antes de mudar: os dois módulos gerados têm zero campo com default literal (1245 + 291default=None, 84 + 70default_factory=list), e oexclude_nonejá descartava osNone— então o único efeito de somarexclude_unseté deixar de mandar lista vazia que ninguém tocou. - Array opcional em modelo que só é enviado passa a ser
| None = None. O tipo passa a distinguir "não informado" de "informado vazio", que é a distinção que a API faz. Vale só para o modelo que o fechamento alcança como corpo e nunca como resposta — a mesma maquinaria que a v0.260.0 usa para decidirextra="allow". Modelo alcançado nas duas direções mantém a grafia de resposta ([]é a leitura certa de um array ausente) e é oexclude_unsetque o segura no envio.
Alcance: 21 campos — 9 na OpenPix, 12 na Mercado Pago. Resposta não
mudou: Charge.additional_info e CreateMerchantOrderResponse.payments
seguem com default_factory=list.
O mesmo model_dump(..., exclude_none=True) escrito à mão em
mercado_pago/pix.py recebeu o mesmo tratamento.
Changed¶
- Modelo de corpo de request: array opcional agora é
list[T] | None. (#236) Quem lê o campo de volta de um payload que construiu —payload.splits— recebeNoneem vez de[]quando não informou. Guia de migração tem a linha exata.
Added¶
tests/openapi/test_payload_arrays.pyfixa a regra nas quatro direções (só request, só response, ambos, obrigatório), etests/integrations/payment/openpix/test_request_body_shape.pylê o corpo enviado de umhttpx.MockTransport— nenhum teste olhava para o corpo, que era exatamente onde estava o defeito. Alimentados com o código da v0.264.0, os dois falham.
[0.264.0] — 2026-08-29¶
Fixed¶
- Um fluxo de ativação sai em uma língua só.
(#235)
O e-mail e a página que aquele e-mail abre resolviam a língua de fontes
diferentes: o e-mail lia
AUTH_DEFAULT_LOCALE(auth/service.py, cinco_maybe_send_*), a página negociavaAccept-Language(auth/router.py, cinco endpoints HTML). Cadastro com o defaultpt-BRnum navegador em inglês mandava o e-mail em português e abria a página em inglês — e nenhuma configuração consertava, porqueAUTH_DEFAULT_LOCALEera justamente o valor que o header atropelava.
Agora as duas pontas chamam resolve_locale, na mesma ordem:
1. ?lang= no link (a língua em que ESTE e-mail saiu)
2. user.locale (a preferência que o consumidor gravou)
3. Accept-Language (só páginas — e-mail não tem navegador)
4. AUTH_DEFAULT_LOCALE
O link vence a linha de propósito: ele registra a língua daquele e-mail, então quem troca a preferência entre o envio e o clique ainda abre uma página que casa com a mensagem que está lendo. Preferir a linha recriaria exatamente o descasamento que a correção fecha.
Nenhuma das duas olhava para o usuário, embora _maybe_send_activation_email
já recebesse o objeto e o descartasse na linha seguinte. A leitura é
duck-typed (getattr(user, "locale", None)), então um model sem a coluna
cai para o próximo sinal em vez de quebrar; quem quer a coluna tipada usa o
LocaleColumnMixin que o SDK já ships.
Medido: alimentados com o código da v0.263.0, 7 dos 8 testes novos de
coerência falham com o sintoma do relato —
assert 'Reset your password' == 'Redefina sua senha' para um usuário
locale="pt-BR", e o link de ativação sem ?lang=. O oitavo passa lá de
propósito: é o que fixa que AUTH_STAMP_LOCALE_IN_LINK=False restaura a
negociação por header. Os quatro fluxos com link entram um a um
(ativação, reset, troca de e-mail, verificação), mais a página de erro,
onde não existe usuário para ler e o carimbo é o único sinal.
Added¶
-
resolve_locale,stamp_localeeLOCALE_QUERY_PARAM. (#235)resolve_localeé o ponto de entrada único das duas pontas do fluxo, para não voltarem a divergir.stamp_localeescreve o?lang=no link do e-mail, preservando todo o resto da query verbatim — o token opaco que já está lá nunca é reencodado. URL que já nomeia o parâmetro com valor volta intocada (o consumidor manda);?lang=vazio é substituído em vez de duplicado, porque parâmetro repetido responde diferente conforme quem lê: medido, oQueryParams.getdo Starlette devolve a última ocorrência e umparse_qs(query)["lang"][0]devolve a primeira, que seria a vazia. -
AUTH_STAMP_LOCALE_IN_LINK(defaultTrue). Desliga o carimbo para quem aponta oAUTH_*_URL_TEMPLATEpara uma rota de frontend que recusa query param desconhecido. Desligado, a língua da página volta a sair doAccept-Language.
[0.263.0] — 2026-08-29¶
Fixed¶
- Os dois clientes Redis que a documentação manda passar voltam a ser
aceitos pelo type-checker.
(#231)
A receita de segurança diz que
redis.asyncio.Redis"funciona out-of-the-box" comoThrottleBackend, e que ofakeredisserve nos testes. Medido com basedpyright contra redis-py 8.1.0: os dois eram recusados.
error: Type "Redis" is not assignable to declared type "ThrottleBackend"
error: Type "FakeRedis" is not assignable to declared type "ThrottleBackend"
O basedpyright trunca a explicação antes de chegar na causa. Ela aparece
isolando cada membro culpado num protocolo de um membro só — expire:
"expire" is an incompatible type
Type "(name: KeyT, time: ExpiryT, nx: bool = False, xx: bool = False, gt: bool = False, lt: bool = False) -> Awaitable[bool]" is not assignable to type "(name: str, seconds: int) -> Awaitable[Any]"
e delete:
"delete" is an incompatible type
Type "(*names: KeyT) -> Awaitable[int]" is not assignable to type "(name: str) -> Awaitable[Any]"
Duas causas, ambas de nome de parâmetro. ThrottleBackend.expire(name,
seconds) exigia que o implementador chamasse o segundo parâmetro de
seconds; o redis-py chama de time. delete(name) exigia um parâmetro
nomeado; o redis-py declara delete(*names), que nenhuma chamada por
keyword alcança. mypy --strict aceita as duas formas — é por isso que
shippou, e é a mesma cegueira que produziu a lição da v0.257.0.
Agora todo parâmetro obrigatório dos protocolos que descrevem um
cliente que não é nosso é posicional (/), então o nome que o cliente usa
deixa de importar. Medido depois: redis.asyncio.Redis e
fakeredis.aioredis.FakeRedis passam nos quatro protocolos, sob mypy e
sob basedpyright.
Parâmetro opcional continua nomeado de propósito: set(key, payload,
ex=ttl) só pode ser passado por keyword, e aí o nome é parte do contrato
— o redis-py também o chama de ex.
Changed¶
- Oito membros de
ProtocoltrocamAwaitable[Any]pelo contrato real. (#231)
Todos eram Awaitable[Any] — sete assinaturas, e a de eval conta duas
vezes porque RedisLike é declarada nos dois middlewares:
ThrottleBackend.get -> Awaitable[str | bytes | None]
ThrottleBackend.expire -> Awaitable[object]
ThrottleBackend.delete -> Awaitable[object]
_RedisHashClient.hget -> Awaitable[str | bytes | None]
_RedisHashClient.hset -> Awaitable[object]
_RedisHashClient.hgetall -> Awaitable[Mapping[str | bytes, str | bytes]]
RedisLike.eval -> Awaitable[list[int]] # rate limit e quota
Awaitable[Any] deixa o call site declarar o que quiser. Medido com o
mypy deste repositório, o mesmo await redis.eval(...) era aceito como
list[int] e como dict[str, complex]; com o tipo concreto, a
segunda linha é erro. O script Lua é nosso, está no repositório, e o
Redis converte todo número Lua em inteiro na resposta — list[int] é o
que ele devolve, não uma esperança.
Resultado descartado vira Awaitable[object], nunca Awaitable[None]:
object aceita qualquer retorno de implementador (o redis-py devolve
bool no expire e int no delete) sem apagar o tipo no call site.
AdminAuthBackendé genérico no principal. (#232) O valor central que o backend manipula atravessava seis métodos comoAny, com o contrato escrito só em prosa ("the value returned byauthenticate"). Agora éAdminAuthBackend(ABC, Generic[PrincipalT]), eUserModelAuthBackenddeclara o que já usava:AdminAuthBackend[BaseUserModel].
O que isso compra, medido: um backend declarado
AdminAuthBackend[LdapEntry] cujo display_name lê dict[str, str] é
recusado por mypy e por basedpyright — o primeiro citando Liskov por
nome, o segundo com reportIncompatibleMethodOverride. Antes, os dois
concordavam com o backend errado.
Subclasse sem parâmetro continua válida em runtime e resolve para
AdminAuthBackend[Any] — mas mypy --strict liga disallow_any_generics
e passa a acusá-la. O guia de migração tem a linha exata e a correção.
Added¶
tests/test_protocol_shape_guard.py. Duas regras que omake checknão tinha como ver: retorno de membro deProtocolque resolve paraAny, e parâmetro obrigatório não-posicional em protocolo de cliente de terceiro. Alimentado com o código exato que shippou, o guard acusa os oito membros erasados e os dezesseis parâmetros nomeados.
[0.262.0] — 2026-08-28¶
Added¶
- Operação que nenhuma fonte confirma passa a dizer isso na própria docstring. (#227) O Mercado Pago não publica OpenAPI, então o documento vendorizado não tem upstream e nem origem registrada. A v0.261.0 fez o SDK oficial do provedor virar a autoridade em conflito — mas ele cobre 65 das 147 operações, e as outras 82 ficavam indistinguíveis dele no cliente gerado.
Agora são três baldes, e cada um tem lastro diferente:
| Balde | Qtd | O que responde por ela |
|---|---|---|
| O SDK oficial chama | 65 | O provedor |
| Sondada viva | 35 | Resposta 401/403/400 sem credencial, em 2026-08-28 |
| Nada responde | 47 | Só o documento vendorizado |
As 47 carregam **Unverified.** na docstring gerada. Não quer dizer que
estão erradas — quer dizer que ninguém verificou, e a diferença entre uma
operação que o provedor chama e uma que só um documento de origem
desconhecida declara não devia ser invisível para quem lê o cliente.
São todas POST/PUT/PATCH/DELETE, e isso não é coincidência: a
sonda que separa rota viva de rota morta é por método e path. Medido,
GET /v1/customers responde 404 enquanto POST /v1/customers é onde o
SDK oficial cria cliente — então um 404 em GET não fala pelo DELETE no
mesmo path. Mandar POST/PUT/DELETE para uma API de pagamento em
produção só para descobrir se rotea não é forma aceitável de responder a
pergunta.
PROBED_OPERATIONSePROBE_DATEemscripts/mercadopago_overlay.py. O inventário de sondagem versionado: o que foi sondado, quando, e com que código cada rota respondeu. 66 rotasGET, zero404— as três que respondiam foram removidas na v0.261.0.
Changed¶
make mercadopago-diffrelata os três baldes em vez de uma lista única de "só nós carregamos", que fazia 82 operações de lastro muito diferente parecerem uniformes.
[0.261.0] — 2026-08-28¶
Added¶
heartbeat— liveness para qualquer endpoint WebSocket. (#225) A mecânica existia desde a v0.197.0, mas só dentro demake_websocket_router, que impõe bearer no handshake e registro numWebSocketHubindexado poruser_id: UUID. Um endpoint fora desse molde — signaling de WebRTC com salas anônimas, endereçado porpeer_id, onde o código da sala é o único segredo — reimplementava o heartbeat inteiro.
Agora é um context manager async que não pede nada além de um socket já aceito:
from fastapi import WebSocket
from tempest_fastapi_sdk import WebSocketSettings
from tempest_fastapi_sdk.websockets import heartbeat
async def relay(ws: WebSocket, settings: WebSocketSettings) -> None:
"""Sala anônima, sem auth e sem hub."""
await ws.accept()
async with heartbeat(ws, settings=settings) as live:
await ws.send_json(
{"type": "hello", "heartbeat_seconds": live.interval_seconds}
)
O problema que resolve é medido: sem heartbeat, um socket cujo peer sumiu sem close frame — celular que perdeu sinal ou foi encerrado à força — fica aberto até o timeout de TCP do kernel, que é de minutos, e nada levanta, porque mandar para socket half-open funciona.
live.touch() marca vida por evidência que o socket não enxerga. O cap de
tamanho é opcional e separado (max_message_bytes=), porque é outra política.
Na saída do bloco o ws.receive é restaurado, então um bloco aninhado não
deixa guard para trás.
CompactPaginationSchemaeCompactPaginationFilterSchema. (#209) Publicam o tamanho de página comosizeno fio, mantendopage_sizecomo nome Python. Para um serviço que já publica{"total", "items", "page", "size", "pages"}, adotar o envelope do SDK deixa de ser um break em todo endpoint paginado de uma vez — e um app já em loja não atualiza em lockstep com o backend.
from tempest_fastapi_sdk import CompactPaginationSchema
page = CompactPaginationSchema[int](
items=[1], total=1, page=1, page_size=20, pages=1
)
page.model_dump(by_alias=True)["size"] # 20
page.page_size # 20 — o nome Python não muda
BaseRepository.paginate continua devolvendo page_size seja qual for o
envelope: a renomeação vive no schema e em nenhum outro lugar.
Changed¶
- BREAKING — o primeiro frame do
make_websocket_routerpassa a ser umhello.{"type": "hello", "data": {"heartbeat_seconds": N}, "request_id": null}, antes de qualquer coisa que o handler mande.
O motivo é do lado do cliente: o socket do browser só reporta conexão que
fecha limpo, e um link que morre em voo deixa o readyState em OPEN
com nada chegando nunca mais. Silêncio é o único sintoma, então o cliente
precisa do próprio watchdog — e se ele hard-codar o intervalo, retunar
WS_HEARTBEAT_SECONDS no servidor faz todo cliente confundir intervalo
normal com queda.
Cliente que despacha por type e ignora o que não conhece não sente. Cliente
que lê o primeiro frame por posição, sim: cinco testes desta suíte
quebraram exatamente assim. Passo a passo em docs/migration.md.
-
Qualquer frame de entrada conta como prova de vida, não só
pong. Um peer no meio de uma negociação está demonstravelmente presente, e exigir a resposta específica desconectava cliente ocupado que só não tinha chegado nela ainda. Opongcontinua sendo consumido antes do handler. -
BasePaginationSchemaeBasePaginationFilterSchemaligampopulate_by_name. Sem isso, sobrescrever um campo para renomeá-lo no fio impedia construir o model pelo nome Python. O nome do fio se escreve duas vezes —validation_aliaspara ler,serialization_aliaspara escrever;Field(alias=...)sozinho passa no mypy e quebra em pyright.
[0.260.0] — 2026-08-28¶
Added¶
- Procedência do documento vendorizado —
vendor/PROVENANCE.md+SPEC_SHA256. O overlay da OpenPix foi desenhado sobre uma promessa: o documento vendorizado continua byte a byte o que a Woovi publica, e tudo que a gente sabe que ele erra vive fora dele. Nada registrava nem verificava isso —test_specification_is_vendoredchecava existência est_size > 100_000, então editar o YAML à mão e regenerar passava verde, e ninguém conseguia dizer de qual publicação o arquivo tinha vindo.
Agora o digest está em scripts/regen_openpix.py e um teste o compara.
make openpix-fetch rebaixa o documento e imprime o hash novo;
make openpix-diff relata a distância entre o vendorizado e o publicado,
sem gatear nada — a resposta a uma divergência é julgamento, não build
vermelho.
-
tests/openapi/test_parse.py::TestPathTemplateGaps. Dois guards no gerador, escritos sobre as duas formas que de fato shipparam. Path com sintaxe Express (/dispute/:id/evidence) passa a ser anotado — não é templating de OpenAPI, o dois-pontos ia literal na URL. E parâmetro declaradoin: pathcujo placeholder não existe no template passa a chegar ao arquivo gerado: a nota existia, mas_build_parametersrodava fora docapture()da operação, então morria no sumário. -
TestWhatTheOverlayLeavesAlone. O overlay tinha teste para tudo que corrige e nenhum para o que deixa passar — que é onde ele estava errado. O conjunto de campos que continuamfloatvirou pin:numbernovo num refresh falha, de propósito.
Changed¶
- BREAKING — a spec da OpenPix foi refrescada, e os 125 métodos mudaram
de nome. O documento vendorizado estava duas versões atrás:
3.0.3intitulado "OpenPix" contra o3.1.0"Woovi" que o provedor publica, 105 operações contra 125.
O documento novo traz operationId em 125 de 125 operações; o antigo
tinha zero, e o gerador derivava nome do path. Então
post_api_v1_charge virou create_charge, get_api_v1_charge_by_id
virou get_charge, get_api_v1_charge virou list_charges — 103
renomeações, com tabela completa em docs/migration.md.
from tempest_fastapi_sdk.integrations.payment.openpix import (
ChargePayload,
OpenPixClient,
)
# até a v0.259.0: await client.post_api_v1_charge(body=payload)
# a partir da v0.260.0:
async def cobrar(client: OpenPixClient, payload: ChargePayload) -> None:
"""Create one charge with the renamed method."""
await client.create_charge(body=payload)
Schemas de components mantiveram o nome (Charge, ChargePayload,
ChargeStatus); o que mudou foi o nome das classes inline derivadas de
operação — GetApiV1ChargeResponsePageInfo virou
ListChargesResponsePageInfo.
A superfície foi de 373 para 686 schemas e de 106 para 125
operações: entram anticipation (7), stablecoin payout e wallets
(7), boleto-transaction (2), kyc-validation (2), files e
webhook/public-keys.
-
BREAKING — dois métodos que não podiam funcionar foram substituídos. Não é regressão do refresh: eram defeitos que a Woovi publicou, corrigiu no documento, e que a gente carregava congelados.
-
post_api_v1_dispute_id_evidencemontavapath = "/api/v1/dispute/:id/evidence"— dois-pontos literal na URL — e a assinatura só recebiabody, sem nenhuma forma de nomear a disputa. Agora éupload_dispute_evidence(id, *, body). -
get_api_v1_account_registertinha docstring "Get account register by CorrelationID" e zero argumentos. Agora éget_account_register(id). -
BREAKING —
OpenPixEnvironment.PRODUCTIONpassa a serhttps://api.woovi.com. É oservers[0]do documento refrescado, e alinha o enum com oDEFAULT_BASE_URLgerado, que já vinha de lá. O host antigo continua no ar: medido 2026-08-28,GET /api/v1/chargedevolve401emapi.openpix.com.br,api.woovi.comeapi.woovi-sandbox.comigualmente, então serviço fixado no nome antigo segue funcionando. -
BREAKING — model de payload volta a recusar chave inesperada quando também é resposta. A v0.259.0 marcava
extra="allow"por alcançabilidade a partir da resposta, e classe que é as duas coisas caía na regra errada. Na OpenPix a âncora éPreRegistrationPayloadObject, body e200da mesma operação, ondemodel_validate({"usre": {...}})levava o typo do caller para o provedor no dump. Agora o fecho do body é subtraído do fecho da resposta: 4 classes voltam paraextra="ignore"na OpenPix (a âncora e as três que ela alcança) e 25 na Mercado Pago. -
Transaction.webhookSent[].statuspassa a serint. O documento o tipanumbere o descreve como "HTTP response status code of the webhook delivery attempt". Vira inteiro porSTATUS_CODE_PATTERN, regra de descrição —statusnão entrou no allowlist de nome, porque afirmar que todostatusde qualquer API é inteiro seria o palpite que o overlay existe para evitar. -
BREAKING — o SDK oficial do Mercado Pago passa a ser a autoridade sobre a API. O provedor não publica OpenAPI — medido em 2026-08-28,
api.mercadopago.com/openapi{,.json}respondem404e a org no GitHub não tem repositório de spec —, entãovendor/mercadopago-openapi.yamlnão tem upstream e ninguém sabe como foi montado.
A autoridade passou a ser mercadopago no PyPI (3.5.0), escrito pelo
provedor, que soletra a URL de toda operação que chama. A regra é
autoridade em conflito, não teto de superfície: onde os dois discordam o
SDK vence; onde o SDK é silencioso o nosso documento fica, porque ele é
wrapper fino e as 82 operações que só nós carregamos respondem 401/403,
não 404.
make mercadopago-diff baixa o sdist e relata as duas direções. A que
importa — o que o SDK chama e não modelamos — está em zero, e
TestTheSdkIsTheAuthority fixa isso offline contra OFFICIAL_SDK_CALLS.
Duas rotas corrigidas:
| Antes | Agora |
|---|---|
DELETE /v1/customers/{id}/delete |
DELETE /v1/customers/{id} |
GET /authorized_payments |
GET /authorized_payments/search |
MercadoPagoClient.delete_customer montava a primeira: um método que nunca
poderia ter funcionado, a mesma forma do :id da OpenPix.
Sete operações adicionadas — get_authenticated_user,
search_advanced_payments, search_chargebacks,
list_disbursement_refunds, create_disbursement_refunds,
create_disbursement_refund, update_advanced_payment_release_date. Corpo
e resposta são dict[str, Any]: path e verbo são medidos, a forma não, e
este repositório não tem credencial para observá-la.
Três operações removidas — GET /instore/integrator, GET /stores/{id}
e GET /post-purchase/v1/claims/reasons/{id}, que respondem 404 onde a
vizinhança responde 401/403 e não têm contraparte no SDK. O
PATCH /instore/integrator fica: 404 é por método, e nenhuma sonda falou
pelo PATCH.
A superfície vai de 324/143 para 323 schemas / 147 operações.
Fixed¶
-
to_centsereais_to_centsrecusam comValueError, sempre. A docstring prometia sóValueError, e quatro entradas escapavam:"abc"e""levantavamdecimal.InvalidOperation,NonelevantavaTypeError,float("inf")levantavaOverflowError. É exatamente o input para o qual a migração recomenda a função — payload cru, corpo de webhook — e quem escreviaexcept ValueErrorem volta de conversão de dinheiro não pegava nenhuma das quatro. -
DELETE /api/v1/payment/{id}foi removido. A v0.259.0 o adicionou ao overlay e publicoudelete_api_v1_payment_by_id, com docstring afirmando que cancela um pagamento pendente. O documento publicado tem sógetnesse path, e nenhum caminho de payment temdelete— o DELETE que a Woovi documenta é em/api/v1/charge/{id}. Corrigir um documento é para o que ele erra; endpoint que ninguém observou é palpite. -
__all__gerado volta a passar noRUF022. A ordenação do gerador era de string, e a do ruff é natural: com o contador de desambiguação chegando a dois dígitos no documento novo, ele queria...Orig9antes de...Orig10. A chave virouscripts/export_order.py, compartilhada pelos dois geradores em vez de duplicada. -
Números da v0.259.0 corrigidos na doc. "18 campos continuam
float" eram 19; "skipelimitem 27 operações cada" contava campo de resposta, e só 6 operações os recebiam como query param; "os demais 51value" eram 50; e "105 operações" era o número de antes da própria release, que shippou 106. Detalhe emvendor/openpix-evidence.md.
[0.259.0] — 2026-08-27¶
Added¶
-
Overlay versionado da spec da OpenPix —
scripts/openpix_overlay.py.vendor/openpix-openapi.yamlcontinua byte a byte o documento que a Woovi publica, então refresh upstream continua sendo um diff só do que eles mudaram. Tudo que a gente sabe que o documento erra passou a viver no overlay, uma correção nomeada por vez, cada uma com a evidência ao lado, e aplicada antes de gerar. Três famílias, e omake openpix-regenimprime o que cada uma fez. -
Charge.fee,Charge.discount,Charge.value_with_discounteChargeRefund.refund_id. Campos que a API responde e o documento não declara — oChargeda spec (linha 21876) não tem nenhum dos três, erefundIdestá declarado só noRefund, de estorno de transação Pix. Sem declaração,extra="ignore"os descartava: quem gravavacharge.feenuma coluna de ledger escreveria zero em toda linha, e o erro só apareceria na conciliação.
from tempest_fastapi_sdk.integrations.payment.openpix import Charge
charge = Charge.model_validate(
{"value": 199000, "fee": 2500, "discount": 0, "valueWithDiscount": 199000}
)
charge.fee, charge.value_with_discount # (2500, 199000)
Todos opcionais — resposta sem eles continua validando. refund_id é campo
próprio, e não um segundo nome para end_to_end_id: no Refund os dois são
identificadores diferentes, e colapsá-los faria o model afirmar o que o
documento contradiz.
Fecha #223.
OpenPixClient.delete_api_v1_payment_by_id(id). O documento não temDELETEde payment, então o fluxo de transferência em dois passos ficava sem caminho de recuperação: com oPOST /paymentcriado e oPOST /payment/approvefalhando, a transferência fica pendente no gateway e pode ser liberada depois — e cancelar exigia descer para oHTTPClientcru justamente no passo mais delicado do fluxo de dinheiro.
O retorno é dict[str, Any] de propósito: este repositório não tem
credencial da Woovi para observar o corpo da resposta, então ele não é
modelado. Modelar um shape que ninguém mediu seria pior que não modelar, e
dict[str, Any] não descarta nada.
Fecha #222.
Changed¶
- BREAKING — dinheiro e contagem na OpenPix passam de
floatparaint(154 campos). A Woovi liquida em centavo inteiro e a spec diz isso na própria descrição, 35 vezes ("Value in cents of this charge", "Number in cents that represent the balance") — e tipa tudo comonumber. O cliente gerado então mandava{"value": 1990.0}onde a API documenta1990, e qualquer aritmética que o consumidor fizesse sobre o valor começava de uma aproximação binária.
ChargePayload(correlation_id="abc-1", value=1000).model_dump(
by_alias=True, mode="json", exclude_none=True
)
# antes: {"correlationID": "abc-1", "value": 1000.0, ...}
# agora: {"correlationID": "abc-1", "value": 1000, ...}
Não é só dinheiro: skip e limit — o par de paginação, repetido em 27
operações cada — também eram float, e um cliente mandando skip=0.0 numa
query string está pedindo tolerância ao provedor.
Os 18 campos que continuam float são os únicos onde a fração é real: a
cotação de stablecoin (basePrice é taxa de câmbio, inputAmount é
documentado como "currency unit, not cents", outputAmount é quantidade de
stablecoin), o balde de tokens de rate limit, e annualRevenue, cuja unidade
o documento não diz. A regra lê a descrição antes do nome exatamente por
isso: inputAmount é centavo na lista de depósito e não é na cotação.
Migração: passo a passo em docs/migration.md, seção 0.259.0.
- Model de resposta gerado passa a usar
extra="allow". Vale para toda integração gerada (OpenPix e Mercado Pago), e é transitivo: o objeto aninhado é justamente onde o campo descartado se esconde. Model de payload continua comextra="ignore"— ali chave inesperada é erro de digitação de quem chamou, e levá-la ao provedor é pior que descartar.
Isso fecha na raiz um defeito que este repo já conhecia e contornava: o
point_of_interaction do Mercado Pago — o QR do Pix — era descartado na
validação, sem exceção e sem aviso, deixando um
transaction_details.external_resource_url com cara de resposta enquanto a
string de copiar-e-colar já tinha sumido. Ele agora sobrevive em
model_extra; parse_pix_payment continua sendo a forma tipada de lê-lo.
Fixed¶
- O adapter de OpenPix trunca
expires_inpara segundo inteiro.timedelta.total_seconds()devolvefloate o campo agora éint.
[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.