Ir para o conteúdo

Helpers brasileiros

Validadores de documentos (CPF, CNPJ, CEP) e normalizador/validador de telefone para formatos BR. Pura stdlib — sem deps extras.

CPF / CNPJ / telefone

tempest_fastapi_sdk.utils.regex traz padrões de regex prontos, validadores, normalizadores e tipos Pydantic para os campos de identidade/contato que aparecem em quase toda API brasileira. Sem extra — pura stdlib + Pydantic (já é uma dependência core).

Símbolo Tipo Propósito
CPF_PATTERN, CNPJ_PATTERN, CPF_CNPJ_PATTERN, PHONE_BR_PATTERN re.Pattern[str] Regex compilada (entrada com máscara ou crua).
is_valid_cpf, is_valid_cnpj, is_valid_cpf_cnpj (str) -> bool Match de formato + matemática dos dígitos verificadores. Sequências de dígitos iguais rejeitadas.
is_valid_phone_br (str) -> bool Formato de telefone BR: +55 opcional, DDD opcional, nono dígito opcional. Aceita fixo.
is_valid_mobile_phone_br (str) -> bool Só celular: DDD + 9 dígitos começando em 9. Recusa fixo.
parse_phone_br (str) para PhoneNumberBR ou None Quebra em DDD, número, is_mobile e E.164. None no que a ANATEL não atribui.
normalize_cpf, normalize_cnpj, normalize_cpf_cnpj, normalize_phone_br (str) -> str Remove a máscara deixando só dígitos; levanta ValueError se inválido.
normalize_mobile_phone_br (str) -> str Sempre os 11 dígitos da forma nacional; levanta ValueError em fixo.
only_digits (str) -> str Remove todo caractere que não é dígito.
CPFField, CNPJField, CPFOrCNPJField, PhoneBRField, MobilePhoneBRField Annotated[str, AfterValidator(...)] Tipos de campo Pydantic plug-and-play — validam + normalizam automaticamente.

Sufixo Field (a partir da v0.76)

Os tipos de campo passaram a usar o sufixo Field (CPFField, CNPJField, CPFOrCNPJField, PhoneBRField, CEPField) pra deixar claro que são campos de schema — igual UFField / CityNameField. Os nomes antigos (CPF, CNPJ, CPFOrCNPJ, PhoneBR, CEP) continuam funcionando como alias deprecado; prefira os novos.

Uso em schema

from pydantic import EmailStr, Field

from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import CPFOrCNPJField, PhoneBRField


class CustomerCreateSchema(BaseSchema):
    """Payload for POST /customers.

    `document` accepts CPF or CNPJ in masked or raw form and is
    stored digits-only after validation. `phone` is normalized the
    same way. Invalid values surface as a Pydantic `ValidationError`
    (HTTP 422 via the SDK exception handler).
    """

    name: str = Field(min_length=1, max_length=128)
    email: EmailStr
    document: CPFOrCNPJField
    phone: PhoneBRField

Entrada válida:

{
    "name": "Ana",
    "email": "ana@example.com",
    "document": "529.982.247-25",
    "phone": "+55 (11) 98888-7777"
}

Após a validação:

from src.schemas import CustomerCreateSchema


CustomerCreateSchema(...).document  # "52998224725"
CustomerCreateSchema(...).phone     # "5511988887777"

Celular, não telefone

is_valid_phone_br responde a uma pergunta de formato: isto tem cara de telefone brasileiro? Quando o número é um endereço de entrega — WhatsApp, SMS, um código de verificação — a pergunta é outra: isto é um celular? Um fixo passa pelo validador de formato, entra no cadastro inteiro sem reclamação, e só falha lá na frente, na hora em que a notificação não é entregue — sem erro para o usuário e sem log óbvio para quem opera.

from tempest_fastapi_sdk.utils import is_valid_mobile_phone_br, is_valid_phone_br


is_valid_phone_br("(11) 3333-4444")          # True  — é um telefone
is_valid_mobile_phone_br("(11) 3333-4444")   # False — mas não é um celular
is_valid_mobile_phone_br("(11) 98888-7777")  # True

Em schema, é a troca de um tipo de campo por outro:

from pydantic import EmailStr, Field

from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import MobilePhoneBRField


