Guia de migração¶
Passo a passo das mudanças que quebram compatibilidade, agrupadas por release minor. Siga a versão que casa com aquela de onde você está atualizando. As seções estão listadas da mais nova para a mais antiga, então num salto de várias versões leia e aplique-as de baixo para cima.
0.316.0 — set-password pede o fator em conta com MFA¶
tempest user set-password numa conta com MFA ativo (e AUTH_MFA_ENABLED=True)
passa a pedir o código — TOTP ou recovery code — e troca a senha por
UserAuthService.change_password_with_mfa. Sem código válido em três
tentativas, o comando sai com código 1 e a senha não muda. Até a 0.315.x o
comando trocava a senha sem pedir o fator.
O que fazer¶
- Script ou CI que roda
set-passwordsem terminal numa conta com MFA deixa de funcionar: não há quem digite o código. - Para recuperar quem perdeu o celular e os recovery codes, rode
tempest user mfa-reset --email <e>(--yesfora de terminal) e depoisset-password. Veja a receita de MFA. - Para aceitar recovery code, o comando precisa da tabela de recovery codes
(
--recovery-code-model, defaultsrc.db.models:UserRecoveryCodeModel).
0.316.0 — o segundo item do UserAuthService.signup pode ser ActivationWithheld¶
A tupla devolvida por signup passa a ter três formas no segundo item:
None (a conta nasceu ativa), ActivationToken (o link foi emitido) e o novo
ActivationWithheld(user_id) (o link foi retido). O terceiro só sai com
AUTH_AUTO_ACTIVATE=false e UserAuthService(refuses_email_link=...)
recusando a conta nova; sem o predicado, o comportamento em runtime é o de
antes. O router do SDK já trata o tipo novo.
O que fazer¶
- Se você chama
signupdireto e lê.urlou.tokendo segundo item, testeisinstance(activation, ActivationToken)antes: comActivationWithhelda conta existe, inativa e sem link. O mypy acusaunion-attronde isso falta. - Só
Noneautoriza emitir o par de JWT logo após o cadastro. - Se você sobrescreve
signupe repassa aosuper(), ajuste a anotação de retorno.
0.314.0 — Python 3.12 passa a ser o mínimo¶
O SDK deixa de suportar Python 3.11. O requires-python sobe para >=3.12, e
as versões suportadas passam a ser 3.12, 3.13 e 3.14.
Quem está em 3.11 fica na 0.313.x: o instalador descarta as releases cujo
requires-python exclui o interpretador. Pedir a versão nova num ambiente 3.11
falha antes de instalar qualquer coisa. Medido com o uv:
error: The requested interpreter resolved to Python 3.11.12, which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
O que fazer¶
- Suba o interpretador do serviço para 3.12 ou mais novo
(
uv python pin 3.13, a imagem base doDockerfile, o runtime da CI). - Ajuste o
requires-pythondo seupyproject.tomlpara>=3.12. Otempest newjá gera assim, comtarget-version = "py312"no ruff epython_version = "3.12"no mypy. - Rode
uv lockeuv sync. Com o piso novo o lock deixa de carregar o split do 3.11: no lock do próprio SDK saíramtomli,async-timeouteaudioread, enumpy,scipyelibrosapassaram a ter uma versão só.
Para continuar em 3.11 por enquanto, fixe a faixa:
uv add "tempest-fastapi-sdk>=0.313.0,<0.314.0".
0.314.0 — login social de conta com TOTP pede o segundo fator¶
GET /auth/oauth/{provider}/callback e POST /auth/oauth/{provider}/token
passam a espelhar o /auth/login. Quando a conta resolvida tem TOTP inscrito
(e AUTH_MFA_ENABLED=True), a resposta não traz o par nem cookie de
sessão: traz mfa_required=true + mfa_token, e o par só sai do
/auth/mfa/verify, com amr == ["ext", "otp"]. Até a 0.313.x o provedor
bastava, e a conta pulava o fator que tinha inscrito.
O que fazer¶
- No cliente do login social, trate o desafio como no login por senha: se
mfa_requiredfortrue, peça o código e façaPOST /auth/mfa/verifycom omfa_token. Conta sem MFA não muda — o par continua na própria resposta, comamr == ["ext"]. - Se você sobrescreve
UserAuthService.login_with_oauth, mova a lógica para o novoauthenticate_oauth(resolve a conta sem emitir nada). As rotas não chamam maislogin_with_oauth, e o router loga um aviso ao ser montado quando acha o override. Chamarlogin_with_oauthdireto continua emitindo o par sem perguntar pela MFA. - Se você sobrescreve
issue_mfa_token, aceite o novoamr: Sequence[str] | None = PASSWORD_AMRe repasse aosuper(). Sem ele o token sai sem o primeiro passo, e o/auth/mfa/verifyvolta ao["pwd", "otp"]de antes — o router também avisa no log.
0.314.0 — ativação e reset por link carimbam amr=["email"]¶
O par que POST /auth/activate/{token} e POST /auth/password-reset/confirm
emitem para conta sem MFA passa a sair com amr == ["email"]
(AMR_EMAIL_LINK), não mais ["pwd"]: nenhum dos dois confere senha. Com
TOTP inscrito, o /auth/mfa/verify desse desafio emite ["email", "otp"]. O
refresh repete o amr da origem, então a sessão continua ["email"] depois
de rotacionar.
O que fazer¶
- Código que lê
"pwd" in amrcomo "esta sessão começou num login" passa a ver"email"nesses dois caminhos. Se a sua regra é "qualquer sessão de um fator", compare comAMR_EMAIL_LINKtambém; se é "a senha foi conferida", a mudança é a correção que você queria. make_mfa_required_dependencynão muda:emailnão está emMFA_AMR_METHODS, e o gate recusa o par como recusava o["pwd"].- Override de
issue_token_pairsemamr=continua sendo chamado sem a keyword, e o par sai com o default do override (["pwd"], se delega aosuper()), como antes.
0.313.0 — classe terminada em Enum não dobra mais o sufixo do tipo¶
enum_type_name tira um Enum do fim do nome da classe antes de acrescentar
_enum. Só muda quem tem enum com esse sufixo:
| Classe | Antes | Agora |
|---|---|---|
CandidateSlugEnum |
candidate_slug_enum_enum |
candidate_slug_enum |
OrderStatus |
order_status_enum |
order_status_enum (igual) |
O banco continua com o nome antigo até uma migration renomear o tipo, e no
PostgreSQL o model passa a escrever o nome novo em todo INSERT, UPDATE e
WHERE com a coluna. Medido no PostgreSQL 16 com asyncpg, antes da migration:
type "candidate_slug_enum" does not exist nos três.
O que fazer¶
-
Atualize o SDK e rode
tempest db revision --autogenerate -m "renomeia tipos de enum". Osync_enum_typesdetecta o tipo renomeado (no PostgreSQL peloinformation_schema, no SQLite pelo nome doCHECK) e escreve umop.rename_enum_typepor tipo, com o reverso nodowngrade:from alembic import op from tempest_fastapi_sdk.db.enum_migrations import EnumColumnRef def upgrade() -> None: """Renomeia o tipo do enum para o nome sem o sufixo dobrado.""" op.rename_enum_type( "candidate_slug_enum_enum", "candidate_slug_enum", columns=[EnumColumnRef(table="candidates", column="slug")], ) -
Aplique a migration antes de o código novo atender tráfego (o
alembic upgrade headdo deploy já faz isso).tempest db checkfalha enquanto a migration não existir, então a CI pega o esquecimento.
Para manter o nome antigo numa coluna, passe-o explícito:
mapped_column(TempestEnum(CandidateSlugEnum, name="candidate_slug_enum_enum")).
0.313.0 — o 429 do auth tem código próprio¶
Os três limites do make_auth_router deixam de responder o code genérico
TOO_MANY_REQUESTS, o mesmo de qualquer outro 429 do app:
| Refuso | Antes | Agora |
|---|---|---|
| login acima do limite, por e-mail ou por cliente | TOO_MANY_REQUESTS |
LOGIN_THROTTLED (LoginThrottledException) |
| signup acima do limite | TOO_MANY_REQUESTS |
SIGNUP_THROTTLED (SignupThrottledException) |
| código MFA errado acima do limite | TOO_MANY_REQUESTS |
MFA_THROTTLED (MfaThrottledException) |
O status continua 429, com Retry-After e details.retry_after_seconds. As
três classes herdam de TooManyRequestsException, então except
TooManyRequestsException continua pegando. Muda para quem compara o code:
troque code == "TOO_MANY_REQUESTS" pelo código do fluxo. Com
default_message_catalog(), o detail passa a ser uma mensagem específica
(Muitas tentativas de login. Aguarde um pouco e tente de novo) em vez de
Requisições em excesso.
0.313.0 — refresh token guarda o amr, e /mfa/confirm responde 200¶
O access token passou a carregar o claim amr (como a sessão foi autenticada),
e o BaseUserRefreshTokenModel ganhou a coluna amr (texto, nullable) para o
refresh repetir esse valor. Como a coluna é mapeada no modelo base, migre
o banco antes de atualizar o pacote se você usa refresh_token_model: sem
ela, toda emissão de par falha — medido com SQLite, POST /auth/signup com
ativação automática respondeu 500 (OperationalError). Quem não passa
refresh_token_model não muda nada no banco.
O que fazer¶
-
Gere a migration (
uv run tempest db revision -m "refresh token amr", oualembic revision --autogenerate) ou escreva-a à mão, com o nome da sua tabela:import sqlalchemy as sa from alembic import op revision: str = "0313_refresh_token_amr" down_revision: str | None = "<a revisão anterior>" def upgrade() -> None: """Adiciona o amr da família de refresh token.""" op.add_column( "user_refresh_tokens", sa.Column("amr", sa.String(length=64), nullable=True), ) def downgrade() -> None: """Remove o amr da família de refresh token.""" op.drop_column("user_refresh_tokens", "amr") -
Rode
uv run tempest db upgrade(oualembic upgrade head) e só então faça o deploy da versão nova.
Linhas antigas ficam com NULL: o refresh delas emite access token sem
amr, que make_mfa_required_dependency trata como "sem segundo fator".
Duas mudanças de comportamento vêm junto:
POST /auth/mfa/confirmresponde200com umLoginResponseSchema, não mais204. Confirmar revoga todos os refresh tokens do usuário, e o par novo no corpo (ou nos cookies, no modo cookie) é o que mantém o dispositivo logado. Cliente que conferestatus === 204passa a checar200e guarda o par novo.mfa_confirmrevoga as sessões por padrão. Quem chama o serviço direto e precisa manter as sessões passarevoke_sessions=False.
O reset de senha e a reativação por link pedem o segundo fator¶
POST /auth/password-reset/confirm e POST /auth/activate/{token} passam a
espelhar o /auth/login: quando a conta tem TOTP inscrito, não devolvem o par
— devolvem mfa_required=true + mfa_token, e o par só sai do
/auth/mfa/verify, com amr de MFA. A senha é trocada de todo jeito.
Cliente que chamava o confirm (ou o activate de uma conta reativada) e
guardava access_token direto precisa tratar o desafio: se mfa_required for
true, peça o código e faça o /auth/mfa/verify. Conta sem MFA não muda —
continua recebendo o par na própria resposta. Ativar uma conta recém-cadastrada
também não muda: ela nunca tem fator.
ActivationResponseSchema ganhou mfa_required / mfa_token, e
access_token / refresh_token passaram a ser opcionais (str | None), como
no LoginResponseSchema. Quem lê o par de uma ativação de conta nova não muda
nada.
O reset de senha agora revoga as sessões já abertas na mesma transação que
grava o hash, como o /auth/mfa/confirm e o tempest user set-password já
faziam: um refresh token DB-backed emitido antes do reset deixa de trocar por
par novo (responde 401). Access tokens já emitidos ainda valem até o TTL. Quem
chamava UserAuthService.confirm_password_reset e precisa manter as sessões
passa revoke_sessions=False — assinatura antiga de override continua sendo
chamada pelo router, e delega para o default que revoga. Refresh token
stateless (sem refresh_token_model) não tem linha a revogar e segue valendo
até expirar.
0.312.0 — Mercado Pago: Pix vencido e busca de chargebacks¶
Duas mudanças de comportamento, as duas medidas no sandbox em 2026-10-10.
Pix vencido agora é EXPIRED. Uma order que o Mercado Pago devolve
canceled / expired passou a ser PaymentStatus.EXPIRED, e o webhook a
entrega como PixEventType.CHARGE_EXPIRED. Até a 0.311.0 ela saía
CANCELLED / CHARGE_CANCELLED. Se o seu serviço tratava o vencimento no
ramo de cancelamento, trate os dois:
from tempest_fastapi_sdk.integrations.payment import PaymentStatus, PixCharge
def is_closed_unpaid(charge: PixCharge) -> bool:
"""Tell whether a Pix charge ended without being paid."""
return charge.status in {PaymentStatus.CANCELLED, PaymentStatus.EXPIRED}
search_chargebacks exige payment_id. A busca respondia 400 sem esse
filtro, inclusive na única forma que o método aceitava (limit / offset),
então ela nunca funcionava. Agora:
| Antes | Agora |
|---|---|
await client.search_chargebacks(limit=10) → sempre 400 |
await client.search_chargebacks(payment_id="123") → ChargebackSearchResponse |
dict[str, Any] |
found.paging.total, found.results (itens ainda dict[str, Any]) |
0.311.0 — Mercado Pago: a API de Payments saiu do SDK¶
O painel do Mercado Pago marca a API de Payments (/v1/payments) como
"Esta API será descontinuada em breve", e o SDK passou a cobrar pela API de
Orders. Quem usava o caminho de Payments troca assim:
| Antes | Agora |
|---|---|
client.create_payment(body=PaymentRequest(...)) com Pix |
MercadoPagoPixProvider(http).create_pix_charge(PixChargeRequest(...)) |
client.create_payment(...) com token de cartão |
MercadoPagoCardProvider(http).create_card_charge(CardChargeRequest(...)) |
create_pix_payment / get_pix_payment / parse_pix_payment |
create_pix_charge / get_pix_charge — o QR vem em PixCharge.br_code e qr_code_base64 |
client.get_payment(id) |
get_pix_charge(order_id) / get_card_charge(order_id), ou client.get_order(order_id) |
client.update_payment(id, body={"capture": True}) |
capture_card_charge(order_id) |
client.update_payment(id, body={"status": "cancelled"}) |
cancel_pix_charge(order_id) / cancel_card_charge(order_id) |
client.create_refund(id, ...) |
refund_card_charge(order_id, amount_cents=...) |
PixPayment, Payment |
PixCharge, CardCharge, ou o Order gerado |
| as 7 operações de QR presencial V1/V2 e QR dinâmico antigo | sem substituto neste SDK: a própria spec as marca deprecated, e a API de Orders da spec só declara type: online |
Removidos sem substituto direto (o equivalente, quando existe, é o
Order gerado ou os contratos PixCharge / CardCharge):
- Métodos de
MercadoPagoClient:create_payment,search_payments,get_payment,update_payment,cancel_payment,create_refund,list_refunds,get_refund, e os 7 de QR presencial —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. - Funções e constantes:
create_pix_payment,get_pix_payment,parse_pix_payment,PAYMENTS_PATH. - Modelos e enums:
PixPayment,PixPointOfInteraction,PixTransactionData,Payment,PaymentRequest,PaymentPayer,PaymentPayer2,PaymentCard,PaymentCardCardholder,PaymentItem,PaymentAdditionalInfo,PaymentAdditionalInfoPayer,PaymentAdditionalInfoShipments,PaymentTransactionDetails,PaymentOperationType,PaymentPaymentTypeId,PaymentProcessingMode,PaymentSearchResult,SearchPaymentsRange,PaymentUpdateRequest,PaymentUpdateRequestStatus,CancelPaymentBody,RefundRequest,CreateRefundResponse,CreateRefundResponseSource,GetRefundResponse,GetRefundResponseSource,ListRefundsResponse,ListRefundsResponseSourceePaymentStatusdemercado_pago. OPaymentStatuscanônico, deintegrations.payment, continua — é o que os contratos usam.
Tipos que mudaram:
Order.status,Order.status_detail,OrderTransactionPayment.statuseOrderTransactionPayment.status_detailpassam de enum paraEnum | str: o sandbox devolveu valores fora da lista. Ummatchexaustivo sobre o enum precisa de um ramo para a string.get_authenticated_user()devolveAuthenticatedUserem vez dedict[str, Any]: troqueuser["id"]poruser.id(o que não foi declarado está emuser.model_extra).- Dois enums ganharam nome próprio:
ListPaymentMethodsResponseItemProcessingModesItem(processing mode delist_payment_methods) eUpdateAdvancedPaymentBodyStatus(status do corpo deupdate_advanced_payment).
Três diferenças que pedem ajuste além do nome:
- O id muda de forma. Uma order tem id de texto (
ORD…), não número. Se você guardava o id do pagamento como inteiro, a coluna vira texto. - O webhook muda de recurso. A notificação deve passar a apontar para a
order — é o que o documento do provedor descreve (ações
order.created/order.updated); uma entrega real de Orders ainda não foi observada aqui. Troque a leitura manual dedata.idpormake_mercado_pago_webhook_delivery_dependency, e marque o evento de Order na configuração de webhooks da aplicação. - A credencial precisa ser de uma aplicação "API de Orders". Medido: a
aplicação Checkout Pro de uma conta vendedora de teste responde
401 Unauthorized use of live credentialsa cobrança direta. A receita contas e credenciais de teste mostra o caminho.
0.309.0 — outras mudanças de comportamento¶
Além da recusa de chave em filters (seção abaixo), a 0.309.0 traz estas
mudanças que podem pedir ajuste:
JobStore.reclaim_stale()devolveReclaimedJobs, nãoint.if await store.reclaim_stale():continua funcionando (__len__); quem compara com número ou soma o retorno passa a ler.total.make_auth_routerlimita o login por padrão: 5 senhas erradas para o mesmo e-mail em 900 s viram429. Passelogin_throttle=Falsepara desligar, ou umAttemptThrottlepróprio para outro orçamento.make_prometheus_router(dependencies=[require_x_token])é recusado na construção comTypeError. Usedependencies=[make_token_dependency(segredo)], que lê o headerX-Token.- Readiness com timeout: cada check de
make_health_routertemtimeout=3.0s por padrão e estourar conta como falha. Check mais lento pedetimeout=maior (ouNone). MinIOSettingsestá deprecado em favor deStorageSettings(STORAGE_*). Os nomesMINIO_*continuam sendo lidos; definir os dois nomes de um par com valores diferentes derruba o boot. Ao regenerar odocker-compose.yaml(que agora escreveSTORAGE_*), remova osMINIO_*antigos do.envou deixe os dois com o mesmo valor.test_session/test_databaseestão deprecados: troque pormake_test_session/make_test_database(mesma assinatura).
0.309.0 — chave desconhecida em filters levanta erro¶
Chave de filters que não é coluna do model ({"usr_id": 1}) ou sufixo que
não é operador ({"user_id__bogus": 1}) era ignorada em silêncio — e com só
a chave errada no dict, delete_many e bulk_update alteravam a tabela
inteira. Agora todo método que recebe filters, o Q, o BaseService, o
BaseController e o TenantScopedRepository levantam
UnknownFilterKeyException (422, code="UNKNOWN_FILTER_KEY",
details={"filter": <chave>}) antes de qualquer statement.
Quem é afetado: quem passava, de propósito ou sem saber, uma chave que o
model não resolve. O caso mais comum é um schema de filtro com campo que não
é coluna (search, include_archived) repassado inteiro por
get_conditions(): o request que preenche esse campo passa a responder 422.
O que fazer¶
-
Campo de schema que não é coluna: tire-o do dict antes de repassar e trate-o no service.
from typing import Any from tempest_fastapi_sdk import BasePaginationFilterSchema class TicketFilterSchema(BasePaginationFilterSchema): status: str | None = None search: str | None = None def split_conditions(f: TicketFilterSchema) -> tuple[dict[str, Any], str | None]: """Separate the column filters from the free-text term.""" conditions = f.get_conditions() search = conditions.pop("search", None) return conditions, search -
Admin:
list_filter,search_fieldse osfiltersde cadaLenspassam a ser conferidos na construção doAdminModel. Um nome errado viraValueErrorno boot (AdminModel lens 'Open' filters on keys Ticket cannot resolve: stauts); corrija o nome. -
Código que capturava o resultado "tudo" de propósito com uma chave inexistente: passe
{}(ou nenhuma chave), que continua sem condição.bulk_updatesegue recusando mapping vazio.
0.308.0 — o CLI tempest virou o extra [cli]¶
O tempest-cli saiu das dependências do pacote base, junto com o typer e o
click, e foi para o extra novo [cli] (que também entra no [all]). O
tempest-cli exige ruff>=0.8.0 em runtime: no base, todo serviço que adota o
SDK carregava um formatador como dependência de produção, e um projeto que
fixa ruff<0.8 no grupo dev nem resolvia o lock (uv lock recusava com
tempest-cli>=0.4.0 depends on ruff>=0.8.0). A biblioteca — import
tempest_fastapi_sdk e tudo o que o serviço importa em runtime — não muda.
Quem é afetado: só quem roda o comando tempest sem [cli] nem [all].
Sem o extra, o script continua instalado, mas imprime a instrução e sai com
código 2:
$ tempest --help
error: missing tempest_cli, typer, click. The tempest CLI needs the optional [cli] extra. Install it with:
uv add --dev "tempest-fastapi-sdk[cli]" (in a project)
uv tool install "tempest-fastapi-sdk[cli]" (as a global command)
O que fazer¶
-
CLI global (
uv tool install tempest-fastapi-sdk): reinstale com o extra. -
CLI no projeto (
uv run tempest check,tempest db upgradeno terminal, na CI): adicione o extra ao grupo dev. Projetos gerados pelotempest newa partir da 0.308.0 já vêm assim. -
tempestdentro da imagem de produção (por exemplo, um entrypoint que rodatempest db upgradeantes de subir o app): o grupo dev não entra numa imagem construída comuv sync --no-dev, então ali o extra vai nas dependências de runtime —"tempest-fastapi-sdk[cli,...]"— ou o passo troca paraalembic upgrade head, que não precisa da CLI. -
Import direto de
tempest_fastapi_sdk.cli.main,.config,.lintou.pr_prompt, ou detempest_fastapi_sdk.cli.app/TempestConfig: sem o extra, levantaImportErrorcom a mesma instrução. Instale[cli]onde esse código roda.
Quem já instala [all] não faz nada: o [all] traz o [cli].
0.306.0 — código TOTP de uso único pede a coluna totp_last_step¶
O MFAMixin ganhou a coluna totp_last_step (inteiro, nullable), onde o SDK
grava o passo de 30 segundos do último código TOTP aceito para recusar o mesmo
código uma segunda vez. Como ela é mapeada no mixin, migre o banco antes de
atualizar o pacote: sem a coluna, toda consulta ao user model falha — signup
e login respondem 500 (no such column: <tabela>.totp_last_step, medido com
SQLite). Projeto que não usa MFAMixin não muda nada.
O que fazer¶
-
Gere a migration (
uv run tempest db revision -m "totp last step", oualembic revision --autogenerate) ou escreva-a à mão:import sqlalchemy as sa from alembic import op revision: str = "0306_totp_last_step" down_revision: str | None = "<a revisão anterior>" def upgrade() -> None: """Adiciona a coluna do último passo TOTP aceito.""" op.add_column("users", sa.Column("totp_last_step", sa.Integer(), nullable=True)) def downgrade() -> None: """Remove a coluna do último passo TOTP aceito.""" op.drop_column("users", "totp_last_step") -
Rode
uv run tempest db upgrade(oualembic upgrade head) e só então faça o deploy da versão nova.
Linhas antigas ficam com NULL, que significa "nenhum código gasto ainda": o
próximo código válido é aceito e grava o passo.
Duas mudanças de comportamento vêm junto, sem passo obrigatório:
- Um código TOTP vale uma aceitação. O código usado no
confirmnão serve mais para o primeiro login, nem o do login para odisablelogo em seguida: o usuário espera o próximo código do app (até 30 segundos). Teste automatizado que reusapyotp.TOTP(secret).now()depois doconfirmpassa a receber401; use o código do passo seguinte,pyotp.TOTP(secret).at(int(time.time()) + 30). POST /auth/mfa/verifyresponde429depois de cinco códigos errados por conta em 15 minutos. O contador padrão vive no processo; com mais de um worker, passemake_auth_router(mfa_throttle=AttemptThrottle(redis, ...))— veja Limite de tentativas.
0.306.0 — make_logs_router recusa segredo vazio¶
make_logs_router(token_secret="") levanta ValueError na construção. Até
a 0.305.0 o segredo vazio desligava a checagem do X-Token, e GET /logs e
DELETE /logs respondiam a qualquer um. O serviço gerado pelo tempest new
monta /logs só quando TOKEN_SECRET não está vazio.
O que fazer¶
- Serviço em produção: preencha
TOKEN_SECRET(por exemplo comuv run tempest secrets init) em todo ambiente. Sem isso, o app passa a não subir. -
Serviço que deve subir sem segredo: monte o router só quando houver segredo, como o template faz:
-
Execução local que precisa de
/logsaberto: passemake_logs_router(..., allow_unauthenticated=True). TOKEN_SECRETcom espaço em volta: o espaço passa a ser removido antes da comparação, então o cliente envia o valor sem ele.
0.306.0 — download só vai inline para tipo seguro¶
as_attachment=False virou pedido: DownloadUtils, FileStoreUtils e
AsyncMinIOClient.download_response/serve_object só respondem
Content-Disposition: inline quando o tipo da resposta está em
INLINE_SAFE_MEDIA_TYPES. Qualquer outro — text/html, image/svg+xml,
application/octet-stream — sai como attachment. Toda resposta de download
também passa a levar X-Content-Type-Options, Content-Security-Policy e
Cross-Origin-Resource-Policy.
accel_redirect_response muda o default para as_attachment=True, e com
as_attachment=False só serve inline quando media_type= é passado.
build_content_disposition(name, as_attachment=False) sem media_type=
devolve attachment.
O que fazer¶
- Rota que mostra imagem, PDF, áudio ou vídeo no navegador: nada, se o
tipo está na lista. No modo
X-Accel-Redirect, passemedia_type=(por exemploguess_media_type(key)), senão o arquivo vira download. - Chamada a
accel_redirect_responseque contava cominlinepor padrão: passeas_attachment=Falseemedia_type=. - Deploy com
STORAGE_ACCEL_REDIRECT=true: acrescente aolocationinterno do nginx oproxy_hide_header X-Content-Type-Options;e os trêsadd_header ... always;da receita de storage. O nginx não repassa os headers que o app põe na resposta vazia. - Página que embute o download em
<iframe>ou de outro site: a CSPsandboxe o CORPsame-sitevalem para ela. Passe o valor que precisa emheaders=— ele substitui o default de mesmo nome. build_content_disposition(..., as_attachment=False)chamado à mão: passemedia_type=com o tipo da resposta.
0.305.0 — token de conta desativada não autentica mais¶
current_user_dependency() e as rotas autenticadas do make_auth_router
passam a recusar conta com is_active=False com 403 ACCOUNT_INACTIVE (ou AuthExceptions.account_inactive), mesmo
com o token ainda válido. Até a 0.304.0 a conta desativada seguia entrando
com o token emitido antes, pelo access TTL inteiro.
O que fazer¶
- Rota que precisa atender conta inativa (reativação, exportação de dados
antes do encerramento): monte a dependência com
current_user_dependency(require_active=False). - Cliente que trata a conta desativada por outro
code: passeinactive_exception=SuaExcecao. - Override de
get_usersó para levantar403em conta inativa: pode ser removido.
0.305.0 — troca de senha com usuário removido é link inválido¶
confirm_password_reset com um token cujo usuário não existe mais levantava
NotFoundException (404). Agora levanta InvalidTokenException
(401 INVALID_TOKEN, ou a classe de AuthExceptions.invalid_token), como
activate e confirm_email_change já faziam: para quem chama, é um link que
não vale.
O que fazer¶
- Cliente que tratava
404emPOST /auth/password-reset/confirm: trate o mesmo erro do link expirado. - Código que constrói
PasswordPolicyViolation(...)à mão: passecode=PasswordViolationCode.<...>, agora obrigatório. Quem só lê o retorno decheck_password_policynão muda nada.
0.305.0 — o signup grava os campos do schema que são coluna¶
make_auth_router(signup_schema=...) passa a gravar na linha, antes do
insert, todo campo que o seu signup_schema acrescenta à SignupSchema e que
também é coluna do user model (fora de SIGNUP_PROTECTED_FIELDS: id,
email, hashed_password, is_active, is_admin). Até a 0.304.0 esses
campos só chegavam à linha se o on_signup os copiasse.
O que fazer¶
on_signupque só copiava os campos (user.phone = payload.phone): pode ser removido.- Campo de schema com nome de coluna que você transforma antes de gravar
(normaliza telefone, por exemplo): o
on_signupcontinua rodando depois, na mesma transação, e sobrescreve o valor. Mas a unicidade é conferida no insert, com o valor cru — se isso importa, normalize num validator do próprio schema. - Campo de schema com nome de coluna que não devia ir para a linha: renomeie o campo.
0.304.0 — o leitor CSV do Google Sheets tem limite por padrão¶
read_google_sheet e read_google_sheet_as ganharam max_bytes e
max_rows, ligados por padrão: um export acima de 10 MiB
(DEFAULT_GOOGLE_CSV_MAX_DOWNLOAD_BYTES) ou com mais de 100 000 linhas de
dado (DEFAULT_XLSX_MAX_ROWS) passa a levantar SpreadsheetTooLargeError
(413, SPREADSHEET_TOO_LARGE). Até a 0.303.2 a mesma aba era lida inteira,
sem teto. Detalhes em Aba grande demais.
O que muda¶
- Aba com 100 001 linhas de dado: antes devolvia as 100 001; agora levanta
SpreadsheetTooLargeErrorcomdetails == {"limit": "rows", "max": 100000, "actual": 100001, "sheet": None, "row": 100002}. - Export acima de 10 MiB: a transferência é fechada e sai o mesmo erro com
details["limit"] == "download_bytes". - Linha em branco não conta para
max_rows.
O que fazer¶
- Aba que você sabe que é grande e confia na origem: suba o limite
(
max_rows=500_000,max_bytes=50 * 1024 * 1024) ou desligue commax_rows=None/max_bytes=None. - Endpoint que repassa a exceção: o erro já é um
AppExceptioncom413; nada a fazer se você usa oregister_exception_handlersdo SDK.
0.304.0 — o SQLite confere chave estrangeira¶
Todo engine SQLite que o AsyncDatabaseManager e o create_test_engine montam
passa a rodar PRAGMA foreign_keys=ON em cada conexão. Antes nenhum rodava, e
o SQLite aceitava o que o PostgreSQL recusa: filho apontando para pai
inexistente, ON DELETE CASCADE que não apagava nada, e add_all([filho,
pai]) sem relationship() mandando o INSERT do filho primeiro. Detalhes e
medição em Banco de dados.
O que muda¶
- Teste que grava filho sem pai falha com
IntegrityError: FOREIGN KEY constraint failed. Era um teste que passava no SQLite e falharia no PostgreSQL. ON DELETE CASCADEapaga os filhos também no SQLite.create_test_engineaplica o fix de savepoint do manager: umbegin_nested()que sai limpo não comita mais a transação de fora.- Engine de migration continua sem FK, e o
env.pygerado recusa uma conexão entregue com a FK ligada — veja abaixo.
O que fazer¶
- Semeie o pai nos testes. Crie a linha referenciada antes da que
referencia, e dê
flush()depois do pai quando os models não têmrelationship(). Não desligue a FK no teste para fazê-lo passar: o defeito é o teste, não a FK. -
Banco SQLite existente: procure órfão antes de subir a versão.
import sqlite3 with sqlite3.connect("app.db") as conn: for row in conn.execute("PRAGMA foreign_key_check"): print(row)Cada linha é
(tabela, rowid, tabela pai, índice da FK): o membro de id 2 apontando para uma organização que não existe sai('member', 2, 'org', 0). Medido no SQLite 3.47.1: com a FK ligada, o banco abre e lê normalmente,UPDATEde outra coluna do órfão eDELETEdele passam; falha gravar uma chave sem pai (INSERT, ouUPDATEda coluna FK para um pai inexistente). 3. Precisa adiar? Desligue pelo caminho que você usa — ignorado em qualquer backend que não seja SQLite:from sqlalchemy.ext.asyncio import AsyncEngine from tempest_fastapi_sdk import AsyncDatabaseManager from tempest_fastapi_sdk.testing import create_test_engine db: AsyncDatabaseManager = AsyncDatabaseManager( "sqlite+aiosqlite:///./app.db", sqlite_foreign_keys=False, ) engine: AsyncEngine = create_test_engine(foreign_keys=False)Pelo ambiente:
DATABASE_SQLITE_FOREIGN_KEYS=false, lido peloDatabaseSettings.database_kwargs(). 4. Conexão compartilhada com o Alembic: se você entrega a conexão de umAsyncDatabaseManageraoenv.py(config.attributes["connection"]), ela agora chega com FK ligada — e o batch mode que recria a tabela pai apaga os filhos comON DELETE CASCADEsem erro (medido com Alembic 1.19.1: 3 linhas → 0). Oenv.pyque já está no seu repositório não recusa. Migre numa conexão deAsyncDatabaseManager(url, sqlite_foreign_keys=False)e regenere oenv.py— passo a passo em Migrações.AlembicHelperetempest db ...abrem engine próprio, sem FK, e não mudam.
0.304.0 — erro cru de ferramenta não chega mais ao modelo¶
Quando uma ferramenta do agente levanta uma exceção que não é
AgentToolError, o modelo passa a ler só tool failed: <Tipo> na observação.
Antes lia o texto inteiro da exceção — e o HTTPStatusError de um
raise_for_status() traz a URL completa: medido, a observação levou
?apikey=s3cr3t ao modelo, que pode repetir o que lê. O traço já guardava só o
tipo desde a 0.300.0; agora a conversa também. Detalhes em
Por que traduzir a exceção.
O que muda¶
- Observação de exceção crua era
HTTPStatusError: Client error '401 Unauthorized' for url '...?apikey=...'e passa a sertool failed: HTTPStatusError. - O traço não muda: continua
HTTPStatusError: the tool failed (details withheld). O log (tempest_fastapi_sdk.agents.agent) continua com a exceção inteira. AgentToolErrornão muda: a mensagem chega ao modelo e ao traço como foi escrita.expose_tool_errors=Trueagora vale para os dois lados: entrega o texto ao modelo e ao traço, com as formas óbvias de credencial mascaradas (?apikey=***,Authorization: Bearer ***,postgresql://admin:***@db).
O que fazer¶
- Ferramenta que contava com o modelo lendo o texto cru para se corrigir
(um 404 que dizia "não existe", um erro de validação de outra lib) traduz a
falha para
AgentToolErrorcom a frase que o modelo precisa ler. - Teste que afirmava o texto cru na observação passa a afirmar
tool failed: <Tipo>— ou ligaexpose_tool_errors=Truenaquele agente. - Para voltar ao texto no modelo em desenvolvimento,
Agent(..., expose_tool_errors=True). Ele também grava o texto no traço, que o router HTTP serve: não ligue num endpoint aberto.
0.302.0 — constraint composta leva todas as colunas no nome¶
NAMING_CONVENTION nomeava unique, índice e foreign key só pela primeira
coluna, então UniqueConstraint("title") e
UniqueConstraint("title", "release_year") viravam ambas uq_books_title. O
PostgreSQL recusa o CREATE TABLE (relation "uq_books_title" already
exists); o SQLite aceita as duas, e é por isso que o defeito passava no banco
de teste. A convenção agora usa todas as colunas.
O que muda¶
- Constraint composta muda de nome.
uq,ixefkcom mais de uma coluna levam 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, nem
pkeck. - 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. - O
constraintdoparse_integrity_erroracompanha o banco. Depois doRENAME, o PostgreSQL devolve o nome novo; mapa seu de nome de constraint para código de erro precisa da chave nova.
O que fazer¶
O alembic revision --autogenerate sobre um banco antigo propõe drop +
create para as uniques e os índices compostos e não vê a FK composta
renomeada (medido contra PostgreSQL 16 e Alembic 1.19.1). Esse drop falha
quando uma FK depende da unique. O caminho é uma revisão com RENAME:
-
Com todos os models importados, gere os
RENAMEcomlegacy_constraint_renames(BaseModel.metadata). Cada item é umConstraintRename(kind, table, schema, columns, old_name, new_name);statement(dialect)escreve o SQL einverse()dá o dodowngrade(). Constraint comname=explícito fica de fora.from sqlalchemy import Index, UniqueConstraint from sqlalchemy.dialects import postgresql, sqlite from sqlalchemy.orm import Mapped, mapped_column from tempest_fastapi_sdk import ( BaseModel, ConstraintRename, legacy_constraint_renames, ) class BookModel(BaseModel): """Livros do catálogo.""" __tablename__ = "books" __table_args__ = ( UniqueConstraint("isbn"), UniqueConstraint("title", "release_year"), Index(None, "author", "title"), ) isbn: Mapped[str] = mapped_column() title: Mapped[str] = mapped_column() release_year: Mapped[int] = mapped_column() author: Mapped[str] = mapped_column() renames: list[ConstraintRename] = legacy_constraint_renames(BaseModel.metadata) for rename in renames: print(rename.kind, rename.old_name, "->", rename.new_name) print(rename.statement(postgresql.dialect())) try: renames[0].statement(sqlite.dialect()) except ValueError as exc: print(f"ValueError: {exc}")Saída:
index ix_books_author -> ix_books_author_books_title ALTER INDEX ix_books_author RENAME TO ix_books_author_books_title unique uq_books_title -> uq_books_title_release_year ALTER TABLE books RENAME CONSTRAINT uq_books_title TO uq_books_title_release_year ValueError: ConstraintRename.statement supports postgresql, not 'sqlite'; SQLite cannot rename a constraint, so use the batch migration alembic --autogenerate rendersO
uq_books_isbn, de uma coluna, não aparece: o nome dele não mudou. -
Cole os comandos numa revisão vazia e rode
alembic upgrade headantes do deploy da versão nova. O passo a passo, com a revisão completa e odowngrade(), está em Banco de dados → Migrar constraints compostas da convenção antiga. - No SQLite o
statement()levantaValueError, como na saída acima: o SQLite não renomeia constraint, e lá a migração em batch que o autogenerate gera funciona.
Para adiar
Dê à constraint composta o nome que ela já tem no banco
(UniqueConstraint("title", "release_year", name="uq_books_title")): nome
explícito vence a convenção, e o legacy_constraint_renames deixa a
constraint de fora. A convenção antiga continua disponível como
LEGACY_NAMING_CONVENTION.
0.301.0 — CSS, DataTable, formulários, SSR e sessões¶
A release que trouxe para o SDK as pontes do painel do tempest-bucket muda
comportamento em cinco áreas que um serviço percebe. Nenhuma exige migration de
banco.
CSS¶
make_css_routerecss_responsemudam oCache-Controldefault depublic, max-age=3600parano-cacheno path sem versão: o browser revalida peloETag(304, sem corpo) antes de usar a cópia. Para o cache longo, linkesheet.url(path), que acrescenta?v=<versão>e recebepublic, max-age=31536000, immutable. Quem já passavacache_control=explícito não muda nada.- O reset do
StyleSheetganhoufont-familynobody. Regra própria que define a família continua vencendo quando vem depois do reset (é o caso doextra=doapp_stylesheet).
Detalhe em CSS tipado.
DataTable¶
O <table> agora sai dentro de <div class="tui-table-scroll">, para a
tabela larga rolar no contêiner em vez de alargar a página no celular.
Seletor próprio que mirava .tui-card__body > table precisa incluir o
wrapper.
Formulários¶
help_textpresente sempre vence.{"ui": {"help_text": ""}},NoneouFalsesuprimem a dica; antes caía nadescriptiondo campo.- Campo
UploadFiledeixou de sair como<input type="text">: gera<input type="file">, e o formulário ganhaenctype="multipart/form-data".
Detalhe em Formulários.
SSR¶
html_response(page) sem title= usa page.document_title() em vez de
levantar ValueError. O erro continua para widget que não é Page.
Sessões¶
make_session_dependency(...)devolve dependencyasync. EmDepends(...)nada muda; teste que chamava o resolver direto 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 aceito antes é recusado agora;Settingsque 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, em vez de sempreSecureeHttpOnly.
Detalhe em Sessões.
0.300.0 — o anexo do chat registra quem fez o upload¶
BaseMessageAttachmentModel ganhou a coluna uploader_id (nullable,
indexada). Como toda tabela de anexo concreta a herda, migre o banco antes
de atualizar o pacote: sem a coluna, toda rota do chat que monta mensagem
responde 500 (no such column: message_attachments.uploader_id, medido
com SQLite).
O que fazer¶
- Gere a migration (
alembic revision --autogenerate) ou escreva-a à mão — umop.add_column(..., sa.Column("uploader_id", sa.Uuid(), nullable=True))e umop.create_index(...). O arquivo completo, comdowngrade(), está na receita de chat. - Rode
alembic upgrade heade só então faça o deploy da versão nova. - Grave o upload por
ChatService.add_attachment(uploader_id, ...), ou passeuploader_id=onde você já grava a linha à mão.
Linhas antigas ficam com uploader_id NULL e seguem reivindicáveis por
qualquer remetente — a janela de transição. Linha nova com autor só é
reivindicada por uma mensagem do próprio autor; outra pessoa recebe o mesmo
404 de um id inexistente.
0.300.0 — o resto das quebras: IA, chat, métricas e migrations¶
A mesma release fecha defeitos de segurança e de recurso nos módulos de IA, e
alguns consertos mudam o contrato. Percorra a lista e aplique o que você usa;
o porquê de cada item, medido, está no CHANGELOG.md.
make_ai_chat_router¶
- O campo
user_idsaiu do corpo — cliente que ainda o manda não quebra, o campo é ignorado. O dono da memória agora vem de uma dependência:make_ai_chat_router(pipeline, current_user_id=...). Pipeline commemory=montado semcurrent_user_id=levantaValueErrorna montagem. Exemplo completo em Memória de longo prazo. historycomrolefora deuser/assistantresponde422.
ContentExtractor¶
- Host privado (intranet,
localhost, metadata de nuvem) é recusado comfailed=True. Para buscar na intranet, passeallow_private_networks=True. - Página acima de 5 MiB precisa de
max_response_bytes=maior. - HTTPS por proxy HTTP passa a falhar fechado; atrás de proxy, use
allow_private_networks=Truee deixe o proxy como controle de saída.
Limites novos que respondem 422¶
make_genai_routerconfereGenAIRequestLimitsantes de rodar o modelo; quem precisa de mais passalimits=GenAIRequestLimits(...)(Limites por request).POST /imagerecusanum_images > 1.make_vision_routerlê no máximo 20 MiB e 50 MP por imagem (max_upload_bytes=/max_image_pixels=).make_chat_routerexigepage >= 1e1 <= page_size <= 100(max_page_size=), e os schemas do chat têm teto de tamanho.POST /predictdomodelopsrecusa lote acima de 10 000 linhas (max_rows=;Nonetira o limite).
Router de agentes¶
- A rota de artefato mudou de
/runs/{index}/artifacts/{name}para/runs/{run_id}/artifacts/{name};POST /rundevolve orun_id. - Sem
owner=, toda execução continua visível para todo chamador — passemake_agent_router(agent, owner=...)quando houver mais de um usuário (Servir por HTTP). - Erro inesperado de ferramenta aparece no traço só como o tipo;
Agent(expose_tool_errors=True)volta ao texto inteiro. - Backend próprio que implementa
ChatBackend/ToolCallingBackendrecebe as mensagens por posição e não precisa mais aceitar**kwargs.
Chat¶
- Quem não enxerga a mensagem recebe
404onde antes recebia403. O remetente que ainda é participante continua recebendo403para mensagem alheia.
RAG¶
addeindexdos stores do SDK e doHybridRetrieversubstituem por fonte: passe todos os chunks de umasourcena mesma chamada, ou os lotes anteriores dela somem.
Métricas¶
genai_requests_totalegenai_request_secondsganharam o rótulostatus(ok/error). Seletor{model, op}continua casando, mas cada série vira duas: some porstatusou filtrestatus="ok"em query e alerta que comparam a série inteira.
modelops¶
/modeldevolve só o nome do arquivo empath;make_prediction_router(expose_model_path=True)restaura o caminho absoluto.[modelops]passa a instalarnumpy.
AlembicHelper¶
- O método síncrono que roda o
env.py, chamado com um event loop rodando (o lifespan do FastAPI), levantaRuntimeError— use o par_async(await helper.upgrade_async()). Oenv.pyque você já tem continua funcionando (receita de migrations).
Outros¶
RedisFactStore: fato gravado antes sobsubject=""ousubject="_"continua na chave antiga{prefix}:_, e a versão nova grava esses subjects em outra chave.- O helper privado
genai/_lifecycle.pymudou parautils/_lifecycle.py, sem shim.
0.274.0 — só o link mais recente abre a conta¶
Nenhuma assinatura muda e nenhum default de campo existente muda. O que muda é quantos links de uma vez ficam válidos: emitir um token de conta agora gasta os outros tokens não usados do mesmo usuário e do mesmo propósito.
O que muda¶
Antes, cada POST /auth/password-reset/request só empilhava linha em
user_tokens, e todos os tokens não usados e não expirados valiam ao mesmo
tempo. Agora vale um por propósito — o mais recente.
Aplica-se a ACTIVATION, PASSWORD_RESET, EMAIL_CHANGE e
EMAIL_VERIFICATION, porque a correção vive dentro do _issue_token, por
onde os quatro fluxos passam.
O escopo é estreito nas duas direções:
- Só o mesmo propósito. Pedir um reset de senha não derruba uma troca de e-mail pendente.
- Só o mesmo usuário. O token de outra pessoa não é tocado.
A linha antiga é marcada used_at em vez de apagada, então a trilha de
auditoria continua completa.
Por que o default mudou¶
Porque o comportamento antigo anulava 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. Sem superseder,
o link do atacante continuava valendo até AUTH_PASSWORD_RESET_TTL_SECONDS.
Medido:
AUTH_SINGLE_ACTIVE_TOKEN=True -> link do atacante: recusado (InvalidTokenException)
AUTH_SINGLE_ACTIVE_TOKEN=False -> link do atacante: AINDA FUNCIONA
O que fazer¶
Na maioria dos serviços, nada. É a propriedade que quase todo provedor já aplica, e é o que o usuário espera.
Quebra num caso: um fluxo que de propósito mantém vários links vivos ao mesmo tempo — por exemplo, um convite reenviado para vários endereços em que qualquer um dos links deve funcionar. Se for o seu caso:
Um sintoma a reconhecer, se você não ligar o flag: um usuário que clica num
link antigo agora recebe InvalidTokenException (400,
INVALID_TOKEN) em vez de entrar. A instrução certa na sua UI é "peça um novo
link", não "tente de novo".
0.272.0 — os checks passaram a ver o que já estava errado¶
Nenhuma assinatura muda, nenhum default muda. O que muda é o que o
tempest check-config reporta — e o CI de quem roda
--fail-level warning pode passar a falhar.
O que muda¶
Três correções, todas na direção de reportar mais:
| Check | Antes | Agora |
|---|---|---|
deployment.I001 |
lia DEBUG, que nenhum mixin declara — nunca disparava |
resolve SERVER_DEBUG e depois DEBUG |
database.W001 |
a isenção de debug nunca valia; avisava com o debug ligado | fica calado com o debug ligado, avisa com ele desligado |
security.W004 |
não existia | avisa quando o segredo ainda é o default declarado no campo |
O security.W004 é o que muda o resultado do gate na prática. O default de
JWTSettings.JWT_SECRET tem exatos 32 caracteres, então nunca acionava
security.W002 — um serviço que nunca definiu JWT_SECRET passava limpo.
Agora não passa.
O que fazer¶
Se o seu CI roda o gate com aviso bloqueante:
...e ele começar a falhar com security.W004, a falha está certa: o
serviço estava assinando token com um valor publicado no código-fonte do
SDK. Gere um segredo de verdade e ponha no ambiente:
Se o serviço declarou um campo DEBUG na mão em vez de compor
ServerSettings, nada muda — a resolução tenta SERVER_DEBUG primeiro e
cai em DEBUG depois.
O texto de duas mensagens mudou
deployment.I001 passou a nomear o campo que carregou o flag
(SERVER_DEBUG is enabled.), e o database.W001 diz
while debug is off em vez de while DEBUG is off. Se alguma
automação casa esse texto por substring, ajuste; o id de cada
mensagem não mudou.
0.270.0 — PixCharge.raw usa a grafia do fio¶
Não muda nenhuma assinatura. Muda as chaves de um dicionário que o contrato canônico entrega.
O que muda¶
PixCharge.raw sempre carregou o payload do provedor, mas com grafia
diferente conforme o caminho. Medido na v0.269.0, mesmo corpo, mesmos 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 dumpava sem by_alias, então campo declarado saía em
snake_case enquanto campo não declarado — que extra="allow"
preserva desde a v0.259/v0.260 — mantinha o nome do fio. O raw do
caminho de API era, literalmente, uma mistura das duas grafias.
A partir da v0.270.0 os dois caminhos usam by_alias=True: a grafia é a
do provedor, sempre.
O que fazer¶
Se o seu serviço lê raw em campo declarado pela especificação, troque
para o nome do fio:
from tempest_fastapi_sdk.integrations.payment import PixCharge
def payment_link(charge: PixCharge) -> object | None:
"""Read OpenPix's payment link out of the raw payload.
Args:
charge (PixCharge): The charge, in canonical shape.
Returns:
object | None: The link, when the provider sent one.
"""
return charge.raw.get("paymentLinkUrl")
Quem já lia campo não declarado (paidAt, por exemplo) não muda nada:
esses já vinham na grafia do fio.
Como achar as ocorrências
Toda leitura de raw com chave em snake_case é candidata. A
contrapartida é o nome que a documentação do provedor usa.
Por que a grafia do fio, e não a do Python
raw existe para o que o contrato não modela. O consumidor
descobre esse campo lendo a documentação do provedor, e lá ele se
chama paymentLinkUrl. Uma grafia que só existe depois de o SDK
renomear é uma que ninguém consegue prever.
0.270.0 — PaymentStatus ganhou UNKNOWN¶
Membro novo num enum. Aditivo: nada que existia mudou de valor.
O que muda¶
Um estado que o SDK não classifica agora chega como
PaymentStatus.UNKNOWN, com a string do provedor preservada em
provider_status. Antes, os dois caminhos da OpenPix respondiam coisas
opostas e nenhuma era verdade:
API status 'CANCELLED' -> ValidationError -> 500 no seu serviço
webhook status 'CANCELLED' -> status=pending
O que fazer¶
Se você ramifica exaustivamente em PaymentStatus — um match sem
case _, um dicionário indexado por membro — acrescente o caso:
from tempest_fastapi_sdk.integrations.payment import PaymentStatus, PixCharge
def describe(charge: PixCharge) -> str:
"""Describe a charge's state for a support screen.
Args:
charge (PixCharge): The charge, in canonical shape.
Returns:
str: A line a human can act on.
"""
if charge.status is PaymentStatus.UNKNOWN:
return f"estado não classificado: {charge.provider_status}"
return charge.status.value
Quem ramifica só em PAID/PENDING com um ramo default não precisa fazer
nada — o comportamento novo é estritamente melhor: uma cobrança que antes
aparecia como PENDING sem ser, ou que derrubava a requisição, agora se
identifica.
0.269.0 — Charge.expires_in virou int¶
Não muda nenhuma assinatura. Muda o tipo de um campo de resposta da OpenPix.
O que muda¶
Charge.expires_in era str | None e passa a ser int | None. O documento
da Woovi declara expiresIn como string, a API devolve 3600, e a
validação estourava antes de qualquer consumidor ver a cobrança:
charge.expiresIn
Input should be a valid string [type=string_type, input_value=3600, input_type=int]
O que fazer¶
Nada, em runtime. Como nenhuma resposta de cobrança chegava a ser
construída, o tipo antigo nunca entregou um valor a ninguém — não existe
código lá fora que tenha lido uma str desse campo e funcionado.
Se você anotou o campo, troque o tipo:
from tempest_fastapi_sdk.integrations.payment.openpix import Charge
def seconds_left(charge: Charge) -> int:
"""Quantos segundos faltam para a cobrança expirar."""
return charge.expires_in or 0
Por que não int | str
Uma união empurraria a ambiguidade para todo consumidor, que passaria a
precisar de int(charge.expires_in) defensivo sem nunca saber se algum
dia recebe texto. O documento se contradiz sobre esse campo em três
lugares (Charge diz string, ChargePayload diz number,
WebhookCharge diz integer) — a evidência está em
vendor/openpix-evidence.md, seção 7.
0.265.0 — array opcional em corpo de request virou | None¶
Não muda assinatura de método. Muda o tipo de alguns campos dos modelos de corpo gerados, e o que vai para o fio.
O que muda¶
Um array que a especificação lista como opcional, num modelo que o SDK só
envia, deixa de ser list[T] com default_factory=list e passa a ser
list[T] | None com default=None. São 21 campos: 9 na OpenPix, 12 na
Mercado Pago.
Junto disso, o _dump gerado passa a usar exclude_unset=True, então
nenhum campo que você não tocou vai para o corpo — inclusive nos modelos
compartilhados, que mantiveram a grafia de lista.
Modelo de resposta não mudou: Charge.additional_info continua []
quando o provedor não manda nada, que é a convenção do SDK.
O que fazer¶
Nada, se você só constrói o payload e envia. O caso que a correção conserta é justamente esse:
antes: {"correlationID": "...", "value": 1190, "additionalInfo": [], "splits": []} → 400
agora: {"correlationID": "...", "value": 1190} → 200
Se você lê o campo de volta do payload que construiu, o valor agora é
None em vez de []. Antes, for split in payload.splits: percorria uma
lista vazia e não executava; agora levanta TypeError. A forma que funciona
nas duas versões:
from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload
payload: ChargePayload = ChargePayload(correlation_id="abc", value=1190)
for split in payload.splits or []:
print(split.pix_key)
Informar lista vazia continua possível — e continua sendo uma afirmação
ChargePayload(..., splits=[]) envia "splits": [], e a Woovi
responde 400 O array de split precisa ter ao menos um item. Isso é
correto: você afirmou algo, e o provedor julgou. O que mudou é que o SDK
parou de afirmar por você.
0.264.0 — o idioma passa a ser do fluxo, não de cada ponta¶
Não quebra assinatura nenhuma. Muda o idioma em que algumas páginas saem — para o correto, mas de forma observável.
O que muda¶
Até a v0.263.0 o e-mail lia AUTH_DEFAULT_LOCALE e a página negociava
Accept-Language, cada um por conta própria. Agora os dois 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 você gravou na linha)
3. Accept-Language (só páginas)
4. AUTH_DEFAULT_LOCALE
Duas consequências observáveis:
- Usuário com
localegravado e navegador em outra língua passa a ver a página no idioma dele. Antes o header vencia. - O link do e-mail ganha
?lang=<locale>. A query anterior é preservada byte a byte — o token não é reencodado —, mas a URL tem um parâmetro a mais.
O que fazer¶
Nada, se você não guarda idioma de usuário e não valida a query do link.
O comportamento sem sinal nenhum é o de antes: Accept-Language, depois
AUTH_DEFAULT_LOCALE.
Se a sua rota de frontend recusa query param desconhecido, desligue o carimbo:
Se você quer que a preferência do usuário mande, guarde-a — o SDK lê o
atributo com getattr, então basta existir:
from tempest_fastapi_sdk import BaseUserModel, LocaleColumnMixin
class UserModel(LocaleColumnMixin, BaseUserModel):
"""A coluna `locale` (BCP-47, nullable) entra pelo mixin."""
__tablename__ = "users"
Locale que o SDK não conhece não é resposta
O SDK ships pt-BR e en-US. Uma linha com locale="fr-FR" não
para a busca: ela cai para o próximo sinal, em vez de renderizar numa
língua que não existe.
0.263.0 — AdminAuthBackend virou genérica¶
Não quebra runtime nenhum. Quebra o type-check de quem subclasseia
AdminAuthBackend e roda mypy --strict — o que o pyproject.toml escrito
pelo tempest new liga por default.
O que muda¶
AdminAuthBackend passou a ser Generic[PrincipalT], parametrizada pelo
principal — o valor que authenticate devolve e que load_principal,
principal_id, display_name, mfa_enabled e verify_mfa consomem. Antes,
os seis passavam esse valor como Any.
Subclassear sem parâmetro continua válido em runtime e resolve para
AdminAuthBackend[Any], que é o comportamento de até a v0.262.0. O que muda
é que mypy --strict liga disallow_any_generics, e aí:
src/admin/backend.py:12: error: Missing type arguments for generic type
"AdminAuthBackend" [type-arg]
Medido: o basedpyright em modo standard não reclama; o mypy strict sim.
O que fazer¶
1. Escreva o seu principal no parâmetro. É a opção que compra alguma
coisa — era class LdapAuthBackend(AdminAuthBackend) com principal: Any em
todo método; passa a ser:
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError
@dataclass
class LdapEntry:
"""O principal que o seu diretório devolve."""
dn: str
common_name: str
DIRECTORY: dict[str, LdapEntry] = {
"admin@example.com": LdapEntry(dn="cn=admin", common_name="Admin"),
}
class LdapAuthBackend(AdminAuthBackend[LdapEntry]):
"""Autentica contra o diretório em vez da tabela de usuários."""
async def authenticate(
self,
session: AsyncSession,
*,
identifier: str,
password: str,
) -> LdapEntry:
"""Devolve a entrada do diretório, ou recusa o login."""
entry = DIRECTORY.get(identifier)
if entry is None:
raise AdminAuthError("Invalid credentials")
return entry
async def load_principal(
self,
session: AsyncSession,
principal_id: str,
) -> LdapEntry | None:
"""Recarrega a entrada a cada request."""
for entry in DIRECTORY.values():
if entry.dn == principal_id:
return entry
return None
def principal_id(self, principal: LdapEntry) -> str:
"""Devolve o que vai serializado no cookie."""
return principal.dn
def display_name(self, principal: LdapEntry) -> str:
"""Devolve o que o cabeçalho do admin mostra."""
return principal.common_name
Com o parâmetro escrito, um display_name que lê outro tipo do que
authenticate devolveu vira erro de override — em mypy (que cita Liskov por
nome) e em basedpyright. Era exatamente o defeito que o Any deixava passar.
2. Ou preserve o comportamento anterior, literalmente. AdminAuthBackend[Any]
é o que a classe sem parâmetro já resolvia:
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError
class LegacyAuthBackend(AdminAuthBackend[Any]):
"""Exatamente o comportamento de até a v0.262.0."""
async def authenticate(
self,
session: AsyncSession,
*,
identifier: str,
password: str,
) -> Any:
"""Devolve o principal que o seu IdP entregar."""
raise AdminAuthError("Invalid credentials")
async def load_principal(
self,
session: AsyncSession,
principal_id: str,
) -> Any | None:
"""Recarrega o principal, ou invalida a sessão."""
return None
def principal_id(self, principal: Any) -> str:
"""Devolve o identificador do cookie."""
return str(principal)
Zero diferença em relação ao que você tinha; só cala o disallow_any_generics.
UserModelAuthBackend não pede nada
A implementação que o SDK ships já declara
AdminAuthBackend[BaseUserModel]. Quem usa o backend default —
auth_backend=UserModelAuthBackend(user_model=UserModel) — não muda
linha nenhuma.
Protocolos de Redis: só ficou mais fácil de satisfazer
A mesma release tornou posicional todo parâmetro obrigatório de
ThrottleBackend, _RedisHashClient e RedisLike, e trocou
Awaitable[Any] pelo tipo concreto. Isso alarga o que é aceito: um
backend seu que já passava continua passando, e
redis.asyncio.Redis/fakeredis — que o basedpyright recusava — passam
agora. A única forma de sentir isso é ter um call site que declarava o
retorno errado apoiado no Any; nesse caso o type-checker vai apontar a
linha.
0.261.0 — o router WebSocket manda um hello antes de tudo¶
Quebra cliente que lê o primeiro frame por posição. Cliente que despacha
por type e ignora o que não conhece não sente nada.
O que muda¶
O primeiro frame que make_websocket_router manda passou a ser:
Antes, o primeiro frame do servidor era o ping, ou o que o seu handler
mandasse.
O que fazer¶
1. Se o seu cliente lê o primeiro frame por posição, consuma o hello.
// até a v0.260.0
socket.onmessage = (event) => {
const first = JSON.parse(event.data); // era ping ou dado de aplicação
};
// a partir da v0.261.0 — despache por tipo, que é estável
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "hello") {
calibrateWatchdog(frame.data.heartbeat_seconds);
return;
}
if (frame.type === "ping") {
socket.send(JSON.stringify({ type: "pong", data: {} }));
return;
}
handle(frame);
};
2. Aproveite o número. O socket do browser só reporta conexão que fecha
limpo; um link que morre em voo deixa o readyState em OPEN com nada
chegando nunca mais. O watchdog de silêncio do cliente não é opcional, e
calibrá-lo pelo hello significa que retunar WS_HEARTBEAT_SECONDS no
servidor não faz mais todo cliente confundir intervalo normal com queda.
O que ganha sem fazer nada¶
- Qualquer frame de entrada conta como prova de vida. Antes só
pongcontava, então um cliente ocupado respondendo outra coisa podia cruzar o deadline e ser fechado com4408. Opongcontinua sendo consumido antes do handler. heartbeatvirou público, utilizável em endpoint sem auth e sem hub. Receita emdocs/recipes/websocket.md, seção "Heartbeat sem o router".CompactPaginationSchema/CompactPaginationFilterSchema, para quem publicasizeem vez depage_size. Nada muda para quem não usa.
0.260.0 — a spec da OpenPix foi refrescada, e os métodos mudaram de nome¶
Quebra todo chamador do OpenPixClient: os 125 métodos mudaram de nome.
Quebra também quem importa classe de schema derivada de operação, quem lê
OpenPixEnvironment.PRODUCTION, e quem dependia de extra="allow" num model
que também é payload.
Por que¶
O documento vendorizado estava duas versões atrás do publicado — 3.0.3
intitulado "OpenPix" contra o 3.1.0 "Woovi" —, e nada no repositório media
isso. O documento novo traz operationId em 125 de 125 operações, onde o
antigo tinha zero: o gerador derivava nome do path, e agora usa o nome que o
provedor deu.
Isso é upside disfarçado de quebra. post_api_v1_charge era o nome que sobrava
de não haver nome; create_charge é o nome da operação.
O que fazer¶
1. Renomeie as chamadas. Cada linha é uma substituição direta. Das 103, só três mudaram de assinatura:
| Método | Mudança |
|---|---|
update_customer |
o path param passou de correlation_id para id — quem chamava por posição não sente, quem chamava por keyword sim |
get_statement |
ganhou company_bank_account, opcional |
get_transaction |
ganhou company_bank_account, opcional |
from tempest_fastapi_sdk.integrations.payment.openpix import (
ChargePayload,
OpenPixClient,
)
async def cobrar(client: OpenPixClient, payload: ChargePayload) -> None:
"""Create one charge with the renamed method."""
# até a v0.259.0: await client.post_api_v1_charge(body=payload)
await client.create_charge(body=payload)
| Antes (v0.259.0) | Agora (v0.260.0) |
|---|---|
get_api_image_qrcode_base64_by_id |
get_charge_qr_code_base64 |
post_api_v1_account |
duplicate_account |
delete_api_v1_account_register_by_id |
delete_account_register |
get_api_v1_account |
list_accounts |
delete_api_v1_account_by_account_id |
close_account |
get_api_v1_account_by_account_id |
get_account |
post_api_v1_account_by_account_id_withdraw |
withdraw_from_account |
delete_api_v1_application |
delete_application |
post_api_v1_application |
create_application |
post_api_v1_boleto_validate |
validate_boleto |
post_api_v1_cashback_fidelity |
create_cashback_fidelity |
get_api_v1_cashback_fidelity_balance_by_tax_id |
get_cashback_fidelity_balance |
get_api_v1_charge |
list_charges |
post_api_v1_charge |
create_charge |
delete_api_v1_charge_by_id |
delete_charge |
get_api_v1_charge_by_id |
get_charge |
patch_api_v1_charge_by_id |
update_charge |
get_api_v1_charge_by_id_refund |
list_charge_refunds |
post_api_v1_charge_by_id_refund |
refund_charge |
get_api_v1_company |
get_company |
get_api_v1_customer |
list_customers |
post_api_v1_customer |
create_customer |
patch_api_v1_customer_by_correlation_id |
update_customer |
get_api_v1_customer_by_id |
get_customer |
post_api_v1_decode_emv |
decode_emv |
get_api_v1_dispute |
list_disputes |
get_api_v1_dispute_by_id |
get_dispute |
post_api_v1_funds_recovery |
create_funds_recovery |
get_api_v1_funds_recovery_by_id |
get_funds_recovery |
post_api_v1_funds_recovery_by_id_cancel |
cancel_funds_recovery |
get_api_v1_installments_by_id |
get_installment |
post_api_v1_installments_by_id_cobr |
create_installment_cobr |
post_api_v1_installments_by_id_cobr_retry |
retry_installment_cobr |
get_api_v1_invoice |
list_invoices |
post_api_v1_invoice |
create_invoice |
get_api_v1_invoice_integration |
get_invoice_integration |
patch_api_v1_invoice_integration |
set_invoice_integration_status |
post_api_v1_invoice_integration |
upsert_invoice_integration |
put_api_v1_invoice_integration |
update_invoice_integration_tax_fields |
post_api_v1_invoice_integration_certificate |
upload_invoice_integration_certificate |
post_api_v1_invoice_integration_test |
test_invoice_integration |
post_api_v1_invoice_by_correlation_id_cancel |
cancel_invoice |
get_api_v1_invoice_by_correlation_id_pdf |
get_invoice_pdf |
get_api_v1_invoice_by_correlation_id_xml |
get_invoice_xml |
post_api_v1_kyc_onboarding |
create_kyc_onboarding |
get_api_v1_limits_by_account_id |
get_account_limits |
get_api_v1_partner_affiliate |
list_partner_affiliates |
post_api_v1_partner_application |
create_partner_application |
get_api_v1_partner_company |
list_partner_companies |
post_api_v1_partner_company |
create_partner_company |
get_api_v1_partner_company_by_tax_id |
get_partner_company |
get_api_v1_payment |
list_payments |
post_api_v1_payment |
create_payment |
post_api_v1_payment_approve |
approve_payment |
get_api_v1_payment_by_id |
get_payment |
get_api_v1_pix_keys |
list_pix_keys |
post_api_v1_pix_keys |
create_pix_key |
post_api_v1_pix_keys_check |
check_pix_key |
get_api_v1_pix_keys_tokens |
list_pix_key_tokens |
get_api_v1_pix_keys_tokens_logs |
list_pix_key_token_logs |
delete_api_v1_pix_keys_by_pix_key |
delete_pix_key |
get_api_v1_pix_keys_by_pix_key_check |
check_pix_key_by_key |
put_api_v1_pix_keys_by_pix_key_default |
set_default_pix_key |
get_api_v1_psp |
list_psps |
get_api_v1_qrcode_static |
list_static_qr_codes |
post_api_v1_qrcode_static |
create_static_qr_code |
delete_api_v1_qrcode_static_by_id |
delete_static_qr_code |
get_api_v1_qrcode_static_by_id |
get_static_qr_code |
get_api_v1_receipt_by_receipt_type_by_end_to_end_id |
get_receipt |
get_api_v1_refund |
list_refunds |
post_api_v1_refund |
create_refund |
get_api_v1_refund_by_id |
get_refund |
post_api_v1_stablecoin_deposit |
create_stablecoin_deposit |
post_api_v1_stablecoin_deposit_approve |
approve_stablecoin_deposit |
get_api_v1_stablecoin_quote |
get_stablecoin_quote |
get_api_v1_stablecoin_subaccount |
list_stablecoin_subaccounts |
post_api_v1_stablecoin_subaccount |
create_stablecoin_subaccount |
get_api_v1_stablecoin_subaccount_by_sub_account_id |
get_stablecoin_subaccount |
get_api_v1_statement |
get_statement |
get_api_v1_subaccount |
list_subaccounts |
post_api_v1_subaccount |
create_subaccount |
post_api_v1_subaccount_transfer |
transfer_between_subaccounts |
delete_api_v1_subaccount_by_id |
delete_subaccount |
get_api_v1_subaccount_by_id |
get_subaccount |
post_api_v1_subaccount_by_id_credit |
credit_subaccount |
post_api_v1_subaccount_by_id_debit |
debit_subaccount |
get_api_v1_subaccount_by_id_statement |
get_subaccount_statement |
post_api_v1_subaccount_by_id_withdraw |
withdraw_from_subaccount |
get_api_v1_subscriptions |
list_subscriptions |
post_api_v1_subscriptions |
create_subscription |
get_api_v1_subscriptions_by_id |
get_subscription |
put_api_v1_subscriptions_by_id_cancel |
cancel_subscription |
get_api_v1_subscriptions_by_id_installments |
list_subscription_installments |
put_api_v1_subscriptions_by_id_value |
update_subscription_value |
get_api_v1_transaction |
list_transactions |
get_api_v1_transaction_by_id |
get_transaction |
post_api_v1_transfer |
create_transfer |
get_api_v1_webhook |
list_webhooks |
post_api_v1_webhook |
create_webhook |
get_api_v1_webhook_events |
list_webhook_events |
get_api_v1_webhook_ips |
list_webhook_ips |
delete_api_v1_webhook_by_id |
delete_webhook |
get_openpix_charge_brcode_image_id_png |
get_charge_qr_code_image |
2. Três métodos não têm substituição direta.
| Antes | Agora | O que estava errado |
|---|---|---|
post_api_v1_dispute_id_evidence(body=...) |
upload_dispute_evidence(id, *, body=...) |
O path era /api/v1/dispute/:id/evidence, com dois-pontos literal na URL, e não havia argumento para nomear a disputa |
get_api_v1_account_register() |
get_account_register(id) |
Docstring dizia "by CorrelationID" e o método não recebia nada |
delete_api_v1_payment_by_id(id) |
removido | O endpoint não existe: o documento publicado tem só get nesse path, e o DELETE que a Woovi documenta é em /api/v1/charge/{id} |
3. Classe de schema derivada de operação mudou de nome. As de
components não mudaram — Charge, ChargePayload, ChargeStatus,
Transaction, Customer seguem iguais. O que mudou é o inline:
O nome que existia até a v0.259.0 era GetApiV1ChargeResponsePageInfo. A
partir da v0.260.0:
O padrão é o mesmo do método: GetApiV1ChargeResponse… vira
ListChargesResponse…, PostApiV1PaymentBody… vira CreatePaymentBody….
4. OpenPixEnvironment.PRODUCTION agora é https://api.woovi.com. É o
servers[0] do documento refrescado. O host antigo continua no ar — medido em
2026-08-28, GET /api/v1/charge devolve 401 em api.openpix.com.br,
api.woovi.com e api.woovi-sandbox.com igualmente —, então serviço com a URL
fixada não quebra. Se você assere o valor em teste, atualize a expectativa.
5. Se você lia model_extra de um model que também é payload. Uma classe
alcançável pela resposta e pelo request body volta a extra="ignore". São
4 classes na OpenPix — PreRegistrationPayloadObject, que é o body e o
200 da mesma operação, mais as três que ela alcança — e 25 na Mercado
Pago. O motivo é o que a v0.259.0 já dizia e não conseguia cumprir: chave
inesperada num payload é typo de quem chamou, e levá-la ao provedor é pior que
descartar.
O que ganha sem fazer nada¶
- 24 operações novas:
anticipation(7),stablecoinpayout e wallets (7),boleto-transaction(2),kyc-validation(2),files,webhook/public-keys. Transaction.webhook_sent[].statuspassa a serint— é um código HTTP.to_centsereais_to_centsrecusam sempre comValueError. Antes,"abc"levantavadecimal.InvalidOperation,NonelevantavaTypeErrorefloat("inf")levantavaOverflowError.
0.259.0 — dinheiro da OpenPix passa a ser int¶
Quebra quem anotou variável com o tipo do model gerado, ou quem compara o dump com um JSON esperado.
O que muda¶
154 campos do pacote integrations/payment/openpix deixam de ser float:
- valor monetário —
Charge.value,ChargePayload.value,ChargeRefundPayload.value,Transaction.value,SubAccount.balance, todos ospix*Limit, e os demais dos 50valuedo documento; - contagem e offset de dia —
skipelimit,installmentsCount,dayDue,daysForDueDate,expiresIn. O par de paginação aparecia 27 vezes como campo de resposta (pageInfo,Pagination) e em 6 operações como query param.
A Woovi liquida em centavo inteiro — a própria spec diz isso na descrição do
campo, em 58 schemas numéricos — e tipava tudo como number. O efeito
visível é no fio:
from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload
ChargePayload(correlation_id="abc-1", value=1000).model_dump(
by_alias=True, mode="json", exclude_none=True
)
# até a v0.258.0: {"correlationID": "abc-1", "value": 1000.0, ...}
# a partir da v0.259.0: {"correlationID": "abc-1", "value": 1000, ...}
19 campos continuam float: basePrice (taxa de câmbio),
inputAmount/outputAmount da cotação de stablecoin (o primeiro é
documentado como "currency unit, not cents"), rate, o balde de tokens de
rate limit, annualRevenue e Installment.expiration — os dois últimos sem
unidade declarada no documento, e por isso deixados como estavam.
Corrigido na v0.260.0
Esta seção dizia originalmente "18 campos", e que eles eram "os únicos onde
a fração é real". Eram 19, e um deles era
Transaction.webhookSent[].status, descrito no próprio documento como
"HTTP response status code of the webhook delivery attempt" — código HTTP
nunca é fracionário. Ele virou int na v0.260.0.
O que fazer¶
Na maioria dos casos, nada — int satisfaz onde float era esperado em tempo
de execução. Confira três pontos:
- Anotação sua.
valor: float = charge.valuepassa a ser erro de tipo. Troque paraint. - Comparação de dump em teste.
assert dumped == {"value": 1000.0}continua passando (1000 == 1000.0), mas comparação contra string JSON, ou snapshot demodel_dump_json(), não passa. to_centssobre model gerado. Continua funcionando e continua validando (recusa negativo e fração), só não estreita mais nada. Para payload cru — o dicionário que saiu do JSON, um webhook — ele continua sendo a forma certa.
O outro lado: seus campos param de sumir¶
Model de resposta gerado agora usa extra="allow". Campo que o provedor
responde e a spec não declara fica em model_extra em vez de ser descartado
na validação:
from tempest_fastapi_sdk.integrations.payment.openpix import Charge
charge = Charge.model_validate({"value": 1000, "wooviAdicionouIsso": "depois"})
(charge.model_extra or {})["wooviAdicionouIsso"] # "depois"
Model de payload não mudou: continua extra="ignore", porque ali chave
inesperada é erro de digitação de quem chamou.
Três campos que estavam nessa situação passaram a ser declarados e tipados:
Charge.fee, Charge.discount e Charge.value_with_discount. Se você lia
algum deles pelo HTTPClient cru por causa disso, agora dá para usar o método
gerado.
0.258.0 — os arquivos de log passam a rotacionar¶
Não quebra assinatura nenhuma. Muda o que existe no disco: quem lia
logs/info.log de fora do SDK passa a ver uma janela, não o histórico
inteiro.
O que muda¶
Os handlers por nível eram logging.FileHandler — crescimento sem teto. Agora
são RotatingFileHandler com max_bytes=10_000_000 e backup_count=5: cada
nível para em ~60 MB (cinco rotacionados mais o que está sendo escrito), e
info.log.1, info.log.2, … aparecem ao lado do arquivo corrente.
O motivo é o mesmo que já tinha fechado o lado leitor deste par: o router de
/logs limita a leitura a DEFAULT_MAX_RECORDS_PER_FILE = 20_000 registros
por arquivo, adicionado depois que um serviço com diretório de log em
gigabytes respondeu com worker morto. Num serviço que loga uma linha por
request, rodando em host de longa duração, é o info.log que enche o disco —
e disco cheio derruba o serviço e o que mais dividir a partição.
O que fazer¶
Nada, na maioria dos casos. Confira dois pontos:
- Coletor que segue o arquivo pelo nome. Filebeat, Promtail, Fluent Bit e
companhia lidam com rotação, mas um
tail -Fcaseiro ou um script que abre o arquivo uma vez no boot perde a virada. Aponte o padrão parainfo.log*se você precisa dos rotacionados. GET /logsmostra a janela corrente. O endpoint lê os nomes exatos (info.log,error.log, …), então rotacionado não entra na resposta. Retenção mais longa é trabalho de coletor, não do endpoint.
Se você quer o comportamento antigo¶
max_bytes=0 volta ao FileHandler puro — para host onde logrotate ou um
sidecar já é o dono da retenção:
Os dois knobs são keyword-only e independentes: backup_count só é lido quando a
rotação está ligada.
0.257.0 — os fakes de agente passam a chamar o parâmetro de tools¶
Quebra quem chamava chat_with_tools(..., specs=[...]) por keyword.
O que muda¶
ScriptedBackend e FailingBackend existem para fingir os protocolos
ChatBackend / ToolCallingBackend — e não satisfaziam nenhum dos dois sob
mypy. O protocolo chama o parâmetro de tools e aceita **kwargs; os fakes
chamavam de specs e não aceitavam nada além. Um membro de protocolo só é
implementado por assinatura com o mesmo nome de parâmetro, então a linha de
abertura da receita de teste era erro de tipo:
from tempest_fastapi_sdk.agents import Agent
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies
agent = Agent(ScriptedBackend([replies("ok")]))
# até a v0.256.0:
# Argument 1 to "Agent" has incompatible type "ScriptedBackend";
# expected "ChatBackend | ToolCallingBackend" [arg-type]
O que fazer¶
Nada, se você chama posicionalmente — que é o que o Agent faz por dentro, e o
que a receita mostra. Se o seu teste chama o fake direto, por keyword:
-decision = await backend.chat_with_tools(messages, specs=specs)
+decision = await backend.chat_with_tools(messages, tools=specs)
O atributo specs_seen — onde o fake grava os nomes de tool oferecidos a cada
turno — não mudou de nome.
O que passou a compilar¶
Três anotações que recusavam o argumento que a própria doc mandava passar:
EventStream.response(on_disconnect=task.cancel), porqueTask.canceldevolveboole a anotação pediaNone;RedisIdempotencyStore(Redis.from_url(...)),RedisResponseCacheStore(...)eRedisWebAuthnChallengeStore(...), porque os protocolos exigiam o nome de parâmetrokey/namee retornoCoroutine, e o redis-py devolveAwaitable;require_authenticated(identity)com umFirebaseIdentity, porque oTypeVarestava preso aBaseUserModel.
Nenhuma delas muda runtime — só param de exigir contorno (# type: ignore,
cast) de quem roda type-checker.
0.256.0 — o 429 do RateLimitMiddleware passa a ser JSON¶
Quebra quem lê o corpo do 429 como texto.
O que muda¶
Até a v0.255.0 o middleware respondia text/plain com o error_message cru:
Agora responde o mesmo envelope que register_exception_handlers escreve em todo handler:
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 60
{"detail": "Too many requests",
"code": "TOO_MANY_REQUESTS",
"details": {"retry_after_seconds": 60, "limit": 15}}
O motivo é uma contradição do próprio SDK: error_responses() sempre apontou o 429 para o ErrorResponseSchema, então cliente gerado a partir do OpenAPI quebrava ao desserializar o texto — e quem adotava register_exception_handlers junto com o middleware ficava com duas formas de erro na mesma API.
O que fazer¶
- Cliente que ramifica por
status === 429: nada. O status e oRetry-Afternão mudaram. - Cliente que lê o corpo como texto: passe a ler JSON e a usar
detailpara exibir,codepara ramificar.
// antes
const message = await response.text();
// depois
const { detail, code } = await response.json();
- Serviço que reescrevia a resposta (subclasse do middleware convertendo o texto em envelope) pode apagar o contorno:
error_messagee o novoerror_codecobrem o caso.
Se você precisa do texto de volta¶
Não há flag para isso. O corpo antigo era incompatível com o schema que a própria rota documenta; manter as duas formas manteria o defeito atrás de uma opção. Um serviço que realmente precise de outro formato pode subclassar o middleware e sobrescrever a resposta, como antes.
0.252.0 — :memory: do SQLite ganha conexão por sessão¶
Não quebra API. Muda a topologia de conexão de um banco :memory:, e por isso vale ler antes de atualizar se você depende do comportamento antigo.
O que muda¶
AsyncDatabaseManager("sqlite+aiosqlite:///:memory:") passa a construir o engine sobre um banco in-memory de cache compartilhado (file:<nome-único>?mode=memory&cache=shared&uri=true) com pool normal, em vez de deixar o SQLAlchemy escolher StaticPool com uma única conexão. O manager mantém uma conexão viva enquanto existir, porque banco de cache compartilhado morre com a última conexão.
Isso conserta o erro que apareceu na v0.200.0, quando o BEGIN explícito passou a ser emitido para todo engine SQLite:
Duas sessões sobrepostas voltam a funcionar em :memory:, e o RELEASE SAVEPOINT continua não sendo um commit disfarçado.
Se você depende de uma conexão só¶
Passe o pool explicitamente — pool informado pelo caller nunca é sobrescrito:
from sqlalchemy.pool import StaticPool
from tempest_fastapi_sdk import AsyncDatabaseManager
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)
Isso restaura a topologia anterior, incluindo a falha em sessões sobrepostas.
Se você trocou :memory: por arquivo temporário como contorno¶
Pode voltar para :memory:. O contorno continua funcionando, então não é urgente.
0.251.0 — a assinatura de webhook do Mercado Pago passa a seguir o algoritmo do provedor¶
Quebra quem passava manifest_template= ou desempacotava o retorno de
parse_signature_header. Se você só chama verify_signature, nada muda no
seu código — só passam a verificar entregas que antes eram rejeitadas.
O manifesto omite par ausente, e por isso não é mais template¶
A implementação anterior renderizava
"id:{data_id};request-id:{request_id};ts:{ts};". O validador oficial do
Mercado Pago (mercadopago/sdk-nodejs, src/utils/webhook/index.ts, commit
99857f33) omite o par cujo valor é ausente. Uma entrega sem data.id
assina request-id:...;ts:...;, e o template fixo assinava
id:;request-id:...;ts:...; — hash diferente, verificação falhando sempre.
from tempest_fastapi_sdk.integrations.payment.mercado_pago import build_manifest
# antes: DEFAULT_MANIFEST_TEMPLATE + str.format
# agora: a regra, exportada
build_manifest(data_id="", request_id="req-1", timestamp="1771891200")
# "request-id:req-1;ts:1771891200;"
DEFAULT_MANIFEST_TEMPLATE e o parâmetro manifest_template= foram
removidos: existiam porque o algoritmo era desconhecido, e um template não
consegue expressar a regra de omissão. Se você havia medido um manifesto
diferente e passava o seu, compare com build_manifest e abra uma issue se
ainda divergir.
parse_signature_header devolve um objeto, não uma tupla¶
from tempest_fastapi_sdk.integrations.payment.mercado_pago import parse_signature_header
# antes
# timestamp, digest = parse_signature_header(header)
# agora
parsed = parse_signature_header("ts=1771891200,v1=abc123")
parsed.timestamp # "1771891200"
parsed.digest() # "abc123" — a primeira versão suportada
parsed.hashes # {"v1": "abc123"} — o header pode carregar v1 e v2
O que passou a existir¶
versions=emverify_signature, default("v1",). Uma migração do provedor parav2viraversions=("v2", "v1"), sem esperar release.tolerance_seconds=(enow=, para teste), que é o que faz otsdo manifesto trabalhar contra replay. Continua opt-in, como no upstream.- Chave de header case-insensitive, valor só-espaço tratado como ausente, e
tsnão-numérico rejeitado como header malformado — três regras do upstream que faltavam.
0.234.0 — modelo gerado se constrói pelo nome Python, e o type-checker aceita¶
Nada muda em runtime. Muda o que o pyright aceita.
Construa pelo nome do campo, não pelo alias¶
Os campos com nome de fio passaram de Field(alias=...) para
Field(validation_alias=..., serialization_alias=...). O runtime é o mesmo — os
modelos já tinham populate_by_name=True, então as duas grafias sempre
validaram. O que muda é o parâmetro que um type-checker enxerga:
from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload
# antes: rodava, mas o pyright acusava "No parameter named correlation_id"
# agora: aceito pelos dois
payload = ChargePayload(correlation_id="pedido-1", value=1990)
Se o seu código escrevia pelo alias só para calar o checker
(ChargePayload(correlationID="pedido-1")), ele continua rodando — a
validação aceita o alias — mas agora é o checker que reclama. Troque pelo nome
Python.
Leitura e escrita não mudaram: model_validate({"correlationID": ...}) continua
aceitando a grafia do fornecedor, e model_dump(by_alias=True) continua
emitindo ela.
0.233.0 — dois enums gerados da OpenPix mudaram de nome¶
Uma mudança, e ela só quebra quem importava os dois enums de pagamento pelo nome.
PaymentType e PaymentDestinationAliasType foram renomeados¶
O gerador passou a emitir as variantes de PaymentCreatePayload (oneOf com quatro formas: Pix key, QR Code, Manual, Boleto), e são elas que agora registram esses enums primeiro. O nome passou a vir da variante:
| Antes | Agora |
|---|---|
PaymentType |
PaymentCreatePayloadPixKeyType |
PaymentDestinationAliasType |
PaymentCreatePayloadPixKeyDestinationAliasType |
Mesmos membros, mesmos valores — só o nome da classe mudou:
from tempest_fastapi_sdk.integrations.payment.openpix import (
PaymentCreatePayloadPixKeyType,
)
assert PaymentCreatePayloadPixKeyType.PIX_KEY.value == "PIX_KEY"
Se você comparava o valor em vez de importar a classe (payment.type == "PIX_KEY"), nada a fazer.
O que não quebrou¶
PaymentCreatePayload e PostApiV1PaymentBody continuam importáveis: viraram alias de união sobre as variantes, então uma anotação body: PostApiV1PaymentBody segue válida. O que mudou é que agora elas carregam os campos do pagamento — antes eram modelos sem propriedade nenhuma, e extra="ignore" descartava em silêncio tudo que você passasse.
0.229.0 — saída estruturada do Ollama sai de /api/generate para /api/chat¶
Uma mudança, e ela só quebra teste, não runtime.
generate_structured agora fala com /api/chat¶
OllamaGenerator.generate_structured postava em /api/generate com o schema no campo format. Isso está quebrado em modelo de raciocínio: contra o gpt-oss:20b, o daemon responde 200 OK com eval_count não-zero e response vazio, porque a resposta cai num canal que aquele endpoint não expõe. Em /api/chat o JSON vem em message.content, e modelo sem raciocínio se comporta igual nos dois.
Em runtime não há o que ajustar — a chamada que devolvia lixo (ou nada) passa a devolver a instância. O que quebra é teste com mock preso ao endpoint antigo:
import httpx
from pydantic import BaseModel
from tempest_fastapi_sdk.genai import OllamaGenerator
from tempest_fastapi_sdk.utils import HTTPClient
class Pessoa(BaseModel):
nome: str
async def antes() -> Pessoa:
"""Mock que casava com /api/generate — para de casar."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"response": '{"nome": "Ana"}', "done": True})
client = HTTPClient(transport=httpx.MockTransport(handler))
gen = OllamaGenerator("llama3.2", http_client=client)
return await gen.generate_structured("Uma pessoa.", Pessoa)
async def depois() -> Pessoa:
"""A resposta agora vem em message.content."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"message": {"content": '{"nome": "Ana"}'}, "done": True},
)
client = HTTPClient(transport=httpx.MockTransport(handler))
gen = OllamaGenerator("llama3.2", http_client=client)
return await gen.generate_structured("Uma pessoa.", Pessoa)
Duas mudanças de comportamento acompanham:
- Conteúdo vazio levanta
ValueErrorem vez de devolver nada. Se você tinhatry/excepttratando resultado vazio como "modelo não respondeu", troque porexcept ValueError. system=é um parâmetro novo, opcional. Use-o para a instrução quando opromptfor um documento longo: instrução colada acima do documento é ignorada — medido, 0 itens extraídos contra 20 com a instrução no turnosystem.
0.174.0 — erros que eram 500 viram 422, e o order_by é validado¶
Correções de robustez. Todas trocam um crash por uma resposta correta; nenhuma exige mudança de código, mas quatro mudam o status ou a exceção que o seu serviço vê.
Senha longa agora é 422¶
Existe um teto: AUTH_PASSWORD_MAX_BYTES, default 72 — o limite duro do bcrypt, contado em bytes UTF-8. Antes, senha acima disso levantava ValueError do hashpw e subia como 500 no signup / reset / troca. Agora é ValidationException (422).
Se o seu frontend não valida comprimento, ele passa a receber 422 onde recebia 500. Se você trocou o hasher por um sem esse limite, suba o valor.
order_by inválido agora é ValidationException¶
BaseRepository.paginate e cursor_paginate resolvem order_by pelo mapper do model. Nome que não é coluna mapeada levanta ValidationException (422) em vez de AttributeError (500).
Mudança de contrato em cursor_paginate: ele levantava ValueError nesse caso. Quem tinha except ValueError em volta precisa ajustar:
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.exceptions import ValidationException
from src.db.models import UserModel
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
repo = BaseRepository(session, model=UserModel)
async def main() -> None:
"""Run this example."""
try:
page = await repo.cursor_paginate(order_by="nao_e_coluna")
except ValidationException:
...
asyncio.run(main())
ValueError continua sendo o erro de cursor malformado.
BodySizeLimitMiddleware: body grande em streaming responde 413¶
O 413 passou a ser emitido no instante em que a contagem estoura, e o que o app enviar depois é descartado. Antes ele saía num finally, depois de o app já ter respondido — e o FastAPI responde, convertendo o ClientDisconnect do guard em 400. O segundo http.response.start fazia o uvicorn levantar RuntimeError: Response already started.
Efeito prático: um upload em streaming acima do limite responde 413 onde recentemente respondia 400 (com um RuntimeError no log). Um handler que não lê o body continua respondendo o que ele mesmo respondia — não há como retirar uma resposta já enviada.
make_csrf_token_dependency grava o cookie¶
Antes ela só devolvia o token, então o cookie ficava ausente e o POST seguinte caía com 403. Agora ela grava (Secure + SameSite=Lax, não HttpOnly — o cliente precisa ler pra ecoar no header).
Se você já gravava o cookie à mão no handler, o valor é o mesmo (request.state.csrf_token) e nada muda: a dependency não sobrescreve cookie existente. Em dev sobre HTTP puro, passe secure=False, senão o browser não devolve o cookie.
OAuthUser.email_verified¶
Campo novo (default None), nada quebra. Mas leia a nota: se você liga login social a conta existente pelo e-mail, exija profile.email_verified is True. No GitHub o valor é sempre None — o GET /user não traz campo de verificação, e o e-mail que ele devolve é o do perfil público, que o GitHub não exige verificar.
GET /logs lê no máximo 20 000 registros por arquivo¶
Ajuste com make_logs_router(max_records_per_file=...). São os mais recentes; o endpoint ordena do mais novo e pagina, então o que ficou fora não era alcançável. Um WARNING é logado quando o corte acontece.
0.173.0 — token só vale onde foi emitido pra valer, e cache não é mais compartilhado¶
Três correções de segurança mudam comportamento de default. Nenhuma exige mexer em código, mas vale conferir se você dependia do comportamento antigo.
Refresh e MFA-pendente deixam de autorizar rota¶
make_bearer_token_dependency, make_jwt_user_dependency, make_role_dependency, make_permission_dependency e UserAuthService.current_user_dependency() aceitam agora só token de tipo access.
Antes, os três JWTs que o UserAuthService emite com o mesmo segredo verificavam identicamente, então o refresh token e o mfa_token do passo 1 do login funcionavam como bearer em qualquer rota autenticada — o segundo fator era contornável com só a senha.
Você é afetado se de propósito mandava um refresh token para uma rota comum:
from tempest_fastapi_sdk import (
JWTUtils,
REFRESH_TOKEN_TYPE,
make_bearer_token_dependency,
)
from src.core.settings import settings
tokens = JWTUtils(settings)
# Volta a aceitar aquele tipo naquela rota específica:
require_refresh = make_bearer_token_dependency(tokens, accepted_typ=(REFRESH_TOKEN_TYPE,))
Token assinado à mão com JWTUtils.encode() e sem typ continua aceito — a atualização não derruba sessão ativa. Só os marcadores que o próprio SDK estampava (refresh: True, purpose: "mfa_pending") passam a ser rejeitados como access.
ResponseCacheMiddleware: private por padrão, credencial não usa o store¶
Dois defaults mudaram:
- O
Cache-Controlemitido passou depublic, max-age=Nparaprivate, max-age=N. Se você servia conteúdo genuinamente compartilhado e contava com cache de CDN, declare de novo:cache_control="public, max-age=N". - Requisição com
AuthorizationouCookienão lê nem escreve no store compartilhado (ETag/304continuam). Para recuperar cache em rota autenticada, passecache_credentialed=True— a credencial entra na chave, então cada chamador tem a sua entrada.
O header X-Cache também só aparece quando existe store=; antes vinha MISS mesmo no modo só-ETag.
IdempotencyMiddleware: chave escopada por chamador¶
A chave passou de (method, path, key) para (chamador, method, path, key), com o chamador vindo de um digest de Authorization/Cookie. Reuse da chave de outra pessoa não devolve mais a resposta dela.
Se o seu cliente troca de credencial entre o pedido original e o retry (rotação de token no meio do backoff), o retry deixa de bater na entrada anterior. Nesse caso aponte a identidade para algo estável:
from fastapi import FastAPI
from tempest_fastapi_sdk import IdempotencyMiddleware, MemoryIdempotencyStore
store = MemoryIdempotencyStore()
app = FastAPI()
app.add_middleware(
IdempotencyMiddleware,
store=store,
principal_resolver=lambda request: request.headers.get("x-api-key-id", ""),
)
Também mudou: 5xx não é mais cacheado (cache_server_errors=True restaura), Set-Cookie fica fora da cópia guardada, e requisições concorrentes com a mesma chave no mesmo processo são serializadas.
0.138.1 — BaseAppSettings tem que ser a última base¶
A 0.138.1 passou a fazer todo mixin de settings herdar BaseAppSettings (antes eles estendiam pydantic_settings.BaseSettings cru). Isso conserta o .env deixando de ser carregado quando um mixin aparecia antes da base — o model_config canônico agora é materializado em cada mixin, independente da ordem.
Em troca, a ordem das bases deixou de ser estilo e virou regra dura: como os mixins são subclasses de BaseAppSettings, a linearização C3 do Python proíbe a base preceder a própria subclasse.
# docs-guard: skip — os dois primeiros exemplos são o erro que a seção descreve
# ❌ quebra em tempo de import
from tempest_fastapi_sdk import BaseAppSettings, DatabaseSettings, RedisSettings
class Settings(DatabaseSettings, BaseAppSettings, RedisSettings): ...
# ❌ também quebra
class Settings(BaseAppSettings, DatabaseSettings): ...
# ✅ BaseAppSettings por último
class Settings(DatabaseSettings, RedisSettings, BaseAppSettings): ...
Antes da 0.159.1 o sintoma era o TypeError cru do pydantic, que não indica a correção:
TypeError: Cannot create a consistent method resolution order (MRO) for bases BaseAppSettings, RedisSettings
e o mypy (com o plugin do pydantic) acusava duas vezes na mesma linha, sendo a segunda enganosa — sugere conflito de metaclasse quando a causa é só a posição de uma base:
settings.py:4: error: Cannot determine consistent method resolution order (MRO) for "Settings" [misc]
settings.py:4: error: Metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases [metaclass]
A partir da 0.159.1, BaseAppSettings usa a metaclasse AppSettingsMeta, que pré-checa a posição das bases e troca a mensagem por uma instrução:
TypeError: Settings: BaseAppSettings must be the LAST base — RedisSettings already subclasses it, so listing BaseAppSettings before it is an invalid method resolution order (MRO). Move BaseAppSettings to the end of the base list: class Settings(RedisSettings, BaseAppSettings).
Verifique¶
# procure Settings com BaseAppSettings fora do fim da lista de bases
grep -rn "class Settings(" -A 12 src/core/settings.py
- Mova
BaseAppSettingspara o último item da lista de bases. - A ordem entre os mixins continua livre — só a posição da base importa.
- Nenhuma mudança de env var, de campo ou de valor: é exclusivamente ordem de herança.
0.92.0 — coluna payload no token de usuário¶
A 0.92.0 adiciona o fluxo de troca / re-verificação / recuperação de e-mail. Para carregar o e-mail pendente até a confirmação, BaseUserTokenModel ganhou uma coluna nova:
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
payload: Mapped[str | None] = mapped_column(String(320), nullable=True, default=None)
Como sua tabela user_tokens herda de BaseUserTokenModel, a coluna aparece automaticamente no modelo — mas o banco precisa de uma migration. É aditiva e segura (coluna anulável, sem default obrigatório):
Ou, na mão:
Só isso
Nenhuma renomeação, nenhum default backfill. Fluxos existentes (ativação, reset de senha) continuam gravando payload = NULL. O novo fluxo de e-mail é totalmente opt-in — a recuperação (POST /auth/email-recovery/request) só é montada com AUTH_EMAIL_RECOVERY_ENABLED=True.
Verifique¶
- Rode a migration antes de subir a 0.92.0 (a coluna precisa existir).
- Se você escreve
src/db/models/user_token.pyà mão em vez de usarmake_user_token_model, a coluna vem da base abstrata — não precisa redeclarar, só migrar.
0.63.0 — usuário autenticado carregado na sessão de request¶
Antes da 0.63.0, UserAuthService.current_user_dependency() carregava o usuário autenticado chamando load_user, que abria a própria sessão (via db.get_session_context()) e a fechava ao terminar. O UserModel entregue à rota ficava detached: mutá-lo e dar commit/refresh na sessão de request (a dos seus repositories) levantava
InvalidRequestError: Instance is not persistent within this Session.
A partir da 0.63.0 a dependência carrega o usuário na sessão de request (db.session_dependency por padrão), via get_user(subject, session). O usuário fica anexado à mesma sessão que os repositories usam, então leituras de relacionamentos lazy e escritas funcionam sem reanexar nada.
Compatibilidade
A dependência de auth e seus repositories precisam compartilhar o mesmo callable de sessão para o cache de sub-dependências do FastAPI casar. Quem segue o padrão recomendado já está coberto:
Se você embrulha a sessão num provider próprio (async def get_session(): ...), passe-o explicitamente para a dependência, senão ela abre uma segunda sessão e o usuário volta a ficar detached:
Defesa adicional
BaseRepository.resolve() agora reanexa instâncias detached via session.merge(). Mesmo que algum fluxo ainda receba um usuário detached, o resolve o traz de volta à sessão ativa em vez de quebrar — então serviços que faziam workarounds manuais (re-fetch por id antes de mutar) podem removê-los.
Verifique¶
- Remova qualquer workaround do tipo "re-fetch por id antes de mutar o usuário autenticado" — não é mais necessário.
- Se você passava um
user_loaderde um argumento paramake_jwt_user_dependency, ele continua funcionando. Para compartilhar a sessão de request, passesession_dependency=e use um loader de dois argumentos(subject, session).
0.8.0 — renomeação de ServerSettings¶
A 0.8.0 renomeia todos os campos de ServerSettings, extrai os campos de log para um novo mixin LogSettings e adiciona onze outros primitivos. As renomeações são as únicas mudanças que quebram — todo primitivo novo é opt-in.
1. Renomeie as variáveis de ambiente¶
| Antiga | Nova | Mixin |
|---|---|---|
HOST |
SERVER_HOST |
ServerSettings |
PORT |
SERVER_PORT |
ServerSettings |
DEBUG |
SERVER_DEBUG |
ServerSettings |
| (nova) | SERVER_RELOAD |
ServerSettings |
LOG_LEVEL |
LOG_LEVEL |
movida para LogSettings |
LOG_JSON |
LOG_JSON |
movida para LogSettings |
sed mecânico em todo .env / docker-compose.yml / manifesto de deploy:
sed -i \
-e 's/^HOST=/SERVER_HOST=/' \
-e 's/^PORT=/SERVER_PORT=/' \
-e 's/^DEBUG=/SERVER_DEBUG=/' \
.env .env.example .env.test
LOG_LEVEL e LOG_JSON mantêm os nomes — só o mixin muda.
2. Renomeie as referências no código¶
# `settings.HOST` → `settings.SERVER_HOST`, idem para PORT/DEBUG
grep -rn "settings\.\(HOST\|PORT\|DEBUG\)\b" src/ tests/
Substitua cada ocorrência pela forma SERVER_*. Se um serviço usava a
flag antiga settings.DEBUG para comportamento de debug a nível de
aplicação, troque para settings.SERVER_DEBUG; se ela era lida apenas
para o auto-reload do uvicorn, troque para settings.SERVER_RELOAD.
3. Misture LogSettings no Settings do projeto¶
from tempest_fastapi_sdk import (
BaseAppSettings,
CORSSettings,
DatabaseSettings,
JWTSettings,
+ LogSettings,
RabbitMQSettings,
RedisSettings,
ServerSettings,
)
class Settings(
ServerSettings,
+ LogSettings,
DatabaseSettings,
RedisSettings,
RabbitMQSettings,
JWTSettings,
CORSSettings,
BaseAppSettings,
):
...
Pule este passo se o serviço nunca leu settings.LOG_LEVEL /
settings.LOG_JSON — configure_logging aceita os valores diretamente
como argumentos nomeados.
4. (Opcional) Adote os novos primitivos¶
Escolha o que se encaixa. Nenhum deles é obrigatório.
- Substitua o
uvicorn.run(...)escrito à mão nosrc/server.pyporrun_server(...). - Substitua o
get_current_userescrito à mão pormake_jwt_user_dependency(tokens, load_user). - Mova os campos
SMTP_*/UPLOAD_*/TOKEN_SECRET/VAPID_*/TASKIQ_*doSettingsdo projeto para o mixin correspondente do SDK (Composição de mixins de settings). - Adote o
Outboxse você já escreve efeitos colaterais a partir da mesma transação que grava as linhas de domínio.
5. Verifique¶
uv sync # pega as novas deps do pyproject
uv run pytest -q # suite completa
uv run ruff check src tests # confirma que nenhuma referência a `HOST`/`PORT`/`DEBUG` escapou
Se o pytest falhar com um ValidationError do Pydantic referenciando
HOST / PORT / DEBUG, alguma variável de ambiente não foi renomeada
(olhe o ambiente do processo ou o .env).