class NotificationTargetSchema(BaseSchema):
    """Payload de POST /users, onde o telefone é canal de entrega.

    `MobilePhoneBRField` recusa fixo com `ValidationError` (HTTP 422
    pelo handler do SDK) e normaliza o valor para os 11 dígitos da
    forma nacional.
    """

    name: str = Field(min_length=1, max_length=128)
    email: EmailStr
    phone: MobilePhoneBRField

Entrada válida:

{
    "name": "Ana",
    "email": "ana@example.com",
    "phone": "+55 (11) 98888-7777"
}

Depois da validação, phone vale "11988887777" — e "(11) 3333-4444" no lugar dela devolve 422.

As duas normalizações não devolvem a mesma coisa

normalize_phone_br preserva o que o usuário digitou: com +55 na entrada, o 55 sai no resultado, então a mesma linha vira "5511988887777" ou "11988887777" conforme a digitação, e a coluna guarda duas strings diferentes para o mesmo número.

normalize_mobile_phone_br sempre devolve os 11 dígitos da forma nacional (DDD + número), independente da grafia de entrada — que é o que faz duas grafias comparar iguais no banco. Precisa do +55? Use parse_phone_br(...).e164.

Quando você precisa das partes: parse_phone_br

Formatar para exibição, gravar E.164 no provedor de push, ramificar por DDD — tudo isso quer o número já quebrado em pedaços, não uma string de dígitos:

from tempest_fastapi_sdk.utils import parse_phone_br


parsed = parse_phone_br("+55 (11) 98888-7777")

parsed.area_code  # "11"
parsed.number     # "988887777"
parsed.is_mobile  # True
parsed.e164       # "+5511988887777"

O retorno é PhoneNumberBR | NoneNone para qualquer coisa que não seja um número que o Brasil atribui. O código de país nunca vaza para area_code nem para number, então as quatro grafias da mesma linha ("11988887777", "+5511988887777", "5511988887777", "(11) 98888-7777") produzem exatamente as mesmas partes.

Mais estrito que is_valid_phone_br, de propósito

parse_phone_br aplica as regras de prefixo da ANATEL: número de assinante de 8 dígitos começa em 2-5. Então "8912345678" passa por is_valid_phone_br e volta None aqui — não é uma faixa que a ANATEL atribui.

DDD 55 não é o código do país

Santa Maria (RS) usa o DDD 55, que colide com o +55. parse_phone_br("(55) 99123-4567") devolve area_code="55" e e164="+5555991234567" — o prefixo de país só é descartado quando o total de dígitos (12 ou 13) prova que ele está lá.

Validação manual (services, controllers, handlers de fila)

from tempest_fastapi_sdk.exceptions import ValidationException
from tempest_fastapi_sdk.utils import is_valid_cpf_cnpj, normalize_cpf_cnpj


def validate_document(raw_document: str) -> str:
    """Valida um CPF/CNPJ e devolve a forma canônica só-dígitos.

    Args:
        raw_document (str): O documento vindo do payload (com ou sem máscara).

    Returns:
        str: O documento normalizado, apenas dígitos.

    Raises:
        ValidationException: Se o documento for inválido.
    """
    if not is_valid_cpf_cnpj(raw_document):
        raise ValidationException(message="Documento inválido")

    return normalize_cpf_cnpj(raw_document)

Filtrando pelos dígitos armazenados

Os normalizadores removem as máscaras antes de salvar, então os filtros de repository e as constraints de unicidade funcionam sobre a forma canônica só-dígitos:

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk.utils import normalize_cpf_cnpj

from src.db.repositories import CustomerRepository

# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

query = "529.982.247-25"

repo = CustomerRepository(session)


async def main() -> None:
    """Run this example."""
    await repo.get({"document": normalize_cpf_cnpj(query)})


asyncio.run(main())

CEP

CEPField é um tipo Annotated[str, AfterValidator(normalize_cep)] — coloque-o em um schema Pydantic e os valores de entrada são aceitos como "01310-100" ou "01310100", normalizados para 8 dígitos, e rejeitados (ValidationError → envelope HTTP 422) quando não casam com o formato. CEPs não têm dígitos verificadores, então a validação é só de formato.

from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import CEPField


class AddressCreateSchema(BaseSchema):
    cep: CEPField
    street: str
    number: str

Variantes imperativas: is_valid_cep(value), normalize_cep(value), mais CEP_PATTERN para uso de regex cru. Use-os dentro de services / handlers de fila onde você não quer um round-trip Pydantic.

Estados e municípios

Toda aplicação brasileira acaba precisando de um <select> de estado e cidade, ou de validar que a UF e o município que chegaram no payload existem de verdade. O SDK embute essa tabela — 27 estados e 5606 municípios — então você não precisa chamar a API do IBGE nem versionar um JSON por serviço.

Offline e sem dependências

Os dados moram em tempest_fastapi_sdk/utils/data/br_locations.json e são carregados sob demanda na primeira chamada, depois ficam em cache pelo processo todo. Zero rede, zero extra a instalar.

A UF é uma StrEnum e cada estado conhece sua macro-região oficial do IBGE:

from tempest_fastapi_sdk import UF, Region, list_states, get_state, states_by_region


# Todos os 27 estados, ordenados pela sigla.
states = list_states()
print(len(states))  # 27

# Um estado específico (sigla em qualquer caixa, ou um membro de UF).
sp = get_state("sp")
print(sp.uf, sp.name, sp.region)        # SP São Paulo Sudeste
print(len(sp.cities), sp.cities[:2])    # 645 ['Adamantina', 'Adolfo']

# Agrupando por região.
sudeste = states_by_region(Region.SOUTHEAST)
print([state.uf.value for state in sudeste])  # ['ES', 'MG', 'RJ', 'SP']

Cada item é um StateBR (uf: UF, name: str, region: Region, cities: list[str]), pronto pra retornar direto de um endpoint.

Validando UF e cidade em um schema

UFField aceita a sigla em qualquer caixa ("sp", " RJ ") e devolve um membro de UF. CityNameField só apara espaços — a validação cruzada "esta cidade existe nesta UF" é regra de negócio, então roda no service com is_valid_city / normalize_city:

from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import UFField, CityNameField


class AddressCreateSchema(BaseSchema):
    uf: UFField
    city: CityNameField
    street: str
    number: str
from tempest_fastapi_sdk import UF, is_valid_city, normalize_city
from tempest_fastapi_sdk.exceptions import ValidationException


def validate_address(uf: UF, city: str) -> str:
    """Garante que a cidade pertence à UF e devolve o nome canônico.

    Args:
        uf (UF): A unidade federativa do endereço.
        city (str): O nome da cidade vindo do payload.

    Returns:
        str: O nome do município em caixa canônica (ex.: "São Paulo").

    Raises:
        ValidationException: Se a cidade não existe na UF informada.
    """
    if not is_valid_city(uf, city):
        raise ValidationException(f"city {city!r} not found in {uf.value}")
    return normalize_city(uf, city)

A busca de cidade ignora acentos e caixa

is_valid_city("SP", "sao paulo") e normalize_city("rj", "RIO DE JANEIRO") funcionam — a comparação derruba acentos, caixa e espaços nas pontas. normalize_city sempre devolve o nome canônico em caixa correta ("São Paulo", "Rio de Janeiro").

Choices prontas para o <select> do frontend

Os mesmos dados servem para dois papéis: validar a entrada (os campos UFField / CityNameField acima) e alimentar os dropdowns do frontend. Para o segundo, uf_choices, region_choices e city_choices devolvem list[ChoiceBR] — cada item é um value (o que você guarda/envia) + label (o que o usuário vê), exatamente o formato que um <option> quer:

from uuid import UUID

from fastapi import APIRouter

from tempest_fastapi_sdk.utils import ChoiceBR, city_choices, region_choices, uf_choices

router = APIRouter(prefix="/api/localidades", tags=["localidades"])


@router.get("/ufs")
def list_uf_choices() -> list[ChoiceBR]:
    """Choices de UF: value = sigla, label = nome do estado."""
    return uf_choices()


@router.get("/regioes")
def list_region_choices() -> list[ChoiceBR]:
    """Choices das 5 macro-regiões do IBGE."""
    return region_choices()


@router.get("/ufs/{uf}/cidades")
def list_city_choices(uf: str) -> list[ChoiceBR]:
    """Choices de cidade de uma UF: value = label = nome do município."""
    return city_choices(uf)

O value de uf_choices() é a sigla — o mesmo valor que UFField valida na volta, então o que o <select> envia já entra direto no seu schema:

from tempest_fastapi_sdk.utils import city_choices, region_choices, uf_choices


uf_choices()[0]          # ChoiceBR(value="AC", label="Acre")
region_choices()[0]      # ChoiceBR(value="Norte", label="Norte")
city_choices("sp")[0]    # ChoiceBR(value="Adamantina", label="Adamantina")

Por que ChoiceBR e não uma tupla?

ChoiceBR é um schema Pydantic (value: str, label: str), então serializa como {"value": ..., "label": ...} no JSON e aparece tipado no OpenAPI/Swagger — sem o "campo mágico sem tipo". Para o caso clássico estado→cidade, o front chama /ufs, e ao escolher uma UF chama /ufs/{uf}/cidades.

Variantes imperativas

Função O que faz
is_valid_uf(value) True se a sigla existe (qualquer caixa/espaço).
normalize_uf(value) Devolve o UF; levanta ValueError se inválido.
cities_by_uf(uf) Lista de municípios da UF, ordenada.
is_valid_city(uf, city) True se a cidade pertence à UF (ignora acentos/caixa).
normalize_city(uf, city) Nome canônico do município; levanta ValueError se não existe.

Endpoint de estados/cidades para o frontend

Para <select>, prefira uf_choices() / region_choices() / city_choices(uf) (formato value/label). Se precisar do estado inteiro com a lista de cidades junto, list_states() devolve cada StateBR com seu cities. Como é tudo em memória, não toca o banco.

Recapitulando

  • UF (StrEnum, 27 siglas) + Region (5 macro-regiões do IBGE).
  • StateBR / CityBR para respostas tipadas; ChoiceBR (value/label) para dropdowns.
  • list_states, get_state, cities_by_uf, states_by_region para consultar a tabela embutida.
  • uf_choices, region_choices, city_choices para <select> do frontend.
  • UFField / CityNameField para campos de schema; is_valid_* / normalize_* para validação imperativa no service.

Dinheiro em real

Duas direções, ambas em tempest_fastapi_sdk.utils, sem extra nenhum.

Ler o que um documento imprimiu, com parse_currency_br:

from decimal import Decimal

from tempest_fastapi_sdk.utils import parse_currency_br

parse_currency_br("R$ 2.930,00")   # Decimal("2930.00")
parse_currency_br("2.930,00")      # Decimal("2930.00")
parse_currency_br("2,930.00")      # Decimal("2930.00") — notação US também
parse_currency_br("-R$ 0,01")      # Decimal("-0.01")
parse_currency_br("sem valor")     # None

Todo serviço que ingere dinheiro escrito para humanos precisa disso: um modelo transcrevendo um PDF, um CSV importado, uma página raspada. Passar esse valor por float antes é o que move o centavo em silêncio.

None não é zero

None significa "o documento não imprimiu preço"; Decimal("0.00") significa "o documento imprimiu R$ 0,00". Colapsar os dois é perder a diferença entre linha sem dado e linha gratuita.

A regra do ponto sozinho

O último separador presente é o decimal. O caso genuinamente ambíguo é um ponto seguido de exatamente três dígitos: "2.930" é lido como dois mil novecentos e trinta, que é o que a notação significa nos documentos que isso lê.

Escrever para prosa — PDF, e-mail, página:

from decimal import Decimal

from tempest_fastapi_sdk.utils import (
    format_currency_br,
    format_percent_br,
    format_quantity_br,
    quantize_money,
)

format_currency_br(Decimal("484365.84"))                # "R$ 484.365,84"
format_currency_br(Decimal("2930"), symbol=False)       # "2.930,00"
format_currency_br(Decimal("-0.01"))                    # "-R$ 0,01"
format_percent_br(Decimal("0.30"))                      # "30,00%"
format_percent_br(Decimal("0.2999998"), places=5)       # "29,99998%"
format_quantity_br(Decimal("1250"))                     # "1.250,00"
quantize_money(Decimal("1.005"))                        # Decimal("1.01")

Nada disso passa por locale, que é global do processo, depende de locales gerados no container e não é thread-safe.

quantize_money arredonda meio para cima

Não é o padrão do Decimal (banker's rounding). A prática contábil brasileira arredonda meio para longe do zero, e é isso que faz um documento gerado reproduzir centavo por centavo um montado à mão.

Célula de planilha recebe número, não texto

Estas funções são para prosa. Em .xlsx, escreva o Decimal e deixe a máscara apresentar — veja Planilhas.

Para valores já guardados em centavos inteiros (a convenção CentsField), tempest_fastapi_sdk.pdf.format_cents faz o mesmo e delega para cá.

Helpers utilitários (utcnow, to_utc, modify_dict)

Pequenos helpers stateless de tempest_fastapi_sdk.utils dos quais o próprio SDK depende e que aparecem em todo serviço. Disponíveis sem nenhum extra.

Helper Assinatura Propósito
utcnow() () -> datetime Horário atual como datetime UTC ciente de timezone — o SDK usa isto para os defaults de created_at / updated_at.
to_utc(value) (datetime) -> datetime Converte datetimes naive para UTC (assumido UTC) e datetimes aware para UTC via astimezone. Usado pelos field validators de BaseResponseSchema.
modify_dict(data, exclude=None, include=None) (dict, list[str] | None, dict | None) -> dict Filtro + merge em uma passada. Remove chaves sensíveis antes de logar ou faz merge de campos computados ao mapear payloads para modelos ORM.

Timestamps do mesmo jeito em todo lugar

utcnow é o "agora" canônico do SDK. Use-o para timestamps de soft-delete, iat / exp de JWT, trilhas de auditoria — qualquer coisa onde misturar datetimes naive e aware te queimaria depois.

from datetime import datetime, timedelta

from fastapi import Request

from tempest_fastapi_sdk import to_utc, utcnow


now = utcnow()                      # timezone-aware UTC
expires_at = now + timedelta(hours=1)


async def parse_scheduled(request: Request) -> datetime:
    """Normalize whatever the caller gave you to a timezone-aware UTC datetime."""
    payload = await request.json()                              # request.json() is async
    incoming: str = payload["scheduled_for"]                    # naive or aware ISO-8601
    return to_utc(datetime.fromisoformat(incoming))

Um datetime naive é marcado como UTC (não convertido do horário local) para ser previsível em workers headless e containers Docker onde time.timezone é incerto.

Remova chaves sensíveis antes de logar / mapear

modify_dict é o pequeno utilitário que alimenta BaseSchema.to_dict(exclude=..., include=...) e BaseModel.update_from_dict(...). Use-o diretamente quando não quiser chamar round-trips do Pydantic:

from tempest_fastapi_sdk import LogUtils, PasswordUtils, modify_dict

passwords = PasswordUtils()


log = LogUtils("app.users")

payload = {"email": "ana@example.com", "password": "s3cr3t", "name": "Ana"}

# Strip password before logging
log.info("user_signup", **modify_dict(payload, exclude=["password"]))

# Merge a computed hash before persisting
user_row = modify_dict(
    payload,
    exclude=["password"],
    include={"password_hash": passwords.hash(payload["password"])},
)

include vence sobre data, então ele dobra como um helper de "definir ou sobrescrever" sem mutar o dict de origem.

Onde cada outro helper está documentado

Todo helper tem sua própria receita — esta seção é o mapa rápido:

Helper Receita
PasswordUtils, JWTUtils Receita de autenticação
EmailUtils Receita de e-mail transacional
UploadUtils Receita de upload de arquivos
DownloadUtils, build_content_disposition Servindo arquivos privados pela API
LogUtils + configure_logging Receita de logging estruturado & request IDs
MetricsUtils (CPU/memória/disco/GPU) Receita de métricas do sistema
CPFField, CNPJField, CPFOrCNPJField, PhoneBRField, CEPField, is_valid_*, normalize_*, only_digits CPF / CNPJ / telefone
UF, Region, StateBR, CityBR, ChoiceBR, UFField, CityNameField, list_states, get_state, cities_by_uf, states_by_region, uf_choices, region_choices, city_choices, is_valid_uf, normalize_uf, is_valid_city, normalize_city Estados e municípios

Recap

  • tempest_fastapi_sdk.utils.regex traz regex, validador, normalizador e tipo Pydantic para CPF, CNPJ, telefone e CEP — tudo stdlib, sem extra nenhum.
  • O tipo Annotated (CPFField, CEPField, …) normaliza na entrada, então o schema aceita "013.100-000" e o seu código lê só dígito.
  • UF e município saem de tabela embutida, para validar payload e montar <select> sem consultar serviço externo.
  • Dinheiro tem as duas direções: número para texto em real, e texto de volta para centavo inteiro.
  • utcnow, to_utc e modify_dict são os helpers stateless que o próprio SDK usa — disponíveis sem extra.