diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d500717 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # Actions fixadas por SHA: o Dependabot atualiza SHA e o comentário de versão juntos. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + + # Só dependências de desenvolvimento existem (o pacote não tem dependências de runtime). + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + groups: + dev: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9392aad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + UV_VERSION: "0.10.8" + UV_LOCKED: "1" + +jobs: + lint: + name: Lint e tipos + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Instalar uv + run: pipx install "uv==${UV_VERSION}" + - run: uv sync --python 3.12 + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run mypy --strict src tests + + test: + name: Testes (${{ matrix.os }}, Python ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + include: + - os: macos-latest + python: "3.12" + - os: windows-latest + python: "3.12" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Instalar uv + run: pipx install "uv==${{ env.UV_VERSION }}" + - run: uv sync --python ${{ matrix.python }} + - name: Testes com cobertura + run: uv run coverage run -m pytest + - name: Mínimos de cobertura (núcleo, erros e webhooks >= 90%, total >= 85%) + run: | + uv run coverage json -o coverage.json + uv run python scripts/check_coverage.py coverage.json + + build: + name: Build e smoke test do wheel + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Instalar uv + run: pipx install "uv==${UV_VERSION}" + - run: uv build + - run: uv run --group dev twine check --strict dist/* + - name: Wheel sem dependências de runtime + run: | + unzip -p dist/*.whl '*/METADATA' > METADATA + if grep -E '^Requires-Dist:' METADATA | grep -v 'extra =='; then + echo "runtime dependency found"; exit 1 + fi + - name: Smoke test num venv limpo + run: | + uv venv --python 3.12 /tmp/smoke + uv pip install --python /tmp/smoke/bin/python dist/*.whl + test "$(uv pip list --python /tmp/smoke/bin/python --format freeze | wc -l)" -eq 1 + /tmp/smoke/bin/python scripts/smoke_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2d834e0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +# Publicação no PyPI só por tag vX.Y.Z, com Trusted Publishing (OIDC, sem token guardado) e +# attestations PEP 740. Os artefatos são construídos uma única vez no job "build"; o job +# "publish" roda no environment protegido "pypi" (aprovação manual) e só baixa e publica. + +on: + push: + tags: ["v*.*.*"] + +permissions: {} + +env: + UV_VERSION: "0.10.8" + UV_LOCKED: "1" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: pipx install "uv==${UV_VERSION}" + - name: Tag igual a __version__ + run: | + version="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/nfeio/_version.py)" + test "v${version}" = "${GITHUB_REF_NAME}" + - run: uv sync --python 3.12 + - run: uv run pytest + - run: uv build + - run: uv run twine check --strict dist/* + # SHA a conferir antes do primeiro push: scripts/verify_action_pins.py (job action-pins). + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Publicar no PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/nfe-io/ + permissions: + id-token: write # Trusted Publishing (OIDC) e attestations + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + attestations: true diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..bf30c74 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,61 @@ +name: Segurança + +on: + pull_request: + push: + branches: [master] + schedule: + - cron: "17 6 * * 1" + +permissions: {} + +env: + UV_VERSION: "0.10.8" + UV_LOCKED: "1" + +jobs: + bandit: + name: bandit + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: pipx install "uv==${UV_VERSION}" + - run: uv sync --python 3.12 + - run: uv run bandit -c pyproject.toml -r src + + pip-audit: + name: pip-audit (dependências de desenvolvimento travadas) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: pipx install "uv==${UV_VERSION}" + - run: uv sync --python 3.12 + - name: Auditar o lock + run: | + uv export --format requirements-txt --all-groups --no-emit-project -o requirements-audit.txt + uv run pip-audit --strict --disable-pip -r requirements-audit.txt + + action-pins: + name: Pins de actions conferem com as tags + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verificar SHA x tag de cada action + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/verify_action_pins.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f28393b --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +.env +.env.* +!.env.example +nfeio-docs +__pycache__/ +*.py[cod] +.venv/ +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +coverage.xml +scripts/probes/out/ +coverage.json +tests/live/out/ +.smoke-venv/ +requirements-audit.txt + +# arquivos locais do agente (não versionar) +CLAUDE.md +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..55ad368 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +Todas as mudanças relevantes deste projeto são registradas aqui. + +O formato segue o [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/) e o projeto adota o +[Versionamento Semântico](https://semver.org/lang/pt-BR/). Enquanto a versão for 0.x, uma minor +pode trazer mudança incompatível; ela sempre será anunciada aqui. + +## [Não lançado] + +## [0.1.0] - não publicada + +Primeira versão do SDK oficial da NFE.io para Python (`pip install nfe-io`, `import nfeio`). + +### Adicionado + +- Clientes `NfeClient` (síncrono) e `AsyncNfeClient` (assíncrono, via `asyncio.to_thread`), com a + mesma superfície e o mesmo comportamento, sem estado global. +- Duas chaves por família de API: `api_key` (NFS-e, empresas, certificados, webhooks) e + `data_api_key` (CNPJ, CPF, CEP), lidas também de `NFE_API_KEY` e `NFE_DATA_API_KEY`. Envio em + `Authorization: `. +- NFS-e (API v1): `create`, `list`, `retrieve`, `find_by_external_id`, `cancel`, `send_email`, + `download_pdf`, `download_xml`, `download_cancellation_xml`, `wait`, `create_and_wait` e + `cancel_and_wait`. +- Empresas (API v2, paginação por cursor): `list`, `retrieve`, `create`, `update`, `delete`. +- Certificados: `upload` (multipart, campo `file`) e `list`, com `expires_on` e `is_expired()`. +- Webhooks da conta: `list`, `retrieve`, `create`, `update`, `delete`, `ping`, `event_types`. +- `nfeio.webhooks.verify_signature` (HMAC-SHA1 em tempo constante, nunca levanta exceção) e + `construct_event` (verifica antes de interpretar o corpo). +- Consultas: `lookups.cnpj` (v3, CNPJ numérico e alfanumérico), `lookups.cnpj_state_taxes`, + `lookups.cpf` e `lookups.cep`, com validação local de dígitos verificadores. +- Modelos `NfeObject`: `Mapping` imutável sobre o JSON do fio, com propriedades tipadas para ids, + status, datas (`datetime` com fuso), valores (`Decimal`) e documentos (CNPJ/CPF normalizados). +- Paginação por índice (1-based) e por cursor com `auto_paging_iter()` síncrono e assíncrono. +- Hierarquia de erros por status com `request_id`, `trace_id`, `error_code` e + `outcome_unknown`; leitura dos cinco formatos de erro usados pela API. +- `RequestOptions` por chamada (`api_key`, `timeout`, `max_retries`, `idempotency_key`, + `extra_headers`) e `app_info` no User-Agent. +- Transporte só com a biblioteca padrão, substituível pelos protocolos `Transport` e + `AsyncTransport`. + +### Segurança + +- Retry ciente de método e da fase da falha: um `POST` (como a emissão) nunca é repetido depois + que pode ter chegado à API; `Idempotency-Key` não muda essa regra. +- TLS sempre verificado (mínimo 1.2), sem opção para desligar; timeouts finitos; respostas + limitadas a 10 MiB; sem compressão. +- JSON não confiável (respostas, erros e webhooks) limitado a 128 níveis de aninhamento, com o + mesmo resultado em qualquer versão do Python. +- Redirect de download seguido só em https e **sem** a chave de API. +- Ids, `externalId` e documentos validados antes de entrar no path (sem path traversal). +- Chaves, senha de certificado e segredo de webhook nunca aparecem em `repr`, logs ou + exceções; logs não registram documentos nem `externalId`. +- Zero dependências de runtime. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..624a39e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contribuindo + +Obrigado pelo interesse. Este guia cobre o ambiente, as regras do projeto e como testar. + +## Ambiente + +Use [uv](https://docs.astral.sh/uv/): + +```bash +uv sync # cria .venv com as dependências de desenvolvimento +uv run pytest # testes unitários (sem rede) +uv run ruff check . # lint +uv run ruff format --check . # formatação +uv run mypy --strict src tests +``` + +Cobertura com os mínimos do CI (núcleo, erros e webhooks >= 90%, total >= 85%): + +```bash +uv run coverage run -m pytest +uv run coverage json -o coverage.json +uv run python scripts/check_coverage.py coverage.json +``` + +Segurança e empacotamento: + +```bash +uv run bandit -c pyproject.toml -r src +uv export --format requirements-txt --all-groups --no-emit-project -o requirements-audit.txt +uv run pip-audit --strict --disable-pip -r requirements-audit.txt +uv build && uv run twine check --strict dist/* +``` + +No GitHub, o CodeQL roda pelo default setup do repositório; não há workflow próprio dele em +`.github/workflows/`. + +## Regras do projeto + +1. **Zero dependências de runtime.** O pacote usa só a biblioteca padrão. Dependências novas só + no grupo `dev`. +2. **Contrato vem da OpenAPI e de sonda ao vivo**, nunca de outro SDK. Quando algo não puder ser + observado, registre como inferência explícita (`docs/contrato/`). +3. **Nunca retentar emissão ambígua.** `POST` não é repetido após possível envio. Não mude a + tabela de `src/nfeio/_core/retry.py` sem evidência da API. +4. **Segurança:** nada de chave, senha ou documento em log, `repr` ou exceção; TLS sempre + verificado; todo valor de path passa por `nfeio._core.paths`; nada de `pickle`/`eval`. +5. **Sync e async não divergem.** Comportamento novo é escrito uma vez como operação em + `resources/` ou `_core/` (gerador de efeitos) e exposto nas duas fachadas com a mesma + assinatura (o teste de paridade verifica). +6. **Modelos enxutos.** Propriedade tipada só para id, status, data, dinheiro ou documento; o + resto fica no acesso por chave. Toda propriedade aponta para uma chave da spec (ou para uma + divergência provada listada em `tests/unit/test_spec_alignment.py`). +7. Código gerado, se um dia existir, fica em `src/nfeio/_generated/` e não é editado à mão. +8. Código, identificadores e docstrings em inglês; documentação e CHANGELOG em pt-BR. + +Ao mudar uma spec em `nfeio-docs`, regenere o snapshot usado pelo teste de alinhamento: + +```bash +uv run python scripts/spec_keys.py +``` + +## Testes de integração (opt-in) + +Os testes em `tests/live/` falam com a API real e ficam pulados por padrão. Eles leem as chaves +do ambiente ou do arquivo `.env` (fora do git). + +```bash +uv run pytest tests/live --run-integration # só leitura +uv run pytest tests/live --run-integration --live-write # também escrita +``` + +As variáveis `NFE_RUN_INTEGRATION=1` e `NFE_LIVE_WRITE=1` têm o mesmo efeito. + +Escrita é restrita: NFS-e só na empresa `NFE_COMPANY_ID` (homologação), sempre com +`externalId` único e cancelamento ao final; uma empresa descartável; webhooks de teste removidos +ao final. Evidências redigidas vão para `tests/live/out/` (fora do git); fixtures versionadas em +`tests/fixtures/live-contracts/` usam valores sintéticos. + +## Commits e versões + +- [Conventional Commits](https://www.conventionalcommits.org/pt-br/) (`feat:`, `fix:`, `docs:`, + `test:`, `build:`, `ci:`). +- Toda mudança visível ao usuário entra no `CHANGELOG.md` (pt-BR, Keep a Changelog). +- A versão vive só em `src/nfeio/_version.py`. Publicação só pelo workflow de release, por tag, + com aprovação no environment `pypi`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8cd98e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NFE.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f667441..14d762d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,374 @@ -# client-python -Python client +# NFE.io SDK para Python + +SDK oficial da [NFE.io](https://nfe.io) para Python. Emite e gerencia **NFS-e**, cadastra +**empresas** e **certificados digitais**, administra **webhooks** e consulta **CNPJ, CPF e CEP**. + +- Python 3.10 a 3.14, cliente síncrono e assíncrono. +- **Zero dependências de runtime**: só a biblioteca padrão. +- Retry seguro: uma emissão nunca é reenviada depois que pode ter chegado à API. +- Tipado (`py.typed`, mypy `--strict`), sem estado global, TLS sempre verificado. + +> Versão 0.1 (alfa). NF-e, NFC-e, CT-e, distribuição DF-e e cálculo de impostos chegam nas +> próximas versões. + +## Instalação + +```bash +pip install nfe-io +# ou +uv add nfe-io +``` + +O pacote se chama `nfe-io` no PyPI e é importado como `nfeio`. + +## Chaves de API + +A NFE.io usa duas chaves, cada uma para um grupo de APIs: + +| Parâmetro | Variável de ambiente | Usada em | +|---|---|---| +| `api_key` | `NFE_API_KEY` | NFS-e, empresas, certificados, webhooks | +| `data_api_key` | `NFE_DATA_API_KEY` | consultas de CNPJ, CPF e CEP | + +O SDK nunca usa uma chave no lugar da outra: os hosts de consulta recusam a chave principal com +403. Se você tem uma chave só, passe o mesmo valor nos dois parâmetros. + +```python +from nfeio import NfeClient + +client = NfeClient(api_key="SUA_CHAVE", data_api_key="SUA_CHAVE_DE_DADOS") +# ou, lendo NFE_API_KEY e NFE_DATA_API_KEY do ambiente: +client = NfeClient() +``` + +Não existe host de homologação: o ambiente (produção ou teste) é configurado na empresa. + +## Início rápido + +```python +from nfeio import NfeClient + +client = NfeClient() +company_id = "ID_DA_EMPRESA" + +invoice = client.service_invoices.create_and_wait( + company_id, + { + "cityServiceCode": "10677", + "description": "Consultoria em tecnologia", + "servicesAmount": 150.00, + "borrower": { + "type": "NaturalPerson", + "name": "Maria Silva", + "federalTaxNumber": "52998224725", + "email": "maria@example.com", + }, + }, + external_id="pedido-1001", # sua chave de deduplicação (recomendado sempre) +) + +print(invoice.flow_status) # "Issued" +print(invoice["number"]) # qualquer campo do JSON, pelo nome original +pdf = client.service_invoices.download_pdf(company_id, invoice.id) +``` + +O corpo da requisição usa **as mesmas chaves (camelCase) da documentação da API**: copie um +exemplo da doc e ele funciona. Os argumentos dos métodos seguem o padrão Python (snake_case). + +### Objetos de resposta + +Toda resposta é um `NfeObject`: um `Mapping` imutável sobre o JSON recebido. + +- `obj["campo"]` dá acesso a **qualquer** campo, inclusive os que o SDK ainda não conhece. + Objetos aninhados também são `NfeObject` (`invoice["borrower"]["address"]["city"]["code"]`). +- Algumas **propriedades tipadas** corrigem ou tipam o que vem no fio: ids, status, datas + (`datetime` com fuso), valores (`Decimal`) e documentos (CNPJ/CPF como `str` com zeros à + esquerda). Exemplos: `invoice.flow_status`, `invoice.services_amount`, `invoice.issued_on`, + `invoice.borrower.federal_tax_number`. +- `obj.to_dict()` devolve uma cópia em dicionários comuns (serializável com `json.dumps`). +- `obj.last_response.request_id` traz o `x-request-id`. Informe esse valor ao suporte. + +Valores monetários aparecem como `float` no acesso por chave (como no JSON) e como `Decimal` nas +propriedades. Nas requisições, `int`, `float` e `Decimal` são aceitos; `Decimal` é enviado sem +perda (`Decimal("100.10")` vira `100.10`). + +## Emissão segura e reconciliação + +A emissão de NFS-e é assíncrona: a API responde `202` e a nota passa por estados +(`WaitingCalculateTaxes`, `WaitingSend`, …) até `Issued`. `create_and_wait` e `wait` consultam a +nota com backoff até um estado final. + +**O SDK nunca reenvia uma emissão sozinho.** A API pode criar a nota e mesmo assim responder +500 ou 504. Por isso: + +- `POST` não é repetido após timeout de leitura, conexão interrompida, 408, 429 ou 5xx; +- esses erros chegam com `outcome_unknown=True` e o `external_id` usado; +- reenviar o mesmo `externalId` é rejeitado pela API (`DuplicateExternalIdError`). + +Receita recomendada: sempre envie `external_id` e, diante de resultado incerto, procure a nota +antes de tentar de novo. + +```python +from nfeio import APIConnectionError, APIError, DuplicateExternalIdError + + +def emitir(client, company_id, pedido): + external_id = f"pedido-{pedido['id']}" + try: + return client.service_invoices.create(company_id, pedido["nfse"], external_id=external_id) + except DuplicateExternalIdError: + pass # a primeira tentativa foi registrada: busque a nota + except (APIError, APIConnectionError) as erro: + if not erro.outcome_unknown: + raise # erro de validação, chave inválida etc.: corrija e tente de novo + nota = client.service_invoices.find_by_external_id(company_id, external_id, wait=30) + if nota is None: + raise RuntimeError("emissão não registrada: é seguro tentar de novo mais tarde") + return nota +``` + +`find_by_external_id(..., wait=30)` repete a busca com backoff por até 30 segundos, cobrindo o +atraso de indexação logo após o `202`. + +### Cancelamento + +```python +nota = client.service_invoices.cancel_and_wait(company_id, invoice_id) +assert nota.flow_status == "Cancelled" +``` + +### Falhas de processamento e prazo + +`wait`, `create_and_wait` e `cancel_and_wait` levantam `InvoiceProcessingError` quando a nota +termina em `IssueFailed`, `CancelFailed` ou `Error` (com `flow_message`, a explicação da +prefeitura) e `PollingTimeoutError` quando o prazo (`timeout=120` s por padrão) acaba. + +## Listagens e paginação + +```python +# NFS-e: paginação por índice, começando em 1 (page_count de 1 a 50) +page = client.service_invoices.list(company_id, page_count=50, issued_begin="2026-01-01") +for nota in page.auto_paging_iter(): # busca as próximas páginas sob demanda + print(nota.id, nota.flow_status) + +# Empresas (API v2): paginação por cursor +for empresa in client.companies.list(limit=50).auto_paging_iter(): + print(empresa.id, empresa["name"]) +``` + +`page.data`, `page.has_more` e `page.next_page()` também estão disponíveis. Não existe método que +carregue todas as páginas na memória de uma vez. + +## Empresas e certificados + +```python +empresa = client.companies.create({ + "name": "Minha Empresa LTDA", + "federalTaxNumber": 11222333000181, + "taxRegime": "SimplesNacional", + "address": { + "country": "BRA", "postalCode": "80010000", "street": "Rua Exemplo", "number": "1", + "district": "Centro", "state": "PR", "city": {"code": "4106902", "name": "Curitiba"}, + }, +}) + +with open("certificado.pfx", "rb") as arquivo: + client.certificates.upload(empresa.id, arquivo, "senha-do-certificado") + +for cert in client.certificates.list(empresa.id): + print(cert.thumbprint, cert.expires_on, cert.is_expired()) +``` + +`companies.update` substitui a empresa inteira (envie todos os campos). `companies.delete` é uma +desativação na API: a empresa continua consultável com `status == "Inactive"`. + +## Consultas de CNPJ, CPF e CEP + +```python +empresa = client.lookups.cnpj("00.000.000/0001-91") # numérico ou alfanumérico +print(empresa["name"], empresa.status, empresa.share_capital) + +inscricoes = client.lookups.cnpj_state_taxes("00000000000191", "SP") +pessoa = client.lookups.cpf("529.982.247-25", "1990-01-31") # data divergente -> NotFoundError +endereco = client.lookups.cep("01310-100") +print(endereco["city"]["code"], endereco["street"]) +``` + +CNPJ, CPF e CEP são validados localmente (inclusive dígitos verificadores) antes da requisição. + +## Webhooks + +### Gerenciar + +```python +hook = client.webhooks.create({ + "uri": "https://erp.exemplo.com.br/nfeio/webhook", + "secret": "um-segredo-longo-e-aleatorio", + "contentType": "json", + "filters": ["service_invoice.issued_successfully", "service_invoice.cancelled_successfully"], +}) +``` + +A API chama a URI na criação para verificá-la: o endpoint precisa estar no ar e responder 2xx. + +`update` **substitui o webhook inteiro**; omitir `status` desativa o webhook. Busque, altere e +envie o objeto completo: + +```python +atual = client.webhooks.retrieve(hook.id).to_dict() +atual["filters"].append("service_invoice.issued_failed") +client.webhooks.update(hook.id, atual) +``` + +### Receber + +Verifique a assinatura `X-Hub-Signature` sobre o **corpo bruto** da requisição (nunca sobre um +`json.dumps` do corpo já interpretado). `verify_signature` nunca levanta exceção e compara em +tempo constante; `construct_event` verifica e só então interpreta o JSON. + +As entregas podem chegar mais de uma vez: use `event.hook_id` (`X-Hook-Id`) para deduplicar. + +**Flask** + +```python +from flask import Flask, request +from nfeio.errors import SignatureVerificationError +from nfeio.webhooks import construct_event + +app = Flask(__name__) +SEGREDO = "um-segredo-longo-e-aleatorio" + + +@app.post("/nfeio/webhook") +def nfeio_webhook(): + try: + event = construct_event(request.get_data(), request.headers, SEGREDO) + except SignatureVerificationError: + return "", 400 + if event.action == "issued_successfully": + registrar_nota(event.hook_id, event.data.id, event.data.flow_status) + return "", 204 +``` + +**Django** + +```python +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from nfeio.errors import SignatureVerificationError +from nfeio.webhooks import construct_event + + +@csrf_exempt +@require_POST +def nfeio_webhook(request): + try: + event = construct_event(request.body, request.headers, SEGREDO) + except SignatureVerificationError: + return HttpResponse(status=400) + processar(event) + return HttpResponse(status=204) +``` + +**FastAPI** + +```python +from fastapi import FastAPI, HTTPException, Request, Response +from nfeio.errors import SignatureVerificationError +from nfeio.webhooks import construct_event + +app = FastAPI() + + +@app.post("/nfeio/webhook", status_code=204) +async def nfeio_webhook(request: Request) -> Response: + try: + event = construct_event(await request.body(), request.headers, SEGREDO) + except SignatureVerificationError: + raise HTTPException(status_code=400) from None + await processar(event) + return Response(status_code=204) +``` + +## Cliente assíncrono + +`AsyncNfeClient` tem os mesmos recursos, métodos e comportamento do `NfeClient`; cada método é +uma corrotina. A E/S roda em threads (`asyncio.to_thread`) e as esperas usam `asyncio.sleep`, +então o event loop não é bloqueado. + +```python +import asyncio +from nfeio import AsyncNfeClient + + +async def main(): + async with AsyncNfeClient() as client: + nota = await client.service_invoices.create_and_wait( + "ID_DA_EMPRESA", {"cityServiceCode": "10677", "description": "x", "servicesAmount": 10}, + external_id="pedido-1002", + ) + async for item in (await client.companies.list()).auto_paging_iter(): + print(item.id) + + +asyncio.run(main()) +``` + +Cancelar a task não interrompe uma requisição que já está em voo (ela termina na thread). Se a +task cancelada estava emitindo uma nota, trate como resultado incerto e reconcilie por +`external_id`. + +## Erros + +Todas as exceções derivam de `nfeio.NfeError`. + +| Exceção | Quando | +|---|---| +| `ConfigurationError` | chave ausente, opção inválida, TLS inseguro | +| `InvalidParameterError` | parâmetro recusado localmente (id, CNPJ, CPF, CEP, data) | +| `InvalidRequestError` | 400, 405, 415, 422 … (`DuplicateExternalIdError` para `externalId` repetido) | +| `AuthenticationError` | 401 | +| `PermissionDeniedError` | 403 (a mensagem diz qual chave a família usa) | +| `NotFoundError` | 404 | +| `ConflictError` | 409 | +| `RateLimitError` | 429 (`retry_after`) | +| `ServerError` | 408 e 5xx | +| `APIConnectionError` / `APITimeoutError` | falha de rede (`phase` diz se a requisição pode ter saído) | +| `InvoiceProcessingError` / `PollingTimeoutError` | espera por estado final | +| `SignatureVerificationError` | assinatura de webhook inválida | + +Erros da API trazem `status_code`, `message`, `error_code`, `request_id`, `trace_id`, `body` e +`outcome_unknown`. Nenhuma exceção, `repr` ou log contém a chave de API. + +## Configuração + +```python +from nfeio import NfeClient, RequestOptions, Timeout + +client = NfeClient( + timeout=Timeout(connect=10, read=60, total=120), # padrões; precisam ser finitos + max_retries=3, # só GET/PUT/DELETE, ou falha antes de conectar + app_info=("meu-modulo-erp", "1.2.0"), # vai no User-Agent + ca_bundle="/etc/ssl/certs/ca-proxy.pem", # CA extra (proxy corporativo) +) + +# Por chamada: +client.service_invoices.list(company_id, options=RequestOptions(timeout=90, max_retries=0)) +``` + +- TLS é sempre verificado (mínimo TLS 1.2). Não existe opção para desligar a verificação. +- Respostas acima de 10 MiB são recusadas (`max_response_bytes`). +- O logger `nfeio` registra requisições e novas tentativas em nível `DEBUG`, sem cabeçalhos, + corpo, query ou documentos (ids e documentos do path aparecem como `*`). +- O transporte padrão usa só a biblioteca padrão. Para usar outro cliente HTTP, implemente + `nfeio.transport.Transport` ou `AsyncTransport`. + +## Desenvolvimento + +Veja [CONTRIBUTING.md](CONTRIBUTING.md). Vulnerabilidades: [SECURITY.md](SECURITY.md). +Histórico de versões: [CHANGELOG.md](CHANGELOG.md). + +## Licença + +[MIT](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ce90cf7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Política de segurança + +## Versões suportadas + +| Versão | Suporte a correções de segurança | +|---|---| +| 0.1.x | sim | +| < 0.1 | não (não publicadas) | + +Enquanto o SDK estiver em 0.x, só a minor mais recente recebe correções. + +## Como reportar uma vulnerabilidade + +**Não abra issue pública.** Use o canal privado do GitHub: + +1. Acesse a aba **Security** do repositório `nfe/client-python`. +2. Clique em **Report a vulnerability** (GitHub Private Vulnerability Reporting). +3. Descreva o problema, a versão afetada e, se possível, um passo a passo para reproduzir. + +Não inclua chaves de API, certificados ou dados fiscais reais no relato. Se precisar mostrar uma +requisição, troque os valores por marcadores (``, ``). + +## Prazos + +- **Primeira resposta:** até 3 dias úteis. +- **Avaliação inicial (confirmação e severidade):** até 10 dias úteis. +- **Correção:** depende da severidade; falhas críticas têm prioridade sobre qualquer outra entrega. + +## Escopo + +Este canal cobre o **código do SDK Python** (`nfe-io` no PyPI, import `nfeio`): por exemplo, +vazamento de chave em log, envio de credencial em redirect, falha na verificação de assinatura de +webhook, validação de parâmetros que permita path traversal, desserialização insegura. + +Problemas na **API da NFE.io** em si (servidores, painel, emissão) devem ir para o suporte da +NFE.io, não para este repositório. + +## Divulgação coordenada + +Pedimos que a vulnerabilidade não seja divulgada antes da publicação da correção. Combinamos a data +de divulgação com quem reportou e damos crédito no aviso de segurança (GitHub Security Advisory), +se a pessoa quiser. diff --git a/docs/contrato/DECISOES-FASE2.md b/docs/contrato/DECISOES-FASE2.md new file mode 100644 index 0000000..fab6b0f --- /dev/null +++ b/docs/contrato/DECISOES-FASE2.md @@ -0,0 +1,112 @@ +# Decisões da fase 2 — para o André confirmar + +Decisões de API pública (ou de processo) que **não estavam cobertas** pelo registro de +`DECISOES-PENDENTES.md` nem pelo design, ou em que a sonda trouxe fato novo. Em cada uma escolhi a +opção mais conservadora e reversível e segui em frente. Resposta curta aceita: "F1 ok, F4 opção B…". + +## Modelos e tipos + +**F1. Acesso por chave a valores aninhados.** O registro recomendava que `Mapping` aninhado vire +`NfeObject`. Implementado assim, e decidi também as listas: `obj["lista"]` devolve uma **lista +nova** (mutar não altera o objeto) cujos `Mapping` viram `NfeObject`. Escalares voltam como +vieram. `to_dict()` devolve o fio puro. +- Alternativa: devolver `tuple` (imutável de verdade), mas `obj["filters"] == ["a"]` passaria a + ser `False`, o que surpreende. Mantive `list`. + +**F2. Data sem fuso.** Texto de data/hora sem offset é interpretado como **UTC** nas propriedades +`datetime` (o fio observado sempre traz offset; isto só cobre exceções). Valor inválido → `None`. + +**F3. Documento inteiro sem `type`.** Em `Party.federal_tax_number` (tomador/prestador), quando o +fio manda inteiro e não há `type` (`LegalEntity`/`NaturalPerson`), uso a heurística "até 11 +dígitos = CPF, acima = CNPJ". Com `type` presente (o normal no fio), não há ambiguidade. +Limitação: CNPJ `00.000.000/0001-91` enviado como `191` sem `type` vira CPF `00000000191`. + +**F4. Datas em corpos de requisição.** `date`/`datetime` dentro do corpo são enviados como texto +ISO 8601 (conveniência; antes daria `TypeError`). Reverter é trivial. + +**F5. `pickle` de `NfeObject`.** Objetos de resposta podem ser serializados com `pickle` (útil +para cache/filas como Celery). O SDK nunca desserializa `pickle`; `SecretStr` recusa ser +serializado. Alternativa: proibir também em `NfeObject`. + +**F6. Retorno de `lookups.cnpj_state_taxes`.** Devolve `LegalEntity` (com `stateTaxes` no acesso +por chave), sem modelo próprio para inscrição estadual. + +## Erros, retry e rede + +**F7. `outcome_unknown` em POST com 429.** D8 decidiu não retentar POST em 429. Também marquei +esse erro com `outcome_unknown=True` (não sabemos se o limitador fica antes do processamento), o +que leva a receita de reconciliação a buscar por `externalId` antes de reenviar. Alternativa: +`False` (supõe que 429 nunca processa). + +**F8. `timeout` numérico.** `timeout=90` significa "esta tentativa pode levar até 90 s": +`read = total = 90`, `connect = min(10, 90)`. Um `Timeout(connect, read, total)` dá controle +fino. Limite conhecido da stdlib: a resolução DNS não respeita o timeout de conexão. + +**F9. Busca por `externalId` estrita.** Só `{"serviceInvoices": []}` significa "não existe". +Corpo vazio, forma desconhecida ou redirect levantam `UnexpectedResponseError` — preferi falhar +a deixar uma reconciliação concluir "seguro reemitir" por engano. + +**F10. Sem suporte a proxy.** `HTTPS_PROXY`/`HTTP_PROXY` são **ignorados** (`http.client` não os +lê e eu não quis adotar uma convenção implícita). Quem precisa de proxy hoje injeta um +`Transport`. Proposta para a v0.2: parâmetro explícito `proxy=` (com `CONNECT` tunelado). + +**F11. Paginação por índice mantém `page_count`.** `auto_paging_iter()` da NFS-e usa o +`page_count` da primeira página (trocar para 50 no meio desloca o `pageIndex` e pula itens); no +cursor, as páginas seguintes usam `limit=50`. Paginação que não avança (mesmo cursor ou mesma +primeira nota) levanta `UnexpectedResponseError` em vez de laço infinito. Spec e design já +ajustados. + +## Recursos + +**F12. `companies.delete` é soft delete.** A sonda mostrou que `DELETE /v2/companies/{id}` +responde 204 e a empresa continua consultável com `status: Inactive`. Mantive o nome `delete` +(espelha o verbo da API) e documentei. Alternativa: renomear para `deactivate`. A empresa +descartável `ad95…37dd` ficou `Inactive` na conta de teste — vale pedir ao backend a remoção +definitiva, se fizer diferença. + +**F13. Webhook de teste fora do `example.com`.** A API chama a URI ao criar o webhook; o +`example.com` responde 405 e a criação falha (400 `40001`). Para validar o CRUD usei +`https://httpbin.org/status/200?nfeio-sdkpy-test=1` (serviço público de teste que responde 200 e +não armazena nada; o ping de verificação só leva os dados do webhook de teste, com segredo +descartável). O webhook ficou ativo ~1 s e foi apagado. **Confirme se essa URL serve para as +próximas execuções** ou indique um endpoint próprio da NFE.io. + +**F14. Upload de certificado.** `password` precisa ser texto não vazio e o arquivo ter entre 1 byte +e 1 MiB (validação local). `file` aceita `bytes`, caminho ou arquivo binário aberto. + +**F15. Evento de webhook tipado.** `construct_event` devolve `data` como `ServiceInvoice` quando +`X-Hook-Event` começa com `service_invoice` (formato do cabeçalho é **inferência** da +documentação; nenhuma entrega real de nota foi capturada). Nos demais casos, `NfeObject`. + +## Processo e infraestrutura + +**F16. Opt-in dos testes ao vivo também por opção do pytest.** Além de `NFE_RUN_INTEGRATION=1` e +`NFE_LIVE_WRITE=1`, aceitei `--run-integration` e `--live-write` (o ambiente desta sessão não +permitia prefixar variáveis no comando). O opt-in nunca é lido do `.env`. + +**F17. CI sem `setup-python`/`setup-uv`.** Para reduzir actions de terceiros, o `uv` é instalado +com `pipx install uv==0.10.8` e o Python vem do próprio `uv`. Só `checkout`, `upload/download- +artifact` e `pypa/gh-action-pypi-publish` são usadas. O CodeQL não tem workflow próprio: roda +pelo default setup do repositório no GitHub. O workflow avançado (`codeql.yml`) foi removido +porque o upload do SARIF é recusado quando o default setup está ligado ("configurations cannot be +processed when the default setup is enabled"). + +**F18. Pins de actions não verificados daqui.** Esta sessão não tinha acesso ao GitHub. O SHA do +`actions/checkout` v4.2.2 foi conferido num repositório local; os de `upload-artifact`, +`download-artifact` e `gh-action-pypi-publish` vieram de memória e **precisam +ser conferidos** antes do primeiro push: `GH_TOKEN=… python3 scripts/verify_action_pins.py` +(o job `action-pins` do workflow de segurança faz o mesmo no CI e falha se não baterem). + +**F19. Arquivos locais não versionados.** O arquivo de instruções do assistente na raiz e o +diretório oculto de configuração dele ficaram fora dos commits (a regra de commits proíbe essa +menção em arquivos versionados). Decida se entram no `.gitignore`. + +**F20. Versão.** `_version.py` já diz `0.1.0`; o CHANGELOG marca a seção como "não publicada". +Alternativa: `0.1.0.dev0` até a publicação. + +--- + +## Registro (André, 2026-10-08) + +- **F12:** manter o nome `delete`. A docstring já documenta que a API só desativa (`status == "Inactive"`); o README deve dizer o mesmo. +- **F13:** aprovado `https://httpbin.org/status/200?nfeio-sdkpy-test=1` como URI dos webhooks de teste ao vivo. diff --git a/docs/contrato/DECISOES-PENDENTES.md b/docs/contrato/DECISOES-PENDENTES.md new file mode 100644 index 0000000..3aa1a5b --- /dev/null +++ b/docs/contrato/DECISOES-PENDENTES.md @@ -0,0 +1,164 @@ +# Decisões — SDK Python v0.1 (todas fechadas em 2026-10-08) + +Para o OK do André antes da implementação (change `add-sdk-foundation-v0-1`). Design e specs já +seguem a **recomendação** de cada item. Se a escolha for outra, ajusto specs e tasks antes de codar. +Evidência: `docs/contrato/probe-2026-10-08.md` (SONDA) e `design.md` (§). + +Resposta curta aceita: "D1 ok, D3 opção A, …". + +--- + +## D1. Como representar os modelos de resposta + +| Opção | Como fica | Prós | Contras | +|---|---|---|---| +| A. Objeto dinâmico (`StripeObject`) | `inv.flowStatus` via `__getattr__` | preserva tudo; zero manutenção | tipagem fraca; nomes camelCase; erro de digitação só em runtime | +| B. `dataclass` congelada + `raw` (como PHP/Ruby) | `inv.flow_status`, `inv.raw["x"]` | tipagem forte | mapeamento campo a campo; histórico de DTO incompleto (PHP cobria 13 de 39 campos) | +| C. `TypedDict` do fio (como o Node) | `inv["flowStatus"]` | zero runtime; preserva tudo | camelCase no código Python; sem datas/Decimal; sem métodos | +| **D. Visão tipada (recomendada)** | `inv.flow_status` **e** `inv["flowStatus"]` | tipagem forte nas propriedades; **não perde campo por construção**; `to_dict()` devolve o fio | modelo inédito na família; propriedades escritas à mão | + +**Recomendação: D.** **Trade-off:** mantemos propriedades à mão, protegidas por um teste de alinhamento +com a spec, e ganhamos compatibilidade futura sem esforço. (design §2) + +## D2. Formato do corpo das requisições + +| Opção | Exemplo | Prós | Contras | +|---|---|---|---| +| **A. `TypedDict` com chaves do fio em camelCase (recomendada)** | `create(cid, {"cityServiceCode": "…", "borrower": {...}})` | copia-e-cola da doc da API; nenhuma camada de tradução; campo novo funciona no mesmo dia | mistura estilos: argumentos em snake_case, corpo em camelCase | +| B. kwargs/TypedDict em snake_case traduzidos | `create(cid, city_service_code="…")` | 100% pythônico | tradutor recursivo para corpos de ~80 campos; bug de tradução vira erro fiscal; diverge da doc | +| C. Aceitar os dois | — | flexível | ambiguidade quando vêm os dois; dobra a superfície de teste | + +**Recomendação: A.** Argumentos de método (`company_id`, `external_id`, `page_index`) continuam em +snake_case. **Trade-off:** inconsistência estética em troca de fidelidade ao contrato e de zero +tradução. (design §1) + +## D3. Empresas: API v1 ou v2 no v0.1 + +**Evidência nova (SONDA §4):** +- O cursor da v2 varreu as 662 empresas sem erro, e o "registro venenoso" de jul/2026 hoje responde 200. + O critério 1 do gate deixou de reproduzir. +- As projeções continuam diferentes: na v2, `municipalTaxNumber` guarda o id da inscrição. +- A v1 só enxerga 357 das 662 empresas, porque lista apenas empresas com inscrição municipal. +- A doc oficial marca a v1 como "em descontinuação". + +| Opção | Prós | Contras | +|---|---|---| +| **A. v2 (cursor) para listar/consultar/CRUD (recomendada)** | API que a NFE.io manda usar; enxerga todas as empresas; sem migração futura | escrita v2 não provada ao vivo (create/delete exigem empresa nova, fora da `NFE_COMPANY_ID`); dados de NFS-e (ambiente, RPS, ISS) ficam em `municipaltaxes`, fora do v0.1 | +| B. v1 (offset) como nos irmãos | contrato provado; projeção com campos de NFS-e | nasce em API descontinuada → quebra garantida na v0.2; vê só 357/662 empresas | +| C. v2 só leitura no v0.1; CRUD na v0.2 | só o provado ao vivo | v0.1 não cadastra empresa (o ERP precisaria do painel) | + +**Recomendação: A**, com a escrita v2 validada na fase 2a. Para `create`/`delete`, preciso de autorização +para criar e apagar **uma** empresa descartável na conta de teste. Sem isso, fico com C. +**Trade-off:** quem precisar de ambiente/RPS/ISS espera a v0.2 (`municipal_taxes`) ou lê pelo acesso +cru do objeto. + +## D4. Versão da consulta de CNPJ + +**Evidência (SONDA §9):** +- v1, v2 e v3 respondem. +- `federalTaxNumber` muda de tipo entre elas: v1 devolve string formatada; v2 devolve **inteiro** + (`191`, perdendo zeros); v3 devolve string de 14 caracteres. +- O header da v2 diz `deprecated 2.0 / supported 3.0`. +- Só a v3 aceita CNPJ alfanumérico: o exemplo da RFB chegou à base. + +| Opção | Prós | Contras | +|---|---|---| +| **A. v3 (recomendada)** | pronta para CNPJ alfanumérico (2026); tipo certo; versão suportada | spec v3 não declara host (provado ao vivo no `legalentity`) | +| B. v2, como os 3 irmãos | paridade com os outros SDKs | já marcada como deprecated; inteiro perde zeros; não suporta alfanumérico | + +**Recomendação: A.** **Trade-off:** diverge dos irmãos, mas eles devem migrar também. + +## D5. O que fazer quando a emissão tem resultado incerto + +Contexto: a API ignora `Idempotency-Key`, e um POST de emissão pode devolver 500/504 **com a nota +criada**. O `externalId` é rejeitado com 400 na segunda tentativa. + +| Opção | Comportamento | Prós | Contras | +|---|---|---|---| +| **A. Reconciliação explícita (recomendada)** | erro com `outcome_unknown=True` + `external_id`; duplicidade → `DuplicateExternalIdError`; helper `find_by_external_id(..., wait=30)`; receita na doc | o SDK nunca esconde um efeito fiscal; o ERP decide (fila, alerta, retry) | cada integração escreve ~6 linhas de reconciliação | +| B. Reconciliação automática quando há `external_id` | em erro incerto ou duplicidade, o SDK busca pelo `externalId` e devolve a nota existente | "simplesmente funciona" | pode devolver nota emitida com **outro** conteúdo (`externalId` reaproveitado por bug do ERP) sem avisar; esconde o incidente | +| C. A + gerar `externalId` automático (UUID) quando omitido | dedupe sempre disponível | protege quem esquece | grava id que o ERP não conhece (não serve para reconciliar depois do crash); altera dado fiscal enviado | + +**Recomendação: A.** Também recomendo que a doc trate `external_id` como obrigatório na prática, sem +exigi-lo na assinatura. **Trade-off:** um pouco mais de código no integrador, nenhuma nota fantasma. + +## D6. Tipo dos valores monetários e alíquotas + +| Opção | Prós | Contras | +|---|---|---| +| **A. `Decimal` nas propriedades tipadas; `float` no dict cru (recomendada)** | aritmética fiscal exata onde o usuário calcula; `to_dict()`/`json.dumps` continuam funcionando | dois tipos para o mesmo campo (`inv.services_amount` × `inv["servicesAmount"]`) | +| B. `Decimal` em tudo (`json.loads(parse_float=Decimal)`) | um tipo só | `json.dumps(inv.to_dict())` quebra sem encoder custom; atrito com Odoo/ERPNext (usam `float`) | +| C. `float` em tudo | simples; igual aos ERPs | erro de arredondamento em soma de impostos; má prática para SDK fiscal | + +**Recomendação: A.** Requisições aceitam `int`, `float` ou `Decimal` (este último serializado sem +perda). **Trade-off:** precisa documentar a dualidade. + +## D7. Nomes e agrupamento dos recursos (e onde entra a NFS-e v3) + +Proposta: +- `client.service_invoices`, `client.companies`, `client.certificates`, `client.webhooks`. +- `client.lookups.cnpj(...)`, `.cnpj_state_taxes(...)`, `.cpf(...)`, `.cep(...)`. + +| Opção | Prós | Contras | +|---|---|---| +| **A. Plano + `lookups` agrupado (recomendada)** | curto; `lookups` deixa explícito "usa a chave de dados"; libera `legal_people`/`natural_people` para os tomadores (CRUD da v1) | nome `lookups` não existe nos irmãos | +| B. Espelhar o Node (`legal_entity_lookup`, `natural_person_lookup`, `addresses`) | paridade de nomes | nomes longos; "addresses" sugere CRUD | +| C. Versionado à la stripe v8 (`client.v1.service_invoices`, `client.v3…`) | acomoda NFS-e v3 sem ambiguidade | verboso; a NFE.io versiona por família, não globalmente; v1/v2/v3 coexistem de forma irregular | + +**NFS-e v3** (CNPJ alfanumérico; a v1 devolve 400 para esses CNPJs, VAULT `PHP/review-07-14-2026/11`). +Ela fica fora do v0.1. A recomendação é que, na v0.2, `client.service_invoices` passe a escolher a +versão por empresa ou parâmetro, sem novo atributo, decisão a tomar com a sonda da v3. +**Trade-off:** A pode exigir um parâmetro `api_version` no futuro; C antecipa esse custo agora para +todos. + +## D8. Retentar POST que recebe 429? + +O PHP retenta, supondo que o rate limiter rejeita **antes** de processar. Isso nunca foi confirmado com +o time da API, e não observamos 429 nem `Retry-After` (SONDA §2). + +| Opção | Prós | Contras | +|---|---|---| +| **A. Não retentar POST em 429; erro com `retry_after` (recomendada)** | coerente com a regra 3 (nunca retentar emissão ambígua) enquanto não houver confirmação | rajadas viram erro visível; o integrador re-enfileira | +| B. Retentar como o PHP | absorve picos | se o 429 vier de camada que já processou, duplica nota | +| C. Retentar POST em 429 exceto nas rotas de emissão/cancelamento | absorve picos em webhooks/certificado | regra por rota, mais complexa; ganho pequeno no v0.1 | + +**Recomendação: A**, e abrir a pergunta ao time da API. Se confirmarem que o limitador fica antes do +processamento, liberar é uma mudança aditiva. **Trade-off:** menos conveniência agora, zero risco de +duplicidade. + +--- + +## Itens menores (confirmar junto; não mudam a arquitetura) + +1. **Esquema de auth no fio:** proponho `Authorization: ` (está na spec e foi provado em todos os + hosts), em vez do `X-NFE-APIKEY` dos irmãos (funciona, mas não está em spec alguma). As regras do projeto + cita `X-NFE-APIKEY`; preciso do ok para mudar. (design §3) +2. **Licença:** MIT, como o `client-nodejs` e o plano `ideias-integracoes/06`. +3. **Canal do `SECURITY.md`:** e-mail dedicado ou GitHub Private Vulnerability Reporting. +4. **Certificado da empresa de teste vence em 2026-11-03** (SONDA §5): renovar antes da fase 2a e dos + testes de emissão. +5. **Achado para o time da API:** a chave de dados recebeu 200 em `GET` de companies v1/v2 e webhooks + (SONDA §1). Pode ser escopo maior que o pretendido. Vale reportar? +6. **Nome no PyPI:** `nfe-io`, `nfeio` e `nfe_io` estão livres hoje (consulta de leitura em 2026-10-08). + Reservar o nome cedo exigiria publicar, o que depende do seu ok. + +--- + +## Registro de decisões (André, 2026-10-08) + +| Item | Decisão | +|---|---| +| D1 | **D enxuta** — `NfeObject` = Mapping imutável do fio (preserva tudo, acesso `obj["campo"]`) + propriedades tipadas SÓ onde corrigem/tipam: ids, status, datas, dinheiro (`Decimal`), documentos (CNPJ/CPF normalizados como `str`). Demais campos ficam no acesso por chave. Atributos dinâmicos (`__getattr__`) NÃO são usados — evita a colisão `items`/`keys` do `dict`. | +| D2 | A — `TypedDict` camelCase do fio no corpo; argumentos em snake_case | +| D3 | A — companies v2; **autorizado criar e apagar UMA empresa descartável** na conta de teste para validar a escrita | +| D4 | A — CNPJ v3 | +| D5 | A — reconciliação explícita (`outcome_unknown`, `find_by_external_id`); o SDK nunca reenvia sozinho | +| D6 | A — `Decimal` nas propriedades, `float` no dict cru | +| D7 | A — recursos planos (`service_invoices`, `companies`, `certificates`, `webhooks`) + `client.lookups.{cnpj,cpf,cep}`; NFS-e v3 entra na v0.2 como adição em `service_invoices` | +| D8 | A — não retentar POST em 429 | +| Menor 1 | `Authorization: ` (sem prefixo) | +| Menor 2 | MIT | +| Menor 3 | GitHub Private Vulnerability Reporting | +| Menor 4 | ok — certificado de teste será renovado | +| Menor 5 | Escopo da chave de dados (companies e webhooks) é intencional; não reportar | +| Menor 6 | Publicar como `nfe-io` no PyPI assim que estiver funcional (com ok final antes do upload) | diff --git a/docs/contrato/probe-2026-10-08.md b/docs/contrato/probe-2026-10-08.md new file mode 100644 index 0000000..f7efcfe --- /dev/null +++ b/docs/contrato/probe-2026-10-08.md @@ -0,0 +1,202 @@ +# Sonda ao vivo — 2026-10-08 (somente leitura) + +Change: `add-sdk-foundation-v0-1` (fase 1, desenho). Objetivo: confirmar, antes de escrever código, +o contrato do que o v0.1 do SDK Python vai consumir. + +- **Método:** só `GET` (o helper `scripts/probes/_probe.py` recusa qualquer outro método antes de + abrir socket). Redirects **não** são seguidos automaticamente; quando seguidos, é sem credencial. +- **Credenciais:** `.env` (`NFE_API_KEY` = "chave principal", `NFE_DATA_API_KEY` = "chave de dados", + `NFE_COMPANY_ID` = empresa de teste do André). Nenhuma escrita. +- **Scripts:** `scripts/probes/p01_auth_matrix.py` … `p06_cnpj_v3.py` (rodar de dentro de `scripts/probes/`). Saída crua em + `scripts/probes/out/` (gitignored; contém dado real de terceiros). +- **Redação deste documento:** ids de empresa/conta → ``/``; CNPJ público de + amostra (Banco do Brasil) → ``; CNPJ da empresa de teste → ``; URIs de webhook + e segredos omitidos; query de URL pré-assinada reduzida aos nomes dos parâmetros. +- **Legenda:** **PROVADO** = observado hoje no fio. **INFERÊNCIA** = não observável com GET; fonte citada. + +## 1. Autenticação: esquema × host × chave + +| Host / rota amostrada | sem chave | chave principal | chave de dados | +|---|---|---|---| +| `api.nfe.io/v1/companies` | 401 | 200 | **200** | +| `api.nfe.io/v1/companies//serviceinvoices` | 401 | 200 | 403 | +| `api.nfse.io/v2/companies` | 401 | 200 | **200** | +| `api.nfse.io/v2/webhooks` | 401 | 200 | **200** | +| `api.nfe.io/v2/webhooks` | 401 | 200 | **200** | +| `legalentity.api.nfe.io/v{1,2}/legalentities/basicInfo/` | 401 | 403 | 200 | +| `address.api.nfe.io/v2/addresses/01310100` | 401 | 403 | 200 | +| `naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{data}` | 401 | 403 | 404 (auth ok, CPF×data não confere) | + +**PROVADO:** +- Hosts de dados (`legalentity`, `address`, `naturalperson`) só aceitam a chave de dados (403 com a principal). +- Emissão NFS-e (`/serviceinvoices`) só aceita a chave principal (403 com a de dados). +- A chave de dados **também** recebe 200 em listagens de companies v1/v2 e webhooks. Isso é + novidade frente à fixture `api-key-host-matrix.json` do Node (que mediu outras rotas de + `api.nfse.io`, com 403). Não generalizar: o SDK continua roteando principal → fiscal, dados → consultas. +- Esquemas aceitos (iguais em todos os hosts testados): `X-NFE-APIKEY: `, `Authorization: `, + `Authorization: Basic ` (chave crua, **sem** base64) e `?apikey=`. + Rejeitados (401): `Basic base64(:)`, `Basic base64()`, `Bearer `. +- 401/403 vêm com **corpo vazio** e sem `Content-Type`; 401 traz `www-authenticate: Bearer`. + +## 2. Cabeçalhos de resposta + +- **PROVADO:** `x-request-id` presente em **todas** as respostas, de todos os hosts, inclusive + 401/403/404 (formato ASP.NET `0HNP51M3F3EVD:00000005`). Também `CF-RAY` (Cloudflare). + O vault registrava "nenhum header de request id" — está desatualizado. +- **PROVADO:** `api-deprecated-versions` / `api-supported-versions`: + `api.nfe.io/v1/companies` → `deprecated: 1.0`; `api.nfse.io/v2/companies` → `deprecated: 2.0`; + `legalentity …/v2/…` → `supported: 3.0, deprecated: 2.0`. +- **PROVADO:** corpos de erro com ProblemDetails trazem `traceId` (W3C `00---01`). +- **INFERÊNCIA:** `Retry-After` em 429 — não observável sem provocar rate limit; nenhum 429 nas sondas. + +## 3. Envelopes de erro observados hoje + +| Forma | Exemplo (redigido) | Onde | +|---|---|---| +| Corpo vazio | — | 401, 403; 404 de rota não servida (sem chave inclusive) | +| String JSON crua | `"pageCount must be between 1 and 50"`, `"Company not found."`, `"service invoice with id (…) was not found"`, `"company id is not valid"` | `api.nfe.io` v1 | +| `{"errors":[{"code":40401,"message":"…"}]}` | `company id not found`, `not found` | companies v1/v2 retrieve, CPF | +| `{"errors":[{"message":"…"}]}` (sem `code`) | `cnpj is null, white space or not valid` | CNPJ | +| `{"errors":[{"code":40001,"message":"…"}]}` | `postalCode with value '00000000' is not valid`, `birth date is not valid` | CEP, CPF | +| ProblemDetails `{type,title,status,traceId[,errors:{campo:[…]}]}` | `"The value 'abc' is not valid."` | binding ASP.NET (v1), 404 de webhook/pdf inexistente | + +**Consequência:** o extrator de mensagem do SDK precisa tratar os 5 formatos e nunca falhar em corpo vazio. + +## 4. Companies + +### v1 (`api.nfe.io/v1/companies`) — **PROVADO** +- Envelope de lista: `{"companies":[…], "page": }` — `page` é **inteiro** (eco do `pageIndex`); + **não há** `totalResults`/`totalPages`. Fim da paginação = página com menos itens que `pageCount`. +- `pageIndex`: 1-based; `0` → 400 `"pageIndex must be greater or equal to 1"`. +- `pageCount`: aceita 2–50; `1` → 400 `"pageCount must be between 1 and 50"` (mensagem mente); + `51`/`100` → 400; omitido → 10 itens. +- Retrieve por id **ou** CNPJ (`/v1/companies/`) → 200 `{"companies": {…}}` — objeto + único sob chave **plural**. +- Id inexistente → 404 `{"errors":[{"code":40401,"message":"company id not found"}]}`. +- Itens trazem `certificate {thumbprint, modifiedOn, expiresOn, status}`. + +### v2 (`api.nfse.io/v2/companies`) — **PROVADO** +- Envelope `{"hasMore": bool, "companies": […]}` (spec não declara `hasMore`). Retrieve → `{"company": {…}}`. +- `limit`: 1–50; `51` → 400 `"limit must be less than 50"` (mensagem mente, 50 é aceito); + `0` → 200 com lista vazia **e `hasMore: true`**; omitido → 10. +- Cursor `startingAfter`/`endingBefore` funciona nos dois sentidos. +- **Varredura completa terminou sem erro:** 662 empresas, sem duplicatas. O "registro venenoso" + (`startingAfter=3450bc62…`) que derrubava o gate em 2026-07 **hoje responde 200**. +- **Paridade v1×v2:** v1 = 357 empresas, v2 = 662; v1 ⊂ v2 (0 só em v1). v1 lista só empresas + com inscrição municipal (escopo NFS-e). +- **Projeções divergem (continua):** v1 tem `certificate, environment, federalTaxDetermination, + fiscalStatus, issRate, lastRpsSent, legalNature, municipalTaxDetermination, rpsNumber, + rpsSerialNumber, specialTaxRegime`; v2 tem `accountId, municipalTaxes, stateTaxes`. + Na empresa de teste, `municipalTaxNumber` é `""` no v1 e, no v2, **contém o id (32-hex) da + inscrição municipal** — mesmo nome, semântica diferente. +- Dados NFS-e (ambiente, RPS, ISS) vivem em `GET /v2/companies/{id}/municipaltaxes` → + `{"hasMore", "municipalTaxes":[{id, companyId, accountId, status, city, taxNumber, + specialTaxRegime, rpsSerialNumber, rpsNumber, issRate, environment, fiscalStatus, …}]}`. + +**Leitura para o gate v1→v2:** critério 1 (varredura quebra com 500) **não reproduz mais**; +critério 2 (projeções/`municipalTaxNumber` divergentes) **continua**. Ver `DECISOES-PENDENTES.md` (D3). + +## 5. Certificado — **PROVADO** + +- `GET api.nfe.io/v1/companies//certificate`, `GET api.nfse.io/v1/…/certificate` e + `GET api.nfse.io/v2/companies//certificates` → os três 200 com o **mesmo** envelope + `{"certificates":[{providerType, resolution, taxPayerId, thumbprint, taxId, subject, validUntil, + modifiedOn, status}]}`. +- **Atenção:** o certificado da empresa de teste vence em **2026-11-03**. Testes de integração de + emissão vão quebrar depois disso se não for renovado. +- Upload (multipart, campo `file`): **INFERÊNCIA** a partir da fixture + `client-nodejs/tests/fixtures/live-contracts/certificate-upload-field.json` (2026-09-01) — não + reprovado hoje por ser escrita. + +## 6. NFS-e (`api.nfe.io/v1/companies/{id}/serviceinvoices`) — **PROVADO** + +- Lista: `{"serviceInvoices":[…], "page": }`. `pageIndex=0` → 400 + `"page index must be greater then zero"` (mensagem diferente da de companies). `pageCount` aceita + **1**–50 aqui (51/100 → 400). Filtros `issuedBegin/issuedEnd` e `hasTotals` aceitos (200), mas + **`hasTotals=true` não acrescenta totais**: o envelope continua `{serviceInvoices, page}` (a spec + promete `totalResults`/`totalPages`). +- Amostra de 50 notas: `flowStatus` ∈ {`Issued`, `Cancelled`}; `status` idêntico ao `flowStatus` + nessas amostras. União de chaves: 43 campos (lista no JSON cru; inclui `paidAmount`, + `paymentMethod`, `nbsCode`, `approximateTotals`, `rpsStatus`). +- Retrieve → 200 objeto nu (sem envelope). +- `GET …/external/{externalId}`: + - existente → 200 `{"serviceInvoices":[{…}], "page":1}` (**lista**, a spec diz objeto); + - inexistente → **200 `{"serviceInvoices":[], "page":1}`** (não 404). +- Id inexistente ou malformado → 404 string `"service invoice with id (…) was not found"`. +- Empresa inexistente (24 ou 32 hex) → **404 `"Company not found."`** (o vault registrava 503 — + mudou, ao menos no GET; para POST continua **INFERÊNCIA** = 503, fonte `PHP/Retry seguro e idempotência.md`). +- `GET …/{id}/status` → **400** ProblemDetails `errors.rpsNumber: "The value 'status' is not valid."` + em 2 amostras. **Não existe rota de status em GET**: o polling tem que usar `GET …/{id}`. +- Downloads: + - `GET …/{id}/pdf` e `…/xml` → **302** para `https://api.nfse.io/v1/blob/download?` + (URL pré-assinada), independente do `Accept`. Seguido **sem credencial** → 200 + `application/pdf` (`%PDF-1.4`, ~55 KB) / `application/xml` (~1,3 KB). + - XML começa em `` **sem** prólogo ``): **INFERÊNCIA** a partir de 3 pings reais capturados em + 2026-06-11 (`client-nodejs/tests/fixtures/webhook-signatures.json`). Não é observável por GET. + +## 9. Consultas de dados — **PROVADO** + +- **CNPJ** (`legalentity.api.nfe.io`, chave de dados): `v1`, `v2` e **`v3`** de `basicInfo` respondem + 200, com **tipos diferentes para `federalTaxNumber`**: v1 = string formatada `"00.000.000/0001-91"` + (objeto nu), v2 = **inteiro** `191` (em `{"legalEntity":…}`), v3 = string de 14 dígitos + `"00000000000191"` (em `{"legalEntity":…}`). O header da v2 anuncia `deprecated 2.0 / supported 3.0`. + `stateTaxInfo/{UF}/{cnpj}` → 200 em v2 e **v3** (v3 também devolve `federalTaxNumber` string de + 14 posições). v3 com o CNPJ alfanumérico de exemplo da RFB (`12ABC34501DE35`, `p06_cnpj_v3.py`) passa + a validação e chega à base: 404 `{"errors":[{"message":"CNPJ not found in Serpro."}]}` — ou seja, a + v3 já aceita o formato alfanumérico. CNPJ com DV errado ou curto → 400 + `{"errors":[{"message":"cnpj is null, white space or not valid"}]}`; CNPJ com máscara no path → 404 vazio. +- **CEP** (`address.api.nfe.io`): `/v2/addresses/{cep}` → `{"address":{state, city{code,name}, + district, additionalInformation, streetSuffix, street, number, postalCode, country}}`; aceita + `01310100` e `01310-100`. `00000000` → 400 `{"errors":[{"code":40001,…}]}`. `/v1/addresses/{cep}` + → objeto nu. Busca `/v2/addresses?$filter=` → 404 (não servida). +- **CPF** (`naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{AAAA-MM-DD}`): CPF válido com data + que não confere → 404 `{"errors":[{"code":40401,"message":"not found"}]}`; CPF inválido → 400 + `40001 "federal tax number is not valid"`; data `DD-MM-AAAA` → 400 `"birth date is not valid"`. + Caminho de sucesso (200) **não observado** (não há par CPF×data real de teste) → shape de sucesso é + **INFERÊNCIA** a partir de `cpf-api.yaml`. + +## 10. Rotas não servidas (baseline) + +- `GET api.nfe.io/v1/this-route-does-not-exist` e `api.nfse.io/v2/…` **sem chave** → 404 vazio. + Rota servida sem chave → 401. Isso distingue "rota inexistente" de "recurso inexistente". + +## 11. O que mudou desde o vault (re-teste com dado real) + +| Afirmação anterior | Fonte | Hoje | +|---|---|---| +| Cursor v2 de companies dá 500 em registro venenoso | `Migração companies v1→v2 …` | **200**; varredura completa ok | +| Empresa inexistente em `/serviceinvoices` → 503 | `PHP/Retry seguro …` | **404** (GET); POST não testado | +| `GET …/serviceinvoices/{id}/status` existe | `PHP/review-07-14-2026/02` | **400** binding `rpsNumber`; não usar | +| Nenhum header de request id | digest do vault | **`x-request-id` em toda resposta** | +| Webhooks só provados em `api.nfe.io` | `PHP/review-07-14-2026/08` | `api.nfse.io` também, mesmo conteúdo | +| Chave de dados → 403 em `api.nfse.io` | `api-key-host-matrix.json` | 403 em `/serviceinvoices`; **200** em companies/webhooks | diff --git a/docs/contrato/probe-fase2.md b/docs/contrato/probe-fase2.md new file mode 100644 index 0000000..55ca594 --- /dev/null +++ b/docs/contrato/probe-fase2.md @@ -0,0 +1,94 @@ +# Sonda da fase 2 — 2026-10-08 (leitura e escrita controlada, pelo próprio SDK) + +Change: `add-sdk-foundation-v0-1`, fase 2 (implementação). Diferente da sonda da fase 1 +(`probe-2026-10-08.md`, só `GET` com scripts avulsos), esta foi feita **pelo SDK**, nos testes +`tests/live/`, com transporte que grava cada troca redigida. + +- **Como rodar:** `uv run pytest tests/live --run-integration [--live-write]` (ou + `NFE_RUN_INTEGRATION=1` / `NFE_LIVE_WRITE=1`). Chaves do `.env`, nunca impressas. +- **Evidência crua redigida:** `tests/live/out/*.json` (fora do git). **Fixtures versionadas** + (envelope real, valores sintéticos): `tests/fixtures/live-contracts/`, reexecutadas por + `tests/unit/test_live_contracts.py`. +- **Redação deste documento:** ids encurtados (`6ac8…ba46`), sem CNPJ, CPF, chave ou URL + pré-assinada. +- **Legenda:** **PROVADO** = observado hoje no fio. **INFERÊNCIA** = não observado; fonte citada. + +## Escritas executadas (dentro da autorização da fase 2) + +| Permissão | O que foi feito | Resultado | Limpeza | +|---|---|---|---| +| a) NFS-e só na `NFE_COMPANY_ID` (≤ ~5 notas) | **2 notas** emitidas em homologação com `externalId` `sdkpy-`: `6ac8…ba46` (cliente sync) e `6ac8…1369` (cliente async). Uma tentativa de duplicata (recusada pela API, não criou nota) | ambas `Issued` | ambas **canceladas** (`Cancelled`, conferido depois por listagem) | +| b) UMA empresa descartável | `SDK-PY TESTE DESCARTAVEL 2026-10-08`, id `ad95…37dd`, CNPJ aleatório com DV válido (prefixo 99) | criar, consultar, alterar ok | `DELETE` → 204 duas vezes; a API faz **soft delete**: a empresa continua na conta com `status: Inactive` (não há remoção definitiva pela API) | +| c) webhooks de teste | 1ª tentativa em `https://example.com/nfeio-sdkpy-test` **recusada pela API** (verificação da URI, 405 → 400); depois 1 webhook em `https://httpbin.org/status/200?nfeio-sdkpy-test=1` | criar, consultar, alterar ok | apagado (204); conferido que não restou nenhum | + +Fora do escopo e **não** executado: `sendemail`, `ping` de webhook, upload de certificado real, +qualquer escrita em outras empresas. Um único `POST` de certificado com **arquivo falso** foi +enviado à empresa descartável para saber se a API aceita upload sem certificado válido (não +aceita: 500). + +## Resultados — provado × inferência + +### NFS-e (`api.nfe.io/v1`) + +| Ponto | Antes (fase 1) | Hoje | +|---|---|---| +| Emissão | INFERÊNCIA (fixture Node RTC): 202 + `Location` http | **PROVADO**: 202, `Location: http://api.nfe.io/v1/companies/{id}/serviceinvoices/{id}`, corpo = **nota completa** (38 chaves, `flowStatus: WaitingCalculateTaxes`), não só `{id, environment, flowStatus}` | +| Busca por `externalId` logo após o 202 | VAULT: atraso de "segundos" | **PROVADO**: achada na **primeira** chamada (0,21 s). O `wait=30` continua recomendado (o atraso pode voltar) | +| `externalId` repetido | VAULT (jul/2026) | **PROVADO**: 400, corpo string `"service invoice with external id (…) already exists"`, sem `code` | +| Polling | `GET …/{id}` (não há `/status`) | **PROVADO**: `WaitingCalculateTaxes` → `Issued` em segundos | +| PDF/XML | PROVADO (302 → blob) | **PROVADO de novo pelo SDK**: 302 → `api.nfse.io/v1/blob/download?b&e&s`, seguido **sem** `Authorization`; PDF ~56 KB `%PDF-1.4`; XML ~1,3 KB começando em `` | +| Cancelamento | INFERÊNCIA: 202 + `Location` | **PROVADO**: `DELETE` → 202 + `Location` http, corpo de nota; termina em `Cancelled` | +| XML de cancelamento | PROVADO (404 em provedor legado) | **PROVADO**: 404 string "National environment only…" | +| Valores | — | `Decimal("10.00")` enviado como `10.00`; `services_amount == Decimal("10.00")` na volta | +| Tomador com CPF inteiro | PROVADO (fio manda int) | **PROVADO**: `borrower.federal_tax_number == "52998224725"` | +| 500/504 com nota criada | VAULT (incidente) | **INFERÊNCIA** (não provocável sem incidente); o SDK trata como `outcome_unknown` | +| Empresa inexistente em POST → 503 | VAULT | **INFERÊNCIA** (não testado: exigiria POST em empresa alheia) | +| `sendemail` | spec | **INFERÊNCIA** (fora da autorização da fase 2) | + +### Empresas (`api.nfse.io/v2/companies`) + +| Ponto | Hoje | +|---|---| +| Criar | **PROVADO**: `POST` com `{"company": {…}}` → **200** (não 201), corpo `{"company": {…}}`, `federalTaxNumber` volta **inteiro** | +| Consultar / alterar | **PROVADO**: `GET`/`PUT` → 200 com envelope `company` | +| Excluir | **PROVADO**: `DELETE` → 204; **soft delete** — a empresa continua listada e consultável com `status: Inactive`; um segundo `DELETE` também dá 204 | +| Varredura por cursor | **PROVADO** de novo: 14 páginas de 50, sem duplicatas, sem erro | +| Chave principal | **PROVADO**: todas as rotas acima com `api_key` | + +### Certificados + +| Ponto | Hoje | +|---|---| +| Listar `GET /v2/companies/{id}/certificates` | **PROVADO** (envelope `certificates`, `validUntil`, `providerType`) | +| Upload v2, campo multipart `file` | **PROVADO em parte**: com arquivo falso a API respondeu **500** ProblemDetails (não 400 "File field is required"), ou seja, o binding aceitou `file`/`password` e falhou ao ler o PFX — mesmo comportamento da v1 em 2026-09-01. O caminho de sucesso exige PFX válido: **não testado** (teste pulado, registrado) | +| Certificado da empresa de teste | vence em **2026-11-03** (renovação combinada) | + +### Webhooks (`api.nfse.io/v2/webhooks`) + +| Ponto | Hoje | +|---|---| +| Verificação da URI na criação | **PROVADO (novo)**: a API chama a URI ao criar; `example.com` respondeu 405 → **400** `{"errors":[{"code":40001,"message":"WebHook verification failed … Error encountered: 405"}]}` | +| Criar | **PROVADO**: 201, envelope `webHook`; **o segredo é ecoado** no corpo; `status: "Inactive"` enviado na criação foi **ignorado** (voltou `Active`) | +| Alterar | **PROVADO**: `PUT` 200, substituição total; `status: "Inactive"` aplicado | +| Excluir | **PROVADO**: 204 | +| Tipos de evento | **PROVADO** (inclui `service_invoice.issued_successfully`) | +| Assinatura `X-Hub-Signature` | **INFERÊNCIA** a partir de 3 entregas reais de 2026-06-11 (vetores nos testes); `ping` não executado | + +### Consultas e autenticação + +| Ponto | Hoje | +|---|---| +| `Authorization: ` sem prefixo | **PROVADO** em todos os hosts usados | +| Chave de dados em NFS-e / chave principal em CEP | **PROVADO**: 403 com corpo vazio; o SDK acrescenta a dica de qual chave a família usa | +| CNPJ v3 (`basicInfo`, `stateTaxInfo`) | **PROVADO** (`federalTaxNumber` string de 14) | +| CEP v2 | **PROVADO** (`address.city.code`) | +| CPF com data divergente | **PROVADO**: 404 `{"errors":[{"code":40401,"message":"not found"}]}`; formato de sucesso segue **INFERÊNCIA** (spec) | +| `x-request-id` | **PROVADO** em todas as respostas, inclusive 403/404 e no blob | + +## Consequências no SDK + +- Fixtures de contrato novas e teste de replay (`test_live_contracts.py`). +- `companies.delete` documenta o soft delete; os testes ao vivo verificam `status == "Inactive"`. +- Teste ao vivo de webhook usa uma URL que responde 2xx (a verificação na criação inviabiliza + `example.com`); ver `DECISOES-FASE2.md`. +- A busca por `externalId` ficou estrita (só lista vazia explícita significa "não existe"). diff --git a/docs/contrato/revisao-seguranca-v0.1.md b/docs/contrato/revisao-seguranca-v0.1.md new file mode 100644 index 0000000..2723b82 --- /dev/null +++ b/docs/contrato/revisao-seguranca-v0.1.md @@ -0,0 +1,135 @@ +# Revisão de segurança — SDK Python v0.1 (2026-10-08) + +Revisão do código final de `src/nfeio/` contra o design §11 e as regras do projeto, feita depois +da implementação e antes de encerrar a fase 2. Cada item diz **onde** está o controle, **como** foi +verificado e o que foi **corrigido** durante a revisão. + +Ferramentas: `bandit -r src` (0 achados), `ruff` com regras `S` (0), `pip-audit` no lock de dev +(0 vulnerabilidades conhecidas), `mypy --strict`, revisão manual e um revisor automático de +segurança que apontou dois itens (ambos corrigidos, abaixo). + +## 1. Segredos em log, `repr` e exceções + +| Superfície | Controle | Verificação | +|---|---|---| +| Chaves no cliente/config | `SecretStr` (`_config.py`): `repr`/`str` mostram `****` + 4 últimos; `pickle` recusado | `test_secret_masking` | +| `RequestOptions(api_key=…)` | `__repr__` próprio mascarado | idem | +| Requisição | `HttpRequest.__repr__` redige `Authorization`/cookies, omite query e corpo, mascara o path | `test_repr_hides_authorization` | +| Cabeçalhos de resposta | `Headers.__repr__` redige `authorization`, `cookie`, `set-cookie`, `x-nfe-apikey` | `test_headers.py` | +| Erros da API | mensagem e `body` passam por `_scrub`: a chave usada (e senha de certificado/segredo de webhook, quando houver) vira `` mesmo se a API ecoar | `test_echoed_key_is_scrubbed`, `test_certificate_upload_500_not_retried_and_password_hidden` | +| Erros de rede | só host e classe da exceção do socket | leitura de `transport.py` | +| Objetos de resposta | `NfeObject.__repr__` mascara chaves `secret`, `password`, `apikey`, `token` (o ping de webhook traz `Secret`; a v1 de empresas traz `loginPassword`) | `test_repr_masks_secrets` | +| Evento de webhook | `WebhookEvent.__repr__` não mostra o corpo | `test_construct_event_ping_and_repr_hides_secret` | +| Logs | logger `nfeio`, só `DEBUG`, `NullHandler` por padrão; registra método, host, path mascarado, status, tentativa, espera e `x-request-id` | `test_retry_log_has_no_secrets`, `test_secret_leak_scan` | + +**Corrigido na revisão:** os logs e o `repr` da requisição gravavam o **path concreto**, que contém +CPF, data de nascimento, CNPJ e `externalId` (dados pessoais e de venda). Agora +`_core/redact.log_path` mantém só segmentos estáticos conhecidos e troca o resto por `*` +(`/v1/naturalperson/status/*/*`). Teste: `test_logs_and_reprs_have_no_personal_data`. + +Varredura completa (`test_secret_leak_scan`): sequência com sucesso, 401, 500, falha de rede e +upload de certificado, com log em `DEBUG`; nenhuma chave, senha ou segredo aparece em logs, +`repr`, `str` ou atributos das exceções. + +## 2. Redirect com credencial + +- Transportes **nunca** seguem redirect (`http.client` não segue; o protocolo exige). +- Só downloads seguem (`requestor.follow_download`): até 3 saltos, **só https**, requisição nova + **sem `Authorization`** nem outro cabeçalho de credencial. Provado ao vivo: o salto para + `api.nfse.io/v1/blob/download` saiu sem `Authorization` (`authorizationSent: false` na fixture). +- `Location` de 202 nunca é seguido como URL: só o path é lido, validado por `fullmatch` e a + empresa precisa coincidir. +- **Corrigido na revisão:** um 3xx fora de download chegava ao parser e, na busca por + `externalId`, podia virar "não existe". Agora `request()` levanta `UnexpectedResponseError` para + 3xx sem `allow_redirect`. Teste: `test_redirect_outside_downloads_is_an_error`. + +## 3. Injeção em path (ids) + +- Todo valor interpolado em path passa por `_core/paths.py`: ids opacos `[A-Za-z0-9_-]{1,64}`; + `externalId` com `quote(safe="")` (um segmento só), rejeitando vazio, `.`/`..`, controle e + > 255; CNPJ (numérico e alfanumérico, DV da RFB), CPF (DV), CEP, UF e data validados. +- Cursores (`starting_after`/`ending_before`) passam pela mesma validação; query via `urlencode`. +- **Corrigido na revisão (revisor automático):** os validadores usavam `^…$` com `re.match`, e em + Python `$` aceita `\n` final (`"abc\n"` passaria). Todos passaram a `fullmatch` (ids, CNPJ, data, + nomes de cabeçalho, `app_info`, campos multipart, `Location`). Teste: + `test_validators_reject_trailing_newline`. +- Cabeçalhos: chave, `Idempotency-Key` e `extra_headers` rejeitam CR/LF/NUL; `extra_headers` não + sobrescreve `Authorization`, `User-Agent` nem `Content-Type`. + +## 4. Tamanho de resposta e desserialização + +- Corpo lido em blocos de 64 KiB até `max_response_bytes` (10 MiB); `Content-Length` maior é + recusado antes de ler; estouro descarta a conexão (`ResponseTooLargeError`). Teste com servidor + local (`test_response_size_limit`). +- `Accept-Encoding: identity` e nenhuma descompressão no SDK (sem bomba de compressão). +- Webhooks: limite de 5 MiB **antes** de verificar; JSON só depois da assinatura. +- Só `json.loads`; nada de `pickle`, `eval`, `yaml.load`, parser de XML (`test_no_dangerous_calls_in_package`). + XML e PDF voltam como `bytes`; a doc recomenda `defusedxml`. +- **Corrigido na revisão:** JSON muito aninhado gerava `RecursionError` fora da hierarquia + `NfeError`; agora é `UnexpectedResponseError` (ou `json_body = None` em erros). Teste: + `test_deeply_nested_json_stays_inside_the_hierarchy`. +- Limite explícito de aninhamento: todo JSON não confiável (respostas, corpos de erro, webhooks) + passa por `_core/jsonutil.loads`, que recusa mais de `MAX_JSON_DEPTH = 128` níveis com + `ValueError` **antes** do `json.loads`, em custo linear (remove as strings e varre só + `[ ] { }`). Antes a proteção dependia do limite de recursão do interpretador, e o Python 3.14 + decodifica 100 mil níveis sem `RecursionError`. Testes: `test_json_depth_limit_is_explicit`, + `test_json_depth_ignores_brackets_inside_strings`, `test_deeply_nested_error_body_and_webhook`. +- Mensagens de erro saneadas (sem caracteres de controle) e truncadas em 1.000 caracteres. + +## 5. TLS + +- `ssl.create_default_context()` + `minimum_version = TLSv1_2`; verificação de cadeia e de nome + sempre ligadas. +- `ssl_context` do usuário é aceito só se `CERT_REQUIRED` e `check_hostname=True`; `ca_bundle` + só acrescenta confiança. Não existe `verify=False`. +- Testes com servidor local autoassinado: falha na fase "não estabelecida" sem a CA e sucesso com + `ca_bundle` (`test_self_signed_rejected_then_trusted_via_ca_bundle`). +- Base URLs só `https`, sem credenciais na URL, sem query/fragmento. + +## 6. Retry e efeitos fiscais + +- `POST` nunca é repetido após possível envio (timeout de leitura, conexão interrompida, 408, 429, + 5xx); só antes de conectar. Pool: `POST` sempre em conexão nova. `Idempotency-Key` não muda isso. +- Erros incertos carregam `outcome_unknown=True` e `external_id`; `DuplicateExternalIdError` é + reconhecido pelo texto. +- **Corrigido na revisão:** `find_by_external_id` tratava corpo vazio/forma desconhecida como "não + existe", o que faria a receita de reconciliação reemitir. Agora só a lista vazia explícita + significa "não existe" (`test_external_lookup_never_reads_garbage_as_not_found`). +- `Retry-After` limitado a 60 s; acima disso, erro com `retry_after` e sem espera. + +## 7. Disponibilidade + +- Timeouts finitos obrigatórios (conexão 10 s, leitura 60 s, total 120 s por tentativa). + Limitação da stdlib: a resolução DNS não respeita o timeout (documentado em `DECISOES-FASE2` F8). +- Polling e busca por `externalId` limitados por prazo; backoff com teto. +- **Corrigido na revisão:** paginação que não avança (mesmo cursor ou mesma página) gerava laço + infinito em `auto_paging_iter`; agora levanta `UnexpectedResponseError`. +- Cliente async: cancelar a task não interrompe a requisição em voo (thread). Cancelar um `create` + em andamento deve ser tratado como resultado incerto: reconcilie por `externalId` (README). + +## 8. Supply chain + +- Zero dependências de runtime (teste de metadados + wheel sem `Requires-Dist` + smoke em venv + limpo com só `nfe-io` instalado). +- `uv.lock` para dev; `pip-audit` no CI; Dependabot só para actions e dev. +- Workflows com `permissions: {}` e permissões mínimas por job; `persist-credentials: false`; + nenhuma interpolação de entrada não confiável em `run:`. +- Release: build único, environment `pypi` com aprovação, Trusted Publishing (OIDC) e attestations + PEP 740. +- **Pendente:** conferir os SHAs das actions (`scripts/verify_action_pins.py`; ver + `DECISOES-FASE2` F18). + +## 9. Webhooks + +- `verify_signature`: exige `sha1=` + 40 hex, HMAC-SHA1 sobre o corpo bruto, `hmac.compare_digest`, + nunca levanta exceção (entradas malformadas, segredo vazio, tipos inesperados → `False`). + Vetores reais (3 pings de 2026-06-11) e negativos da spec do Node. +- Sem proteção anti-replay no protocolo (não há timestamp assinado): a doc manda deduplicar por + `X-Hook-Id`. + +## Riscos aceitos / fora do escopo + +- Sem suporte a proxy na v0.1 (F10). +- `NfeObject` pode ser serializado com `pickle` pelo usuário (F5); o SDK nunca desserializa. +- `check_ssl_context` eleva `minimum_version` do contexto do usuário para TLS 1.2 (efeito colateral + no objeto recebido; documentado aqui). diff --git a/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml b/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/add-sdk-foundation-v0-1/design.md b/openspec/changes/add-sdk-foundation-v0-1/design.md new file mode 100644 index 0000000..510601c --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/design.md @@ -0,0 +1,583 @@ +## Context + +Motivação e escopo: ver `proposal.md`. Este documento decide **como** construir o v0.1. + +Restrições que moldam tudo (regras do projeto, não renegociadas aqui): Python 3.10+, zero dependências de +runtime, sync + async desde o início (async via `asyncio.to_thread`), PyPI `nfe-io` / import `nfeio`, +design inspirado no stripe-python, contrato decidido por spec + sonda ao vivo. + +Fontes de evidência citadas abaixo, por sigla: + +| Sigla | Fonte | +|---|---| +| **SONDA** | `docs/contrato/probe-2026-10-08.md` (seções §1–§11), saída crua em `scripts/probes/out/` | +| **SPEC** | `nfeio-docs/static/api/*` (cópia mais fresca) | +| **FIX** | `../client-nodejs/tests/fixtures/live-contracts/*.json` e `../client-nodejs/tests/fixtures/webhook-signatures.json` | +| **VAULT** | `~/Documents/obsidian/nfeio/SDKs/…` (nota citada pelo nome) | +| **NODE/PHP/RUBY** | código dos irmãos (só como referência de design, nunca de contrato) | + +Decisões marcadas **[D#]** foram **fechadas pelo André em 2026-10-08** (seção "Registro de decisões" de +`docs/contrato/DECISOES-PENDENTES.md`). Resumo do que está valendo: D1 = "D enxuta" (§2), D2 = A, D3 = A +(companies v2 com CRUD), D4 = A (CNPJ v3), D5 = A (reconciliação explícita), D6 = A, D7 = A +(`client.lookups`), D8 = A; auth `Authorization: `; licença MIT; `SECURITY.md` via GitHub Private +Vulnerability Reporting. Decisões novas surgidas na implementação ficam em +`docs/contrato/DECISOES-FASE2.md`. + +## Goals / Non-Goals + +**Goals:** + +- Núcleo único (sans-IO) para sync e async, sem duplicar lógica de retry, paginação e polling. +- Nenhum caminho de código que possa reenviar uma emissão cujo resultado é desconhecido. +- Modelos de resposta tipados (mypy `--strict`) **sem perder** nenhum campo do fio. +- Toda divergência spec × fio conhecida tratada explicitamente e coberta por teste com fixture. +- Superfície pequena e estável: o que entra no v0.1 precisa sobreviver até o 1.0 sem quebra. + +**Non-Goals:** + +- NF-e, NFC-e, CT-e, DC-e, distribuição DF-e, cálculo de impostos, RTC (v0.2+). +- NFS-e v3 (CNPJ alfanumérico) — ver Riscos; o desenho reserva espaço para ela ([D7]). +- Pessoas jurídicas/físicas (`legalpeople`/`naturalpeople`), notificações, `municipaltaxes` (escrita), + `DELETE /v2/webhooks` (apaga todos — fora por segurança). +- Geração de código a partir da OpenAPI (ver §12). +- Transportes httpx/aiohttp (o protocolo permite; não entregamos). + +## Decisions + +### §1. API pública: cliente explícito + serviços por recurso + +```python +from nfeio import NfeClient, AsyncNfeClient, RequestOptions + +client = NfeClient(api_key="…", data_api_key="…") # ou env NFE_API_KEY / NFE_DATA_API_KEY +inv = client.service_invoices.create( + company_id, {"borrower": {...}, "cityServiceCode": "…", "servicesAmount": 100.0}, + external_id="pedido-123", + options=RequestOptions(timeout=90, max_retries=0), +) +inv.flow_status # propriedade tipada snake_case (só campos curados, D1 enxuta) +inv["flowStatus"] # acesso ao campo do fio (sempre disponível) +inv["borrower"]["name"] # aninhado por chave: Mapping vira NfeObject; campo sem propriedade fica na chave +inv.last_response.request_id + +async with AsyncNfeClient(api_key="…") as aclient: + inv = await aclient.service_invoices.create_and_wait(company_id, params, external_id="pedido-123") +``` + +- **Cliente explícito, sem estado global** (stripe-python v8 `StripeClient`; o `stripe.api_key` global foi + a maior fonte de bug multi-tenant no ecossistema). ERPs como Odoo são multi-empresa/multi-chave: cada + `NfeClient` carrega sua configuração imutável. +- **Serviços como atributos** (`client.service_invoices`, `client.companies`, `client.certificates`, + `client.webhooks`, `client.lookups`) — nomes e agrupamento em **[D7]**. +- **Métodos e parâmetros em snake_case** (`company_id`, `external_id`, `page_index`). Corpos de requisição + seguem o fio em camelCase via `TypedDict` **[D2]**. +- **`RequestOptions`** (dataclass congelada, todos os campos opcionais), por chamada, sobrescreve o cliente: + `api_key` (substitui a chave que o recurso usaria), `timeout` (`float | Timeout`), `max_retries`, + `idempotency_key` (enviado como `Idempotency-Key`; **não** muda a política de retry porque a API o + ignora — VAULT `PHP/Ask API — Idempotency-Key…`), `extra_headers` (não pode sobrescrever + `Authorization`, `User-Agent`, `Content-Type`). +- **`external_id` não é `RequestOptions`:** é campo do corpo (`externalId`) com semântica de negócio (chave + de dedupe). Vira argumento nomeado de `service_invoices.create` para ficar visível na assinatura. +- **Chaves:** `api_key` (emissão/gestão) e `data_api_key` (consultas). Se omitidas, lê `NFE_API_KEY` / + `NFE_DATA_API_KEY`. **Sem fallback** de chave principal para hosts de dados: a chave principal recebe + 403 em todos os hosts de dados (SONDA §1), então o fallback só espalharia a chave mais poderosa sem + ganho. Consulta sem `data_api_key` → `ConfigurationError` antes de abrir socket. Quem tem uma chave só + passa o mesmo valor nos dois parâmetros, explicitamente. +- `NfeClient` é thread-safe e reutilizável; `close()` / context manager liberam o pool. + +Alternativas descartadas: módulo global estilo `stripe.api_key` (estado compartilhado); resources como +métodos de classe do modelo (`ServiceInvoice.create(...)`, stripe legado) — mistura dado e transporte. + +### §2. Modelos de resposta: visão tipada sobre o dict do fio [D1] + +| Opção | Tipagem | Campos desconhecidos | Custo de manutenção | Usado por | +|---|---|---|---|---| +| A. Objeto dinâmico (`StripeObject`, dict + `__getattr__`) | fraca (stubs à parte) | preservados | baixo | stripe-python | +| B. `@dataclass(frozen, slots)` + `raw` | forte | só em `raw`; risco de DTO incompleto | alto (mapear cada campo) | PHP (DTO + `raw`), Ruby (`Data.define` + `raw`) | +| C. `TypedDict` do fio (camelCase) | média (chaves) | preservados (é dict) | baixo | Node (interfaces sobre JSON) | +| **D. Visão tipada: `Mapping` imutável do fio + propriedades snake_case tipadas** | forte nas propriedades | **preservados por construção** | médio | — (híbrido de A e B) | + +**Decidido: D enxuta (André, 2026-10-08).** `NfeObject` implementa `Mapping[str, Any]` imutável sobre o +JSON decodificado. Regras: + +- **Propriedades tipadas só onde corrigem ou tipam algo:** ids, status/enums abertos, datas (`datetime` + aware; `date` para datas puras), dinheiro e alíquotas (`Decimal`), documentos (CNPJ/CPF como `str` + normalizada) e os aninhados que contêm documento (`borrower`, `provider`). Texto livre (`name`, + `description`, `cityServiceCode`, `uri`, …) fica **só** no acesso por chave. Cada modelo tem uma lista + pequena; nada de espelhar a spec inteira. +- **Sem `__getattr__` dinâmico.** Evita a colisão com `items`/`keys`/`values`/`get` do `Mapping` (a NF-e + da v0.2 tem campo `items`) e mantém erro de digitação visível para o mypy. +- **`obj["x"]` devolve o valor do fio**, com uma conversão: `Mapping` aninhado vira `NfeObject` genérico + (imutável, com `to_dict()`), e lista vira `list` **nova** cujos `Mapping` também viram `NfeObject` + (mutar a lista devolvida não altera o objeto). Escalares voltam como vieram (`float` para dinheiro, + D6). `obj.to_dict()` continua devolvendo o fio puro (dicts e listas comuns). +- **Aninhado por propriedade** devolve o modelo tipado do aninhado (`inv.borrower` → `Party` com + `federal_tax_number`); por chave devolve `NfeObject` genérico. +- Propriedades são **descritores declarativos** (`StrField("flowStatus")`, `DecimalField(...)`, …) que + registram a chave do fio; o teste de alinhamento (§12) lê esse registro. +- `repr` mascara chaves sensíveis (`secret`, `password`, `apiKey`, sem distinção de maiúsculas) — a + entrega de ping de webhook traz o `Secret` em claro; `to_dict()` não mascara. + +- **Forward-compat sem esforço:** nada é copiado nem filtrado; campo novo no fio aparece em `obj["novo"]` + e em `obj.to_dict()` no mesmo dia. Elimina a classe de bug "DTO cobre 13 de 39 campos" (VAULT + `PHP/DTO ServiceInvoice incompleto`) e o `totalAmount` fantasma. +- **Lição do Ruby:** classes geradas que descartam chaves não listadas (`Data.define` + `from_api`) perderam + dado — D não tem esse modo de falha. +- `to_dict()` devolve cópia profunda, JSON-serializável, com as chaves do fio; `__eq__` compara o dict. +- `last_response` (`status_code`, `headers` case-insensitive, `request_id`) em todo objeto/página. +- **Datas:** propriedades `datetime` (aware) com parser tolerante (sufixo `Z`, 0–7 dígitos fracionários, + data sem hora) porque `datetime.fromisoformat` do 3.10 não aceita os formatos do fio + (`2026-09-01T02:55:33.418+00:00`). Valor não parseável → propriedade `None`; o texto segue em `obj[...]`. + Texto sem fuso é interpretado como UTC (**INFERÊNCIA**, registrada em `DECISOES-FASE2.md`). +- **Dinheiro e alíquotas [D6]:** propriedades devolvem `Decimal` construído de `repr(float)` (recupera o + literal decimal do JSON para até 15 dígitos significativos); o dict cru fica com `float` (JSON nativo, + serializável). Na requisição, `Decimal` é aceito e serializado como número JSON exato. +- **Enums abertos:** `flow_status` é `str`, com constantes em `FlowStatus` e `Literal` para os valores + conhecidos — valor novo do servidor não quebra (ex.: `Error`, `WaitingReturnCancel`, ver §16). +- Tipos de documento fiscal (`federalTaxNumber`) expostos como `str` normalizado (só dígitos/letras), + porque o fio alterna `int` (perde zeros à esquerda: CNPJ `00000000000191` vira `191`), string formatada + e string crua (SONDA §9) — e o CNPJ alfanumérico não cabe em `int`. + +### §3. Matriz host × família × chave + +| Família (v0.1) | Base | Chave | SPEC | Prova | +|---|---|---|---|---| +| NFS-e v1 | `https://api.nfe.io/v1/companies/{id}/serviceinvoices` | principal | `nf-servico-v1.yaml` servers[0] | SONDA §1 (dados → 403), §6 | +| Empresas v2 **[D3]** | `https://api.nfse.io/v2/companies` | principal | `contribuintes-v2.json` | SONDA §1, §4 | +| Empresas v1 (legado; **não usada** — D3 = v2) | `https://api.nfe.io/v1/companies` | principal | `nf-servico-v1.yaml` | SONDA §4 | +| Certificados | `https://api.nfse.io/v2/companies/{id}/certificates` | principal | `contribuintes-v2.json` | SONDA §5 (GET); upload v2 = **INFERÊNCIA** (FIX provou o campo `file` só no `POST /v1/…/certificate`; confirmar na fase 2a, com v1 como plano B) | +| Webhooks | `https://api.nfse.io/v2/webhooks` | principal | `nf-servico-v1.yaml` servers[1] "Webhooks" | SONDA §8 (`api.nfe.io` idem) | +| CNPJ **[D4]** | `https://legalentity.api.nfe.io/v3/legalentities` | dados | `consulta-cnpj-v3.json` (sem host) + `consulta-cnpj.yaml` (host) | SONDA §9 | +| CPF | `https://naturalperson.api.nfe.io/v1/naturalperson` | dados | `cpf-api.yaml` | SONDA §1, §9 | +| CEP | `https://address.api.nfe.io/v2/addresses` | dados | `consulta-endereco.yaml` | SONDA §1, §9 | +| Download (redirect) | `https://api.nfse.io/v1/blob/download?b&e&s` | **nenhuma** | — | SONDA §6 | + +- Cada recurso declara sua `ApiFamily` (`FISCAL` | `DATA`); o requestor escolhe chave e host pela família. + `base_urls` é configurável por família (testes, proxy corporativo), nunca por recurso solto. +- **Esquema de auth: `Authorization: `** (crua, sem prefixo — decidido, Menor 1). A chave **nunca** + segue um redirect (download 302 pré-assinado vai para outro host sem nenhum cabeçalho de credencial). É o único esquema que está **na spec** + (`securitySchemes` de todas as famílias) **e** provado em todos os hosts (SONDA §1). `X-NFE-APIKEY` + também funciona mas não está em spec alguma; e `Authorization` é redigido por padrão por proxies, + APMs e loggers, enquanto um header custom tende a vazar em log. `?apikey=` nunca (vaza em log de URL). +- Não existe host sandbox: ambiente é atributo da empresa/chave (VAULT `PHP/review-07-14-2026/08`). +- A chave de dados recebeu 200 em companies/webhooks (SONDA §1) — anotado, **não** explorado: o + roteamento segue a família. + +### §4. Transporte stdlib plugável + +```python +class Transport(Protocol): + def send(self, request: HttpRequest) -> HttpResponse: ... # nunca segue redirect + def close(self) -> None: ... + +class AsyncTransport(Protocol): + async def send(self, request: HttpRequest) -> HttpResponse: ... + async def aclose(self) -> None: ... +``` + +- **Implementação padrão sobre `http.client`, não `urllib.request`.** Motivo decisivo: classificar a + **fase da falha** (§5). Com `http.client` o `connect()` é explícito: DNS, conexão recusada e handshake + TLS falham **antes** de qualquer byte da requisição sair (`ConnectionNotEstablished`); qualquer falha + depois disso é `RequestMaybeSent`. `urllib` mistura as duas num `URLError`. Além disso `urllib` segue + redirects **reenviando headers ao novo host** — inaceitável com a chave em `Authorization`. +- **Pool:** LIFO de `HTTPSConnection` por host, com lock, máx. 10 ociosas/host. **Requisições não + idempotentes (POST) sempre usam conexão nova**: falha em conexão keep-alive reaproveitada é ambígua + (o servidor pode ou não ter lido), e uma emissão não pode cair nesse caso por economia de handshake. +- **TLS:** `ssl.create_default_context()`, `minimum_version = TLSv1_2`, verificação de hostname e cadeia + sempre. Aceita `ssl_context` ou `ca_bundle` do usuário **somente para acrescentar confiança** (proxy + corporativo); contexto com `CERT_NONE` ou `check_hostname=False` → `ConfigurationError`. Não há flag + `verify=False`. Quem precisar mesmo (laboratório) injeta o próprio `Transport` — escolha explícita e + visível em code review. +- **Timeouts finitos:** padrão `Timeout(connect=10.0, read=60.0)`; `read` é por operação de socket e há + um **prazo total** por tentativa (`total=120.0`) checado entre leituras de chunk. `None`/infinito é + rejeitado. 60 s de leitura acompanha o PHP e o 408 "60s" documentado na emissão (SPEC + `nf-servico-v1.yaml` 408). +- **Limite de resposta:** leitura em chunks de 64 KiB até `max_response_bytes` (padrão 10 MiB, PDFs de NFS-e + têm ~55 KB — SONDA §6); estourou → `ResponseTooLargeError`, conexão descartada. Envia + `Accept-Encoding: identity` (sem gzip ⇒ sem bomba de descompressão). +- **User-Agent honesto:** `nfe-io-python/<__version__> python/. ` + + sufixo opcional `app_info=("odoo-l10n_br_nfse_nfeio", "18.0.1.0")` → `… odoo-l10n_br_nfse_nfeio/18.0.1.0`. + `__version__` vem de um único `src/nfeio/_version.py`, lido pelo hatchling; teste falha se outra literal + de versão aparecer no pacote. Lição do Node v6 (CHANGELOG: 93.995 requisições/30 dias, 23 variantes de + UA, **uma** versão reportada, nome de pacote inexistente). O sufixo `app_info` atende o plano de + telemetria por módulo de ERP (VAULT `ideias-integracoes/03`). +- **Multipart sem dependências** (`_multipart.encode`): boundary `secrets.token_hex(16)`, partes `file` + (filename sanitizado: sem `"`, CR, LF; `Content-Type: application/x-pkcs12`) e `password`; corpo em + `bytes` (certificado A1 tem poucos KB). Nome do campo `file` (minúsculo, SPEC v1 e FIX + `certificate-upload-field.json`: `file`/`File` aceitos, `certificate` → 400). +- **Redirects:** o transporte devolve o 3xx. Só a operação de download segue: até 3 saltos, **só https**, + **sem nenhuma credencial** (a URL pré-assinada é autoportante; o salto muda de `api.nfe.io` para + `api.nfse.io` — SONDA §6). Sem allowlist de host (a inbound usa R2, VAULT `P1/00` ②). +- **`Location` de 202** nunca é seguido como URL: só o **path** é lido (vem `http://` — FIX + `service-invoice-rtc-create.json`), validado contra `/v1/companies/{company_id}/serviceinvoices/{id}` e o + id extraído; a URL de polling é reconstruída a partir da base configurada. + +### §5. Retry ciente de método e de fase (port do PHP `RetryingTransport`) + +Tabela (VAULT `PHP/Retry seguro e idempotência`, PHP `src/Http/RetryingTransport.php:18-22`, adaptada): + +| Método | 429 | 408, 500, 502, 503, 504 | falha antes de conectar | falha após envio / timeout de leitura | +|---|---|---|---|---| +| GET, HEAD, PUT, DELETE | retenta | retenta | retenta | retenta | +| POST (qualquer) | **não** [D8] | **não** | retenta | **não** | + +- Outros 4xx nunca são retentados. 501/505+ não são transitórios. +- **Diferença deliberada do PHP:** o PHP retenta POST com `Idempotency-Key` em tudo. Aqui **não** — a API + ignora o header (VAULT `PHP/Ask API…`), então a premissa é falsa hoje. Quando a API honrar, basta + liberar POST+chave na tabela (mudança aditiva). +- **PUT de webhook é full-replace** (idempotente de fato) → retentável. `DELETE` de cancelamento é + idempotente no efeito; um segundo cancelamento devolve erro de negócio, não duplica. +- Backoff: `delay(n) = min(max_delay, base · 2^(n−1)) · U(0.7, 1.3)`, padrões `max_retries=3` (4 + tentativas), `base=1.0 s`, `max_delay=30 s` — mesmos números de PHP/Ruby/Node para comportamento + previsível entre SDKs. Jitter via `random.SystemRandom` (evita alerta do bandit, sem custo relevante). +- **`Retry-After`:** segundos inteiros **ou** HTTP-date (`email.utils.parsedate_to_datetime`); usado se + ≤ `max_retry_after` (60 s); maior que isso → não retenta, levanta `RateLimitError(retry_after=…)`. + (PHP/Ruby só aceitam inteiro.) **INFERÊNCIA:** a API manda `Retry-After` — nunca observado (SONDA §2). +- Cada tentativa é registrada em `logging.getLogger("nfeio")` em DEBUG, com método, host, path, status, + tentativa, atraso, `x-request-id` — **nunca** headers de auth, corpo ou query. + +**Dedupe de emissão por `externalId` [D5]:** + +- `service_invoices.create(..., external_id=…)` grava `externalId` no corpo. A API rejeita o segundo POST + com o mesmo valor (`400 "service invoice with external id (…) already exists"`, sem código) — VAULT + `PHP/Retry seguro…`, probe 2026-07-05. +- Erros com resultado incerto (5xx, 408, falha após envio, em POST de emissão) saem como a classe normal + (`ServerError`, `APIConnectionError`) **com `outcome_unknown=True` e `external_id`** preenchidos. O 400 + de duplicidade vira `DuplicateExternalIdError(InvalidRequestError)` reconhecido pelo texto (casamento + tolerante: "external id" + "already exists"), carregando `external_id`. +- `find_by_external_id(company_id, external_id, *, wait=0.0) -> ServiceInvoice | None`: `GET + …/external/{id}`; desembrulha a **lista** (`{"serviceInvoices":[…]}`; vazio = `None`, não 404 — + SONDA §6). Com `wait>0`, repete com backoff até o prazo para cobrir o atraso de indexação logo após o + 202 (VAULT: "segundos"). +- Recomendação: **reconciliação explícita** — o SDK nunca reenvia uma emissão sozinho; a doc traz a + receita de 6 linhas (`try create / except outcome_unknown or Duplicate → find_by_external_id(wait=30)`). + +### §6. Hierarquia de erros + +``` +NfeError +├── ConfigurationError (também ValueError) chave ausente, TLS inseguro, opções inválidas +├── InvalidParameterError (também ValueError) ID/CNPJ/CPF/CEP inválido antes da requisição +├── APIError status_code, message, error_code, body (bytes), json_body, headers, +│ │ request_id (x-request-id), trace_id (ProblemDetails), outcome_unknown +│ ├── InvalidRequestError 400, 405, 410, 415, 422 e demais 4xx não listados abaixo +│ │ └── DuplicateExternalIdError +│ ├── AuthenticationError 401 +│ ├── PermissionDeniedError 403 (mensagem orienta: "esta família usa api_key|data_api_key") +│ ├── NotFoundError 404 +│ ├── ConflictError 409 +│ ├── RateLimitError 429 (+ retry_after) +│ └── ServerError 5xx (408 também, por ser timeout do lado do servidor) +├── APIConnectionError falha de rede; phase = NOT_ESTABLISHED | MAYBE_SENT; outcome_unknown +│ └── APITimeoutError +├── ResponseTooLargeError +├── UnexpectedResponseError 2xx com corpo não parseável ou forma impossível (ex.: 202 sem id) +├── InvoiceProcessingError polling terminou em IssueFailed/CancelFailed/Error (+ invoice, flow_message) +├── PollingTimeoutError prazo do wait esgotado (+ último estado) +└── SignatureVerificationError só em construct_event (verify_signature devolve bool) +``` + +- Nomes evitam colidir com builtins (`APITimeoutError`, não `TimeoutError`). +- **Extração de mensagem** cobre os 5 envelopes vistos hoje (SONDA §3): corpo vazio → texto padrão por + status; **string JSON crua**; `{"errors":[{"code","message"}]}` (junta mensagens, `error_code` = 1º + `code`); `{"code","message"}`; ProblemDetails (`title` + `errors{campo:[…]}` → `"campo: msg"`; + `traceId`). Mensagem saneada (sem caracteres de controle) e truncada em 1.000 caracteres; corpo + integral fica em `body`. Node/PHP/Ruby cobrem só parte disso (PHP e Ruby perdem string crua e + ModelState; Node mapeia 403 como `ValidationError`). +- `str(err)` e `repr(err)` nunca incluem headers de auth nem o corpo inteiro. + +### §7. Paginação + +- **Offset v1 (1-based)** — NFS-e: `list(company_id, *, page_index=1, page_count=?, issued_begin=…, + issued_end=…, created_begin=…, created_end=…) -> OffsetPage[ServiceInvoice]`. Nomes espelham o fio + (`pageIndex`/`pageCount`) para casar com a doc da API; `page_count` = itens por página. +- O fio **não traz totais** (`{serviceInvoices, page:int}`, mesmo com `hasTotals=true` — SONDA §6), logo + `has_more = len(data) == page_count` (**inferência** controlada: no pior caso, 1 requisição extra que + volta vazia). +- Validação local: `page_index ≥ 1`; `page_count` em 1–50 para NFS-e, **2–50** para companies v1 (o + servidor rejeita 1 com mensagem que diz "between 1 and 50" — SONDA §4). Limites em uma tabela única. +- **Cursor** — companies v2 (**[D3]**): `list(*, limit=10, starting_after=None, ending_before=None) -> + CursorPage[Company]`; `has_more` do fio. `limit` validado em 1–50 (`limit=0` devolve + `hasMore:true` com lista vazia — loop infinito garantido sem a validação, SONDA §4). +- `page.auto_paging_iter()` (sync: `Iterator[T]`; async: `AsyncIterator[T]`) percorre a página atual e as + seguintes e para em página vazia **independentemente** de `has_more`. No cursor, as páginas seguintes usam + `limit=50`; no offset, mantém o `page_count` da primeira página (trocar o tamanho no meio desloca o + `pageIndex` e pularia itens) — a doc recomenda `list(..., page_count=50).auto_paging_iter()`. Também + `page.next_page() -> Page | None`. +- Sem `list_all()` que materializa tudo em memória (contas com milhares de notas). + +### §8. Fluxo assíncrono 202 + Location e polling + +- `create()` aceita 202 (assíncrono, caso normal), 200/201 (imediato). Com 202: o corpo + (`{id, environment, flowStatus[, flowMessage]}`, FIX `service-invoice-rtc-create.json`) é **preservado** + como `ServiceInvoice` parcial (o Node descarta o corpo); `id` do corpo, ou do path do `Location` se + faltar; sem nenhum dos dois → `UnexpectedResponseError`. +- `cancel()` idem: 202 + `Location` (**INFERÊNCIA** de escrita: VAULT memória + `integration-tests-need-emission-capable-company`, `Node/review-07-20-2026/01`; spec diz 200 string). + Aceita também 200 com corpo de nota ou string. +- `wait(company_id, invoice_id, *, timeout=120, initial_interval=1.0, max_interval=10.0, backoff=1.5, + raise_on_failure=True) -> ServiceInvoice`: faz `GET …/serviceinvoices/{id}` até `flowStatus` terminal. + **Não** usa `/status`: essa rota não existe em GET (400 de binding `rpsNumber` — SONDA §6, contrariando + a review do PHP). +- Terminais: `Issued`, `Cancelled` (sucesso); `IssueFailed`, `CancelFailed`, `Error` (falha → + `InvoiceProcessingError` com `flow_message`). Não terminais conhecidos (SPEC enum + RTC): + `PullFromCityHall`, `WaitingCalculateTaxes`, `WaitingDefineRpsNumber`, `WaitingSend`, `WaitingSendCancel`, + `WaitingReturn`, `WaitingReturnCancel`, `WaitingDownload`. **Valor desconhecido = não terminal** (continua + até o prazo) — mais seguro que declarar sucesso/falha por palpite. +- 404 nos primeiros `not_found_grace=10 s` é tratado como "ainda indexando" (**INFERÊNCIA**, simétrica ao + atraso do `/external`); depois disso propaga. +- Atalhos: `create_and_wait(...)`, `cancel_and_wait(...)`. O `wait` herda o retry de GET; o prazo total é + do `wait`, não de cada tentativa. +- Alternativa descartada: `client.poll_until_complete(location)` genérico do Node (heurística fraca + `status==='completed'`, engole erros). + +### §9. Webhooks + +- **CRUD** em `/v2/webhooks` (`api.nfse.io`, SPEC servers[1]; `api.nfe.io` serve o mesmo conjunto — + SONDA §8): `list()` desembrulha `webHooks`; `retrieve(id)` desembrulha `webHook`; `create(params)` e + `update(id, params)` embrulham em `{"webHook": …}` (sem envelope → 400, NODE spec `account-webhooks`); + `delete(id)`; `ping(id)` (`PUT …/pings`); `event_types()` (`/eventtypes`, desembrulha `eventTypes`). +- **`update` é substituição total no fio** — omitir `status` desativa o webhook (NODE CHANGELOG 5.1.0). + O SDK não faz merge mágico; a doc e o docstring avisam, e o exemplo mostra `retrieve` → alterar → + `update`. +- `contentType`/`status` são **strings** no fio (`"json"`, `"Active"`), apesar da spec dizer inteiro + (SONDA §8, FIX `webhooks-wire-types.json`). Tipados como `str` com constantes conhecidas. +- **`nfeio.webhooks.verify_signature(payload, signature, secret) -> bool`** — função de módulo (não precisa + de cliente): + - `payload: bytes | str` (str é codificada em UTF-8; a doc exige o corpo cru, nunca `json.dumps` de um + dict re-serializado); `signature: str | Sequence[str] | None` (valor de `X-Hub-Signature`; lista → + primeiro); `secret: str | bytes`. + - Exige prefixo `sha1=` (case-insensitive) + exatamente 40 hex; calcula `HMAC-SHA1(secret, payload)`; + compara com **`hmac.compare_digest`** sobre os hex em minúsculas (o servidor manda maiúsculas). + - **Nunca lança**: qualquer entrada malformada, segredo vazio ou exceção interna → `False`. + - Vetores de teste: os 3 pings reais de `FIX webhook-signatures.json` (verificados com `hmac` do + Python em 2026-10-08: os 3 conferem) + os negativos da spec Node `webhook-signature-verification` + (adulteração de 1 byte, `sha256=`, hex sem prefixo, `sha1=abc`, não-hex, segredo vazio, header vazio). +- **`construct_event(payload, headers_or_signature, secret) -> WebhookEvent`**: verifica primeiro (falha → + `SignatureVerificationError`), só então faz `json.loads` (limite de 5 MiB). Envelopes: entrega de evento + `{"action": "issued_successfully", "payload": {nota}}` (SPEC prosa `catalogo-saida-nfse.md`) e ping + `{"action":"ping","webHook":{…}}` (FIX). `WebhookEvent` expõe `action`, `event_type` (`X-Hook-Event`), + `hook_id` (`X-Hook-Id`, chave de idempotência da entrega at-least-once), `data` (`ServiceInvoice` quando + o tipo é `service_invoice`, senão `NfeObject`) e `raw: bytes`. Sem proteção anti-replay no protocolo + (não há timestamp assinado) — a doc manda deduplicar por `X-Hook-Id`. +- `X-NFe-Signature`/SHA-256 legado: não suportado (RUBY `webhook.rb:27-29`). + +### §10. Cliente assíncrono sem duplicar lógica + +Núcleo **sans-IO baseado em geradores**: cada operação é um gerador que produz efeitos e recebe +resultados. + +```python +Op = Generator[Effect, Any, T] # Effect = Send(HttpRequest) | Sleep(seconds) | Now() + +def create_service_invoice(ctx, company_id, params, external_id, options) -> Op[ServiceInvoice]: + resp = yield from send_with_retry(ctx, build_request(...), options) # retry também é um Op + return parse_create(resp, company_id, external_id) + +def run_sync(op, transport, clock) -> T: ... # Send → transport.send; Sleep → time.sleep +async def run_async(op, atransport) -> T: ... # Send → await atransport.send; Sleep → asyncio.sleep +``` + +- Retry, paginação, polling, follow de redirect e reconciliação por `externalId` são escritos **uma vez**, + como `Op`. As fachadas `NfeClient`/`AsyncNfeClient` só repetem **assinaturas** (necessário para tipos e + docs) e delegam: `return self._run(ops.create_service_invoice(...))` / + `return await self._run(ops.create_service_invoice(...))`. +- Teste de paridade: para cada método público, `inspect.signature` sync == async (salvo `async`) — impede + deriva das fachadas. +- `AsyncTransport` padrão = `ThreadedAsyncTransport(transport_sync)` → `asyncio.to_thread(sync.send, req)`. + O executor é o default do loop (o usuário pode trocar via `loop.set_default_executor`). Cancelamento de + task não interrompe a thread em voo; o timeout de socket limita quanto ela vive (documentado). Um + transporte httpx nativo no futuro implementa `AsyncTransport` sem tocar no núcleo. +- Testes do núcleo rodam os `Op` com um driver falso determinístico (sem rede, sem sleep real) — o mesmo + teste vale para sync e async. + +Alternativas: (a) duas implementações à mão — deriva garantida; (b) `unasync` no build — ferramenta extra, +código gerado versionado (regra 5 exigiria `_generated/`) e o caminho sync vira derivado do async, o +oposto do que a maioria dos usuários (Odoo/ERPNext síncronos) roda. + +### §11. Segurança + +- **Segredos:** `api_key`/`data_api_key` guardados em `SecretStr` interno com `__repr__`/`__str__` + mascarados (`'nfe_…a1b2'` → só últimos 4); `repr(client)`, `repr(config)`, `repr(RequestOptions)` + mascarados. Logger `nfeio` nunca recebe headers, query ou corpo; erros não ecoam o header + `Authorization`. Senha de certificado nunca aparece em repr/log/exceção. Teste varre `repr`/logs/ + exceções de uma execução completa procurando a chave. +- **IDs em path:** toda interpolação passa por `_paths.segment(kind, value)`: IDs opacos casam + `^[A-Za-z0-9_-]{1,64}$` (cobre os 24 e 32 hex observados sem cravar tamanho); `external_id` (texto + livre do cliente) é percent-encoded com `quote(safe="")`, rejeita vazio, `.`/`..`, controle e > 255; + CNPJ (14 chars `[0-9A-Z]{12}[0-9]{2}`, aceita máscara, valida DV — algoritmo alfanumérico da RFB), CPF + (11 dígitos + DV), CEP (8 dígitos), data `date`/`AAAA-MM-DD`. Falha → `InvalidParameterError` sem + requisição (consultas de dados podem ser tarifadas — validar DV local evita custo). +- **Desserialização:** só `json.loads` (com limite de tamanho já aplicado); nada de `pickle`, `eval`, + `yaml.load`, `xml` parse no SDK (XML/PDF são devolvidos como `bytes`, o usuário escolhe o parser — + recomendar `defusedxml` na doc). +- **TLS:** §4. **Redirects:** §4 (sem credencial, só https). **Logs:** §5. +- **Supply chain:** zero deps de runtime (a superfície de ataque do pacote instalado é só o próprio + código); `uv.lock` para dev; GitHub Actions com actions **fixadas por SHA**, `permissions: {}` por padrão + e mínimas por job; **PyPI Trusted Publishing (OIDC)** — sem token de PyPI em secret; publicação com + **attestations PEP 740** (Sigstore) pelo `pypa/gh-action-pypi-publish`; build único, artefato + verificado e publicado em job separado com environment `pypi` protegido (aprovação manual); + `pip-audit` sobre `uv export` (dev); Dependabot só para `github-actions` e dev-deps; CodeQL (python) + pelo default setup do repositório no GitHub, sem workflow próprio; `bandit -r src` no CI; `ruff` com regras `S` (flake8-bandit). +- **`SECURITY.md`:** versões suportadas, canal privado = **GitHub Private Vulnerability Reporting** + (decidido, Menor 3), prazo de resposta, escopo (SDK, não a API), política de divulgação coordenada. + +### §12. Código gerado a partir da OpenAPI: não no v0.1 + +- Evidência: PHP gerou 856 arquivos **0% usados em runtime** (VAULT `PHP/review-07-14-2026/08`: "~600 + arquivos ociosos"); Node: 33% de 30.487 linhas geradas sem consumidor; Ruby: 551 arquivos que **descartam + campos**; o gerador do PHP não via schemas inline e emitia nada em silêncio (VAULT `PHP/Codegen ignora + specs inline`) — exatamente o caso de `nf-servico-v1` (resposta 202/200 inline, `checkCode` repetido 4×). +- A spec erra em pontos centrais do v0.1 (§16): gerar a partir dela cristalizaria os erros. +- **No lugar:** modelos à mão (§2) + **teste de alinhamento ancorado em path** (lição do `operationId` + duplicado `ServiceInvoices_idGet`): cada propriedade tipada aponta para uma chave que existe no schema + da spec **ou** numa lista explícita de divergências provadas; TypedDicts de requisição checados contra os + campos do corpo da spec. PyYAML só como dev-dep do teste. +- `src/nfeio/_generated/` fica reservado (só `README` explicando a regra 5) para quando houver ganho + claro (provável: TypedDicts dos corpos enormes de NF-e na v0.2). + +### §13. Tooling + +| Item | Escolha | Motivo | +|---|---|---| +| Ambiente | `uv` (venv local, `uv.lock`) | rápido, lock reproduzível, já em uso | +| Build backend | `hatchling` | padrão PyPA, versão dinâmica de `_version.py`, sem setup.py | +| Lint/format | `ruff` (E, F, W, I, B, UP, S, SIM, RUF, PT, ASYNC; format) | um binário; `S` cobre bandit no editor | +| Tipos | `mypy --strict` + `py.typed` | consumidores tipados (Odoo 18 já usa hints) | +| Testes | `pytest` + `pytest-asyncio` (dev) | async direto nos testes; zero impacto em runtime | +| Cobertura | `coverage` ≥ 90% em `nfeio._core`, `nfeio.errors`, `nfeio.webhooks`; ≥ 85% global | núcleo é onde mora o risco | +| Segurança | `bandit`, `pip-audit`, CodeQL (default setup do repositório) | §11 | +| CI | GitHub Actions; matriz 3.10, 3.11, 3.12, 3.13, 3.14 (ubuntu) + 3.12 em macOS e Windows | 3.14 = atual; 3.10 = piso (Odoo 17/ERPNext 15) | +| Integração | `pytest -m live`, lê `.env` (parser stdlib no conftest), pula sem chave; escrita só com `NFE_LIVE_WRITE=1` e só em `NFE_COMPANY_ID` | regra 2 das regras do projeto | + +Comandos: `uv run pytest`, `uv run ruff check`, `uv run ruff format --check`, `uv run mypy --strict src tests`, +`uv build`. + +### §14. Layout + +``` +src/nfeio/ + __init__.py # API pública: NfeClient, AsyncNfeClient, RequestOptions, Timeout, __version__ + _version.py # única fonte da versão + py.typed + _client.py # fachadas NfeClient / AsyncNfeClient + _config.py # ClientConfig, RequestOptions, Timeout, ApiFamily, hosts, SecretStr + errors.py # hierarquia pública + webhooks.py # verify_signature, construct_event, WebhookEvent (sem cliente) + types.py # FlowStatus e aliases públicos + _core/ + ops.py # Effect, Send, Sleep, run_sync, run_async + transport.py # Transport/AsyncTransport, HttpRequest/HttpResponse, HttpClientTransport, ThreadedAsyncTransport + retry.py backoff.py # política, tabela, Retry-After + requestor.py # monta requisição por família, auth, UA, decodifica, mapeia erro + paths.py # validação/encoding de segmentos, CNPJ/CPF/CEP + multipart.py json.py (Decimal-aware) errors_extract.py pagination.py polling.py user_agent.py + models/ + _base.py # NfeObject, ResponseInfo, parsers de data/Decimal + service_invoice.py company.py certificate.py webhook.py lookups.py + resources/ + service_invoices.py companies.py certificates.py webhooks.py lookups.py # Ops + fachadas + _generated/README.md # reservado (regra 5) +tests/ + unit/ contract/ (replay de fixtures redigidas) live/ (opt-in) fixtures/ +scripts/probes/ # sondas (já existem) +docs/contrato/ # evidência e decisões +``` + +### §15. Escopo de operações do v0.1 (resumo; contrato nas specs) + +- `service_invoices`: `create`, `list`, `retrieve`, `find_by_external_id`, `cancel`, `send_email`, + `download_pdf`, `download_xml`, `download_cancellation_xml`, `wait`, `create_and_wait`, `cancel_and_wait`. +- `companies` [D3]: `list`, `retrieve`, `create`, `update`, `delete`. +- `certificates`: `upload`, `list` (status/validade). +- `lookups` [D4][D7]: `cnpj` (v3), `cnpj_state_taxes` (inscrições estaduais, v3), `cpf`, `cep`. +- `webhooks`: `list`, `retrieve`, `create`, `update`, `delete`, `ping`, `event_types` + módulo + `nfeio.webhooks`. + +### §16. Divergências spec × fio que o SDK trata + +| # | Ponto | Spec diz | Fio faz | Tratamento no SDK | Fonte | +|---|---|---|---|---|---| +| 1 | Auth | `Authorization` / `?apikey` | também `X-NFE-APIKEY`, `Basic `; base64/Bearer → 401 | usa `Authorization: ` | SONDA §1 | +| 2 | Chave por host | — | dados ↔ fiscal complementares (403 cruzado) | família → chave; sem fallback | SONDA §1, FIX matrix | +| 3 | 401/403 | corpo `ErrorsResource` | **corpo vazio** | mensagem padrão + dica de chave | SONDA §1 | +| 4 | Erros | `{errors:[{code,message}]}` | 5 envelopes (string crua, errors c/ e s/ code, code/message, ProblemDetails) | extrator único | SONDA §3, FIX `unreachable-methods` | +| 5 | Request id | não documentado | `x-request-id` em toda resposta; `traceId` no ProblemDetails | `request_id`, `trace_id` | SONDA §2 | +| 6 | POST emissão | 202 sem headers | 202 + `Location` **`http://`** | só o path; id do corpo | FIX `service-invoice-rtc-create` | +| 7 | POST emissão falho | — | 500/504 **com nota criada** | sem retry; `outcome_unknown` | VAULT `Incidente 504`, `Retry seguro` | +| 8 | `Idempotency-Key` | — | ignorado | não altera retry | VAULT `Ask API` | +| 9 | `externalId` duplicado | não documentado | 400 texto livre, sem código | `DuplicateExternalIdError` | VAULT `Retry seguro` | +| 10 | `/external/{id}` | objeto; parâmetro `externalId` ≠ `{id}`; operationId duplicado | **lista**; miss = 200 vazio | desembrulha; `None` | SONDA §6, VAULT `P1/02` | +| 11 | Cancelar | `DELETE` → 200 string | 202 + `Location` (**provado na fase 2**) | trata como assíncrono | `probe-fase2.md` | +| 12 | `/status` | ausente | **não existe** (400 binding) | polling via `GET /{id}` | SONDA §6 | +| 13 | PDF/XML | 200 JSON string | **302** p/ blob pré-assinado em outro host | segue sem credencial; `bytes` | SONDA §6 | +| 14 | XML NFS-e | — | começa em ` bool`; `subject`, +`resolution` e os demais campos ficam no acesso por chave. + +#### Scenario: Validade +- **WHEN** a resposta traz `"validUntil": "2026-11-03T16:18:00+00:00"` +- **THEN** `cert.expires_on` é esse instante e `cert.is_expired(at=datetime(2026,12,1,tzinfo=UTC))` é `True` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md new file mode 100644 index 0000000..b18a377 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md @@ -0,0 +1,160 @@ +## Purpose + +Define como o usuário cria e configura o cliente da NFE.io em Python: chaves por família de API, hosts, +opções por chamada, proteção de segredos, validação de identificadores e representação dos modelos de +resposta com preservação integral do que vem no fio. + +## ADDED Requirements + +### Requirement: Cliente explícito sem estado global +O SDK SHALL expor `NfeClient` (síncrono) e `AsyncNfeClient` (assíncrono) como pontos de entrada, cada +instância com configuração própria e imutável. O SDK MUST NOT manter chave de API, host ou opção em +estado global de módulo, de modo que duas instâncias com chaves diferentes coexistam no mesmo processo +sem interferência. + +#### Scenario: Dois clientes com chaves diferentes no mesmo processo +- **WHEN** o usuário cria `NfeClient(api_key="A")` e `NfeClient(api_key="B")` e chama o mesmo método nos dois +- **THEN** cada requisição sai com a chave do seu cliente + +#### Scenario: Recursos expostos como atributos +- **WHEN** o usuário acessa `client.service_invoices`, `client.companies`, `client.certificates`, `client.webhooks` e `client.lookups` +- **THEN** cada atributo devolve o serviço do recurso, com métodos em snake_case + +### Requirement: Chaves por família de API +O SDK SHALL aceitar `api_key` (famílias fiscais e de gestão) e `data_api_key` (consultas de CNPJ, CPF e +CEP) e, quando omitidas, SHALL ler `NFE_API_KEY` e `NFE_DATA_API_KEY` do ambiente. Cada recurso MUST +usar a chave da sua família. O SDK MUST NOT usar a chave principal como substituta da chave de dados. + +#### Scenario: Consulta sem chave de dados +- **WHEN** o cliente foi criado só com `api_key` e o usuário chama uma consulta de CNPJ +- **THEN** o SDK levanta `ConfigurationError` informando que `data_api_key` é necessária, sem abrir conexão + +#### Scenario: Emissão usa a chave principal +- **WHEN** o cliente tem `api_key` e `data_api_key` e o usuário lista notas de serviço +- **THEN** a requisição sai com a `api_key` + +#### Scenario: Nenhuma chave disponível +- **WHEN** o cliente é criado sem `api_key` e sem `NFE_API_KEY` no ambiente e o usuário chama um recurso fiscal +- **THEN** o SDK levanta `ConfigurationError` antes de qualquer requisição + +### Requirement: Autenticação pelo cabeçalho Authorization +O SDK SHALL enviar a chave no cabeçalho `Authorization` com o valor cru da chave, sem prefixo, em todos +os hosts. O SDK MUST NOT enviar a chave na query string nem em requisição a destino de redirect. + +#### Scenario: Cabeçalho de autenticação +- **WHEN** qualquer requisição autenticada é enviada +- **THEN** ela contém `Authorization: ` e a URL não contém a chave + +### Requirement: Hosts por família configuráveis +O SDK SHALL usar, por padrão, `https://api.nfe.io` para NFS-e, `https://api.nfse.io` para empresas, +certificados e webhooks, `https://legalentity.api.nfe.io` para CNPJ, `https://naturalperson.api.nfe.io` +para CPF e `https://address.api.nfe.io` para CEP, e SHALL permitir sobrescrever a base de cada família na +criação do cliente. O SDK MUST rejeitar base que não use `https`. + +#### Scenario: Base sobrescrita para testes +- **WHEN** o usuário cria o cliente com a base da família fiscal apontando para `https://localhost:8443` +- **THEN** as requisições de NFS-e vão para esse host e as das demais famílias continuam nos padrões + +#### Scenario: Base sem TLS +- **WHEN** o usuário informa uma base `http://` +- **THEN** o SDK levanta `ConfigurationError` + +### Requirement: Opções por chamada +Todo método público que faz requisição SHALL aceitar `options: RequestOptions | None`, com `api_key`, +`timeout`, `max_retries`, `idempotency_key` e `extra_headers`, que sobrescrevem a configuração do cliente +só naquela chamada. `extra_headers` MUST NOT sobrescrever `Authorization`, `User-Agent` nem +`Content-Type`. `idempotency_key` SHALL ser enviado como cabeçalho `Idempotency-Key` e MUST NOT alterar a +política de retry. + +#### Scenario: Timeout e retries por chamada +- **WHEN** o usuário chama `create(..., options=RequestOptions(timeout=90, max_retries=0))` +- **THEN** só essa chamada usa 90 s de timeout e nenhuma nova tentativa + +#### Scenario: Cabeçalho protegido +- **WHEN** `extra_headers` contém `Authorization` +- **THEN** o SDK levanta `ConfigurationError` e não envia a requisição + +### Requirement: Segredos mascarados +O SDK MUST NOT expor chaves de API ou senha de certificado em `repr`, `str`, mensagens de exceção ou +registros de log. Representações de cliente, configuração e opções SHALL mostrar no máximo os 4 últimos +caracteres da chave. + +#### Scenario: repr do cliente +- **WHEN** o usuário imprime `repr(client)` +- **THEN** a saída não contém a chave completa e mostra apenas um sufixo mascarado + +#### Scenario: Varredura de vazamento +- **WHEN** uma sequência de chamadas com sucesso, erro 401, erro 500 e falha de rede é executada com log em nível DEBUG +- **THEN** nenhuma linha de log, `repr` ou mensagem de exceção contém a chave ou a senha do certificado + +### Requirement: Validação de identificadores antes de compor o path +O SDK SHALL validar todo valor interpolado em path antes da requisição: identificadores opacos MUST casar +`^[A-Za-z0-9_-]{1,64}$`; texto livre (como `external_id`) MUST ser codificado por percent-encoding de +todos os caracteres reservados e MUST ser rejeitado se vazio, igual a `.` ou `..`, com caractere de +controle ou com mais de 255 caracteres. Valor inválido SHALL levantar `InvalidParameterError` sem +requisição. + +#### Scenario: Tentativa de path traversal +- **WHEN** o usuário chama `retrieve(company_id, "../../v2/webhooks")` +- **THEN** o SDK levanta `InvalidParameterError` e nenhuma requisição é enviada + +#### Scenario: externalId com barra +- **WHEN** o usuário busca o `external_id` `"pedido/123"` +- **THEN** o path enviado contém `pedido%2F123` em um único segmento + +### Requirement: Modelos preservam o fio +Todo objeto de resposta SHALL ser um `Mapping[str, Any]` imutável que dá acesso de leitura a todas as +chaves do JSON recebido, inclusive as que o SDK não conhece, pelo nome original do fio (`obj["chave"]`), +e SHALL oferecer `to_dict()` com cópia profunda serializável em JSON. No acesso por chave, um `Mapping` +aninhado SHALL ser devolvido como `NfeObject` e uma lista SHALL ser devolvida como lista nova cujos +`Mapping` também são `NfeObject`; escalares SHALL ser devolvidos como vieram. Propriedades tipadas em +snake_case SHALL existir apenas para campos que o SDK corrige ou tipa (ids, status, datas, valores +monetários e alíquotas, documentos e aninhados que contêm documento). O SDK MUST NOT descartar campos +desconhecidos e MUST NOT resolver atributos dinamicamente a partir das chaves do fio (`__getattr__`). + +#### Scenario: Campo novo no servidor +- **WHEN** a API passa a devolver `"novoCampo": 1` numa nota de serviço +- **THEN** `invoice["novoCampo"] == 1` e `invoice.to_dict()["novoCampo"] == 1` sem atualização do SDK + +#### Scenario: Propriedade tipada +- **WHEN** a resposta contém `"flowStatus": "Issued"` +- **THEN** `invoice.flow_status == "Issued"` + +#### Scenario: Aninhado por chave +- **WHEN** a resposta contém `"borrower": {"name": "Cliente", "address": {"city": {"code": "3550308"}}}` +- **THEN** `invoice["borrower"]["address"]["city"]["code"] == "3550308"`, `invoice["borrower"]` é um `NfeObject` e `invoice.to_dict()["borrower"]` é um `dict` comum + +#### Scenario: Campo do fio com nome de método de Mapping +- **WHEN** a resposta contém `"items": [{"code": "1"}]` +- **THEN** `obj["items"][0]["code"] == "1"` e `obj.items()` continua sendo o método de `Mapping` + +#### Scenario: Imutabilidade +- **WHEN** o usuário altera a lista devolvida por `obj["filters"]` +- **THEN** `obj["filters"]` e `obj.to_dict()["filters"]` continuam com o valor original + +### Requirement: Tipos de dados robustos ao fio +Propriedades de data SHALL devolver `datetime` com fuso, aceitando sufixo `Z`, offset e de 0 a 7 dígitos +fracionários, e SHALL devolver `None` quando o texto não for interpretável, mantendo o valor original no +acesso cru. Propriedades monetárias e de alíquota SHALL devolver `Decimal` sem erro de representação +binária para valores de até 15 dígitos significativos. Números de documento (CNPJ/CPF) SHALL ser +expostos como `str` normalizada, preservando zeros à esquerda. + +#### Scenario: Data com milissegundos em Python 3.10 +- **WHEN** a resposta contém `"createdOn": "2026-09-01T02:55:33.418+00:00"` +- **THEN** a propriedade devolve o `datetime` correspondente com fuso UTC em todas as versões suportadas + +#### Scenario: Valor monetário +- **WHEN** a resposta contém `"servicesAmount": 1234.56` +- **THEN** a propriedade devolve `Decimal("1234.56")` + +#### Scenario: CNPJ devolvido como inteiro pela API +- **WHEN** uma resposta traz `"federalTaxNumber": 191` num contexto de CNPJ +- **THEN** a propriedade devolve `"00000000000191"` + +### Requirement: Metadados da última resposta +Todo objeto e página devolvidos SHALL expor `last_response` com `status_code`, cabeçalhos +(consulta sem distinção de maiúsculas) e `request_id` lido de `x-request-id`. + +#### Scenario: Request id disponível +- **WHEN** a API responde com `x-request-id: 0HNP51M3F3EVD:00000006` +- **THEN** `obj.last_response.request_id == "0HNP51M3F3EVD:00000006"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md new file mode 100644 index 0000000..da4a650 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md @@ -0,0 +1,56 @@ +## Purpose + +Define o gerenciamento de empresas (contribuintes) pelo SDK sobre a API de Empresas v2 +(`api.nfse.io/v2/companies`), que a documentação oficial indica como substituta da v1 em +descontinuação (decisão D3 = A, 2026-10-08). + +## ADDED Requirements + +### Requirement: Listar empresas por cursor +`companies.list(*, limit=10, starting_after=None, ending_before=None)` SHALL enviar `GET /v2/companies` +com a chave principal e devolver uma página por cursor de `Company` lida de `companies`, com `has_more` +lido de `hasMore`. + +#### Scenario: Varredura de todas as empresas +- **WHEN** a conta tem 662 empresas e o usuário itera `companies.list().auto_paging_iter()` +- **THEN** recebe 662 empresas distintas usando `limit=50` e `startingAfter` com o id do último item + +### Requirement: Consultar empresa +`companies.retrieve(company_id)` SHALL enviar `GET /v2/companies/{company_id}` e devolver a empresa +desembrulhada de `company`. + +#### Scenario: Empresa inexistente +- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"…"}]}` +- **THEN** o SDK levanta `NotFoundError` com `error_code == 40401` + +### Requirement: Criar, alterar e excluir empresa +`companies.create(params)` SHALL enviar `POST /v2/companies` com o corpo embrulhado em `{"company": …}`; +`companies.update(company_id, params)` SHALL enviar `PUT /v2/companies/{company_id}` com o mesmo envelope +e substituição completa; `companies.delete(company_id)` SHALL enviar `DELETE /v2/companies/{company_id}` +e aceitar 204. As respostas com corpo SHALL ser desembrulhadas de `company`. A documentação de +`delete` SHALL avisar que a API faz desativação (soft delete): a empresa continua consultável com +`status` `Inactive` (provado em 2026-10-08). + +#### Scenario: Criação +- **WHEN** o usuário chama `create({"name": "…", "federalTaxNumber": …, "taxRegime": "SimplesNacional", "address": {…}})` +- **THEN** o corpo enviado é `{"company": {…}}` e o retorno é a empresa criada + +#### Scenario: Exclusão +- **WHEN** a API responde 204 +- **THEN** o método retorna `None` sem erro + +#### Scenario: Empresa excluída continua consultável +- **WHEN** o usuário consulta uma empresa depois de `delete` +- **THEN** o SDK devolve a empresa com `status == "Inactive"`, como a API responde + +### Requirement: Modelo Company +`Company` SHALL expor propriedades tipadas apenas para `id`, `account_id`, `federal_tax_number` +(string normalizada de 14 caracteres), `tax_regime`, `status`, `municipal_tax_ids` (lista de ids de +`municipalTaxes`), `state_tax_ids`, `created_on` e `modified_on`; `name`, `tradeName`, `address` e os +demais campos ficam no acesso por chave. O SDK MUST NOT expor +o campo `municipalTaxNumber` da v2 como inscrição municipal, porque o fio o preenche com o id da +inscrição; o valor continua acessível pelo nome do fio. + +#### Scenario: municipalTaxNumber com id +- **WHEN** a resposta v2 traz `"municipalTaxNumber": "0a1b2c3d4e5f60718293a4b5c6d7e8f9"` +- **THEN** não existe propriedade tipada que o apresente como inscrição municipal e `company["municipalTaxNumber"]` devolve o valor cru diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md new file mode 100644 index 0000000..f20004f --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md @@ -0,0 +1,68 @@ +## Purpose + +Define as consultas de dados cadastrais oferecidas pelo SDK — pessoa jurídica por CNPJ, situação de +pessoa física por CPF e endereço por CEP — que usam a chave de dados e hosts próprios. + +## ADDED Requirements + +### Requirement: Consulta de CNPJ +`lookups.cnpj(cnpj, *, update_address=None, update_city_code=None)` SHALL enviar +`GET https://legalentity.api.nfe.io/v3/legalentities/basicInfo/{cnpj}` com a chave de dados e devolver +`LegalEntity` desembrulhada de `legalEntity`. O SDK SHALL aceitar CNPJ numérico ou alfanumérico, com ou +sem máscara, normalizá-lo para 14 caracteres maiúsculos e validar os dígitos verificadores pelo +algoritmo da Receita Federal antes da requisição. + +#### Scenario: CNPJ com máscara +- **WHEN** o usuário consulta `"00.000.000/0001-91"` +- **THEN** a requisição usa o path `/v3/legalentities/basicInfo/00000000000191` + +#### Scenario: CNPJ alfanumérico +- **WHEN** o usuário consulta `"12.ABC.345/01DE-35"` +- **THEN** a validação local aceita e a requisição usa `12ABC34501DE35` + +#### Scenario: Dígito verificador errado +- **WHEN** o usuário consulta `"00000000000100"` +- **THEN** o SDK levanta `InvalidParameterError` sem requisição + +### Requirement: Inscrições estaduais por CNPJ +`lookups.cnpj_state_taxes(cnpj, state)` SHALL enviar `GET …/v3/legalentities/stateTaxInfo/{UF}/{cnpj}` +com a UF validada entre as 27 unidades federativas. + +#### Scenario: UF inválida +- **WHEN** o usuário informa `state="XX"` +- **THEN** o SDK levanta `InvalidParameterError` sem requisição + +### Requirement: Consulta de CPF +`lookups.cpf(cpf, birth_date)` SHALL enviar +`GET https://naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{AAAA-MM-DD}` com a chave de dados, +aceitando `birth_date` como `date` ou texto `AAAA-MM-DD` e validando os dígitos do CPF antes da +requisição. CPF existente com data divergente SHALL resultar em `NotFoundError`. + +#### Scenario: Data divergente +- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"not found"}]}` +- **THEN** o SDK levanta `NotFoundError` com `error_code == 40401` + +#### Scenario: Data em formato brasileiro +- **WHEN** o usuário passa `birth_date="01/01/1990"` +- **THEN** o SDK levanta `InvalidParameterError` sem requisição + +### Requirement: Consulta de CEP +`lookups.cep(cep)` SHALL enviar `GET https://address.api.nfe.io/v2/addresses/{cep}` com a chave de +dados, aceitando CEP com ou sem hífen (normalizado para 8 dígitos), e devolver `Address` desembrulhado +de `address`. O SDK MUST NOT oferecer busca de endereço por termo ou filtro. + +#### Scenario: CEP com hífen +- **WHEN** o usuário consulta `"01310-100"` +- **THEN** a requisição usa o path `/v2/addresses/01310100` e o retorno tem `address["city"]["code"] == "3550308"` + +### Requirement: Modelos de consulta +Os modelos de consulta SHALL seguir a regra enxuta de `client-core`: `LegalEntity` SHALL expor +propriedades tipadas para `federal_tax_number` (string de 14 caracteres), `status`, `opened_on`, +`status_on` e `share_capital` (`Decimal`); `NaturalPerson` para `federal_tax_number` (string de 11 +dígitos), `status` e `birth_on`; `Address` para `postal_code` (8 dígitos). Os demais campos (`name`, +`tradeName`, `address`, `phones`, `economicActivities`, `street`, `district`, `city`, `state`, …) SHALL +estar disponíveis no acesso por chave. + +#### Scenario: Endereço +- **WHEN** a resposta é `{"address":{"state":"SP","city":{"code":"3550308","name":"São Paulo"},"postalCode":"01310-100",…}}` +- **THEN** `address["state"] == "SP"`, `address["city"]["name"] == "São Paulo"` e `address.postal_code == "01310100"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md new file mode 100644 index 0000000..6132438 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md @@ -0,0 +1,82 @@ +## Purpose + +Define as exceções do SDK: hierarquia por categoria e status HTTP, preservação da resposta da API, +extração de mensagem dos formatos de erro realmente usados pela NFE.io e identificação da requisição +para suporte. + +## ADDED Requirements + +### Requirement: Hierarquia tipada de erros +Todas as exceções do SDK SHALL derivar de `NfeError`. Respostas HTTP de erro SHALL ser mapeadas para +subclasses de `APIError`: 401 → `AuthenticationError`, 403 → `PermissionDeniedError`, 404 → +`NotFoundError`, 409 → `ConflictError`, 429 → `RateLimitError`, 408 e 5xx → `ServerError`, demais 4xx → +`InvalidRequestError`. Falhas de rede SHALL ser `APIConnectionError`, com `APITimeoutError` como +subclasse para timeouts. Erros de configuração e de parâmetro local SHALL derivar também de `ValueError`. + +#### Scenario: 403 da API +- **WHEN** a API responde 403 +- **THEN** o SDK levanta `PermissionDeniedError` com `status_code == 403` + +#### Scenario: 422 da API +- **WHEN** a API responde 422 +- **THEN** o SDK levanta `InvalidRequestError` + +#### Scenario: Captura genérica +- **WHEN** o usuário captura `NfeError` +- **THEN** qualquer exceção levantada pelo SDK é capturada + +### Requirement: Preservação da resposta +`APIError` SHALL expor `status_code`, `message`, `error_code` (quando houver), `body` (bytes integrais), +`json_body` (quando o corpo for JSON), `headers`, `request_id` (de `x-request-id`) e `trace_id` (de +`traceId` em ProblemDetails). + +#### Scenario: Request id no erro +- **WHEN** uma resposta 404 traz `x-request-id: abc:1` +- **THEN** a exceção tem `request_id == "abc:1"` + +### Requirement: Extração de mensagem dos envelopes da API +O SDK SHALL extrair a mensagem de erro de: corpo vazio (mensagem padrão por status), string JSON crua, +`{"errors":[{"code","message"}]}` (todas as mensagens; `error_code` = primeiro `code`), +`{"errors":[{"message"}]}`, `{"code","message"}` e ProblemDetails (`title` mais cada +`campo: mensagem` de `errors`). A mensagem MUST ter caracteres de controle removidos e no máximo 1.000 +caracteres; o corpo integral permanece em `body`. + +#### Scenario: String JSON crua +- **WHEN** a API responde 400 com o corpo `"pageCount must be between 1 and 50"` +- **THEN** `message == "pageCount must be between 1 and 50"` + +#### Scenario: Lista de erros com código +- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"company id not found"}]}` +- **THEN** `message == "company id not found"` e `error_code == 40401` + +#### Scenario: ProblemDetails de validação +- **WHEN** a API responde 400 com `{"title":"One or more validation errors occurred.","errors":{"pageIndex":["The value 'abc' is not valid."]},"traceId":"00-x-y-01"}` +- **THEN** a mensagem contém `pageIndex: The value 'abc' is not valid.` e `trace_id == "00-x-y-01"` + +#### Scenario: 401 com corpo vazio +- **WHEN** a API responde 401 sem corpo +- **THEN** o SDK levanta `AuthenticationError` com mensagem padrão não vazia + +### Requirement: Dica de chave em 403 +`PermissionDeniedError` SHALL informar na mensagem qual parâmetro de chave (`api_key` ou `data_api_key`) +a família do recurso usa. + +#### Scenario: Consulta de CNPJ com chave errada +- **WHEN** uma consulta de CNPJ recebe 403 +- **THEN** a mensagem menciona `data_api_key` + +### Requirement: Erro não expõe segredos +`str`, `repr` e atributos de qualquer exceção MUST NOT conter a chave de API nem o cabeçalho +`Authorization` enviado. + +#### Scenario: Erro de autenticação +- **WHEN** a API responde 401 +- **THEN** `str(err)` e `repr(err)` não contêm a chave usada + +### Requirement: Resposta inesperada +Uma resposta 2xx cujo corpo não seja JSON válido quando JSON é esperado, ou que não permita identificar o +recurso criado, SHALL levantar `UnexpectedResponseError` com o status e o corpo preservados. + +#### Scenario: 202 sem identificação +- **WHEN** a emissão responde 202 sem `id` no corpo e sem `Location` +- **THEN** o SDK levanta `UnexpectedResponseError` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md new file mode 100644 index 0000000..f88d3d8 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md @@ -0,0 +1,116 @@ +## Purpose + +Define o transporte HTTP do SDK: implementação só com a biblioteca padrão, substituível por protocolo, +com TLS sempre verificado, timeouts finitos, limite de tamanho de resposta, User-Agent honesto, envio +multipart sem dependências e tratamento seguro de redirects. + +## ADDED Requirements + +### Requirement: Zero dependências de runtime +O pacote instalado MUST NOT declarar dependências de runtime; todo o transporte SHALL usar apenas a +biblioteca padrão do Python. + +#### Scenario: Metadados do pacote +- **WHEN** o wheel publicado é inspecionado +- **THEN** a lista `Requires-Dist` não contém dependências obrigatórias + +### Requirement: Transporte substituível +O SDK SHALL definir protocolos públicos de transporte síncrono e assíncrono e SHALL aceitar uma +implementação do usuário na criação do cliente. O transporte MUST NOT seguir redirects por conta própria. + +#### Scenario: Transporte customizado +- **WHEN** o usuário passa um objeto que implementa o protocolo de transporte +- **THEN** todas as requisições do cliente passam por ele, com retry, erros e paginação funcionando igual + +### Requirement: Classificação da fase da falha de rede +O transporte padrão SHALL distinguir falhas que ocorrem antes de qualquer byte da requisição ser enviado +(resolução de nome, conexão recusada, handshake TLS) das que ocorrem depois (timeout de leitura, +conexão encerrada), expondo a fase na exceção. Requisições POST MUST usar conexão nova, nunca uma +conexão reaproveitada do pool. + +#### Scenario: DNS falha +- **WHEN** o host não resolve +- **THEN** a exceção de conexão informa a fase "não estabelecida" + +#### Scenario: Timeout de leitura +- **WHEN** a conexão é estabelecida, a requisição é enviada e a resposta não chega dentro do timeout +- **THEN** a exceção de timeout informa a fase "pode ter sido enviada" + +### Requirement: TLS sempre verificado +O SDK SHALL verificar certificado e nome do host em toda conexão, com versão mínima TLS 1.2. O SDK MAY +aceitar um contexto TLS ou pacote de CAs do usuário para acrescentar confiança, mas MUST rejeitar com +`ConfigurationError` qualquer contexto que desligue a verificação de certificado ou de nome do host. O +SDK MUST NOT oferecer opção para desligar a verificação. + +#### Scenario: Contexto inseguro +- **WHEN** o usuário passa um `ssl.SSLContext` com `verify_mode = CERT_NONE` +- **THEN** a criação do cliente levanta `ConfigurationError` + +#### Scenario: Certificado inválido no servidor +- **WHEN** o servidor apresenta certificado autoassinado não confiável +- **THEN** a requisição falha com erro de conexão na fase "não estabelecida" + +### Requirement: Timeouts finitos +O SDK SHALL aplicar por padrão 10 s de timeout de conexão, 60 s de timeout de leitura e 120 s de prazo +total por tentativa, configuráveis no cliente e por chamada. O SDK MUST rejeitar timeout nulo, zero, +negativo ou infinito. + +#### Scenario: Timeout infinito recusado +- **WHEN** o usuário configura `timeout=None` +- **THEN** o SDK levanta `ConfigurationError` + +### Requirement: Limite de tamanho de resposta +O SDK SHALL ler respostas em blocos e interromper a leitura ao exceder `max_response_bytes` (padrão +10 MiB), levantando `ResponseTooLargeError` e descartando a conexão. O SDK SHALL pedir respostas sem +compressão (`Accept-Encoding: identity`). + +#### Scenario: Resposta gigante +- **WHEN** o servidor envia um corpo maior que o limite configurado +- **THEN** o SDK levanta `ResponseTooLargeError` sem carregar o corpo inteiro em memória + +### Requirement: User-Agent honesto +Toda requisição SHALL enviar `User-Agent: nfe-io-python/ python/. `, +onde a versão vem da fonte única de versão do pacote, e SHALL acrescentar `/` quando o +usuário informar `app_info`. O pacote MUST NOT conter outra literal de versão. + +#### Scenario: Formato do User-Agent +- **WHEN** o SDK 0.1.0 roda em Python 3.12 no Linux +- **THEN** o cabeçalho é `nfe-io-python/0.1.0 python/3.12 linux` + +#### Scenario: Identificação do módulo de ERP +- **WHEN** o cliente é criado com `app_info=("odoo-l10n_br_nfse_nfeio", "18.0.1.0")` +- **THEN** o cabeçalho termina com ` odoo-l10n_br_nfse_nfeio/18.0.1.0` + +### Requirement: Multipart sem dependências +O SDK SHALL montar corpos `multipart/form-data` com fronteira aleatória imprevisível, nome de arquivo +saneado (sem aspas, CR ou LF) e partes binárias intactas. + +#### Scenario: Nome de arquivo malicioso +- **WHEN** o nome do arquivo contém `"` e quebra de linha +- **THEN** o corpo enviado não contém esses caracteres no cabeçalho da parte + +### Requirement: Redirects seguidos só sem credencial +Quando uma operação de download recebe 3xx, o SDK SHALL seguir no máximo 3 redirects, somente para +URLs `https`, e MUST NOT enviar a chave de API nem outros cabeçalhos de autenticação ao destino do +redirect. Redirect para `http` SHALL levantar erro. + +#### Scenario: Download de PDF +- **WHEN** `GET …/pdf` responde 302 para uma URL pré-assinada em outro host +- **THEN** o SDK busca a URL sem `Authorization` e devolve os bytes do PDF + +#### Scenario: Redirect fora de download +- **WHEN** uma operação que não é download recebe 3xx +- **THEN** o SDK levanta `UnexpectedResponseError` sem seguir o redirect + +#### Scenario: Redirect para http +- **WHEN** o destino do redirect usa `http://` +- **THEN** o SDK levanta `UnexpectedResponseError` sem fazer a requisição + +### Requirement: Location de resposta assíncrona não é seguido como URL +O SDK SHALL extrair somente o path do cabeçalho `Location` de respostas 202, validá-lo contra o formato +esperado do recurso e reconstruir qualquer URL derivada a partir da base configurada. O SDK MUST NOT +fazer requisição ao host ou esquema informados no `Location`. + +#### Scenario: Location com http +- **WHEN** a emissão responde 202 com `Location: http://api.nfe.io/v1/companies/C/serviceinvoices/I` +- **THEN** o SDK registra o id `I` e consultas posteriores vão para a base `https` configurada diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md new file mode 100644 index 0000000..81239db --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md @@ -0,0 +1,60 @@ +## Purpose + +Define como o SDK é empacotado, verificado e publicado: metadados, tipagem, qualidade mínima, matriz de +Python suportada, testes de integração opcionais e a cadeia de publicação protegida contra ataques de +supply chain. + +## ADDED Requirements + +### Requirement: Pacote nfe-io importável como nfeio +O SDK SHALL ser distribuído no PyPI como `nfe-io`, importado como `nfeio`, com `requires-python >= 3.10`, +licença MIT declarada, `py.typed` incluído e `nfeio.__version__` igual à versão publicada. + +#### Scenario: Instalação limpa +- **WHEN** o usuário roda `pip install nfe-io` num ambiente vazio com Python 3.10 +- **THEN** `import nfeio` funciona e nenhum outro pacote é instalado + +### Requirement: Qualidade verificada no CI +Cada pull request SHALL passar `ruff check`, `ruff format --check`, `mypy --strict` sobre `src` e `tests`, +e a suíte de testes em Python 3.10, 3.11, 3.12, 3.13 e 3.14 no Linux e em 3.12 no macOS e no Windows, +com cobertura mínima de 90% nos módulos de núcleo, erros e webhooks e de 85% no total. + +#### Scenario: Cobertura abaixo do mínimo +- **WHEN** uma mudança reduz a cobertura do núcleo para 89% +- **THEN** o CI falha + +### Requirement: Testes de integração opcionais e seguros +Os testes contra a API real SHALL ser marcados e pulados quando não houver credenciais; testes de escrita +SHALL exigir opt-in explícito (`NFE_LIVE_WRITE=1`) e MUST operar apenas na empresa `NFE_COMPANY_ID`. + +#### Scenario: Sem credenciais +- **WHEN** a suíte roda sem `NFE_API_KEY` +- **THEN** os testes de integração aparecem como pulados e a suíte passa + +### Requirement: Publicação com Trusted Publishing e attestations +A publicação no PyPI SHALL ocorrer apenas por workflow do GitHub Actions disparado por tag, usando +Trusted Publishing (OIDC) sem token armazenado, com attestations de proveniência, a partir de artefatos +construídos uma única vez num job separado, em environment protegido com aprovação manual. As actions +MUST ser fixadas por SHA de commit e os jobs MUST declarar permissões mínimas. + +#### Scenario: Release +- **WHEN** a tag `v0.1.0` é aprovada no environment de publicação +- **THEN** wheel e sdist publicados no PyPI têm attestations verificáveis vinculadas ao workflow do repositório + +### Requirement: Auditoria de segurança contínua +O CI SHALL executar `bandit` sobre o código do pacote, `pip-audit` sobre as dependências de +desenvolvimento travadas e análise CodeQL (pelo default setup do repositório), e SHALL manter +Dependabot apenas para GitHub Actions e dependências de desenvolvimento. + +#### Scenario: Vulnerabilidade em dependência de dev +- **WHEN** `pip-audit` encontra vulnerabilidade conhecida no lock +- **THEN** o job de auditoria falha + +### Requirement: Política de segurança e changelog +O repositório SHALL conter `SECURITY.md` com versões suportadas, canal privado de reporte (GitHub Private +Vulnerability Reporting) e prazos, e +`CHANGELOG.md` em pt-BR no formato Keep a Changelog, atualizado a cada versão. + +#### Scenario: Reporte de vulnerabilidade +- **WHEN** um pesquisador abre o repositório procurando como reportar falha +- **THEN** `SECURITY.md` indica o canal privado e o prazo de primeira resposta diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md new file mode 100644 index 0000000..9b8c639 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md @@ -0,0 +1,75 @@ +## Purpose + +Define como o SDK expõe listagens paginadas da NFE.io — páginas por índice 1-based (API v1) e por cursor +(API v2) — e a iteração automática síncrona e assíncrona, respeitando os limites que a API realmente +aplica. + +## ADDED Requirements + +### Requirement: Página por índice 1-based +Listagens da API v1 SHALL aceitar `page_index` (primeira página = 1) e `page_count` (itens por página) e +SHALL devolver uma página com `data`, `page_index`, `page_count` e `has_more`. Como a API não informa +totais, `has_more` SHALL ser verdadeiro quando a página vier cheia (`len(data) == page_count`). + +#### Scenario: Primeira página +- **WHEN** o usuário lista notas com `page_index=1, page_count=2` e a API devolve 2 itens +- **THEN** a requisição envia `pageIndex=1&pageCount=2` e a página tem `has_more == True` + +#### Scenario: Última página +- **WHEN** a API devolve menos itens que `page_count` +- **THEN** `has_more == False` + +### Requirement: Limites de página validados localmente +O SDK SHALL rejeitar com `InvalidParameterError`, sem requisição, `page_index < 1` e `page_count` fora +do intervalo aceito pela rota (1–50 em listagens de notas de serviço), mantendo os limites por rota numa +tabela única. + +#### Scenario: Índice zero +- **WHEN** o usuário pede `page_index=0` +- **THEN** o SDK levanta `InvalidParameterError` explicando que a paginação começa em 1 + +#### Scenario: Página grande demais +- **WHEN** o usuário pede `page_count=100` +- **THEN** o SDK levanta `InvalidParameterError` informando o máximo de 50 + +### Requirement: Página por cursor +Listagens da API v2 SHALL aceitar `limit`, `starting_after` e `ending_before` e SHALL devolver uma página +com `data` e `has_more` lidos do fio (`hasMore`). `limit` MUST estar entre 1 e 50. + +#### Scenario: Cursor para frente +- **WHEN** o usuário lista com `limit=2, starting_after="X"` +- **THEN** a requisição envia `limit=2&startingAfter=X` + +#### Scenario: limit zero +- **WHEN** o usuário pede `limit=0` +- **THEN** o SDK levanta `InvalidParameterError` sem requisição + +### Requirement: Iteração automática +Toda página SHALL oferecer `auto_paging_iter()`, que percorre os itens de todas as páginas seguintes sob +demanda, e `next_page()`, que devolve a próxima página ou `None`. Na paginação por cursor, as páginas +seguintes SHALL usar o tamanho máximo (`limit=50`); na paginação por índice, SHALL manter o `page_count` +da página inicial, porque trocar o tamanho desloca o índice e pularia itens. A iteração MUST parar ao +receber página vazia, mesmo que a API indique `hasMore: true`, e MUST levantar +`UnexpectedResponseError` quando a próxima página repetir o cursor ou a primeira nota da anterior +(paginação que não avança). O SDK MUST NOT +oferecer método que carregue todas as páginas em memória de uma vez. + +#### Scenario: Varredura completa +- **WHEN** existem 120 notas e o usuário itera `list(...).auto_paging_iter()` +- **THEN** recebe as 120 notas em ordem, buscando páginas sob demanda + +#### Scenario: Paginação que não avança +- **WHEN** a API devolve de novo o mesmo cursor da página anterior +- **THEN** a iteração levanta `UnexpectedResponseError` em vez de repetir indefinidamente + +#### Scenario: hasMore inconsistente +- **WHEN** a API devolve `{"hasMore": true, "companies": []}` +- **THEN** a iteração termina sem nova requisição + +### Requirement: Iteração assíncrona +No cliente assíncrono, `auto_paging_iter()` SHALL devolver um iterador assíncrono (`async for`) com o +mesmo comportamento da versão síncrona. + +#### Scenario: async for +- **WHEN** o usuário faz `async for inv in (await aclient.service_invoices.list(cid)).auto_paging_iter()` +- **THEN** recebe todos os itens sem bloquear o event loop diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md new file mode 100644 index 0000000..92b94ab --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md @@ -0,0 +1,68 @@ +## Purpose + +Define como o SDK trata operações fiscais assíncronas da NFE.io (resposta 202 com `Location`) e como o +usuário espera a nota chegar a um estado final, com prazos, backoff e erros claros. + +## ADDED Requirements + +### Requirement: Resposta 202 preservada +Ao receber 202 numa emissão ou cancelamento, o SDK SHALL devolver a nota parcial construída a partir do +corpo da resposta (por exemplo `id`, `environment`, `flowStatus`), usando o id extraído do path do +`Location` quando o corpo não trouxer `id`. + +#### Scenario: Corpo com id +- **WHEN** a emissão responde 202 com `{"id":"I","flowStatus":"WaitingCalculateTaxes"}` +- **THEN** o objeto devolvido tem `id == "I"` e `flow_status == "WaitingCalculateTaxes"` + +#### Scenario: Corpo de nota completa no 202 +- **WHEN** a emissão responde 202 com a nota completa (como observado em 2026-10-08) +- **THEN** o objeto devolvido preserva todos os campos e `flow_status` reflete o estado inicial + +#### Scenario: Corpo vazio com Location +- **WHEN** a emissão responde 202 sem corpo e com `Location: http://api.nfe.io/v1/companies/C/serviceinvoices/I` +- **THEN** o objeto devolvido tem `id == "I"` + +### Requirement: Espera por estado terminal +`service_invoices.wait(company_id, invoice_id, ...)` SHALL consultar a nota por `GET` do recurso até que +`flowStatus` seja terminal, com intervalo inicial de 1 s multiplicado por 1,5 a cada consulta até o +máximo de 10 s e prazo total padrão de 120 s, todos configuráveis. O SDK MUST NOT usar a rota +`/serviceinvoices/{id}/status`. + +#### Scenario: Emissão concluída +- **WHEN** a nota passa de `WaitingSend` para `Issued` +- **THEN** `wait` devolve a nota com `flow_status == "Issued"` + +#### Scenario: Prazo esgotado +- **WHEN** a nota continua `WaitingReturn` até o fim do prazo +- **THEN** o SDK levanta `PollingTimeoutError` contendo o último estado observado + +### Requirement: Estados terminais e falhas de processamento +O SDK SHALL tratar `Issued` e `Cancelled` como terminais de sucesso e `IssueFailed`, `CancelFailed` e +`Error` como terminais de falha. Ao atingir falha, `wait` SHALL levantar `InvoiceProcessingError` com a +nota e o `flowMessage`, a menos que o usuário peça `raise_on_failure=False`. Valores de `flowStatus` não +conhecidos MUST ser tratados como não terminais. + +#### Scenario: Rejeição da prefeitura +- **WHEN** a nota chega a `IssueFailed` com `flowMessage` "max retry …" +- **THEN** o SDK levanta `InvoiceProcessingError` com `flow_message` preenchido + +#### Scenario: Estado novo desconhecido +- **WHEN** a API devolve `flowStatus: "WaitingSomethingNew"` +- **THEN** `wait` continua consultando até um estado terminal ou o prazo + +### Requirement: Tolerância a atraso de indexação +Durante os primeiros `not_found_grace` segundos (padrão 10 s) de uma espera, respostas 404 da consulta +SHALL ser tratadas como "ainda processando"; depois disso, SHALL propagar `NotFoundError`. + +#### Scenario: 404 logo após a emissão +- **WHEN** a primeira consulta do `wait` recebe 404 e a segunda recebe a nota +- **THEN** `wait` continua normalmente + +### Requirement: Atalhos de emitir e cancelar com espera +O SDK SHALL oferecer `create_and_wait` e `cancel_and_wait`, que executam a operação e em seguida `wait` +com os mesmos parâmetros de espera, propagando `external_id` e marcação de resultado incerto quando a +operação inicial falhar. + +#### Scenario: Cancelar e esperar +- **WHEN** o usuário chama `cancel_and_wait(company_id, invoice_id)` e a nota chega a `Cancelled` +- **THEN** o método devolve a nota com `flow_status == "Cancelled"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md new file mode 100644 index 0000000..d43f0a4 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md @@ -0,0 +1,113 @@ +## Purpose + +Define quando o SDK repete uma requisição e quando não pode repetir, para que falhas transitórias sejam +absorvidas sem nunca duplicar uma emissão fiscal, e como o usuário reconcilia emissões de resultado +incerto usando `externalId`. + +## ADDED Requirements + +### Requirement: Retry ciente de método e de fase +O SDK SHALL repetir requisições GET, HEAD, PUT e DELETE em respostas 408, 429, 500, 502, 503 e 504 e em +falhas de rede de qualquer fase. Para POST, o SDK SHALL repetir apenas falhas de rede ocorridas antes de +a conexão ser estabelecida, e MUST NOT repetir em resposta 408, 429 ou 5xx nem em falha ocorrida depois +do envio. Demais respostas 4xx MUST NOT ser repetidas. + +#### Scenario: GET com 503 +- **WHEN** um GET recebe 503 e depois 200 +- **THEN** o SDK devolve o resultado do 200 após uma nova tentativa + +#### Scenario: POST de emissão com 504 +- **WHEN** o POST de emissão recebe 504 +- **THEN** o SDK não faz nova tentativa e levanta `ServerError` com `outcome_unknown=True` + +#### Scenario: POST com conexão recusada +- **WHEN** o POST falha por conexão recusada antes do envio e a nova tentativa conecta +- **THEN** o SDK envia a requisição uma única vez ao servidor e devolve a resposta + +#### Scenario: POST com timeout de leitura +- **WHEN** o POST é enviado e a leitura da resposta estoura o timeout +- **THEN** o SDK não repete e levanta `APITimeoutError` com `outcome_unknown=True` + +#### Scenario: POST com 429 +- **WHEN** um POST recebe 429 +- **THEN** o SDK não repete e levanta `RateLimitError` com `retry_after` quando informado + +### Requirement: Idempotency-Key não libera retry +O SDK MUST NOT tratar a presença de `Idempotency-Key` como autorização para repetir POST, enquanto a API +não honrar esse cabeçalho. + +#### Scenario: POST com idempotency_key e 500 +- **WHEN** o usuário envia um POST com `idempotency_key` e recebe 500 +- **THEN** o SDK não repete a requisição + +### Requirement: Backoff exponencial com jitter +O SDK SHALL esperar entre tentativas `min(max_delay, base · 2^(n−1))` multiplicado por fator aleatório +uniforme entre 0,7 e 1,3, com padrões `max_retries=3`, `base=1,0 s` e `max_delay=30 s`, todos +configuráveis no cliente, e `max_retries` também por chamada. + +#### Scenario: Retries desligados +- **WHEN** a chamada usa `max_retries=0` e recebe 503 num GET +- **THEN** o SDK levanta `ServerError` após uma única tentativa + +#### Scenario: Limite de tentativas +- **WHEN** um GET recebe 503 em todas as tentativas com a configuração padrão +- **THEN** o SDK faz 4 tentativas no total e levanta `ServerError` + +### Requirement: Respeito a Retry-After +Em resposta repetível com `Retry-After`, o SDK SHALL aguardar o valor informado, em segundos inteiros ou +data HTTP, quando ele for menor ou igual a `max_retry_after` (padrão 60 s); acima disso, o SDK MUST NOT +repetir e SHALL levantar o erro correspondente com `retry_after` preenchido. + +#### Scenario: Retry-After em data HTTP +- **WHEN** um GET recebe 429 com `Retry-After` em formato de data 5 s no futuro +- **THEN** o SDK espera cerca de 5 s antes da nova tentativa + +#### Scenario: Retry-After longo demais +- **WHEN** um GET recebe 429 com `Retry-After: 3600` +- **THEN** o SDK levanta `RateLimitError` com `retry_after == 3600` sem esperar + +### Requirement: Registro de tentativas sem dados sensíveis +O SDK SHALL registrar cada nova tentativa no logger `nfeio` em nível DEBUG com método, host, path, +status, número da tentativa, espera e request id, e MUST NOT registrar cabeçalhos de autenticação, query +string ou corpo. + +#### Scenario: Log de retry +- **WHEN** um GET é repetido após 502 com log DEBUG ativo +- **THEN** existe um registro com status 502 e tentativa 1, sem a chave de API + +### Requirement: Dedupe de emissão por externalId +`service_invoices.create` SHALL aceitar `external_id` e enviá-lo como `externalId` no corpo. Quando a API +rejeitar a emissão por `externalId` já existente, o SDK SHALL levantar `DuplicateExternalIdError` com o +`external_id`. Erros de resultado incerto em emissão SHALL carregar `outcome_unknown=True` e o +`external_id`. O SDK MUST NOT reenviar uma emissão automaticamente após resultado incerto. + +#### Scenario: externalId repetido +- **WHEN** a API responde 400 com `service invoice with external id (pedido-1) already exists` +- **THEN** o SDK levanta `DuplicateExternalIdError` com `external_id == "pedido-1"` + +#### Scenario: Resultado incerto +- **WHEN** a emissão com `external_id="pedido-2"` recebe 500 +- **THEN** o erro tem `outcome_unknown=True` e `external_id == "pedido-2"` e nenhuma segunda emissão é enviada + +### Requirement: Busca por externalId com espera de indexação +`service_invoices.find_by_external_id(company_id, external_id, wait=0)` SHALL devolver a nota quando a +API retornar lista com itens e `None` quando retornar lista vazia, e, com `wait > 0`, SHALL repetir a +busca com backoff até encontrar ou esgotar o prazo. Qualquer outra resposta (corpo vazio, forma +desconhecida, redirect) MUST levantar `UnexpectedResponseError` e MUST NOT ser interpretada como +"nota inexistente". + +#### Scenario: Nota encontrada +- **WHEN** a API responde `{"serviceInvoices":[{…}], "page":1}` +- **THEN** o método devolve o primeiro item como `ServiceInvoice` + +#### Scenario: Nota inexistente +- **WHEN** a API responde 200 com `{"serviceInvoices":[], "page":1}` +- **THEN** o método devolve `None` sem levantar erro + +#### Scenario: Resposta inesperada na busca +- **WHEN** a busca responde 200 com corpo vazio +- **THEN** o SDK levanta `UnexpectedResponseError` em vez de devolver `None` + +#### Scenario: Indexação atrasada +- **WHEN** `wait=30` e a primeira busca volta vazia e a terceira traz a nota +- **THEN** o método devolve a nota diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md new file mode 100644 index 0000000..f18fb35 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md @@ -0,0 +1,101 @@ +## Purpose + +Define as operações de Nota Fiscal de Serviço eletrônica (NFS-e, API v1 em `api.nfe.io`) oferecidas pelo +SDK: emitir, listar, consultar por id e por `externalId`, cancelar, reenviar e-mail e baixar PDF e XML, +seguindo o contrato observado no fio. + +## ADDED Requirements + +### Requirement: Emitir NFS-e +`service_invoices.create(company_id, params, *, external_id=None, options=None)` SHALL enviar +`POST /v1/companies/{company_id}/serviceinvoices` com o corpo informado (chaves do fio) acrescido de +`externalId` quando `external_id` for informado, e SHALL aceitar 202 (assíncrono) e 200/201 (imediato). + +#### Scenario: Emissão assíncrona +- **WHEN** a API responde 202 +- **THEN** o SDK devolve a nota parcial com `id` e `flow_status` e `last_response.status_code == 202` + +#### Scenario: Conflito de externalId informado nos dois lugares +- **WHEN** o usuário passa `external_id="A"` e `params["externalId"] == "B"` +- **THEN** o SDK levanta `InvalidParameterError` sem requisição + +### Requirement: Listar NFS-e +`service_invoices.list(company_id, *, page_index=1, page_count=10, issued_begin=None, issued_end=None, +created_begin=None, created_end=None)` SHALL enviar `GET /v1/companies/{company_id}/serviceinvoices` +com os filtros informados (datas em `AAAA-MM-DD`) e SHALL devolver uma página por índice de +`ServiceInvoice` lida de `serviceInvoices`. + +#### Scenario: Filtro por data de emissão +- **WHEN** o usuário lista com `issued_begin=date(2026,1,1)` e `issued_end=date(2026,12,31)` +- **THEN** a requisição envia `issuedBegin=2026-01-01&issuedEnd=2026-12-31` + +### Requirement: Consultar NFS-e +`service_invoices.retrieve(company_id, invoice_id)` SHALL enviar `GET …/serviceinvoices/{invoice_id}` e +devolver a nota (objeto sem envelope). + +#### Scenario: Nota inexistente +- **WHEN** a API responde 404 com a string `service invoice with id (X) was not found` +- **THEN** o SDK levanta `NotFoundError` com essa mensagem + +### Requirement: Consultar por externalId +`service_invoices.find_by_external_id` SHALL enviar `GET …/serviceinvoices/external/{external_id}` com o +valor codificado e SHALL tratar a resposta como lista conforme a capacidade `retry-idempotency`. + +#### Scenario: Codificação do externalId +- **WHEN** o usuário busca `external_id="WOO NFE/110"` +- **THEN** o path enviado termina em `/external/WOO%20NFE%2F110` + +### Requirement: Cancelar NFS-e +`service_invoices.cancel(company_id, invoice_id)` SHALL enviar `DELETE …/serviceinvoices/{invoice_id}` e +SHALL aceitar 202 (assíncrono, tratado como na capacidade `polling`) e 200 (corpo de nota ou texto). + +#### Scenario: Cancelamento assíncrono +- **WHEN** a API responde 202 com `Location` +- **THEN** o SDK devolve a nota parcial com o id da nota cancelada + +### Requirement: Reenviar e-mail +`service_invoices.send_email(company_id, invoice_id)` SHALL enviar +`PUT …/serviceinvoices/{invoice_id}/sendemail` sem corpo e SHALL concluir sem erro em resposta 2xx. + +#### Scenario: Reenvio aceito +- **WHEN** a API responde 200 +- **THEN** o método retorna sem levantar exceção + +### Requirement: Baixar PDF e XML +`download_pdf`, `download_xml` e `download_cancellation_xml` SHALL enviar `GET` para `…/{invoice_id}/pdf`, +`…/xml` e `…/cancellation-xml` e SHALL devolver os `bytes` do documento, seguindo redirects conforme a +capacidade `http-transport`. O XML SHALL ser devolvido sem alteração, inclusive quando não tiver prólogo +`` +- **THEN** os bytes devolvidos são idênticos aos recebidos + +#### Scenario: XML de cancelamento indisponível +- **WHEN** a API responde 404 com a explicação de que o XML de cancelamento só existe no ambiente nacional +- **THEN** o SDK levanta `NotFoundError` com essa mensagem + +### Requirement: Modelo ServiceInvoice +`ServiceInvoice` SHALL expor propriedades tipadas apenas para `id`, `external_id`, `environment`, +`flow_status`, `flow_message`, `status`, `services_amount`, `base_tax_amount`, `deductions_amount`, +`iss_rate`, `iss_tax_amount`, `amount_net`, `issued_on`, `cancelled_on`, `created_on`, `modified_on`, +`provider` e `borrower` (estes dois como `Party`, com `id` e `federal_tax_number` normalizado), cada uma +correspondendo a uma chave presente na spec ou observada no fio, e SHALL preservar todas as demais chaves +(por exemplo `number`, `checkCode`, `rpsNumber`, `description`, `cityServiceCode`) no acesso por chave +conforme a capacidade `client-core`. + +#### Scenario: Tomador com documento numérico +- **WHEN** a resposta traz `"borrower": {"federalTaxNumber": 52998224725}` +- **THEN** `invoice.borrower.federal_tax_number == "52998224725"` + +### Requirement: Corpo de emissão tipado +O SDK SHALL publicar um tipo de corpo de emissão com as chaves do fio documentadas na spec de NFS-e v1, +aceitando valores monetários como `int`, `float` ou `Decimal`, serializados como número JSON sem perda. + +#### Scenario: Valor Decimal na emissão +- **WHEN** o corpo contém `"servicesAmount": Decimal("100.10")` +- **THEN** o JSON enviado contém `"servicesAmount":100.10` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md new file mode 100644 index 0000000..e8917e2 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md @@ -0,0 +1,86 @@ +## Purpose + +Define o gerenciamento dos webhooks da conta (`/v2/webhooks`) e o tratamento seguro das entregas +recebidas: verificação de assinatura HMAC-SHA1 em tempo constante e interpretação do evento. + +## ADDED Requirements + +### Requirement: CRUD de webhooks da conta +O SDK SHALL oferecer em `client.webhooks`: `list()` (`GET /v2/webhooks`, desembrulha `webHooks`), +`retrieve(id)` (desembrulha `webHook`), `create(params)` e `update(id, params)` (corpo embrulhado em +`{"webHook": …}`), `delete(id)` e `ping(id)` (`PUT /v2/webhooks/{id}/pings`), usando a chave principal. +O SDK MUST NOT oferecer a remoção de todos os webhooks numa chamada. + +#### Scenario: Criação com envelope +- **WHEN** o usuário chama `create({"uri": "https://erp.exemplo/hook", "secret": "…", "filters": ["service_invoice.issued_successfully"]})` +- **THEN** o corpo enviado é `{"webHook": {…}}` e o retorno é o webhook desembrulhado + +#### Scenario: URI recusada na verificação +- **WHEN** a API não consegue validar a URI na criação e responde 400 com `code` 40001 +- **THEN** o SDK levanta `InvalidRequestError` com `error_code == 40001` e a mensagem da API + +#### Scenario: Valores textuais do fio +- **WHEN** a listagem traz `"contentType": "json"` e `"status": "Active"` +- **THEN** `hook.content_type == "json"` e `hook.status == "Active"` + +### Requirement: Atualização é substituição completa +`webhooks.update` SHALL enviar o objeto informado como substituição completa, sem mesclar com o estado +atual, e sua documentação MUST avisar que omitir `status` desativa o webhook. + +#### Scenario: Update sem merge +- **WHEN** o usuário chama `update(id, {"uri": "https://novo"})` +- **THEN** o corpo enviado contém apenas `{"webHook": {"uri": "https://novo"}}` + +### Requirement: Tipos de evento +`webhooks.event_types()` SHALL enviar `GET /v2/webhooks/eventtypes` e devolver a lista lida de +`eventTypes`, com `id` tratado como texto livre. + +#### Scenario: Lista de tipos +- **WHEN** a API devolve 47 tipos, incluindo identificadores fora do padrão +- **THEN** o método devolve os 47 sem erro + +### Requirement: Verificação de assinatura +`nfeio.webhooks.verify_signature(payload, signature, secret) -> bool` SHALL devolver `True` somente +quando `signature` tiver o prefixo `sha1=` (sem distinção de maiúsculas) seguido de exatamente 40 +dígitos hexadecimais iguais, sem distinção de maiúsculas, ao HMAC-SHA1 de `payload` com `secret`. A +comparação MUST usar `hmac.compare_digest`. A função MUST NOT levantar exceção para nenhuma entrada: +assinatura ausente, vazia, malformada, com outro algoritmo, segredo vazio ou tipos inesperados SHALL +resultar em `False`. `payload` SHALL aceitar `bytes` ou `str` (codificada em UTF-8) e `signature` +SHALL aceitar `str`, sequência de `str` (usa o primeiro) ou `None`. Não requer cliente. + +#### Scenario: Entregas reais +- **WHEN** a função recebe cada um dos 3 corpos de ping reais do arquivo de vetores com o segredo e o `X-Hub-Signature` capturados +- **THEN** devolve `True` para os três + +#### Scenario: Hex em minúsculas +- **WHEN** a assinatura válida é enviada em minúsculas +- **THEN** a função devolve `True` + +#### Scenario: Corpo adulterado +- **WHEN** um byte do corpo é alterado +- **THEN** a função devolve `False` + +#### Scenario: Entradas inválidas +- **WHEN** a assinatura é `sha256=`, ou 40 hex sem prefixo, ou `sha1=abc`, ou contém não hex, ou `None`, ou o segredo é vazio +- **THEN** a função devolve `False` sem levantar exceção + +### Requirement: Construção do evento +`nfeio.webhooks.construct_event(payload, headers, secret) -> WebhookEvent` SHALL verificar a assinatura +lida de `X-Hub-Signature` antes de interpretar o corpo e SHALL levantar `SignatureVerificationError` +quando ela não for válida. Só após a verificação o corpo SHALL ser decodificado como JSON, com limite de +5 MiB. `WebhookEvent` SHALL expor `action`, `event_type` (de `X-Hook-Event`), `hook_id` (de `X-Hook-Id`), +`data` e `raw` (bytes recebidos). Para corpo `{"action", "payload"}`, `data` SHALL ser o `payload` +(como `ServiceInvoice` quando `event_type` for `service_invoice`); para ping +`{"action":"ping","webHook"}`, `data` SHALL ser o `webHook`. + +#### Scenario: Entrega de nota emitida +- **WHEN** chega `{"action":"issued_successfully","payload":{"id":"…","flowStatus":"Issued"}}` com `X-Hook-Event: service_invoice` e assinatura válida +- **THEN** `event.action == "issued_successfully"` e `event.data.flow_status == "Issued"` + +#### Scenario: Assinatura inválida +- **WHEN** a assinatura não confere +- **THEN** o SDK levanta `SignatureVerificationError` sem decodificar o corpo + +#### Scenario: Cabeçalhos sem distinção de maiúsculas +- **WHEN** os cabeçalhos chegam como `x-hub-signature` e `x-hook-id` +- **THEN** o evento é construído normalmente diff --git a/openspec/changes/add-sdk-foundation-v0-1/tasks.md b/openspec/changes/add-sdk-foundation-v0-1/tasks.md new file mode 100644 index 0000000..0827057 --- /dev/null +++ b/openspec/changes/add-sdk-foundation-v0-1/tasks.md @@ -0,0 +1,105 @@ +## 0. Pré-requisitos (bloqueiam a implementação) + +- [x] 0.1 Obter o OK do André em D1–D8 de `docs/contrato/DECISOES-PENDENTES.md` e ajustar specs/design ao que for decidido (D1 = "D enxuta"); verificar com `openspec validate add-sdk-foundation-v0-1 --strict` +- [x] 0.2 Sonda de escrita controlada, feita pelos testes live do próprio SDK (seção 15), dentro das permissões da fase 2: (a) NFS-e só na `NFE_COMPANY_ID`, `externalId` único `sdkpy-`, no máximo ~5 notas: emitir, repetir o POST (esperar 400 de duplicidade), `find_by_external_id` logo após o 202 (medir atraso de indexação), baixar PDF/XML, cancelar (confirmar 202 + `Location`); (b) criar e apagar UMA empresa descartável `SDK-PY TESTE DESCARTAVEL ` (CRUD v2); (c) criar e apagar webhooks apontando para `https://example.com/nfeio-sdkpy-test`. Fora do escopo: `sendemail` ao vivo e upload de certificado real. Registrar em `docs/contrato/probe-fase2.md` e evidências redigidas em `tests/fixtures/live-contracts/` +- [x] 0.3 Renovação do certificado da empresa de teste (Menor 4: ok, será renovado) e canal do `SECURITY.md` (Menor 3: GitHub Private Vulnerability Reporting) — registrados no DECISOES + +## 1. Fundação do repositório + +- [x] 1.1 Criar `pyproject.toml` (hatchling, `name = "nfe-io"`, `requires-python = ">=3.10"`, sem `dependencies`, versão dinâmica de `src/nfeio/_version.py`, grupos dev) e `uv.lock`; verificar com `uv sync` e `uv build` gerando wheel sem `Requires-Dist` +- [x] 1.2 Criar `src/nfeio/__init__.py`, `_version.py`, `py.typed` e `_generated/README.md` (regra 5); verificar `uv run python -c "import nfeio; print(nfeio.__version__)"` +- [x] 1.3 Configurar ruff (regras E, F, W, I, B, UP, S, SIM, RUF, PT, ASYNC + format), mypy `--strict`, pytest (marcador `live`), coverage com mínimos por módulo; verificar os quatro comandos verdes no repositório vazio +- [x] 1.4 Adicionar `LICENSE`, `SECURITY.md`, `CHANGELOG.md` (pt-BR, Keep a Changelog, seção `[Não lançado]`) e `README.md` mínimo; verificar presença no sdist com `tar tzf dist/*.tar.gz` +- [x] 1.5 Teste que falha se houver literal de versão fora de `_version.py`; verificar introduzindo uma literal falsa e vendo o teste falhar + +## 2. Núcleo sans-IO + +- [x] 2.1 Implementar `Effect` (`Send`, `Sleep`, `Now`), `run_sync` e `run_async` em `_core/ops.py`; verificar com testes de um `Op` de exemplo rodando nos dois drivers com transporte falso +- [x] 2.2 Criar `FakeTransport` de teste (fila de respostas roteiro, registro de requisições) e relógio falso; verificar que testes não fazem I/O real nem `sleep` real (tempo da suíte unitária < 5 s) + +## 3. Configuração, segredos e paths + +- [x] 3.1 Implementar `ClientConfig`, `RequestOptions`, `Timeout`, `ApiFamily` e tabela de hosts, com leitura de `NFE_API_KEY`/`NFE_DATA_API_KEY` e validações (https, timeouts finitos, cabeçalhos protegidos); verificar cenários de `client-core` +- [x] 3.2 Implementar `SecretStr` e `repr` mascarado de cliente/config/opções; verificar teste de varredura de vazamento (log DEBUG + repr + exceções) +- [x] 3.3 Implementar `_core/paths.py`: segmento opaco, `external_id` percent-encoded, CNPJ (numérico e alfanumérico, DV da RFB), CPF, CEP, UF, data; verificar com testes tabulares incluindo `../`, vazio, controle, 256 chars, CNPJ `12ABC34501DE35` e `00000000000100` + +## 4. Transporte + +- [x] 4.1 Implementar protocolos `Transport`/`AsyncTransport`, `HttpRequest`/`HttpResponse` (cabeçalhos case-insensitive); verificar com transporte customizado nos testes +- [x] 4.2 Implementar `HttpClientTransport` sobre `http.client`: connect explícito, classificação de fase, pool LIFO por host com lock, POST sempre em conexão nova; verificar com servidor local de teste (DNS inválido, porta fechada, servidor que não responde, servidor que fecha a conexão) +- [x] 4.3 TLS: contexto padrão TLS ≥ 1.2, aceitar `ssl_context`/`ca_bundle` só com verificação ligada; verificar rejeição de `CERT_NONE`/`check_hostname=False` e falha contra servidor local autoassinado +- [x] 4.4 Timeouts (connect, read, prazo total) e limite de resposta em chunks com `Accept-Encoding: identity`; verificar com servidor local lento e com corpo maior que o limite +- [x] 4.5 User-Agent e `app_info`; verificar formato exato em teste +- [x] 4.6 Encoder multipart; verificar corpo byte a byte e saneamento de filename +- [x] 4.7 `ThreadedAsyncTransport` (`asyncio.to_thread`); verificar teste de concorrência com `asyncio.gather` e tarefa paralela progredindo + +## 5. Erros + +- [x] 5.1 Implementar hierarquia de `nfeio.errors` e mapeamento status → classe; verificar tabela de status (400, 401, 403, 404, 405, 408, 409, 415, 422, 429, 500, 502, 503, 504) +- [x] 5.2 Implementar extrator de mensagem para os 5 envelopes + corpo vazio, saneamento e truncamento, `request_id`/`trace_id`; verificar com as respostas reais redigidas de `scripts/probes/out/` convertidas em fixtures de teste +- [x] 5.3 Dica de chave em `PermissionDeniedError`; verificar mensagem para família fiscal e de dados + +## 6. Retry e idempotência + +- [x] 6.1 Implementar tabela método × status × fase, backoff com jitter e `Retry-After` (segundos e data HTTP, teto); verificar cada célula da tabela em teste parametrizado e nos dois drivers +- [x] 6.2 Marcar `outcome_unknown` em erros de POST incertos e logging DEBUG sem dados sensíveis; verificar cenários de `retry-idempotency` +- [x] 6.3 `DuplicateExternalIdError` por casamento tolerante da mensagem; verificar com a mensagem real registrada no vault e variações de caixa + +## 7. Modelos + +- [x] 7.1 Implementar `NfeObject` (Mapping imutável sem `__getattr__`, aninhados por chave como `NfeObject`, listas copiadas, `to_dict`, `__eq__`, `last_response`, `repr` com chaves sensíveis mascaradas), descritores de campo tipado e parser tolerante de datas/`Decimal`; verificar cenários de `client-core` em Python 3.10 e 3.14 +- [x] 7.2 Encoder JSON que aceita `Decimal` sem perda; verificar `Decimal("100.10")` → `100.10` +- [x] 7.3 Teste de alinhamento ancorado em path: cada propriedade tipada aponta para chave da spec ou da lista de divergências provadas; verificar falha ao incluir propriedade fantasma + +## 8. Paginação + +- [x] 8.1 Implementar `OffsetPage`/`CursorPage` com `next_page` e `auto_paging_iter` (sync e async), parada em página vazia e tabela de limites por rota; verificar cenários de `pagination`, incluindo `hasMore:true` com lista vazia + +## 9. Polling + +- [x] 9.1 Implementar parse de 202 (corpo + path do `Location`), `wait` com backoff, estados terminais, tolerância a 404 inicial e `PollingTimeoutError`/`InvoiceProcessingError`; verificar cenários de `polling` com relógio falso + +## 10. NFS-e + +- [x] 10.1 Implementar `service_invoices.create/list/retrieve/find_by_external_id/cancel/send_email`; verificar cenários de `service-invoices` com fixtures redigidas +- [x] 10.2 Implementar `download_pdf/xml/cancellation_xml` com follow de redirect sem credencial; verificar que a requisição ao destino não tem `Authorization` e que o XML sem prólogo volta intacto +- [x] 10.3 Implementar `create_and_wait`/`cancel_and_wait`; verificar sequência 202 → WaitingSend → Issued +- [x] 10.4 Modelo `ServiceInvoice` (+ `Borrower`, `Provider`, `Address`) e `TypedDict` do corpo de emissão; verificar com a nota real redigida da sonda + +## 11. Empresas e certificados + +- [x] 11.1 Implementar `companies.list/retrieve/create/update/delete` conforme D3; verificar cenários de `companies` +- [x] 11.2 Implementar `certificates.upload/list` e modelo `Certificate` com `expires_on`/`is_expired`; verificar cenários de `certificates` e ausência da senha em logs/erros + +## 12. Consultas de dados + +- [x] 12.1 Implementar `lookups.cnpj`, `cnpj_state_taxes`, `cpf`, `cep` com chave de dados e validação local; verificar cenários de `data-lookups` + +## 13. Webhooks + +- [x] 13.1 Implementar `client.webhooks` (list, retrieve, create, update, delete, ping, event_types) com envelopes; verificar cenários de CRUD +- [x] 13.2 Implementar `nfeio.webhooks.verify_signature` e `construct_event`; verificar com os 3 vetores reais de `client-nodejs/tests/fixtures/webhook-signatures.json` (copiados para `tests/fixtures/`) e todos os negativos da spec + +## 14. Cliente assíncrono + +- [x] 14.1 Implementar fachadas `AsyncNfeClient` delegando aos mesmos `Op`; verificar teste de paridade de assinaturas e teste de comportamento idêntico (mesmo roteiro de respostas nos dois clientes) +- [x] 14.2 `with`/`close` e `async with`/`aclose`; verificar que o pool fica vazio após o bloco + +## 15. Testes de integração (opt-in) + +- [x] 15.1 Conftest com parser stdlib de `.env`, marcador `live`, pulo sem chave e trava `NFE_LIVE_WRITE=1` + `NFE_COMPANY_ID`; verificar `uv run pytest` sem `.env` (pulados) e `uv run pytest -m live` com `.env` (leitura passa) +- [x] 15.2 Testes live de leitura: auth por família, varredura de companies v2, lista/retrieve/external/downloads de NFS-e, certificados, webhooks, CNPJ/CEP; verificar contra a conta de teste +- [x] 15.3 Testes live de escrita (opt-in `NFE_LIVE_WRITE=1`): NFS-e só na `NFE_COMPANY_ID` (`create_and_wait` com `external_id`, duplicidade, `find_by_external_id(wait=30)`, downloads, `cancel_and_wait`; pular se o certificado estiver vencido); CRUD v2 de UMA empresa descartável com remoção garantida em `finally`; CRUD de webhook de teste com remoção garantida; upload de certificado só unitário (ou pulado e registrado) + +## 16. Documentação + +- [x] 16.1 README (instalação, chaves por família, exemplo sync/async, paginação, polling) e guia "emissão segura" com a receita de reconciliação por `external_id`; verificar exemplos executados por teste de documentação contra `FakeTransport` +- [x] 16.2 Guia de webhooks (corpo cru em Django/Flask/FastAPI, deduplicação por `X-Hook-Id`, aviso de update full-replace); verificar exemplos por teste +- [x] 16.3 Atualizar `CHANGELOG.md` com a entrada 0.1.0 em pt-BR; verificar formato Keep a Changelog + +## 17. CI e release (sem publicar) + +- [ ] 17.1 Workflow de CI: lint, format, mypy, testes na matriz 3.10–3.14 (Linux) + 3.12 (macOS, Windows), cobertura, build; actions fixadas por SHA e `permissions` mínimas; verificar execução verde num PR — **escrito** (`.github/workflows/ci.yml`); comandos equivalentes verdes localmente em 3.10–3.14; falta a primeira execução no GitHub (sem push nesta fase) +- [ ] 17.2 Workflows de segurança: bandit, pip-audit (`uv export` dev), CodeQL; Dependabot para actions e dev; verificar execução verde — **escrito** (`security.yml`, `dependabot.yml`; o CodeQL roda pelo default setup do repositório no GitHub, e o `codeql.yml` próprio foi removido porque o GitHub recusa o SARIF com o default setup ligado); bandit e pip-audit verdes localmente; pins de actions a conferir com `scripts/verify_action_pins.py` (sem acesso ao GitHub nesta sessão) +- [ ] 17.3 Workflow de release por tag com build único, environment `pypi` protegido, Trusted Publishing OIDC e attestations; verificar em dry-run contra TestPyPI **somente após OK do André** — **escrito** (`release.yml`); dry-run no TestPyPI aguarda OK do André +- [ ] 17.4 Revisão final: `openspec validate add-sdk-foundation-v0-1 --strict`, checklist de segurança do design §11 item a item; verificar e registrar no PR — validate `--strict` ok e checklist registrado em `docs/contrato/revisao-seguranca-v0.1.md`; falta anexar ao PR diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..302559b --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,28 @@ +schema: spec-driven + +context: | + Language: pt-BR + All artifacts must be written in pt-BR. + Keep OpenSpec structural headings and SHALL/MUST keywords in English. + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..95b23aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,139 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "nfe-io" +dynamic = ["version"] +description = "SDK oficial da NFE.io para Python: NFS-e, empresas, certificados, webhooks e consultas de CNPJ, CPF e CEP." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "NFE.io", email = "suporte@nfe.io" }] +keywords = ["nfe.io", "nfse", "nota fiscal", "nfe", "fiscal", "brasil", "sdk"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Natural Language :: Portuguese (Brazilian)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Office/Business :: Financial :: Accounting", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +# Zero runtime dependencies (project rule). Do not add anything here. +dependencies = [] + +[project.urls] +Homepage = "https://nfe.io" +Documentation = "https://nfe.io/docs" +Source = "https://github.com/nfe/client-python" +Changelog = "https://github.com/nfe/client-python/blob/master/CHANGELOG.md" +Issues = "https://github.com/nfe/client-python/issues" + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "coverage[toml]>=7.6", + "ruff>=0.7", + "mypy>=1.13", + "bandit>=1.8", + "pip-audit>=2.7", + "pyyaml>=6.0", + "types-pyyaml>=6.0", + "twine>=6.0", +] + +[tool.hatch.version] +path = "src/nfeio/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/nfeio"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src/nfeio", + "/tests", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/SECURITY.md", + "/CONTRIBUTING.md", +] +exclude = ["/tests/live/out"] + +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["src", "tests"] +extend-exclude = ["scripts/probes", "*.md"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF", "PT", "ASYNC"] +ignore = [ + "RUF001", # Portuguese strings legitimately contain non-ASCII lookalikes + "RUF002", + "RUF003", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "S101", # assert is the point of tests + "S105", # fake secrets in fixtures + "S106", + "S107", + "S311", # non-crypto random in tests + "PT018", # compound assertions are fine in tests +] +"scripts/**" = ["S101", "S310"] + +[tool.ruff.lint.isort] +known-first-party = ["nfeio"] + +[tool.mypy] +strict = true +python_version = "3.10" +files = ["src", "tests"] +warn_unreachable = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_decorators = false + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers" +markers = [ + "live: chamadas reais à API da NFE.io (opt-in, NFE_RUN_INTEGRATION=1)", + "live_write: escrita real na API (exige também NFE_LIVE_WRITE=1)", +] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = ["error"] + +[tool.coverage.run] +source = ["nfeio"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +fail_under = 85 +exclude_also = [ + "if TYPE_CHECKING:", + "@overload", + "raise NotImplementedError", + "\\.\\.\\.$", +] + +[tool.bandit] +exclude_dirs = ["tests"] diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 0000000..d8193d5 --- /dev/null +++ b/scripts/check_coverage.py @@ -0,0 +1,53 @@ +"""Enforce per-group coverage minimums (core, errors and webhooks >= 90%; total >= 85%). + +Usage (after ``coverage run -m pytest``):: + + uv run coverage json -o coverage.json + uv run python scripts/check_coverage.py coverage.json +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +GROUPS = { + "nfeio._core": ("src/nfeio/_core/", 90.0), + "nfeio.errors": ("src/nfeio/errors.py", 90.0), + "nfeio.webhooks": ("src/nfeio/webhooks.py", 90.0), +} +TOTAL_MINIMUM = 85.0 + + +def _percent(covered: int, total: int) -> float: + return 100.0 if total == 0 else 100.0 * covered / total + + +def main(path: str) -> int: + data = json.loads(Path(path).read_text("utf-8")) + files = data["files"] + failures = [] + for name, (prefix, minimum) in GROUPS.items(): + covered = total = 0 + for filename, info in files.items(): + if prefix in filename.replace("\\", "/"): + summary = info["summary"] + covered += summary["covered_lines"] + summary.get("covered_branches", 0) + total += summary["num_statements"] + summary.get("num_branches", 0) + percent = _percent(covered, total) + print(f"{name:<16} {percent:6.2f}% (min {minimum:.0f}%)") + if total == 0 or percent < minimum: + failures.append(name) + total_percent = float(data["totals"]["percent_covered"]) + print(f"{'total':<16} {total_percent:6.2f}% (min {TOTAL_MINIMUM:.0f}%)") + if total_percent < TOTAL_MINIMUM: + failures.append("total") + if failures: + print("coverage below minimum: " + ", ".join(failures)) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "coverage.json")) diff --git a/scripts/probes/README.md b/scripts/probes/README.md new file mode 100644 index 0000000..2ade13c --- /dev/null +++ b/scripts/probes/README.md @@ -0,0 +1,21 @@ +# Sondas ao vivo (somente leitura) + +Scripts stdlib que confirmam o contrato da API antes de qualquer decisão do SDK +(regra 1 das regras do projeto). Resumo redigido: `docs/contrato/probe-2026-10-08.md`. + +```bash +cd scripts/probes +python3 p01_auth_matrix.py # chave × host × esquema de auth +python3 p02_companies.py # companies v1 (offset) e v2 (cursor), limites, paridade +python3 p03_service_invoices.py # NFS-e: lista, retrieve, externalId, downloads (redirect) +python3 p04_certs_webhooks_lookups.py +python3 p05_rechecks.py # reconfere afirmações antigas do vault +python3 p06_cnpj_v3.py +``` + +- Credenciais vêm de `../../.env` (`NFE_API_KEY`, `NFE_DATA_API_KEY`, `NFE_COMPANY_ID`). +- `_probe.request()` recusa qualquer método além de GET/HEAD. Sondas de escrita (fase 2a) vão em + scripts próprios, com trava para operar só na `NFE_COMPANY_ID`. +- Saída crua em `out/` (gitignored): pode conter dado real de terceiros. Credenciais e query de URL + pré-assinada são removidas antes de gravar; `save()` aborta se uma chave vazar. +- Consultas de CNPJ/CPF/CEP podem ser tarifadas: rode só o necessário. diff --git a/scripts/probes/_probe.py b/scripts/probes/_probe.py new file mode 100644 index 0000000..3d3f38f --- /dev/null +++ b/scripts/probes/_probe.py @@ -0,0 +1,236 @@ +"""Read-only live probe helpers for the NFE.io API (stdlib only). + +Safety rails: +- Only GET and HEAD are allowed; any other method raises before touching the network. +- API keys are read from ``.env`` and never written to disk or stdout. Request headers + saved to ``out/`` have credentials replaced by ````. +- Redirects are NOT followed automatically so the raw 3xx (e.g. pre-signed download URLs) + is observable. Pre-signed query strings are stripped before saving. +- Response bodies are capped at ``MAX_BODY`` bytes. + +Raw output goes to ``scripts/probes/out/`` (gitignored). It can contain real third-party +data; the versioned summary in ``docs/contrato/`` must be redacted by hand. +""" + +from __future__ import annotations + +import base64 +import json +import os +import platform +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +OUT = Path(__file__).resolve().parent / "out" +MAX_BODY = 512 * 1024 +ALLOWED_METHODS = frozenset({"GET", "HEAD"}) +USER_AGENT = ( + f"nfe-io-python-probe/0.0 python/{sys.version_info.major}.{sys.version_info.minor} " + f"{platform.system().lower()}" +) +SENSITIVE_HEADERS = frozenset({"authorization", "x-nfe-apikey", "apikey"}) + + +def load_env() -> dict[str, str]: + env: dict[str, str] = {} + path = ROOT / ".env" + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip().strip('"').strip("'") + return env + + +ENV = load_env() +KEYS = {"main": ENV.get("NFE_API_KEY", ""), "data": ENV.get("NFE_DATA_API_KEY", "")} +COMPANY_ID = ENV.get("NFE_COMPANY_ID", "") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + +_OPENER = urllib.request.build_opener( + _NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context()) +) + + +def _auth(key_name: str | None, scheme: str) -> tuple[dict[str, str], dict[str, str]]: + """Return (headers, query) for the chosen key and auth scheme.""" + if key_name is None: + return {}, {} + key = KEYS[key_name] + if not key: + raise SystemExit(f"missing key {key_name} in .env") + if scheme == "x-nfe-apikey": + return {"X-NFE-APIKEY": key}, {} + if scheme == "authorization": + return {"Authorization": key}, {} + if scheme == "basic": + token = base64.b64encode(f"{key}:".encode()).decode() + return {"Authorization": f"Basic {token}"}, {} + if scheme == "query": + return {}, {"apikey": key} + raise ValueError(scheme) + + +def _strip_presigned(url: str) -> str: + """Keep only query parameter NAMES of a redirect target (values may be bearer tokens).""" + parts = urllib.parse.urlsplit(url) + if not parts.query: + return url + names = ",".join(k for k, _ in urllib.parse.parse_qsl(parts.query, keep_blank_values=True)) + return urllib.parse.urlunsplit(parts._replace(query=f"")) + + +def request( + host: str, + path: str, + *, + key: str | None = "main", + scheme: str = "x-nfe-apikey", + method: str = "GET", + params: dict[str, Any] | None = None, + accept: str = "application/json", + timeout: float = 30.0, +) -> dict[str, Any]: + method = method.upper() + if method not in ALLOWED_METHODS: + raise RuntimeError(f"probe refused: {method} is not read-only") + auth_headers, auth_query = _auth(key, scheme) + query = {**(params or {}), **auth_query} + url = f"https://{host}{path}" + if query: + url += "?" + urllib.parse.urlencode(query) + headers = {"Accept": accept, "User-Agent": USER_AGENT, **auth_headers} + req = urllib.request.Request(url, method=method, headers=headers) + started = time.monotonic() + status: int | None + try: + resp = _OPENER.open(req, timeout=timeout) + status, resp_headers, body = resp.status, dict(resp.headers.items()), resp.read(MAX_BODY + 1) + except urllib.error.HTTPError as e: + status, resp_headers, body = e.code, dict(e.headers.items()), e.read(MAX_BODY + 1) + except (urllib.error.URLError, TimeoutError, OSError) as e: + status, resp_headers, body = None, {}, f"".encode() + elapsed = round(time.monotonic() - started, 3) + truncated = len(body) > MAX_BODY + body = body[:MAX_BODY] + location = next((v for k, v in resp_headers.items() if k.lower() == "location"), None) + for k in [k for k in resp_headers if k.lower() == "location"]: + resp_headers[k] = _strip_presigned(resp_headers[k]) + safe_query = {k: ("" if k == "apikey" else v) for k, v in query.items()} + return { + "request": { + "method": method, + "host": host, + "path": path, + "query": safe_query, + "key": key, + "authScheme": scheme if key else None, + "headers": { + k: ("" if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items() + }, + }, + "status": status, + "elapsedSeconds": elapsed, + "headers": resp_headers, + "bodyLength": len(body), + "bodyTruncated": truncated, + "body": _decode(body, _ct(resp_headers)), + # raw redirect target kept in memory only (never saved): see follow_redirect() + "_location": location, + } + + +def _ct(headers: dict[str, str]) -> str: + return next((v for k, v in headers.items() if k.lower() == "content-type"), "") + + +def follow_redirect(r: dict[str, Any], accept: str = "*/*") -> dict[str, Any] | None: + """GET the 3xx target WITHOUT credentials (an SDK must not forward the key cross-host).""" + location = r.pop("_location", None) + if not location: + return None + parts = urllib.parse.urlsplit(location) + req = urllib.request.Request(location, method="GET", headers={"Accept": accept, "User-Agent": USER_AGENT}) + try: + resp = _OPENER.open(req, timeout=60) + status, hdrs, body = resp.status, dict(resp.headers.items()), resp.read(MAX_BODY + 1) + except urllib.error.HTTPError as e: + status, hdrs, body = e.code, dict(e.headers.items()), e.read(MAX_BODY + 1) + for k in [k for k in hdrs if k.lower() == "location"]: + hdrs[k] = _strip_presigned(hdrs[k]) + return { + "request": {"method": "GET", "host": parts.netloc, "path": parts.path, "key": None, + "authScheme": None, "note": "redirect target fetched without credentials"}, + "status": status, + "headers": hdrs, + "bodyLength": min(len(body), MAX_BODY), + "bodyTruncated": len(body) > MAX_BODY, + "body": _decode(body[:MAX_BODY], _ct(hdrs)), + } + + +def _decode(body: bytes, content_type: str) -> Any: + ct = content_type.lower() + if "json" in ct: + try: + return json.loads(body.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError): + pass + if ct.startswith("text/") or "xml" in ct or "json" in ct or not ct: + try: + text = body.decode("utf-8-sig") + return {"_text_head": text[:2000], "_startsWith": text[:40]} + except UnicodeDecodeError: + pass + return {"_binary_head_hex": body[:16].hex(), "_length": len(body)} + + +def save(name: str, results: Any) -> Path: + OUT.mkdir(parents=True, exist_ok=True) + path = OUT / f"{name}.json" + _drop_private(results) + text = json.dumps(results, ensure_ascii=False, indent=2, default=str) + for secret in (*KEYS.values(),): + if secret: + assert secret not in text, "secret leaked into probe output" + path.write_text(text, encoding="utf-8") + return path + + +def _drop_private(node: Any) -> None: + if isinstance(node, dict): + node.pop("_location", None) + for v in node.values(): + _drop_private(v) + elif isinstance(node, list): + for v in node: + _drop_private(v) + + +def brief(r: dict[str, Any]) -> str: + req = r["request"] + body = r["body"] + shape: Any + if isinstance(body, dict): + shape = sorted(body.keys())[:12] + elif isinstance(body, list): + shape = f"list[{len(body)}]" + else: + shape = type(body).__name__ + return ( + f"{r['status']!s:>4} {req['method']} {req['host']}{req['path']} " + f"key={req['key']} auth={req['authScheme']} ct={_ct(r['headers']) or None} shape={shape}" + ) diff --git a/scripts/probes/p01_auth_matrix.py b/scripts/probes/p01_auth_matrix.py new file mode 100644 index 0000000..4ae13a7 --- /dev/null +++ b/scripts/probes/p01_auth_matrix.py @@ -0,0 +1,50 @@ +"""P01 — which key and which auth scheme each host accepts (read-only). + +For each family endpoint, try: no key, main key, data key; and for the accepted key, +each auth scheme (X-NFE-APIKEY, raw Authorization, Basic, ?apikey=). +""" + +from __future__ import annotations + +from _probe import COMPANY_ID, brief, request, save + +# Public, non-personal sample values (Banco do Brasil CNPJ; Av. Paulista CEP; documented test CPF). +CNPJ = "00000000000191" +CEP = "01310100" +CPF = "52998224725" +BIRTH = "1990-01-01" + +TARGETS = [ + ("api.nfe.io", "/v1/companies", {"pageIndex": 1, "pageCount": 2}), + ("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", {"pageIndex": 1, "pageCount": 2}), + ("api.nfse.io", "/v2/companies", {"limit": 2}), + ("api.nfse.io", "/v2/webhooks", None), + ("api.nfe.io", "/v2/webhooks", None), + ("legalentity.api.nfe.io", f"/v2/legalentities/basicInfo/{CNPJ}", None), + ("legalentity.api.nfe.io", f"/v1/legalentities/basicInfo/{CNPJ}", None), + ("address.api.nfe.io", f"/v2/addresses/{CEP}", None), + ("naturalperson.api.nfe.io", f"/v1/naturalperson/status/{CPF}/{BIRTH}", None), +] +SCHEMES = ["x-nfe-apikey", "authorization", "basic", "query"] + + +def main() -> None: + results = [] + for host, path, params in TARGETS: + accepted = None + for key in (None, "main", "data"): + r = request(host, path, key=key, params=params) + results.append(r) + print(brief(r)) + if accepted is None and r["status"] is not None and r["status"] < 400: + accepted = key + if accepted: + for scheme in SCHEMES[1:]: + r = request(host, path, key=accepted, scheme=scheme, params=params) + results.append(r) + print(brief(r)) + print("saved", save("p01_auth_matrix", results)) + + +if __name__ == "__main__": + main() diff --git a/scripts/probes/p02_companies.py b/scripts/probes/p02_companies.py new file mode 100644 index 0000000..0867b14 --- /dev/null +++ b/scripts/probes/p02_companies.py @@ -0,0 +1,105 @@ +"""P02 — companies v1 (offset, 1-based?) and v2 (cursor) shape, limits and parity (read-only).""" + +from __future__ import annotations + +from typing import Any + +from _probe import COMPANY_ID, brief, request, save + + +def main() -> None: + results: list[dict[str, Any]] = [] + + def run(host: str, path: str, **kw: Any) -> dict[str, Any]: + r = request(host, path, **kw) + results.append(r) + page = r["body"].get("page") if isinstance(r["body"], dict) else None + extra = "" + if isinstance(r["body"], dict): + for k in ("companies",): + if isinstance(r["body"].get(k), list): + extra += f" n={len(r['body'][k])}" + if "hasMore" in r["body"]: + extra += f" hasMore={r['body']['hasMore']}" + print(brief(r), f"page={page}{extra}") + return r + + # v1 pagination semantics: index 0 vs 1, pageCount limits + for idx in (0, 1, 2): + run("api.nfe.io", "/v1/companies", params={"pageIndex": idx, "pageCount": 2}) + for count in (1, 2, 50, 51, 100): + run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": count}) + run("api.nfe.io", "/v1/companies") # server defaults + # v1 retrieve by id and by tax number + one = run("api.nfe.io", f"/v1/companies/{COMPANY_ID}") + tax = None + if isinstance(one["body"], dict): + c = one["body"].get("companies") or one["body"] + if isinstance(c, dict): + tax = c.get("federalTaxNumber") + if tax: + run("api.nfe.io", f"/v1/companies/{tax}") + run("api.nfe.io", "/v1/companies/000000000000000000000000") # unknown id -> error shape + + # v2 cursor + first = run("api.nfse.io", "/v2/companies", params={"limit": 2}) + if isinstance(first["body"], dict) and first["body"].get("companies"): + last = first["body"]["companies"][-1]["id"] + nxt = run("api.nfse.io", "/v2/companies", params={"limit": 2, "startingAfter": last}) + if isinstance(nxt["body"], dict) and nxt["body"].get("companies"): + run( + "api.nfse.io", + "/v2/companies", + params={"limit": 2, "endingBefore": nxt["body"]["companies"][0]["id"]}, + ) + for limit in (0, 1, 50, 51, 100): + run("api.nfse.io", "/v2/companies", params={"limit": limit}) + run("api.nfse.io", "/v2/companies") # server default limit + run("api.nfse.io", f"/v2/companies/{COMPANY_ID}") + run("api.nfse.io", "/v2/companies/000000000000000000000000") + + # parity: total count v1 vs full v2 iteration (bounded) + v1_total = None + r = run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": 50}) + if isinstance(r["body"], dict): + # observed 2026-10-08: "page" is an int (echo of pageIndex), no totals in the envelope + page = r["body"].get("page") + v1_total = page.get("totalResults") if isinstance(page, dict) else None + v2_ids: list[str] = [] + cursor = None + for _ in range(30): + params: dict[str, Any] = {"limit": 50} + if cursor: + params["startingAfter"] = cursor + r = request("api.nfse.io", "/v2/companies", params=params) + body = r["body"] if isinstance(r["body"], dict) else {} + batch = body.get("companies") or [] + v2_ids += [c["id"] for c in batch] + if not body.get("hasMore") or not batch: + break + cursor = batch[-1]["id"] + v1_ids: list[str] = [] + for idx in range(1, 40): + r = request("api.nfe.io", "/v1/companies", params={"pageIndex": idx, "pageCount": 50}) + batch = (r["body"] or {}).get("companies") or [] if isinstance(r["body"], dict) else [] + v1_ids += [c["id"] for c in batch] + if len(batch) < 50: + break + parity = { + "v1_totalResults": v1_total, + "v1_iterated": len(v1_ids), + "v1_unique": len(set(v1_ids)), + "v2_iterated": len(v2_ids), + "v2_unique": len(set(v2_ids)), + "only_in_v1": len(set(v1_ids) - set(v2_ids)), + "only_in_v2": len(set(v2_ids) - set(v1_ids)), + "test_company_in_v1": COMPANY_ID in v1_ids, + "test_company_in_v2": COMPANY_ID in v2_ids, + } + print("parity", parity) + results.append({"parity": parity}) + print("saved", save("p02_companies", results)) + + +if __name__ == "__main__": + main() diff --git a/scripts/probes/p03_service_invoices.py b/scripts/probes/p03_service_invoices.py new file mode 100644 index 0000000..44b2392 --- /dev/null +++ b/scripts/probes/p03_service_invoices.py @@ -0,0 +1,75 @@ +"""P03 — NFS-e read paths on the test company: list, retrieve, external id, downloads (read-only).""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + +from _probe import COMPANY_ID, brief, follow_redirect, request, save + +BASE = f"/v1/companies/{COMPANY_ID}/serviceinvoices" + + +def main() -> None: + results: list[dict[str, Any]] = [] + + def run(path: str, **kw: Any) -> dict[str, Any]: + r = request("api.nfe.io", path, **kw) + results.append(r) + loc = next((v for k, v in r["headers"].items() if k.lower() == "location"), None) + print(brief(r), "| Location:", loc) + return r + + def follow(r: dict[str, Any]) -> None: + t = follow_redirect(r) + if t: + results.append(t) + body = t["body"] + head = body.get("_startsWith") or body.get("_binary_head_hex") if isinstance(body, dict) else None + print(" ->", t["status"], t["request"]["host"], t["request"]["path"], t["headers"].get("Content-Type"), + t["bodyLength"], repr(head)) + + run(BASE, params={"pageIndex": 0, "pageCount": 2}) + page1 = run(BASE, params={"pageIndex": 1, "pageCount": 50}) + run(BASE, params={"pageIndex": 1, "pageCount": 51}) + run(BASE, params={"pageIndex": 1, "pageCount": 100}) + run(BASE, params={"pageIndex": 1, "pageCount": 2, "hasTotals": "true"}) + run(BASE, params={"pageIndex": 1, "pageCount": 2, "issuedBegin": "2026-01-01", "issuedEnd": "2026-12-31"}) + invoices = page1["body"].get("serviceInvoices", []) if isinstance(page1["body"], dict) else [] + stats = { + "flowStatus": Counter(i.get("flowStatus") for i in invoices), + "status": Counter(i.get("status") for i in invoices), + "withExternalId": sum(1 for i in invoices if i.get("externalId")), + "keys_union": sorted({k for i in invoices for k in i}), + } + print("stats", stats) + results.append({"stats": {k: (dict(v) if isinstance(v, Counter) else v) for k, v in stats.items()}}) + + issued = next((i for i in invoices if i.get("flowStatus") == "Issued"), None) + cancelled = next((i for i in invoices if i.get("flowStatus") == "Cancelled"), None) + with_ext = next((i for i in invoices if i.get("externalId")), None) + if invoices: + run(f"{BASE}/{invoices[0]['id']}") + if issued: + run(f"{BASE}/{issued['id']}/pdf", accept="application/pdf") + follow(run(f"{BASE}/{issued['id']}/pdf", accept="*/*")) + run(f"{BASE}/{issued['id']}/xml", accept="application/xml") + follow(run(f"{BASE}/{issued['id']}/xml", accept="*/*")) + # second issued invoice, to avoid generalising from one sample + other = next((i for i in invoices if i.get("flowStatus") == "Issued" and i is not issued), None) + if other: + follow(run(f"{BASE}/{other['id']}/xml", accept="*/*")) + if cancelled: + run(f"{BASE}/{cancelled['id']}") + run(f"{BASE}/{cancelled['id']}/cancellation-xml", accept="*/*") + if with_ext: + run(f"{BASE}/external/{with_ext['externalId']}") + run(f"{BASE}/external/sdk-python-probe-does-not-exist") + run(f"{BASE}/000000000000000000000000") + run(f"{BASE}/not-a-valid-id") + run(f"{BASE}/000000000000000000000000/pdf", accept="*/*") + print("saved", save("p03_service_invoices", results)) + + +if __name__ == "__main__": + main() diff --git a/scripts/probes/p04_certs_webhooks_lookups.py b/scripts/probes/p04_certs_webhooks_lookups.py new file mode 100644 index 0000000..9bff23d --- /dev/null +++ b/scripts/probes/p04_certs_webhooks_lookups.py @@ -0,0 +1,84 @@ +"""P04 — certificate envelopes, webhooks list/event types, data lookups and error shapes (read-only).""" + +from __future__ import annotations + +from typing import Any + +from _probe import COMPANY_ID, KEYS, brief, request, save + +CNPJ = "00000000000191" +CEP = "01310100" + + +def main() -> None: + results: list[dict[str, Any]] = [] + + def run(host: str, path: str, **kw: Any) -> dict[str, Any]: + r = request(host, path, **kw) + results.append(r) + print(brief(r)) + return r + + # certificates + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/certificate") + run("api.nfse.io", f"/v1/companies/{COMPANY_ID}/certificate") + run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/certificates") + + # webhooks (secrets are redacted before saving) + hooks = run("api.nfse.io", "/v2/webhooks") + run("api.nfse.io", "/v2/webhooks/eventtypes") + run("api.nfse.io", "/v2/webhooks/eventTypes") + run("api.nfe.io", "/v1/hooks") + run("api.nfe.io", "/v1/eventTypes") + body = hooks["body"] if isinstance(hooks["body"], dict) else {} + for h in body.get("webHooks") or []: + if h.get("id"): + results.append(request("api.nfse.io", f"/v2/webhooks/{h['id']}")) + print(brief(results[-1])) + break + run("api.nfse.io", "/v2/webhooks/000000000000000000000000") + for r in results: + _redact_secrets(r.get("body")) + + # data lookups + run("legalentity.api.nfe.io", f"/v1/legalentities/basicInfo/{CNPJ}", key="data") + run("legalentity.api.nfe.io", f"/v2/legalentities/basicInfo/{CNPJ}", key="data") + run("legalentity.api.nfe.io", f"/v3/legalentities/basicInfo/{CNPJ}", key="data") + run("legalentity.api.nfe.io", f"/v2/legalentities/stateTaxInfo/DF/{CNPJ}", key="data") + run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/00000000000100", key="data") # bad DV + run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/123", key="data") + run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/00.000.000/0001-91", key="data") + run("address.api.nfe.io", f"/v2/addresses/{CEP}", key="data") + run("address.api.nfe.io", "/v2/addresses/01310-100", key="data") + run("address.api.nfe.io", "/v2/addresses/00000000", key="data") + run("address.api.nfe.io", "/v1/addresses/" + CEP, key="data") + run("address.api.nfe.io", "/v2/addresses", key="data", params={"$filter": "city eq 'Curitiba'"}) + run("naturalperson.api.nfe.io", "/v1/naturalperson/status/52998224725/1990-01-01", key="data") + run("naturalperson.api.nfe.io", "/v1/naturalperson/status/12345678900/1990-01-01", key="data") + run("naturalperson.api.nfe.io", "/v1/naturalperson/status/52998224725/01-01-1990", key="data") + + # error shapes with a well-formed but wrong key (401 with key present) + KEYS["bogus"] = "0" * 32 + run("api.nfe.io", "/v1/companies", key="bogus") + run("api.nfse.io", "/v2/companies", key="bogus") + run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": 0}) + run("api.nfe.io", "/v1/companies", params={"pageIndex": -1, "pageCount": 2}) + run("api.nfe.io", "/v1/companies", params={"pageIndex": "abc"}) + del KEYS["bogus"] + print("saved", save("p04_certs_webhooks_lookups", results)) + + +def _redact_secrets(node: Any) -> None: + if isinstance(node, dict): + for k in list(node): + if k.lower() in {"secret", "password"} and node[k]: + node[k] = "" + else: + _redact_secrets(node[k]) + elif isinstance(node, list): + for x in node: + _redact_secrets(x) + + +if __name__ == "__main__": + main() diff --git a/scripts/probes/p05_rechecks.py b/scripts/probes/p05_rechecks.py new file mode 100644 index 0000000..e986414 --- /dev/null +++ b/scripts/probes/p05_rechecks.py @@ -0,0 +1,104 @@ +"""P05 — re-check vault claims that may have changed (read-only). + +- Basic auth variants ("Basic " vs base64). +- Webhooks: same list on api.nfe.io and api.nfse.io? +- Companies v2 "poison record" cursor 500 (repro from the 2026-07 gate note). +- municipalTaxNumber v1 vs v2 for the test company. +- Off-spec NFS-e /status route; 503 vs 404 for an unknown company; people listings. +- CNPJ v3 for the test company's own CNPJ (alphanumeric-ready string?). +""" + +from __future__ import annotations + +import base64 +from typing import Any + +import _probe +from _probe import COMPANY_ID, KEYS, brief, request, save + +POISON = "3450bc6208cb4b97a1a3616b89f77a4b" + + +def main() -> None: + results: list[dict[str, Any]] = [] + + def run(host: str, path: str, **kw: Any) -> dict[str, Any]: + r = request(host, path, **kw) + results.append(r) + print(brief(r)) + return r + + # Basic variants: patch the auth header builder for two extra schemes + key = KEYS["main"] + original = _probe._auth + + def patched(key_name: str | None, scheme: str) -> tuple[dict[str, str], dict[str, str]]: + if scheme == "basic-raw": + return {"Authorization": f"Basic {key}"}, {} + if scheme == "basic-b64-nocolon": + return {"Authorization": "Basic " + base64.b64encode(key.encode()).decode()}, {} + if scheme == "bearer": + return {"Authorization": f"Bearer {key}"}, {} + return original(key_name, scheme) + + _probe._auth = patched + for scheme in ("basic-raw", "basic-b64-nocolon", "bearer"): + run("api.nfe.io", "/v1/companies", scheme=scheme, params={"pageIndex": 1, "pageCount": 2}) + run("api.nfse.io", "/v2/companies", scheme=scheme, params={"limit": 2}) + _probe._auth = original + + # Webhooks parity across hosts + a = run("api.nfe.io", "/v2/webhooks") + b = run("api.nfse.io", "/v2/webhooks") + ids_a = sorted(h["id"] for h in (a["body"] or {}).get("webHooks", [])) + ids_b = sorted(h["id"] for h in (b["body"] or {}).get("webHooks", [])) + parity = {"webhooks_same_ids_both_hosts": ids_a == ids_b, "n_api_nfe_io": len(ids_a), "n_api_nfse_io": len(ids_b)} + print(parity) + results.append({"webhooksHostParity": parity}) + + # Poison cursor repro + run("api.nfse.io", "/v2/companies", params={"limit": 1, "startingAfter": POISON}) + run("api.nfse.io", f"/v2/companies/{POISON}") + + # municipalTaxNumber v1 vs v2 (test company) + v1 = run("api.nfe.io", f"/v1/companies/{COMPANY_ID}") + v2 = run("api.nfse.io", f"/v2/companies/{COMPANY_ID}") + c1 = (v1["body"] or {}).get("companies") or {} + c2 = (v2["body"] or {}).get("company") or {} + cmp = { + "v1_keys_not_in_v2": sorted(set(c1) - set(c2)), + "v2_keys_not_in_v1": sorted(set(c2) - set(c1)), + "same_municipalTaxNumber": c1.get("municipalTaxNumber") == c2.get("municipalTaxNumber"), + "federalTaxNumber_types": [type(c1.get("federalTaxNumber")).__name__, type(c2.get("federalTaxNumber")).__name__], + } + print(cmp) + results.append({"companyV1vsV2": cmp}) + if c2.get("federalTaxNumber"): + tax = str(c2["federalTaxNumber"]).zfill(14) + run("legalentity.api.nfe.io", f"/v3/legalentities/basicInfo/{tax}", key="data") + for mt in (c2.get("municipalTaxes") or [])[:1]: + mt_id = mt.get("id") if isinstance(mt, dict) else mt + run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/municipaltaxes/{mt_id}") + run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/municipaltaxes", params={"limit": 5}) + + # NFS-e /status (off-spec) and unknown company behaviour + lst = request("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 1, "pageCount": 3}) + for inv in (lst["body"] or {}).get("serviceInvoices", [])[:2]: + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices/{inv['id']}/status") + run("api.nfe.io", "/v1/companies/000000000000000000000000/serviceinvoices", params={"pageIndex": 1, "pageCount": 2}) + run("api.nfe.io", "/v1/companies/00000000000000000000000000000000/serviceinvoices", params={"pageIndex": 1, "pageCount": 2}) + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 0, "pageCount": 2}) + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 1, "pageCount": 1}) + + # people listings (24-hex vs 32-hex company id) + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/legalpeople") + run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/naturalpeople") + + # unserved path baseline (to distinguish "not served" 404 from "not found" 404) + run("api.nfe.io", "/v1/this-route-does-not-exist", key=None) + run("api.nfse.io", "/v2/this-route-does-not-exist", key=None) + print("saved", save("p05_rechecks", results)) + + +if __name__ == "__main__": + main() diff --git a/scripts/probes/p06_cnpj_v3.py b/scripts/probes/p06_cnpj_v3.py new file mode 100644 index 0000000..dc65734 --- /dev/null +++ b/scripts/probes/p06_cnpj_v3.py @@ -0,0 +1,22 @@ +"""P06 — CNPJ v3 routes declared in consulta-cnpj-v3.json (read-only, data key).""" + +from __future__ import annotations + +from _probe import brief, request, save + +CNPJ = "00000000000191" # public sample (Banco do Brasil) + + +def main() -> None: + results = [ + request("legalentity.api.nfe.io", f"/v3/legalentities/stateTaxInfo/DF/{CNPJ}", key="data"), + request("legalentity.api.nfe.io", "/v3/legalentities/basicInfo/00000000000100", key="data"), + request("legalentity.api.nfe.io", "/v3/legalentities/basicInfo/12ABC34501DE35", key="data"), + ] + for r in results: + print(brief(r), str(r["body"])[:200]) + print("saved", save("p06_cnpj_v3", results)) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..de4c0d8 --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,58 @@ +"""Smoke test of an installed wheel in a clean environment (no network, no extra packages). + +python scripts/smoke_test.py +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import importlib.metadata +import sys + +import nfeio +from nfeio import AsyncNfeClient, ConfigurationError, NfeClient, ServiceInvoice +from nfeio.transport import Headers, HttpRequest, HttpResponse +from nfeio.webhooks import verify_signature + + +class _Transport: + def send(self, request: HttpRequest) -> HttpResponse: + assert request.headers["Authorization"] == "smoke-key-123456" + return HttpResponse(200, Headers({"x-request-id": "smoke"}), b'{"webHooks": []}') + + def close(self) -> None: + pass + + +def main() -> int: + assert importlib.metadata.version("nfe-io") == nfeio.__version__ + requires = importlib.metadata.requires("nfe-io") or [] + assert [r for r in requires if "extra ==" not in r] == [], requires + client = NfeClient(api_key="smoke-key-123456", transport=_Transport()) + assert client.webhooks.list() == [] + assert "smoke-key-123456" not in repr(client) + try: + NfeClient(api_key="k" * 10, base_urls={"service_invoices": "http://insecure"}) + except ConfigurationError: + pass + else: + raise AssertionError("http base URL must be rejected") + invoice = ServiceInvoice({"servicesAmount": 1234.56, "flowStatus": "Issued"}) + assert str(invoice.services_amount) == "1234.56" + body = b'{"action":"ping"}' + signature = "sha1=" + hmac.new(b"s", body, hashlib.sha1).hexdigest().upper() + assert verify_signature(body, signature, "s") + + async def run_async() -> None: + async with AsyncNfeClient(api_key="k" * 12) as aclient: + assert repr(aclient) + + asyncio.run(run_async()) + print(f"nfeio {nfeio.__version__} smoke test ok on Python {sys.version.split()[0]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/spec_keys.py b/scripts/spec_keys.py new file mode 100644 index 0000000..a0f7979 --- /dev/null +++ b/scripts/spec_keys.py @@ -0,0 +1,139 @@ +"""Extract the wire keys of the OpenAPI schemas the SDK models map to. + +Writes ``tests/fixtures/spec-keys.json`` (versioned snapshot, so CI does not need the +``nfeio-docs`` checkout). Anchored on *paths* (not operationIds, which are duplicated in the +NFS-e spec). Run from the repo root: + + uv run python scripts/spec_keys.py # regenerate the snapshot + uv run python scripts/spec_keys.py --check # fail if the snapshot is stale +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SPECS = ROOT / "nfeio-docs" / "static" / "api" +OUT = ROOT / "tests" / "fixtures" / "spec-keys.json" + + +def _load(name: str) -> dict[str, Any]: + text = (SPECS / name).read_text("utf-8") + data = json.loads(text) if name.endswith(".json") else yaml.safe_load(text) + assert isinstance(data, dict) + return data + + +def _resolve(doc: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]: + while "$ref" in node: + target: Any = doc + for part in node["$ref"].lstrip("#/").split("/"): + target = target[part] + node = target + if "allOf" in node: + merged: dict[str, Any] = {"properties": {}} + for sub in node["allOf"]: + merged["properties"].update(_resolve(doc, sub).get("properties", {})) + return merged + return node + + +def _props(doc: dict[str, Any], node: dict[str, Any], *walk: str) -> list[str]: + node = _resolve(doc, node) + for step in walk: + if step == "[]": + node = _resolve(doc, node["items"]) + else: + node = _resolve(doc, node["properties"][step]) + return sorted(node.get("properties", {})) + + +def _response(doc: dict[str, Any], path: str, method: str = "get", status: str = "200") -> Any: + response = doc["paths"][path][method]["responses"][status] + if "content" in response: + return next(iter(response["content"].values()))["schema"] + return response["schema"] + + +def _body(doc: dict[str, Any], path: str, method: str = "post") -> Any: + content = doc["paths"][path][method]["requestBody"]["content"] + return content.get("application/json", next(iter(content.values())))["schema"] + + +def extract() -> dict[str, list[str]]: + nfse = _load("nf-servico-v1.yaml") + taxpayers = _load("contribuintes-v2.json") + cnpj = _load("consulta-cnpj-v3.json") + cpf = _load("cpf-api.yaml") + cep = _load("consulta-endereco.yaml") + invoice = _response(nfse, "/v1/companies/{company_id}/serviceinvoices/{id}") + create = _body(nfse, "/v1/companies/{company_id}/serviceinvoices") + hooks = _response(nfse, "/v2/webhooks") + hook_body = _body(nfse, "/v2/webhooks") + company_schemas = taxpayers["components"]["schemas"] + certs = _response(taxpayers, "/v2/companies/{company_id}/certificates") + return { + "ServiceInvoice": _props(nfse, invoice), + "Party.borrower": _props(nfse, invoice, "borrower"), + "Party.provider": _props(nfse, invoice, "provider"), + "ServiceInvoiceCreateParams": _props(nfse, create), + "BorrowerParams": _props(nfse, create, "borrower"), + "AddressParams": _props(nfse, create, "borrower", "address"), + "CityParams": _props(nfse, create, "borrower", "address", "city"), + "Company": _props( + taxpayers, company_schemas["DFeTech.TaxPayers.Resources.CompanyResourceItem"] + ), + "CompanyParams": sorted( + set( + _props( + taxpayers, + company_schemas["DFeTech.TaxPayers.Resources.CreateCompanyResourceItem"], + ) + ) + | set( + _props( + taxpayers, + company_schemas["DFeTech.TaxPayers.Resources.UpdateCompanyResourceItem"], + ) + ) + ), + "CompanyAddressParams": _props( + taxpayers, company_schemas["DFeTech.TaxPayers.Resources.AddressResource"] + ), + "Certificate": _props(taxpayers, certs, "certificates", "[]"), + "Webhook": _props(nfse, hooks, "webHooks", "[]"), + "WebhookParams": _props(nfse, hook_body, "webHook"), + "WebhookEventType": _props( + nfse, _response(nfse, "/v2/webhooks/eventtypes"), "eventTypes", "[]" + ), + "LegalEntity": _props( + cnpj, _response(cnpj, "/v3/legalentities/basicInfo/{federalTaxNumber}"), "legalEntity" + ), + "NaturalPerson": _props( + cpf, _response(cpf, "/v1/naturalperson/status/{federalTaxNumber}/{birthDate}") + ), + # Spec wraps a list ("addresses"); the wire sends one "address" (divergence #32). + "Address": _props(cep, _response(cep, "/v2/addresses/{postalCode}"), "addresses", "[]"), + } + + +def main() -> int: + snapshot = json.dumps( + {"_generated_by": "scripts/spec_keys.py", "keys": extract()}, indent=2, sort_keys=True + ) + if "--check" in sys.argv: + if OUT.read_text("utf-8").strip() != snapshot.strip(): + print("tests/fixtures/spec-keys.json is stale: run scripts/spec_keys.py") + return 1 + return 0 + OUT.write_text(snapshot + "\n", "utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_action_pins.py b/scripts/verify_action_pins.py new file mode 100644 index 0000000..b991935 --- /dev/null +++ b/scripts/verify_action_pins.py @@ -0,0 +1,66 @@ +"""Check that every ``uses: owner/repo@ # vX.Y.Z`` pin points to the commit of that tag. + +Runs in CI (job ``action-pins``) with the ``gh`` CLI and ``GH_TOKEN``. Exit 1 on any mismatch, +unpinned action or missing version comment. Stdlib + ``gh`` only. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +USES = re.compile(r"uses:\s*([\w.-]+/[\w.-]+)(/[\w./-]+)?@(\S+)(?:\s*#\s*(v\S+))?") + + +def _tag_commit(repo: str, tag: str) -> str: + raw = subprocess.run( # noqa: S603 - fixed program, arguments from our own workflow files + ["gh", "api", f"repos/{repo}/git/ref/tags/{tag}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout + ref = json.loads(raw)["object"] + if ref["type"] == "tag": # annotated tag: dereference to the commit + raw = subprocess.run( # noqa: S603 + ["gh", "api", f"repos/{repo}/git/tags/{ref['sha']}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout + ref = json.loads(raw)["object"] + return str(ref["sha"]) + + +def main() -> int: + problems: list[str] = [] + checked: dict[tuple[str, str], str] = {} + for workflow in sorted((ROOT / ".github" / "workflows").glob("*.y*ml")): + for number, line in enumerate(workflow.read_text("utf-8").splitlines(), 1): + match = USES.search(line) + if not match: + continue + repo, _subpath, ref, tag = match.groups() + where = f"{workflow.name}:{number}" + if not re.fullmatch(r"[0-9a-f]{40}", ref): + problems.append(f"{where}: {repo}@{ref} is not pinned to a commit SHA") + continue + if not tag: + problems.append(f"{where}: {repo}@{ref} has no '# vX.Y.Z' comment") + continue + key = (repo, tag) + if key not in checked: + checked[key] = _tag_commit(repo, tag) + if checked[key] != ref: + problems.append(f"{where}: {repo} {tag} is {checked[key]}, pinned {ref}") + for problem in problems: + print(problem) + print(f"checked {len(checked)} action tags, {len(problems)} problem(s)") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/nfeio/__init__.py b/src/nfeio/__init__.py new file mode 100644 index 0000000..420cf27 --- /dev/null +++ b/src/nfeio/__init__.py @@ -0,0 +1,103 @@ +"""SDK oficial da NFE.io para Python. + +Quick start:: + + from nfeio import NfeClient + + client = NfeClient(api_key="...") # or NFE_API_KEY + invoice = client.service_invoices.create_and_wait( + company_id, {"cityServiceCode": "...", "description": "...", "servicesAmount": 100}, + external_id="pedido-123", + ) + print(invoice.flow_status, invoice["number"]) +""" + +import logging as _logging + +from ._client import AsyncNfeClient, NfeClient +from ._config import ApiFamily, RequestOptions, Timeout +from ._version import __version__ +from .errors import ( + APIConnectionError, + APIError, + APITimeoutError, + AuthenticationError, + ConfigurationError, + ConflictError, + DuplicateExternalIdError, + FailurePhase, + InvalidParameterError, + InvalidRequestError, + InvoiceProcessingError, + NfeError, + NotFoundError, + PermissionDeniedError, + PollingTimeoutError, + RateLimitError, + ResponseTooLargeError, + ServerError, + SignatureVerificationError, + UnexpectedResponseError, +) +from .models import ( + Address, + Certificate, + Company, + LegalEntity, + NaturalPerson, + NfeObject, + Party, + ResponseInfo, + ServiceInvoice, + Webhook, + WebhookEventType, +) +from .pagination import AsyncCursorPage, AsyncOffsetPage, CursorPage, OffsetPage +from .types import FlowStatus + +_logging.getLogger("nfeio").addHandler(_logging.NullHandler()) + +__all__ = [ + "APIConnectionError", + "APIError", + "APITimeoutError", + "Address", + "ApiFamily", + "AsyncCursorPage", + "AsyncNfeClient", + "AsyncOffsetPage", + "AuthenticationError", + "Certificate", + "Company", + "ConfigurationError", + "ConflictError", + "CursorPage", + "DuplicateExternalIdError", + "FailurePhase", + "FlowStatus", + "InvalidParameterError", + "InvalidRequestError", + "InvoiceProcessingError", + "LegalEntity", + "NaturalPerson", + "NfeClient", + "NfeError", + "NfeObject", + "NotFoundError", + "OffsetPage", + "Party", + "PermissionDeniedError", + "PollingTimeoutError", + "RateLimitError", + "RequestOptions", + "ResponseInfo", + "ResponseTooLargeError", + "ServerError", + "ServiceInvoice", + "SignatureVerificationError", + "Timeout", + "UnexpectedResponseError", + "Webhook", + "WebhookEventType", + "__version__", +] diff --git a/src/nfeio/_client.py b/src/nfeio/_client.py new file mode 100644 index 0000000..4624b23 --- /dev/null +++ b/src/nfeio/_client.py @@ -0,0 +1,227 @@ +"""``NfeClient`` and ``AsyncNfeClient``: explicit clients, no global state.""" + +from __future__ import annotations + +import asyncio +import os +import ssl +import time +from collections.abc import Awaitable, Callable, Mapping +from types import TracebackType +from typing import TypeVar + +from ._config import DEFAULT_MAX_RESPONSE_BYTES, ApiFamily, ClientConfig, Timeout +from ._core.ops import Op, run_async, run_sync +from ._core.transport import ( + AsyncTransport, + HttpClientTransport, + ThreadedAsyncTransport, + Transport, +) +from .errors import ConfigurationError +from .resources.certificates import AsyncCertificatesService, CertificatesService +from .resources.companies import AsyncCompaniesService, CompaniesService +from .resources.lookups import AsyncLookupsService, LookupsService +from .resources.service_invoices import AsyncServiceInvoicesService, ServiceInvoicesService +from .resources.webhooks import AsyncWebhooksService, WebhooksService + +__all__ = ["AsyncNfeClient", "NfeClient"] + +T = TypeVar("T") +_DEFAULT_TIMEOUT = Timeout() + + +def _check_transport_args( + transport: object, + ssl_context: ssl.SSLContext | None, + ca_bundle: str | os.PathLike[str] | None, +) -> None: + if transport is not None and (ssl_context is not None or ca_bundle is not None): + raise ConfigurationError( + "ssl_context/ca_bundle configure the default transport; with a custom transport, " + "configure TLS in the transport itself" + ) + + +class NfeClient: + """Synchronous NFE.io client. Thread-safe; reuse one instance per configuration. + + Args: + api_key: key for NFS-e, companies, certificates and webhooks. Defaults to the + ``NFE_API_KEY`` environment variable. + data_api_key: key for CNPJ/CPF/CEP lookups. Defaults to ``NFE_DATA_API_KEY``. The main + key is never used as a fallback (data hosts reject it with 403). + base_urls: override the base URL of an API family (``"service_invoices"``, + ``"account"``, ``"legal_entity"``, ``"natural_person"``, ``"address"``); https only. + timeout: :class:`~nfeio.Timeout` or seconds per attempt (default connect 10 s, + read 60 s, total 120 s). Must be finite. + max_retries: retries of safe requests (default 3). POSTs are never retried after the + request may have reached the API. + retry_base_delay, retry_max_delay: exponential backoff (1 s doubling up to 30 s, + with +-30% jitter). + max_retry_after: largest ``Retry-After`` honoured (default 60 s). + max_response_bytes: response size limit (default 10 MiB). + app_info: ``(name, version)`` appended to the User-Agent, e.g. + ``("odoo-l10n_br_nfse_nfeio", "18.0.1.0")``. + ssl_context: a verifying ``ssl.SSLContext`` (e.g. corporate CA). Contexts that + disable verification are rejected. + ca_bundle: path to extra trusted CA certificates (PEM). + transport: custom :class:`~nfeio.transport.Transport`. + """ + + service_invoices: ServiceInvoicesService + companies: CompaniesService + certificates: CertificatesService + webhooks: WebhooksService + lookups: LookupsService + + def __init__( + self, + api_key: str | None = None, + *, + data_api_key: str | None = None, + base_urls: Mapping[ApiFamily | str, str] | None = None, + timeout: float | Timeout = _DEFAULT_TIMEOUT, + max_retries: int = 3, + retry_base_delay: float = 1.0, + retry_max_delay: float = 30.0, + max_retry_after: float = 60.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + app_info: tuple[str, str] | None = None, + ssl_context: ssl.SSLContext | None = None, + ca_bundle: str | os.PathLike[str] | None = None, + transport: Transport | None = None, + ) -> None: + _check_transport_args(transport, ssl_context, ca_bundle) + self._config = ClientConfig.build( + api_key=api_key, + data_api_key=data_api_key, + base_urls=base_urls, + timeout=timeout, + max_retries=max_retries, + retry_base_delay=retry_base_delay, + retry_max_delay=retry_max_delay, + max_retry_after=max_retry_after, + max_response_bytes=max_response_bytes, + app_info=app_info, + ) + self._transport: Transport = ( + transport + if transport is not None + else HttpClientTransport(ssl_context=ssl_context, ca_bundle=ca_bundle) + ) + self._sleep: Callable[[float], None] = time.sleep + self._monotonic: Callable[[], float] = time.monotonic + self.service_invoices = ServiceInvoicesService(self) + self.companies = CompaniesService(self) + self.certificates = CertificatesService(self) + self.webhooks = WebhooksService(self) + self.lookups = LookupsService(self) + + def _run(self, op: Op[T]) -> T: + return run_sync(op, self._transport, sleep=self._sleep, monotonic=self._monotonic) + + def close(self) -> None: + """Close pooled connections.""" + self._transport.close() + + def __enter__(self) -> NfeClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def __repr__(self) -> str: + return ( + f"NfeClient(api_key={self._config.api_key!r}, " + f"data_api_key={self._config.data_api_key!r})" + ) + + +class AsyncNfeClient: + """Asynchronous NFE.io client with the same surface and behaviour as :class:`NfeClient`. + + I/O runs in worker threads (``asyncio.to_thread`` over the stdlib transport) and waits use + ``asyncio.sleep``, so the event loop is never blocked. Pass a native + :class:`~nfeio.transport.AsyncTransport` to use another HTTP stack. + """ + + service_invoices: AsyncServiceInvoicesService + companies: AsyncCompaniesService + certificates: AsyncCertificatesService + webhooks: AsyncWebhooksService + lookups: AsyncLookupsService + + def __init__( + self, + api_key: str | None = None, + *, + data_api_key: str | None = None, + base_urls: Mapping[ApiFamily | str, str] | None = None, + timeout: float | Timeout = _DEFAULT_TIMEOUT, + max_retries: int = 3, + retry_base_delay: float = 1.0, + retry_max_delay: float = 30.0, + max_retry_after: float = 60.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + app_info: tuple[str, str] | None = None, + ssl_context: ssl.SSLContext | None = None, + ca_bundle: str | os.PathLike[str] | None = None, + transport: AsyncTransport | None = None, + ) -> None: + _check_transport_args(transport, ssl_context, ca_bundle) + self._config = ClientConfig.build( + api_key=api_key, + data_api_key=data_api_key, + base_urls=base_urls, + timeout=timeout, + max_retries=max_retries, + retry_base_delay=retry_base_delay, + retry_max_delay=retry_max_delay, + max_retry_after=max_retry_after, + max_response_bytes=max_response_bytes, + app_info=app_info, + ) + self._transport: AsyncTransport = ( + transport + if transport is not None + else ThreadedAsyncTransport( + HttpClientTransport(ssl_context=ssl_context, ca_bundle=ca_bundle) + ) + ) + self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep + self._monotonic: Callable[[], float] = time.monotonic + self.service_invoices = AsyncServiceInvoicesService(self) + self.companies = AsyncCompaniesService(self) + self.certificates = AsyncCertificatesService(self) + self.webhooks = AsyncWebhooksService(self) + self.lookups = AsyncLookupsService(self) + + async def _run(self, op: Op[T]) -> T: + return await run_async(op, self._transport, sleep=self._sleep, monotonic=self._monotonic) + + async def aclose(self) -> None: + """Close pooled connections.""" + await self._transport.aclose() + + async def __aenter__(self) -> AsyncNfeClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + def __repr__(self) -> str: + return ( + f"AsyncNfeClient(api_key={self._config.api_key!r}, " + f"data_api_key={self._config.data_api_key!r})" + ) diff --git a/src/nfeio/_config.py b/src/nfeio/_config.py new file mode 100644 index 0000000..b10a583 --- /dev/null +++ b/src/nfeio/_config.py @@ -0,0 +1,385 @@ +"""Client configuration, per-call options, timeouts, API families and secret masking.""" + +from __future__ import annotations + +import enum +import math +import os +import platform +import re +import ssl +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Final +from urllib.parse import urlsplit + +from ._version import __version__ +from .errors import ConfigurationError + +__all__ = [ + "DEFAULT_BASE_URLS", + "ApiFamily", + "ClientConfig", + "RequestOptions", + "SecretStr", + "Timeout", +] + + +class ApiFamily(str, enum.Enum): + """Group of endpoints that share a host and an API key. + + The value is the key accepted in ``NfeClient(base_urls={...})``. + """ + + #: NFS-e v1 — ``https://api.nfe.io`` — ``api_key``. + SERVICE_INVOICES = "service_invoices" + #: Companies v2, certificates, webhooks — ``https://api.nfse.io`` — ``api_key``. + ACCOUNT = "account" + #: CNPJ lookups — ``https://legalentity.api.nfe.io`` — ``data_api_key``. + LEGAL_ENTITY = "legal_entity" + #: CPF lookups — ``https://naturalperson.api.nfe.io`` — ``data_api_key``. + NATURAL_PERSON = "natural_person" + #: CEP lookups — ``https://address.api.nfe.io`` — ``data_api_key``. + ADDRESS = "address" + + @property + def uses_data_key(self) -> bool: + return self in (ApiFamily.LEGAL_ENTITY, ApiFamily.NATURAL_PERSON, ApiFamily.ADDRESS) + + @property + def key_param(self) -> str: + """Name of the client parameter that holds this family's key.""" + return "data_api_key" if self.uses_data_key else "api_key" + + +DEFAULT_BASE_URLS: Final[Mapping[ApiFamily, str]] = MappingProxyType( + { + ApiFamily.SERVICE_INVOICES: "https://api.nfe.io", + ApiFamily.ACCOUNT: "https://api.nfse.io", + ApiFamily.LEGAL_ENTITY: "https://legalentity.api.nfe.io", + ApiFamily.NATURAL_PERSON: "https://naturalperson.api.nfe.io", + ApiFamily.ADDRESS: "https://address.api.nfe.io", + } +) + +DEFAULT_MAX_RESPONSE_BYTES: Final = 10 * 1024 * 1024 +_PROTECTED_HEADERS: Final = frozenset({"authorization", "user-agent", "content-type"}) +_TOKEN_RE: Final = re.compile(r"[A-Za-z0-9._+\-]{1,64}") +_HEADER_NAME_RE: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,64}") + + +def mask_secret(value: str) -> str: + """Return ``'****'`` plus at most the last 4 characters (none for short values).""" + if len(value) <= 8: + return "****" + return "****" + value[-4:] + + +class SecretStr: + """A string that never shows itself in ``repr``/``str``/logs.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def get_secret_value(self) -> str: + return self._value + + def __repr__(self) -> str: + return f"SecretStr({mask_secret(self._value)!r})" + + __str__ = __repr__ + + def __eq__(self, other: object) -> bool: + return isinstance(other, SecretStr) and other._value == self._value + + def __hash__(self) -> int: + return hash(self._value) + + def __bool__(self) -> bool: + return bool(self._value) + + def __reduce__(self) -> str | tuple[object, ...]: + raise TypeError("SecretStr cannot be pickled") + + +def _check_seconds(name: str, value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigurationError(f"{name} must be a number of seconds, got {type(value).__name__}") + seconds = float(value) + if not math.isfinite(seconds) or seconds <= 0: + raise ConfigurationError(f"{name} must be a finite number of seconds greater than zero") + return seconds + + +@dataclass(frozen=True) +class Timeout: + """Network deadlines, in seconds, applied to each attempt. + + Attributes: + connect: DNS + TCP + TLS handshake. + read: each socket read (waiting for the response headers and each body chunk). + total: whole attempt, from connecting to the last body byte. + """ + + connect: float = 10.0 + read: float = 60.0 + total: float = 120.0 + + def __post_init__(self) -> None: + for name in ("connect", "read", "total"): + object.__setattr__(self, name, _check_seconds(f"timeout.{name}", getattr(self, name))) + + @classmethod + def coerce(cls, value: float | Timeout | None) -> Timeout: + """Accept a :class:`Timeout` or a number of seconds. + + A number ``t`` means "this attempt may take up to ``t`` seconds": ``read = total = t`` and + ``connect = min(10, t)``. ``None``, zero, negative and infinite values are rejected. + """ + if isinstance(value, Timeout): + return value + if value is None: + raise ConfigurationError("timeout must be finite; None (no timeout) is not allowed") + seconds = _check_seconds("timeout", value) + return cls(connect=min(10.0, seconds), read=seconds, total=seconds) + + +def _check_retries(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 10: + raise ConfigurationError("max_retries must be an integer between 0 and 10") + return value + + +def _check_header_value(name: str, value: object) -> str: + if not isinstance(value, str) or any(ch in value for ch in "\r\n\x00"): + raise ConfigurationError(f"invalid value for header {name!r}") + return value + + +@dataclass(frozen=True, repr=False) +class RequestOptions: + """Per-call overrides. Every field is optional; ``None`` keeps the client setting. + + Attributes: + api_key: key used for this call instead of the one the resource would use. + timeout: :class:`Timeout` or seconds (see :meth:`Timeout.coerce`). + max_retries: retries for this call (0 disables). Never makes a POST retryable on + ambiguous failures. + idempotency_key: sent as ``Idempotency-Key``. The API ignores it today, so it does + **not** change the retry policy. + extra_headers: additional headers; cannot override ``Authorization``, ``User-Agent`` + or ``Content-Type``. + """ + + api_key: str | None = None + timeout: float | Timeout | None = None + max_retries: int | None = None + idempotency_key: str | None = None + extra_headers: Mapping[str, str] | None = field(default=None) + + def __post_init__(self) -> None: + if self.api_key is not None and (not isinstance(self.api_key, str) or not self.api_key): + raise ConfigurationError("api_key must be a non-empty string") + if self.api_key is not None: + _check_header_value("Authorization", self.api_key) + if self.timeout is not None: + object.__setattr__(self, "timeout", Timeout.coerce(self.timeout)) + if self.max_retries is not None: + _check_retries(self.max_retries) + if self.idempotency_key is not None: + key = _check_header_value("Idempotency-Key", self.idempotency_key) + if not key or len(key) > 255: + raise ConfigurationError("idempotency_key must have 1 to 255 characters") + if self.extra_headers is not None: + frozen: dict[str, str] = {} + for name, value in self.extra_headers.items(): + if not isinstance(name, str) or not _HEADER_NAME_RE.fullmatch(name): + raise ConfigurationError(f"invalid header name {name!r}") + if name.lower() in _PROTECTED_HEADERS: + raise ConfigurationError( + f"extra_headers cannot override {name!r}; use the client options instead" + ) + frozen[name] = _check_header_value(name, value) + object.__setattr__(self, "extra_headers", MappingProxyType(frozen)) + + def __repr__(self) -> str: + key = mask_secret(self.api_key) if self.api_key else None + return ( + f"RequestOptions(api_key={key!r}, timeout={self.timeout!r}, " + f"max_retries={self.max_retries!r}, idempotency_key={self.idempotency_key!r}, " + f"extra_headers={dict(self.extra_headers) if self.extra_headers else None!r})" + ) + + +def _normalize_base_url(family: ApiFamily, url: object) -> str: + if not isinstance(url, str): + raise ConfigurationError(f"base URL for {family.value!r} must be a string") + parts = urlsplit(url) + if parts.scheme != "https": + raise ConfigurationError(f"base URL for {family.value!r} must use https: {url!r}") + if not parts.hostname or parts.username or parts.password or parts.query or parts.fragment: + raise ConfigurationError( + f"base URL for {family.value!r} must be https://host[:port][/prefix] without " + "credentials, query or fragment" + ) + return url.rstrip("/") + + +def build_user_agent(app_info: tuple[str, str] | None) -> str: + system = platform.system().lower() or "unknown" + ua = ( + f"nfe-io-python/{__version__} " + f"python/{sys.version_info.major}.{sys.version_info.minor} {system}" + ) + if app_info is not None: + ua += f" {app_info[0]}/{app_info[1]}" + return ua + + +def _check_app_info(app_info: object) -> tuple[str, str] | None: + if app_info is None: + return None + if ( + not isinstance(app_info, tuple) + or len(app_info) != 2 + or not all(isinstance(part, str) and _TOKEN_RE.fullmatch(part) for part in app_info) + ): + raise ConfigurationError( + "app_info must be a (name, version) tuple of 1-64 characters from [A-Za-z0-9._+-]" + ) + return (app_info[0], app_info[1]) + + +def check_ssl_context(context: ssl.SSLContext) -> ssl.SSLContext: + """Reject contexts that disable certificate or hostname verification.""" + if not isinstance(context, ssl.SSLContext): + raise ConfigurationError("ssl_context must be an ssl.SSLContext") + if context.verify_mode != ssl.CERT_REQUIRED or not context.check_hostname: + raise ConfigurationError( + "ssl_context must verify certificates and hostnames (CERT_REQUIRED and " + "check_hostname=True); TLS verification cannot be disabled" + ) + if context.minimum_version < ssl.TLSVersion.TLSv1_2: + context.minimum_version = ssl.TLSVersion.TLSv1_2 + return context + + +def build_ssl_context( + ssl_context: ssl.SSLContext | None = None, ca_bundle: str | os.PathLike[str] | None = None +) -> ssl.SSLContext: + """Default verified context (TLS >= 1.2), optionally trusting an extra CA bundle.""" + if ssl_context is not None and ca_bundle is not None: + raise ConfigurationError("pass either ssl_context or ca_bundle, not both") + if ssl_context is not None: + return check_ssl_context(ssl_context) + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + if ca_bundle is not None: + try: + context.load_verify_locations(cafile=os.fspath(ca_bundle)) + except (OSError, ssl.SSLError) as exc: + raise ConfigurationError(f"could not load ca_bundle: {exc}") from None + return check_ssl_context(context) + + +def _env_secret(explicit: str | None, env_name: str, param: str) -> SecretStr | None: + value = explicit if explicit is not None else os.environ.get(env_name) + if value is None or value == "": + if explicit == "": + raise ConfigurationError(f"{param} must not be empty") + return None + if not isinstance(value, str): + raise ConfigurationError(f"{param} must be a string") + _check_header_value("Authorization", value) + return SecretStr(value.strip()) + + +@dataclass(frozen=True, repr=False) +class ClientConfig: + """Immutable, validated configuration shared by every call of a client.""" + + api_key: SecretStr | None + data_api_key: SecretStr | None + base_urls: Mapping[ApiFamily, str] + timeout: Timeout + max_retries: int + retry_base_delay: float + retry_max_delay: float + max_retry_after: float + max_response_bytes: int + user_agent: str + #: Uniform random source in [0, 1) used for jitter (injectable for tests). + random: Callable[[], float] + + @classmethod + def build( + cls, + *, + api_key: str | None = None, + data_api_key: str | None = None, + base_urls: Mapping[ApiFamily | str, str] | None = None, + timeout: float | Timeout = Timeout(), # noqa: B008 - frozen, immutable default + max_retries: int = 3, + retry_base_delay: float = 1.0, + retry_max_delay: float = 30.0, + max_retry_after: float = 60.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + app_info: tuple[str, str] | None = None, + random: Callable[[], float] | None = None, + ) -> ClientConfig: + urls = dict(DEFAULT_BASE_URLS) + for raw_family, url in (base_urls or {}).items(): + try: + family = ApiFamily(raw_family) + except ValueError: + valid = ", ".join(f.value for f in ApiFamily) + raise ConfigurationError( + f"unknown API family {raw_family!r} in base_urls (valid: {valid})" + ) from None + urls[family] = _normalize_base_url(family, url) + if ( + isinstance(max_response_bytes, bool) + or not isinstance(max_response_bytes, int) + or max_response_bytes < 1024 + ): + raise ConfigurationError("max_response_bytes must be an integer >= 1024") + if random is None: + import random as _random + + random = _random.SystemRandom().random + return cls( + api_key=_env_secret(api_key, "NFE_API_KEY", "api_key"), + data_api_key=_env_secret(data_api_key, "NFE_DATA_API_KEY", "data_api_key"), + base_urls=MappingProxyType(urls), + timeout=Timeout.coerce(timeout), + max_retries=_check_retries(max_retries), + retry_base_delay=_check_seconds("retry_base_delay", retry_base_delay), + retry_max_delay=_check_seconds("retry_max_delay", retry_max_delay), + max_retry_after=_check_seconds("max_retry_after", max_retry_after), + max_response_bytes=max_response_bytes, + user_agent=build_user_agent(_check_app_info(app_info)), + random=random, + ) + + def key_for(self, family: ApiFamily, options: RequestOptions | None) -> str: + if options is not None and options.api_key is not None: + return options.api_key + secret = self.data_api_key if family.uses_data_key else self.api_key + if secret is None: + env = "NFE_DATA_API_KEY" if family.uses_data_key else "NFE_API_KEY" + raise ConfigurationError( + f"{family.key_param} is required for this operation: pass " + f"NfeClient({family.key_param}=...) or set {env}" + ) + return secret.get_secret_value() + + def __repr__(self) -> str: + return ( + f"ClientConfig(api_key={self.api_key!r}, data_api_key={self.data_api_key!r}, " + f"timeout={self.timeout!r}, max_retries={self.max_retries})" + ) diff --git a/src/nfeio/_core/__init__.py b/src/nfeio/_core/__init__.py new file mode 100644 index 0000000..5986aa9 --- /dev/null +++ b/src/nfeio/_core/__init__.py @@ -0,0 +1 @@ +"""Internal, I/O-free core of the SDK. Nothing here is public API.""" diff --git a/src/nfeio/_core/error_mapping.py b/src/nfeio/_core/error_mapping.py new file mode 100644 index 0000000..0ad1990 --- /dev/null +++ b/src/nfeio/_core/error_mapping.py @@ -0,0 +1,181 @@ +"""Turn an HTTP error response into the right :class:`~nfeio.errors.APIError`. + +The NFE.io API uses (at least) these error bodies, all handled here: + +* empty body (401/403, unserved routes); +* a raw JSON string: ``"pageCount must be between 1 and 50"``; +* ``{"errors": [{"code": 40401, "message": "..."}]}`` (with or without ``code``); +* ``{"code": ..., "message": "..."}``; +* ASP.NET ProblemDetails: ``{"title", "status", "traceId", "errors": {"field": ["msg"]}}``. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Any + +from .._config import ApiFamily +from ..errors import ( + APIError, + AuthenticationError, + ConflictError, + DuplicateExternalIdError, + InvalidRequestError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, +) +from . import jsonutil +from .retry import is_idempotent, parse_retry_after +from .transport import HttpResponse + +MAX_MESSAGE_CHARS = 1000 +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+") +_DUPLICATE_RE = re.compile(r"external\s*id.*already\s+exists", re.IGNORECASE | re.DOTALL) +_DUPLICATE_ID_RE = re.compile(r"external\s*id\s*\((.*?)\)", re.IGNORECASE | re.DOTALL) + + +def sanitize(text: str) -> str: + cleaned = _CONTROL_RE.sub(" ", text).strip() + cleaned = re.sub(r" {2,}", " ", cleaned) + if len(cleaned) > MAX_MESSAGE_CHARS: + cleaned = cleaned[: MAX_MESSAGE_CHARS - 1] + "…" + return cleaned + + +def _default_message(status: int) -> str: + try: + phrase = HTTPStatus(status).phrase + except ValueError: + phrase = "HTTP error" + return f"{status} {phrase}" + + +def _from_errors_list(items: list[Any]) -> tuple[str, int | str | None]: + messages: list[str] = [] + code: int | str | None = None + for item in items: + if isinstance(item, dict): + msg = item.get("message") or item.get("Message") + if code is None and isinstance(item.get("code"), (int, str)): + code = item["code"] + if isinstance(msg, str) and msg: + messages.append(msg) + elif isinstance(item, str) and item: + messages.append(item) + return "; ".join(messages), code + + +def extract_error(body: bytes, status: int) -> tuple[str, int | str | None, str | None]: + """Return ``(message, error_code, trace_id)`` from an error body.""" + if not body or not body.strip(): + return "", None, None + try: + text = body.decode("utf-8-sig") + except UnicodeDecodeError: + return "", None, None + try: + data: Any = jsonutil.loads(text) + except (ValueError, RecursionError): + return sanitize(text), None, None + message = "" + code: int | str | None = None + trace_id: str | None = None + if isinstance(data, str): + message = data + elif isinstance(data, dict): + raw_trace = data.get("traceId") + trace_id = raw_trace if isinstance(raw_trace, str) else None + errors = data.get("errors") + if isinstance(errors, list): + message, code = _from_errors_list(errors) + elif isinstance(errors, dict): + details = [] + for name, msgs in errors.items(): + values: Iterable[Any] = msgs if isinstance(msgs, list) else [msgs] + for msg in values: + if isinstance(msg, str) and msg: + details.append(f"{name}: {msg}" if name else msg) + title = data.get("title") if isinstance(data.get("title"), str) else "" + message = " ".join(part for part in (title, "; ".join(details)) if part) + if not message: + for key in ("message", "Message", "detail", "title", "error"): + value = data.get(key) + if isinstance(value, str) and value: + message = value + break + if code is None and isinstance(data.get("code"), (int, str)): + code = data["code"] + return sanitize(message), code, trace_id + + +def _scrub(text: str, secrets: Iterable[str]) -> str: + for secret in secrets: + if secret and len(secret) >= 4: + text = text.replace(secret, "") + return text + + +def error_from_response( + response: HttpResponse, + *, + family: ApiFamily, + method: str, + external_id: str | None = None, + secrets: Iterable[str] = (), +) -> APIError: + status = response.status_code + secrets = tuple(secrets) + body = response.body + if secrets: + for secret in secrets: + if secret and len(secret) >= 4 and secret.encode() in body: + body = body.replace(secret.encode(), b"") + message, code, trace_id = extract_error(body, status) + message = _scrub(message, secrets) + outcome_unknown = not is_idempotent(method) and (status in (408, 429) or status >= 500) + kwargs: dict[str, Any] = { + "status_code": status, + "body": body, + "headers": response.headers, + "error_code": code, + "trace_id": trace_id, + "outcome_unknown": outcome_unknown, + "external_id": external_id, + } + + if status == 400 and _DUPLICATE_RE.search(message): + if external_id is None: + match = _DUPLICATE_ID_RE.search(message) + kwargs["external_id"] = match.group(1) if match else None + return DuplicateExternalIdError(message, **kwargs) + if status == 401: + return AuthenticationError( + message or f"authentication failed: missing or invalid {family.key_param}", **kwargs + ) + if status == 403: + hint = ( + f"permission denied for this API family ({family.value}); it uses " + f"{family.key_param} — check that the right key was configured" + ) + return PermissionDeniedError(f"{message} — {hint}" if message else hint, **kwargs) + if status == 404: + return NotFoundError( + message or "404 Not Found (empty body: the route may not exist)", **kwargs + ) + if status == 409: + return ConflictError(message or _default_message(status), **kwargs) + if status == 429: + retry_after = parse_retry_after( + response.headers.get("retry-after"), datetime.now(timezone.utc) + ) + return RateLimitError( + message or _default_message(status), retry_after=retry_after, **kwargs + ) + if status == 408 or status >= 500: + return ServerError(message or _default_message(status), **kwargs) + return InvalidRequestError(message or _default_message(status), **kwargs) diff --git a/src/nfeio/_core/headers.py b/src/nfeio/_core/headers.py new file mode 100644 index 0000000..9dfb73d --- /dev/null +++ b/src/nfeio/_core/headers.py @@ -0,0 +1,66 @@ +"""Case-insensitive, immutable HTTP header mapping.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping + +#: Header names whose values are never shown in ``repr``. +SENSITIVE_HEADERS = frozenset( + {"authorization", "proxy-authorization", "x-nfe-apikey", "apikey", "cookie", "set-cookie"} +) + + +class Headers(Mapping[str, str]): + """Read-only header mapping with case-insensitive lookup. + + Repeated names are joined with ``", "`` (RFC 9110 §5.3). Iteration yields the names as first + seen on the wire. + """ + + __slots__ = ("_store",) + + def __init__(self, items: Mapping[str, str] | Iterable[tuple[str, str]] | None = None) -> None: + store: dict[str, tuple[str, str]] = {} + pairs: Iterable[tuple[str, str]] + if items is None: + pairs = () + elif isinstance(items, Mapping): + pairs = items.items() + else: + pairs = items + for name, value in pairs: + key = name.lower() + if key in store: + original, previous = store[key] + store[key] = (original, f"{previous}, {value}") + else: + store[key] = (name, value) + self._store = store + + def __getitem__(self, key: str) -> str: + return self._store[key.lower()][1] + + def __contains__(self, key: object) -> bool: + return isinstance(key, str) and key.lower() in self._store + + def __iter__(self) -> Iterator[str]: + return (original for original, _ in self._store.values()) + + def __len__(self) -> int: + return len(self._store) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Mapping): + return NotImplemented + return {k.lower(): v for k, v in self.items()} == { + str(k).lower(): v for k, v in other.items() + } + + __hash__ = None # type: ignore[assignment] + + def __repr__(self) -> str: + shown = { + name: ("" if name.lower() in SENSITIVE_HEADERS else value) + for name, value in self._store.values() + } + return f"Headers({shown!r})" diff --git a/src/nfeio/_core/jsonutil.py b/src/nfeio/_core/jsonutil.py new file mode 100644 index 0000000..1eedb36 --- /dev/null +++ b/src/nfeio/_core/jsonutil.py @@ -0,0 +1,95 @@ +"""JSON encoding/decoding with exact ``Decimal`` support and size awareness.""" + +from __future__ import annotations + +import json +import math +import re +import secrets +from collections.abc import Mapping +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from ..errors import InvalidParameterError + +MAX_JSON_DEPTH = 128 +"""Deepest array/object nesting accepted from untrusted JSON (API responses, error bodies and +webhooks). NFE.io payloads nest a handful of levels; the explicit limit rejects a hostile body the +same way on every Python version and platform instead of relying on the interpreter's recursion +limit (Python 3.14 decodes 100,000 nested levels without ``RecursionError``).""" + +# A JSON string, honouring escaped quotes and backslashes. The closing quote is optional so an +# unterminated string consumes the rest of the text instead of being rescanned from every escaped +# quote inside it (which would be quadratic); such a body is invalid JSON anyway. +_JSON_STRING = re.compile(r'"[^"\\]*(?:\\.[^"\\]*)*"?', re.DOTALL) +_NOT_BRACKET = re.compile(r"[^\[\]{}]+") + + +def _format_decimal(value: Decimal) -> str: + if not value.is_finite(): + raise InvalidParameterError(f"cannot send non-finite number {value!s}") + text = format(value, "f") + return "0" if text in ("-0", "") else text + + +def dumps(obj: Any) -> bytes: + """Serialise a request body. ``Decimal`` becomes an exact JSON number + (``Decimal("100.10")`` → ``100.10``); ``date``/``datetime`` become ISO 8601 strings.""" + token = secrets.token_hex(8) + decimals: list[Decimal] = [] + + def convert(value: Any) -> Any: + if isinstance(value, Decimal): + decimals.append(value) + return f"__nfeio_decimal_{token}_{len(decimals) - 1}__" + if isinstance(value, bool) or value is None or isinstance(value, (str, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise InvalidParameterError(f"cannot send non-finite number {value!r}") + return value + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, Mapping): + out: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise InvalidParameterError( + f"request body keys must be strings, got {type(key).__name__}" + ) + out[key] = convert(item) + return out + if isinstance(value, (list, tuple)): + return [convert(item) for item in value] + raise InvalidParameterError(f"cannot serialise {type(value).__name__} in a request body") + + text = json.dumps(convert(obj), ensure_ascii=False, allow_nan=False, separators=(",", ":")) + for index, value in enumerate(decimals): + text = text.replace(f'"__nfeio_decimal_{token}_{index}__"', _format_decimal(value), 1) + return text.encode("utf-8") + + +def _check_depth(text: str) -> None: + """Raise ``ValueError`` when ``text`` nests arrays/objects deeper than :data:`MAX_JSON_DEPTH`. + + Linear time and no recursion: strings are removed first (brackets inside them do not count), + then only ``[ ] { }`` are scanned.""" + if text.count("[") + text.count("{") <= MAX_JSON_DEPTH: + return + depth = 0 + for char in _NOT_BRACKET.sub("", _JSON_STRING.sub("", text)): + if char in "[{": + depth += 1 + if depth > MAX_JSON_DEPTH: + raise ValueError(f"JSON nested deeper than {MAX_JSON_DEPTH} levels") + else: + depth -= 1 + + +def loads(body: bytes | str) -> Any: + """Decode untrusted JSON (bytes as UTF-8 with optional BOM). Raises ``ValueError`` when the + body is invalid or nested deeper than :data:`MAX_JSON_DEPTH`.""" + text = body.decode("utf-8-sig") if isinstance(body, bytes) else body + _check_depth(text) + return json.loads(text) diff --git a/src/nfeio/_core/multipart.py b/src/nfeio/_core/multipart.py new file mode 100644 index 0000000..d12e4e7 --- /dev/null +++ b/src/nfeio/_core/multipart.py @@ -0,0 +1,61 @@ +"""``multipart/form-data`` encoder without dependencies (RFC 7578).""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Sequence +from dataclasses import dataclass + +_UNSAFE_FILENAME_RE = re.compile(r'[^\x20-\x7e]|["\\]') +_FIELD_NAME_RE = re.compile(r"[A-Za-z0-9_.\-]{1,64}") + + +@dataclass(frozen=True) +class FilePart: + name: str + filename: str + content: bytes + content_type: str = "application/octet-stream" + + +def sanitize_filename(filename: str) -> str: + """Drop quotes, backslashes, CR/LF and any non-printable/non-ASCII character.""" + cleaned = _UNSAFE_FILENAME_RE.sub("_", filename).strip() or "file" + return cleaned[:255] + + +def encode(fields: Sequence[tuple[str, str]], files: Sequence[FilePart]) -> tuple[bytes, str]: + """Return ``(body, content_type)``. The boundary is 128 random bits.""" + for name in [n for n, _ in fields] + [f.name for f in files]: + if not _FIELD_NAME_RE.fullmatch(name): + raise ValueError(f"invalid multipart field name {name!r}") + while True: + boundary = "nfeio-" + secrets.token_hex(16) + marker = boundary.encode("ascii") + if all(marker not in f.content for f in files) and all( + boundary not in v for _, v in fields + ): + break + crlf = b"\r\n" + parts: list[bytes] = [] + for name, value in fields: + parts += [ + b"--" + marker, + f'Content-Disposition: form-data; name="{name}"'.encode("ascii"), + b"", + value.encode("utf-8"), + ] + for part in files: + parts += [ + b"--" + marker, + ( + f'Content-Disposition: form-data; name="{part.name}"; ' + f'filename="{sanitize_filename(part.filename)}"' + ).encode("ascii"), + f"Content-Type: {part.content_type}".encode("ascii"), + b"", + part.content, + ] + parts += [b"--" + marker + b"--", b""] + return crlf.join(parts), f"multipart/form-data; boundary={boundary}" diff --git a/src/nfeio/_core/ops.py b/src/nfeio/_core/ops.py new file mode 100644 index 0000000..1e47235 --- /dev/null +++ b/src/nfeio/_core/ops.py @@ -0,0 +1,131 @@ +"""Sans-IO operations. + +Every SDK operation (retry, pagination, polling, redirects, ...) is written once as a generator +that *yields effects* and receives their results. Two drivers execute them: :func:`run_sync` +(blocking transport, ``time.sleep``) and :func:`run_async` (async transport, +``asyncio.sleep``). The sync and async clients therefore share every line of behaviour. +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Generator +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, TypeVar, Union + +from ..errors import APIConnectionError, FailurePhase, NfeError +from .transport import AsyncTransport, HttpRequest, Transport + +T = TypeVar("T") + + +@dataclass(frozen=True) +class Send: + """Send an HTTP request; the result is an :class:`HttpResponse` (or a raised NfeError).""" + + request: HttpRequest + + +@dataclass(frozen=True) +class Sleep: + """Wait ``seconds``.""" + + seconds: float + + +@dataclass(frozen=True) +class Now: + """Read the monotonic clock (seconds, float).""" + + +@dataclass(frozen=True) +class UtcNow: + """Read the wall clock as an aware UTC ``datetime`` (for HTTP dates).""" + + +Effect = Union[Send, Sleep, Now, UtcNow] # noqa: UP007 - runtime alias on 3.10 +Op = Generator[Effect, Any, T] + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _wrap_os_error(exc: OSError) -> APIConnectionError: + err = APIConnectionError( + f"transport failed: {type(exc).__name__}", phase=FailurePhase.MAYBE_SENT + ) + err.__cause__ = exc + return err + + +def run_sync( + op: Op[T], + transport: Transport, + *, + sleep: Callable[[float], None] = time.sleep, + monotonic: Callable[[], float] = time.monotonic, + utcnow: Callable[[], datetime] = _utcnow, +) -> T: + """Drive ``op`` to completion with a blocking transport.""" + value: Any = None + error: BaseException | None = None + while True: + try: + effect = op.throw(error) if error is not None else op.send(value) + except StopIteration as stop: + return stop.value # type: ignore[no-any-return] + value, error = None, None + if isinstance(effect, Send): + try: + value = transport.send(effect.request) + except NfeError as exc: + error = exc + except OSError as exc: + error = _wrap_os_error(exc) + elif isinstance(effect, Sleep): + if effect.seconds > 0: + sleep(effect.seconds) + elif isinstance(effect, Now): + value = monotonic() + elif isinstance(effect, UtcNow): + value = utcnow() + else: # pragma: no cover - defensive + raise TypeError(f"unknown effect {effect!r}") + + +async def run_async( + op: Op[T], + transport: AsyncTransport, + *, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + monotonic: Callable[[], float] = time.monotonic, + utcnow: Callable[[], datetime] = _utcnow, +) -> T: + """Drive ``op`` to completion with an async transport, never blocking the event loop.""" + value: Any = None + error: BaseException | None = None + while True: + try: + effect = op.throw(error) if error is not None else op.send(value) + except StopIteration as stop: + return stop.value # type: ignore[no-any-return] + value, error = None, None + if isinstance(effect, Send): + try: + value = await transport.send(effect.request) + except NfeError as exc: + error = exc + except OSError as exc: + error = _wrap_os_error(exc) + elif isinstance(effect, Sleep): + if effect.seconds > 0: + await sleep(effect.seconds) + elif isinstance(effect, Now): + value = monotonic() + elif isinstance(effect, UtcNow): + value = utcnow() + else: # pragma: no cover - defensive + raise TypeError(f"unknown effect {effect!r}") diff --git a/src/nfeio/_core/paths.py b/src/nfeio/_core/paths.py new file mode 100644 index 0000000..e0b5dd9 --- /dev/null +++ b/src/nfeio/_core/paths.py @@ -0,0 +1,144 @@ +"""Validation and encoding of every value interpolated into a URL path. + +Nothing reaches a path without going through this module: opaque ids must match a strict +pattern (no path traversal), free text is percent-encoded as a single segment, and Brazilian +documents are normalised and checked locally (data lookups may be billed per call). +""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from typing import Final +from urllib.parse import quote + +from ..errors import InvalidParameterError + +_OPAQUE_ID_RE: Final = re.compile(r"[A-Za-z0-9_-]{1,64}") +_CONTROL_RE: Final = re.compile(r"[\x00-\x1f\x7f]") +_DOC_SEPARATORS_RE: Final = re.compile(r"[.\-/\s]") +_CNPJ_RE: Final = re.compile(r"[0-9A-Z]{12}[0-9]{2}") +_ISO_DATE_RE: Final = re.compile(r"\d{4}-\d{2}-\d{2}") + +UFS: Final = frozenset( + { + "AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO", "MA", "MT", "MS", "MG", "PA", + "PB", "PR", "PE", "PI", "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO", + } +) # fmt: skip + + +def opaque_id(value: object, param: str) -> str: + """An API identifier, full match of ``[A-Za-z0-9_-]{1,64}`` (24/32-hex ids seen).""" + if not isinstance(value, str) or not _OPAQUE_ID_RE.fullmatch(value): + raise InvalidParameterError( + f"{param} must be a non-empty identifier of letters, digits, '_' or '-' " + f"(max 64), got {value!r}", + param=param, + ) + return value + + +def check_free_text(value: object, param: str) -> str: + if not isinstance(value, str) or not value: + raise InvalidParameterError(f"{param} must be a non-empty string", param=param) + if value in (".", "..") or _CONTROL_RE.search(value) or len(value) > 255: + raise InvalidParameterError( + f"{param} must not be '.' or '..', contain control characters or exceed 255 characters", + param=param, + ) + return value + + +def free_text_segment(value: object, param: str) -> str: + """Client-chosen text (e.g. ``externalId``) encoded as exactly one path segment.""" + return quote(check_free_text(value, param), safe="") + + +def _alnum_value(char: str) -> int: + # RFB alphanumeric CNPJ: value = ASCII code - 48 ('0'..'9' -> 0..9, 'A' -> 17 ... 'Z' -> 42) + return ord(char) - 48 + + +def _cnpj_check_digit(body: str) -> int: + weights = ([2, 3, 4, 5, 6, 7, 8, 9] * 2)[: len(body)] + total = sum(_alnum_value(ch) * w for ch, w in zip(reversed(body), weights, strict=True)) + remainder = total % 11 + return 0 if remainder < 2 else 11 - remainder + + +def is_valid_cnpj(value: str) -> bool: + if not _CNPJ_RE.fullmatch(value) or value == "0" * 14: + return False + first = _cnpj_check_digit(value[:12]) + second = _cnpj_check_digit(value[:12] + str(first)) + return value[12:] == f"{first}{second}" + + +def cnpj(value: object, param: str = "cnpj") -> str: + """Normalise (remove mask, uppercase) and validate a numeric or alphanumeric CNPJ.""" + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise InvalidParameterError(f"{param} must be a string", param=param) + text = str(value) if isinstance(value, int) else value + normalized = _DOC_SEPARATORS_RE.sub("", text).upper() + if isinstance(value, int): + normalized = normalized.zfill(14) + if not is_valid_cnpj(normalized): + raise InvalidParameterError(f"{param} is not a valid CNPJ: {value!r}", param=param) + return normalized + + +def is_valid_cpf(value: str) -> bool: + if len(value) != 11 or not value.isdigit() or value == value[0] * 11: + return False + digits = [int(ch) for ch in value] + for size in (9, 10): + total = sum(d * w for d, w in zip(digits[:size], range(size + 1, 1, -1), strict=True)) + check = (total * 10) % 11 % 10 + if digits[size] != check: + return False + return True + + +def cpf(value: object, param: str = "cpf") -> str: + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise InvalidParameterError(f"{param} must be a string", param=param) + normalized = _DOC_SEPARATORS_RE.sub("", str(value)) + if isinstance(value, int): + normalized = normalized.zfill(11) + if not is_valid_cpf(normalized): + raise InvalidParameterError(f"{param} is not a valid CPF: {value!r}", param=param) + return normalized + + +def cep(value: object, param: str = "cep") -> str: + if not isinstance(value, str): + raise InvalidParameterError(f"{param} must be a string", param=param) + normalized = _DOC_SEPARATORS_RE.sub("", value) + if len(normalized) != 8 or not normalized.isdigit(): + raise InvalidParameterError(f"{param} must have 8 digits, got {value!r}", param=param) + return normalized + + +def uf(value: object, param: str = "state") -> str: + if not isinstance(value, str) or value.strip().upper() not in UFS: + raise InvalidParameterError( + f"{param} must be a Brazilian state code (UF), got {value!r}", param=param + ) + return value.strip().upper() + + +def iso_date(value: object, param: str) -> str: + """``date`` (or ``datetime``, date part) or ``"YYYY-MM-DD"`` → ``"YYYY-MM-DD"``.""" + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str) and _ISO_DATE_RE.fullmatch(value): + try: + return date.fromisoformat(value).isoformat() + except ValueError: + pass + raise InvalidParameterError( + f"{param} must be a date or a 'YYYY-MM-DD' string, got {value!r}", param=param + ) diff --git a/src/nfeio/_core/redact.py b/src/nfeio/_core/redact.py new file mode 100644 index 0000000..308ced9 --- /dev/null +++ b/src/nfeio/_core/redact.py @@ -0,0 +1,22 @@ +"""Redaction helpers for anything that may end up in logs or reprs.""" + +from __future__ import annotations + +#: Static path segments that may appear in logs; anything else (ids, documents, dates, +#: externalId) is replaced by ``*`` because it can identify a person or a sale. +_LOGGABLE_SEGMENTS = frozenset( + { + "v1", "v2", "v3", "companies", "serviceinvoices", "external", "pdf", "xml", + "cancellation-xml", "sendemail", "certificates", "webhooks", "pings", "eventtypes", + "legalentities", "basicInfo", "stateTaxInfo", "naturalperson", "status", "addresses", + "blob", "download", + } +) # fmt: skip + + +def log_path(path: str) -> str: + """Path safe for logs: keep known static segments, mask everything else.""" + return "/".join( + segment if not segment or segment in _LOGGABLE_SEGMENTS else "*" + for segment in path.split("/") + ) diff --git a/src/nfeio/_core/requestor.py b/src/nfeio/_core/requestor.py new file mode 100644 index 0000000..342e812 --- /dev/null +++ b/src/nfeio/_core/requestor.py @@ -0,0 +1,235 @@ +"""Build requests per API family, send them with retry, decode bodies and map errors.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable, Mapping +from typing import Any +from urllib.parse import urlencode, urljoin, urlsplit + +from .._config import ApiFamily, ClientConfig, RequestOptions, Timeout +from ..errors import UnexpectedResponseError +from ..models._base import ResponseInfo +from . import jsonutil +from .error_mapping import error_from_response +from .ops import Op +from .redact import log_path +from .retry import send_with_retry +from .transport import HttpRequest, HttpResponse + +logger = logging.getLogger("nfeio") + +REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +MAX_REDIRECTS = 3 + +QueryValue = str | int | bool | None + + +def _encode_query(query: Mapping[str, QueryValue] | None) -> str: + if not query: + return "" + pairs: list[tuple[str, str]] = [] + for key, value in query.items(): + if value is None: + continue + if isinstance(value, bool): + pairs.append((key, "true" if value else "false")) + else: + pairs.append((key, str(value))) + return "?" + urlencode(pairs) if pairs else "" + + +def _timeout(cfg: ClientConfig, options: RequestOptions | None) -> Timeout: + if options is not None and isinstance(options.timeout, Timeout): + return options.timeout + return cfg.timeout + + +def _retries(cfg: ClientConfig, options: RequestOptions | None) -> int: + if options is not None and options.max_retries is not None: + return options.max_retries + return cfg.max_retries + + +def request( + cfg: ClientConfig, + method: str, + family: ApiFamily, + path: str, + *, + query: Mapping[str, QueryValue] | None = None, + json: Any = None, + body: bytes | None = None, + content_type: str | None = None, + accept: str = "application/json", + options: RequestOptions | None = None, + external_id: str | None = None, + secrets: Iterable[str] = (), + allow_redirect: bool = False, +) -> Op[HttpResponse]: + """Send an authenticated request and return the response if its status is < 300. + + Raises the mapped :class:`~nfeio.errors.APIError` for >= 400. A 3xx is returned only when + ``allow_redirect`` is set (downloads, which follow it without credentials); anywhere else + it raises :class:`UnexpectedResponseError` instead of being mistaken for an empty answer. + """ + key = cfg.key_for(family, options) # ConfigurationError before any I/O + method = method.upper() + url = cfg.base_urls[family] + path + _encode_query(query) + headers: dict[str, str] = { + "Authorization": key, + "User-Agent": cfg.user_agent, + "Accept": accept, + "Accept-Encoding": "identity", + } + if json is not None: + body = jsonutil.dumps(json) + content_type = "application/json; charset=utf-8" + if content_type is not None: + headers["Content-Type"] = content_type + if options is not None: + if options.idempotency_key is not None: + headers["Idempotency-Key"] = options.idempotency_key + if options.extra_headers: + headers.update(options.extra_headers) + req = HttpRequest( + method=method, + url=url, + headers=headers, + body=body, + timeout=_timeout(cfg, options), + max_response_bytes=cfg.max_response_bytes, + ) + response = yield from send_with_retry( + cfg, req, max_retries=_retries(cfg, options), external_id=external_id + ) + logger.debug( + "nfeio request: %s %s%s -> %d (request_id=%s)", + method, + urlsplit(url).hostname, + log_path(path), + response.status_code, + response.headers.get("x-request-id"), + ) + if response.status_code >= 400: + raise error_from_response( + response, + family=family, + method=method, + external_id=external_id, + secrets=(key, *secrets), + ) + if response.status_code >= 300 and not allow_redirect: + raise UnexpectedResponseError( + f"unexpected redirect status {response.status_code}", + status_code=response.status_code, + headers=response.headers, + ) + return response + + +def follow_download( + cfg: ClientConfig, + response: HttpResponse, + *, + family: ApiFamily, + origin_url: str, + accept: str, + options: RequestOptions | None, +) -> Op[HttpResponse]: + """Follow up to 3 redirects of a download **without any credential**, https only.""" + current_url = origin_url + hops = 0 + while response.status_code in REDIRECT_STATUSES: + if hops >= MAX_REDIRECTS: + raise UnexpectedResponseError( + f"too many redirects (more than {MAX_REDIRECTS})", + status_code=response.status_code, + headers=response.headers, + ) + location = response.headers.get("location") + if not location: + raise UnexpectedResponseError( + "redirect without Location header", + status_code=response.status_code, + headers=response.headers, + ) + target = urljoin(current_url, location.strip()) + parts = urlsplit(target) + if parts.scheme != "https" or not parts.hostname: + raise UnexpectedResponseError( + "refusing to follow a redirect that is not https", + status_code=response.status_code, + headers=response.headers, + ) + hops += 1 + # The pre-signed URL is self-authenticating: never forward the API key. + req = HttpRequest( + method="GET", + url=target, + headers={ + "User-Agent": cfg.user_agent, + "Accept": accept, + "Accept-Encoding": "identity", + }, + timeout=_timeout(cfg, options), + max_response_bytes=cfg.max_response_bytes, + ) + response = yield from send_with_retry(cfg, req, max_retries=_retries(cfg, options)) + current_url = target + if response.status_code >= 400: + raise error_from_response(response, family=family, method="GET") + return response + + +def decode_json(response: HttpResponse, *, allow_empty: bool = False) -> Any: + """Decode a 2xx JSON body or raise :class:`UnexpectedResponseError`.""" + if not response.body.strip(): + if allow_empty: + return None + raise UnexpectedResponseError( + "expected a JSON body but the response was empty", + status_code=response.status_code, + headers=response.headers, + ) + try: + return jsonutil.loads(response.body) + except (UnicodeDecodeError, ValueError, RecursionError): + raise UnexpectedResponseError( + "response body is not valid JSON", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) from None + + +def expect_object(response: HttpResponse, *, unwrap: str | None = None) -> dict[str, Any]: + """Decode a JSON object, optionally unwrapping ``{unwrap: {...}}``.""" + data = decode_json(response) + if unwrap is not None and isinstance(data, dict) and isinstance(data.get(unwrap), dict): + data = data[unwrap] + if not isinstance(data, dict): + raise UnexpectedResponseError( + "expected a JSON object in the response", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + return data + + +def expect_list(response: HttpResponse, key: str) -> tuple[dict[str, Any], list[Any]]: + """Decode ``{key: [...] , ...}`` and return ``(envelope, items)``.""" + data = decode_json(response) + if not isinstance(data, dict) or not isinstance(data.get(key), list): + raise UnexpectedResponseError( + f"expected a JSON object with a {key!r} list", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + return data, data[key] + + +def response_info(response: HttpResponse) -> ResponseInfo: + return ResponseInfo(status_code=response.status_code, headers=response.headers) diff --git a/src/nfeio/_core/retry.py b/src/nfeio/_core/retry.py new file mode 100644 index 0000000..22a91c1 --- /dev/null +++ b/src/nfeio/_core/retry.py @@ -0,0 +1,155 @@ +"""Method- and phase-aware retry policy (port of the PHP SDK ``RetryingTransport``, stricter). + +| Method | 429 | 408/500/502/503/504 | not established | maybe sent | +|-------------------------|------|---------------------|-----------------|------------| +| GET, HEAD, PUT, DELETE | yes | yes | yes | yes | +| POST (any) | no | no | yes | no | + +A POST is never retried after the request may have reached the server: the API is known to +create the invoice and still answer 500/504. ``Idempotency-Key`` does not change this table +because the API ignores the header today. +""" + +from __future__ import annotations + +import logging +import math +from datetime import datetime +from email.utils import parsedate_to_datetime +from urllib.parse import urlsplit + +from .._config import ClientConfig +from ..errors import APIConnectionError, FailurePhase +from .ops import Op, Send, Sleep, UtcNow +from .redact import log_path +from .transport import HttpRequest, HttpResponse + +logger = logging.getLogger("nfeio") + +IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}) +RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504}) + + +def is_idempotent(method: str) -> bool: + return method.upper() in IDEMPOTENT_METHODS + + +def should_retry_status(method: str, status: int) -> bool: + return is_idempotent(method) and status in RETRYABLE_STATUS + + +def should_retry_failure(method: str, phase: FailurePhase) -> bool: + return is_idempotent(method) or phase is FailurePhase.NOT_ESTABLISHED + + +def backoff_delay(attempt: int, cfg: ClientConfig) -> float: + """``min(max_delay, base * 2**(attempt-1)) * U(0.7, 1.3)`` for ``attempt >= 1``.""" + raw = min(cfg.retry_max_delay, cfg.retry_base_delay * (2 ** max(0, attempt - 1))) + return float(raw * (0.7 + 0.6 * cfg.random())) + + +def parse_retry_after(value: str | None, now: datetime) -> float | None: + """Parse ``Retry-After`` as integer seconds or an HTTP-date. ``None`` if absent/invalid.""" + if value is None: + return None + text = value.strip() + if not text: + return None + if text.isdigit(): + return float(int(text)) + try: + when = parsedate_to_datetime(text) + except (TypeError, ValueError, IndexError): + return None + if when.tzinfo is None: + return None + seconds = (when - now).total_seconds() + if not math.isfinite(seconds): + return None + return max(0.0, seconds) + + +def send_with_retry( + cfg: ClientConfig, + request: HttpRequest, + *, + max_retries: int, + external_id: str | None = None, +) -> Op[HttpResponse]: + """Send ``request``, retrying per the table above. Returns the last response (any status). + + Raises the transport's :class:`APIConnectionError` when retries are exhausted or not + allowed; for non-idempotent requests that may have been sent it is flagged + ``outcome_unknown=True`` and carries ``external_id``. + """ + method = request.method.upper() + parts = urlsplit(request.url) + attempt = 0 + while True: + try: + response: HttpResponse = yield Send(request) + except APIConnectionError as exc: + if attempt < max_retries and should_retry_failure(method, exc.phase): + attempt += 1 + delay = backoff_delay(attempt, cfg) + _log_retry( + method, + parts.hostname, + log_path(parts.path), + type(exc).__name__, + attempt, + max_retries, + delay, + None, + ) + yield Sleep(delay) + continue + if not is_idempotent(method) and exc.phase is FailurePhase.MAYBE_SENT: + exc.outcome_unknown = True + exc.external_id = external_id + raise + status = response.status_code + if attempt < max_retries and should_retry_status(method, status): + now: datetime = yield UtcNow() + retry_after = parse_retry_after(response.headers.get("retry-after"), now) + if retry_after is not None and retry_after > cfg.max_retry_after: + return response + attempt += 1 + delay = retry_after if retry_after is not None else backoff_delay(attempt, cfg) + _log_retry( + method, + parts.hostname, + log_path(parts.path), + str(status), + attempt, + max_retries, + delay, + response.headers.get("x-request-id"), + ) + yield Sleep(delay) + continue + return response + + +def _log_retry( + method: str, + host: str | None, + path: str, + reason: str, + attempt: int, + max_retries: int, + delay: float, + request_id: str | None, +) -> None: + # Never log headers, query strings or bodies: they may carry keys or pre-signed tokens. + logger.debug( + "nfeio retry: %s %s%s got %s; attempt %d of %d in %.2fs (request_id=%s)", + method, + host, + path, + reason, + attempt, + max_retries, + delay, + request_id, + ) diff --git a/src/nfeio/_core/transport.py b/src/nfeio/_core/transport.py new file mode 100644 index 0000000..77e12bf --- /dev/null +++ b/src/nfeio/_core/transport.py @@ -0,0 +1,332 @@ +"""HTTP transport: protocols, request/response values and the stdlib implementation. + +The default transport is built on :mod:`http.client` (not :mod:`urllib.request`) because the +SDK must know *when* a network failure happened: ``connect()`` is explicit, so DNS, refused +connections and TLS handshake errors are classified as ``NOT_ESTABLISHED`` (nothing was sent) +and anything after that as ``MAYBE_SENT``. ``urllib`` also follows redirects re-sending +headers to the new host, which is unacceptable with the API key in ``Authorization``. + +Transports never follow redirects. +""" + +from __future__ import annotations + +import asyncio +import http.client +import os +import ssl +import threading +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable +from urllib.parse import urlsplit + +from .._config import DEFAULT_MAX_RESPONSE_BYTES, Timeout, build_ssl_context +from ..errors import ( + APIConnectionError, + APITimeoutError, + ConfigurationError, + FailurePhase, + ResponseTooLargeError, +) +from .headers import SENSITIVE_HEADERS, Headers +from .redact import log_path + +__all__ = [ + "AsyncTransport", + "HttpClientTransport", + "HttpRequest", + "HttpResponse", + "ThreadedAsyncTransport", + "Transport", +] + +_CHUNK = 64 * 1024 +#: Methods whose requests may travel on a reused keep-alive connection. +_REUSABLE_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}) +#: Errors that mean "the idle keep-alive connection was already closed by the server". +_STALE_CONNECTION_ERRORS = ( + http.client.RemoteDisconnected, + BrokenPipeError, + ConnectionResetError, + ConnectionAbortedError, +) + + +@dataclass(frozen=True, repr=False) +class HttpRequest: + """A fully-built HTTP request handed to a :class:`Transport`.""" + + method: str + url: str + headers: Mapping[str, str] = field(default_factory=dict) + body: bytes | None = None + timeout: Timeout = field(default_factory=Timeout) + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES + + def __repr__(self) -> str: + parts = urlsplit(self.url) + shown = { + k: ("" if k.lower() in SENSITIVE_HEADERS else v) + for k, v in self.headers.items() + } + return ( + f"HttpRequest(method={self.method!r}, host={parts.netloc!r}, " + f"path={log_path(parts.path)!r}, headers={shown!r}, " + f"body_bytes={len(self.body or b'')})" + ) + + +@dataclass(frozen=True, repr=False) +class HttpResponse: + """Raw HTTP response returned by a :class:`Transport`.""" + + status_code: int + headers: Headers + body: bytes = b"" + + def __repr__(self) -> str: + return ( + f"HttpResponse(status_code={self.status_code}, " + f"request_id={self.headers.get('x-request-id')!r}, body_bytes={len(self.body)})" + ) + + +@runtime_checkable +class Transport(Protocol): + """Synchronous transport. + + ``send`` must not follow redirects, must honour ``request.timeout`` and + ``request.max_response_bytes``, and must raise :class:`~nfeio.errors.APIConnectionError` + (with the right :class:`~nfeio.errors.FailurePhase`) for network failures. + """ + + def send(self, request: HttpRequest) -> HttpResponse: ... + + def close(self) -> None: ... + + +@runtime_checkable +class AsyncTransport(Protocol): + """Asynchronous transport, same contract as :class:`Transport`.""" + + async def send(self, request: HttpRequest) -> HttpResponse: ... + + async def aclose(self) -> None: ... + + +_PoolKey = tuple[str, str, int] + + +class HttpClientTransport: + """Default transport on top of :mod:`http.client`, thread-safe. + + * TLS: verified context, TLS >= 1.2; an extra CA bundle or a custom (still verifying) + ``ssl.SSLContext`` may be supplied. Disabling verification is rejected. + * Keep-alive pool: LIFO per host, at most ``max_idle_per_host`` idle connections. + Non-idempotent requests (POST) always use a brand-new connection, so a failure is never + ambiguous because of a stale reused socket. + * Body read in 64 KiB chunks, aborted above ``request.max_response_bytes``. + """ + + def __init__( + self, + *, + ssl_context: ssl.SSLContext | None = None, + ca_bundle: str | os.PathLike[str] | None = None, + max_idle_per_host: int = 10, + ) -> None: + self._ssl_context = build_ssl_context(ssl_context, ca_bundle) + self._max_idle = max(0, max_idle_per_host) + self._pool: dict[_PoolKey, list[http.client.HTTPConnection]] = {} + self._lock = threading.Lock() + self._closed = False + + # -- pool ------------------------------------------------------------------------------- + + def _acquire(self, key: _PoolKey) -> http.client.HTTPConnection | None: + with self._lock: + idle = self._pool.get(key) + return idle.pop() if idle else None + + def _release(self, key: _PoolKey, conn: http.client.HTTPConnection) -> None: + with self._lock: + if not self._closed: + idle = self._pool.setdefault(key, []) + if len(idle) < self._max_idle: + idle.append(conn) + return + conn.close() + + def idle_connections(self) -> int: + """Number of idle pooled connections (useful in tests and diagnostics).""" + with self._lock: + return sum(len(v) for v in self._pool.values()) + + def close(self) -> None: + with self._lock: + self._closed = True + conns = [c for idle in self._pool.values() for c in idle] + self._pool.clear() + for conn in conns: + conn.close() + + # -- sending ---------------------------------------------------------------------------- + + def _connect( + self, scheme: str, host: str, port: int, timeout: Timeout + ) -> http.client.HTTPConnection: + conn: http.client.HTTPConnection + if scheme == "https": + conn = http.client.HTTPSConnection( + host, port, timeout=timeout.connect, context=self._ssl_context + ) + else: + conn = http.client.HTTPConnection(host, port, timeout=timeout.connect) + try: + conn.connect() + except TimeoutError: + conn.close() + raise APITimeoutError( + f"timed out connecting to {host}", phase=FailurePhase.NOT_ESTABLISHED + ) from None + except (OSError, http.client.HTTPException) as exc: + conn.close() + raise APIConnectionError( + f"could not connect to {host}: {_describe(exc)}", + phase=FailurePhase.NOT_ESTABLISHED, + ) from None + return conn + + def send(self, request: HttpRequest) -> HttpResponse: + if self._closed: + raise ConfigurationError("transport is closed") + parts = urlsplit(request.url) + scheme = parts.scheme.lower() + if scheme not in ("https", "http") or not parts.hostname: + raise ConfigurationError(f"unsupported URL scheme for request: {scheme!r}") + host = parts.hostname + port = parts.port or (443 if scheme == "https" else 80) + target = parts.path or "/" + if parts.query: + target += "?" + parts.query + key: _PoolKey = (scheme, host, port) + method = request.method.upper() + reusable = method in _REUSABLE_METHODS + + conn = self._acquire(key) if reusable else None + if conn is not None: + try: + return self._exchange(conn, key, method, target, request, host, reusable) + except APIConnectionError as exc: + if not isinstance(exc.__cause__, _STALE_CONNECTION_ERRORS): + raise + # The idle socket was closed by the server: replay once on a fresh connection. + # Only idempotent methods ever travel on reused connections. + conn = self._connect(scheme, host, port, request.timeout) + return self._exchange(conn, key, method, target, request, host, reusable) + + def _exchange( + self, + conn: http.client.HTTPConnection, + key: _PoolKey, + method: str, + target: str, + request: HttpRequest, + host: str, + reusable: bool, + ) -> HttpResponse: + timeout = request.timeout + deadline = time.monotonic() + timeout.total + try: + _set_timeout(conn, timeout.read, deadline) + conn.request(method, target, body=request.body, headers=dict(request.headers)) + resp = conn.getresponse() + headers = Headers(resp.getheaders()) + body = _read_body(conn, resp, timeout.read, deadline, request.max_response_bytes) + except ResponseTooLargeError: + conn.close() + raise + except APITimeoutError: + conn.close() + raise + except TimeoutError as exc: + conn.close() + raise APITimeoutError( + f"timed out waiting for {host}", phase=FailurePhase.MAYBE_SENT + ) from exc + except (OSError, http.client.HTTPException) as exc: + conn.close() + raise APIConnectionError( + f"connection to {host} failed: {_describe(exc)}", phase=FailurePhase.MAYBE_SENT + ) from exc + if reusable and not resp.will_close: + self._release(key, conn) + else: + conn.close() + return HttpResponse(status_code=resp.status, headers=headers, body=body) + + +def _describe(exc: BaseException) -> str: + text = str(exc).strip() + return f"{type(exc).__name__}: {text}" if text else type(exc).__name__ + + +def _set_timeout(conn: http.client.HTTPConnection, read: float, deadline: float) -> None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise APITimeoutError("total request deadline exceeded", phase=FailurePhase.MAYBE_SENT) + if conn.sock is not None: + conn.sock.settimeout(min(read, remaining)) + + +def _read_body( + conn: http.client.HTTPConnection, + resp: http.client.HTTPResponse, + read_timeout: float, + deadline: float, + limit: int, +) -> bytes: + declared = resp.getheader("content-length") + if declared is not None and declared.strip().isdigit() and int(declared) > limit: + raise ResponseTooLargeError( + f"response declares {int(declared)} bytes, above max_response_bytes={limit}", + limit=limit, + ) + chunks: list[bytes] = [] + total = 0 + while True: + _set_timeout(conn, read_timeout, deadline) + chunk = resp.read(_CHUNK) + if not chunk: + break + total += len(chunk) + if total > limit: + raise ResponseTooLargeError( + f"response body exceeded max_response_bytes={limit}", limit=limit + ) + chunks.append(chunk) + return b"".join(chunks) + + +class ThreadedAsyncTransport: + """Async adapter that runs a synchronous :class:`Transport` in a worker thread. + + Uses :func:`asyncio.to_thread` (the loop's default executor). Cancelling the awaiting task + does not interrupt the request already in flight; its socket timeouts bound how long the + thread lives. + """ + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + @property + def sync_transport(self) -> Transport: + return self._transport + + async def send(self, request: HttpRequest) -> HttpResponse: + return await asyncio.to_thread(self._transport.send, request) + + async def aclose(self) -> None: + await asyncio.to_thread(self._transport.close) diff --git a/src/nfeio/_generated/README.md b/src/nfeio/_generated/README.md new file mode 100644 index 0000000..17fdf5f --- /dev/null +++ b/src/nfeio/_generated/README.md @@ -0,0 +1,10 @@ +# `nfeio._generated` + +Reservado para código gerado a partir das specs OpenAPI da NFE.io. + +Regra: nada aqui é editado à mão. Se um arquivo gerado estiver errado, corrija o gerador ou a spec +(com o patch registrado) e gere de novo. + +No v0.1 este diretório está vazio de propósito: os modelos são escritos à mão (visão tipada sobre o +dicionário do fio) e protegidos por um teste de alinhamento com a spec. Ver o design da change +`add-sdk-foundation-v0-1`, §12. diff --git a/src/nfeio/_version.py b/src/nfeio/_version.py new file mode 100644 index 0000000..ff93cef --- /dev/null +++ b/src/nfeio/_version.py @@ -0,0 +1,3 @@ +"""Single source of truth for the package version (read by hatchling and the User-Agent).""" + +__version__ = "0.1.0" diff --git a/src/nfeio/errors.py b/src/nfeio/errors.py new file mode 100644 index 0000000..faca1ac --- /dev/null +++ b/src/nfeio/errors.py @@ -0,0 +1,330 @@ +"""Exceptions raised by the NFE.io SDK. + +Every exception derives from :class:`NfeError`. HTTP error responses become subclasses of +:class:`APIError` chosen by status code; network failures become :class:`APIConnectionError`. + +Hierarchy:: + + NfeError + ├── ConfigurationError (also ValueError) + ├── InvalidParameterError (also ValueError) + ├── APIError + │ ├── InvalidRequestError 400, 405, 410, 415, 422 and other 4xx + │ │ └── DuplicateExternalIdError + │ ├── AuthenticationError 401 + │ ├── PermissionDeniedError 403 + │ ├── NotFoundError 404 + │ ├── ConflictError 409 + │ ├── RateLimitError 429 + │ └── ServerError 408 and 5xx + ├── APIConnectionError + │ └── APITimeoutError + ├── ResponseTooLargeError + ├── UnexpectedResponseError + ├── InvoiceProcessingError + ├── PollingTimeoutError + └── SignatureVerificationError + +No exception ever carries the API key or the ``Authorization`` header. +""" + +from __future__ import annotations + +import enum +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from ._core.headers import Headers + +if TYPE_CHECKING: + from .models import ServiceInvoice + +__all__ = [ + "APIConnectionError", + "APIError", + "APITimeoutError", + "AuthenticationError", + "ConfigurationError", + "ConflictError", + "DuplicateExternalIdError", + "FailurePhase", + "InvalidParameterError", + "InvalidRequestError", + "InvoiceProcessingError", + "NfeError", + "NotFoundError", + "PermissionDeniedError", + "PollingTimeoutError", + "RateLimitError", + "ResponseTooLargeError", + "ServerError", + "SignatureVerificationError", + "UnexpectedResponseError", +] + + +class FailurePhase(str, enum.Enum): + """When a network failure happened relative to sending the request.""" + + #: DNS, refused connection or TLS handshake: no byte of the request left the machine. + NOT_ESTABLISHED = "not_established" + #: The request may have reached the server (read timeout, connection reset, ...). + MAYBE_SENT = "maybe_sent" + + +class NfeError(Exception): + """Base class for every exception raised by the SDK.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + def __str__(self) -> str: + return self.message + + +class ConfigurationError(NfeError, ValueError): + """Invalid client configuration: missing key, insecure TLS, invalid option.""" + + +class InvalidParameterError(NfeError, ValueError): + """A parameter was rejected locally, before any request was sent.""" + + def __init__(self, message: str, *, param: str | None = None) -> None: + super().__init__(message) + self.param = param + + +class APIError(NfeError): + """The API answered with an HTTP error status. + + Attributes: + status_code: HTTP status. + message: message extracted from the response body (sanitised, at most 1,000 chars). + error_code: first ``code`` found in the body, when present. + body: raw response body. + headers: response headers (case-insensitive). + request_id: value of ``x-request-id`` — quote it when contacting support. + trace_id: ``traceId`` of ASP.NET ProblemDetails bodies. + outcome_unknown: ``True`` when the request was not idempotent (POST) and the server may + have processed it anyway (408/429/5xx). Reconcile before retrying — see + ``service_invoices.find_by_external_id``. + external_id: ``externalId`` of the invoice being issued, when applicable. + """ + + def __init__( + self, + message: str, + *, + status_code: int, + body: bytes = b"", + headers: Mapping[str, str] | None = None, + error_code: int | str | None = None, + trace_id: str | None = None, + outcome_unknown: bool = False, + external_id: str | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + self.headers = headers if isinstance(headers, Headers) else Headers(headers) + self.error_code = error_code + self.trace_id = trace_id + self.outcome_unknown = outcome_unknown + self.external_id = external_id + + @property + def request_id(self) -> str | None: + return self.headers.get("x-request-id") + + @property + def json_body(self) -> Any: + """Body decoded as JSON, or ``None`` when it is empty or not JSON.""" + if not self.body: + return None + # Imported here: jsonutil imports this module. + from ._core import jsonutil + + try: + return jsonutil.loads(self.body) + except (UnicodeDecodeError, ValueError, RecursionError): + return None + + def __str__(self) -> str: + details = [f"status={self.status_code}"] + if self.error_code is not None: + details.append(f"code={self.error_code}") + if self.request_id: + details.append(f"request_id={self.request_id}") + if self.outcome_unknown: + details.append("outcome_unknown=True") + return f"{self.message} ({', '.join(details)})" + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(status_code={self.status_code}, message={self.message!r}, " + f"error_code={self.error_code!r}, request_id={self.request_id!r}, " + f"outcome_unknown={self.outcome_unknown})" + ) + + +class InvalidRequestError(APIError): + """400, 405, 410, 415, 422 and any other 4xx not mapped to a more specific class.""" + + +class DuplicateExternalIdError(InvalidRequestError): + """The API rejected an issuance because ``externalId`` already exists. + + The invoice from the first attempt exists: fetch it with + ``service_invoices.find_by_external_id(company_id, err.external_id)``. + """ + + +class AuthenticationError(APIError): + """401 — missing or invalid API key.""" + + +class PermissionDeniedError(APIError): + """403 — the key is valid but cannot access this API family (wrong key for the host).""" + + +class NotFoundError(APIError): + """404 — resource (or route) not found.""" + + +class ConflictError(APIError): + """409 — conflicting state.""" + + +class RateLimitError(APIError): + """429 — too many requests. + + Attributes: + retry_after: seconds the server asked to wait, when it sent ``Retry-After``. + """ + + def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None: + super().__init__(message, **kwargs) + self.retry_after = retry_after + + +class ServerError(APIError): + """408 or 5xx — server-side failure or timeout.""" + + +class APIConnectionError(NfeError): + """The request could not be completed at the network level. + + Attributes: + phase: :class:`FailurePhase` — whether the request may have reached the server. + outcome_unknown: ``True`` for non-idempotent requests that may have been processed. + external_id: ``externalId`` of the invoice being issued, when applicable. + """ + + def __init__( + self, + message: str, + *, + phase: FailurePhase, + outcome_unknown: bool = False, + external_id: str | None = None, + ) -> None: + super().__init__(message) + self.phase = phase + self.outcome_unknown = outcome_unknown + self.external_id = external_id + + def __str__(self) -> str: + suffix = f"phase={self.phase.value}" + if self.outcome_unknown: + suffix += ", outcome_unknown=True" + return f"{self.message} ({suffix})" + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(message={self.message!r}, phase={self.phase.value!r}, " + f"outcome_unknown={self.outcome_unknown})" + ) + + +class APITimeoutError(APIConnectionError): + """A connect, read or total deadline expired.""" + + +class ResponseTooLargeError(NfeError): + """The response body exceeded ``max_response_bytes``; the connection was discarded.""" + + def __init__(self, message: str, *, limit: int) -> None: + super().__init__(message) + self.limit = limit + + +class UnexpectedResponseError(NfeError): + """A successful status with a body the SDK cannot interpret (or an unsafe redirect).""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + body: bytes = b"", + headers: Mapping[str, str] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + self.headers = headers if isinstance(headers, Headers) else Headers(headers) + + @property + def request_id(self) -> str | None: + return self.headers.get("x-request-id") + + +class InvoiceProcessingError(NfeError): + """Polling reached a failure state (``IssueFailed``, ``CancelFailed`` or ``Error``). + + Attributes: + invoice: the last :class:`~nfeio.models.ServiceInvoice` observed. + flow_status: the terminal failure status. + flow_message: the explanation sent by the API (city hall rejection, ...). + """ + + def __init__( + self, + message: str, + *, + invoice: ServiceInvoice, + flow_status: str | None, + flow_message: str | None, + ) -> None: + super().__init__(message) + self.invoice = invoice + self.flow_status = flow_status + self.flow_message = flow_message + + +class PollingTimeoutError(NfeError): + """The wait deadline expired before the invoice reached a terminal status. + + Attributes: + invoice: the last invoice observed (``None`` if every poll returned 404). + flow_status: last ``flowStatus`` observed. + elapsed: seconds spent waiting. + """ + + def __init__( + self, + message: str, + *, + invoice: ServiceInvoice | None, + flow_status: str | None, + elapsed: float, + ) -> None: + super().__init__(message) + self.invoice = invoice + self.flow_status = flow_status + self.elapsed = elapsed + + +class SignatureVerificationError(NfeError): + """A webhook delivery failed signature verification (raised by ``construct_event`` only).""" diff --git a/src/nfeio/models/__init__.py b/src/nfeio/models/__init__.py new file mode 100644 index 0000000..e3abb4b --- /dev/null +++ b/src/nfeio/models/__init__.py @@ -0,0 +1,172 @@ +"""Response models: immutable typed views over the API JSON (see :class:`NfeObject`). + +Only fields that the SDK corrects or types have properties (ids, statuses, dates, money, +documents). Everything else is available by wire name: ``invoice["description"]``. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from ._base import ( + DateTimeField, + DecimalField, + DocumentField, + NfeObject, + ObjectField, + ResponseInfo, + StrField, + StrListField, +) + +__all__ = [ + "Address", + "Certificate", + "Company", + "LegalEntity", + "NaturalPerson", + "NfeObject", + "Party", + "ResponseInfo", + "ServiceInvoice", + "Webhook", + "WebhookEventType", +] + + +class Party(NfeObject): + """Borrower (tomador) or provider (prestador) nested in a service invoice.""" + + __slots__ = () + + id = StrField("id") + #: CNPJ (14) or CPF (11) as a normalised string; the API may send it as an integer that + #: lost its leading zeros. The sibling ``type`` field decides which one it is. + federal_tax_number = DocumentField("federalTaxNumber", "auto", type_key="type") + + +class ServiceInvoice(NfeObject): + """NFS-e (``/v1/companies/{company_id}/serviceinvoices``).""" + + __slots__ = () + + id = StrField("id") + external_id = StrField("externalId") + environment = StrField("environment") + #: Processing status (open set, see :class:`nfeio.types.FlowStatus`). + flow_status = StrField("flowStatus") + flow_message = StrField("flowMessage") + status = StrField("status") + services_amount = DecimalField("servicesAmount") + base_tax_amount = DecimalField("baseTaxAmount") + deductions_amount = DecimalField("deductionsAmount") + iss_rate = DecimalField("issRate") + iss_tax_amount = DecimalField("issTaxAmount") + amount_net = DecimalField("amountNet") + issued_on = DateTimeField("issuedOn") + cancelled_on = DateTimeField("cancelledOn") + created_on = DateTimeField("createdOn") + modified_on = DateTimeField("modifiedOn") + borrower = ObjectField("borrower", Party) + provider = ObjectField("provider", Party) + + +class Company(NfeObject): + """Company (contribuinte) from the Companies API v2 (``api.nfse.io/v2/companies``). + + Note: in v2 the wire field ``municipalTaxNumber`` holds the *id* of the municipal + enrollment, not the enrollment number, so it has no typed property. + """ + + __slots__ = () + + id = StrField("id") + account_id = StrField("accountId") + federal_tax_number = DocumentField("federalTaxNumber", "cnpj") + tax_regime = StrField("taxRegime") + status = StrField("status") + #: Ids of the municipal enrollments (``municipalTaxes``). + municipal_tax_ids = StrListField("municipalTaxes") + #: Ids of the state enrollments (``stateTaxes``). + state_tax_ids = StrListField("stateTaxes") + created_on = DateTimeField("createdOn") + modified_on = DateTimeField("modifiedOn") + + +class Certificate(NfeObject): + """Digital certificate (A1) metadata of a company.""" + + __slots__ = () + + thumbprint = StrField("thumbprint") + tax_id = DocumentField("taxId", "auto") + status = StrField("status") + provider_type = StrField("providerType") + #: Expiration, read from ``validUntil`` (certificate API) or ``expiresOn`` (company list). + expires_on = DateTimeField("validUntil", "expiresOn") + modified_on = DateTimeField("modifiedOn") + + def is_expired(self, at: datetime | None = None) -> bool: + """``True`` if the certificate is expired at ``at`` (default: now, UTC). + + A certificate without a readable expiration is reported as expired. + """ + expires = self.expires_on + if expires is None: + return True + moment = at if at is not None else datetime.now(timezone.utc) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return expires <= moment + + +class Webhook(NfeObject): + """Account webhook (``/v2/webhooks``). ``contentType``/``status`` are strings on the wire.""" + + __slots__ = () + + id = StrField("id") + status = StrField("status") + content_type = StrField("contentType") + created_on = DateTimeField("createdOn") + modified_on = DateTimeField("modifiedOn") + + +class WebhookEventType(NfeObject): + """Event type that a webhook can filter on (ids are free text on the wire).""" + + __slots__ = () + + id = StrField("id") + status = StrField("status") + + +class LegalEntity(NfeObject): + """CNPJ lookup result (``legalentity.api.nfe.io/v3``).""" + + __slots__ = () + + federal_tax_number = DocumentField("federalTaxNumber", "cnpj") + status = StrField("status") + opened_on = DateTimeField("openedOn") + status_on = DateTimeField("statusOn") + share_capital = DecimalField("shareCapital") + + +class NaturalPerson(NfeObject): + """CPF status lookup result (``naturalperson.api.nfe.io``).""" + + __slots__ = () + + federal_tax_number = DocumentField("federalTaxNumber", "cpf") + status = StrField("status") + birth_on = DateTimeField("birthOn") + + +class Address(NfeObject): + """CEP lookup result (``address.api.nfe.io/v2``). City is ``address["city"]["code"]``.""" + + __slots__ = () + + #: 8-digit postal code without the hyphen. + postal_code = DocumentField("postalCode", "cep") diff --git a/src/nfeio/models/_base.py b/src/nfeio/models/_base.py new file mode 100644 index 0000000..05c5bd4 --- /dev/null +++ b/src/nfeio/models/_base.py @@ -0,0 +1,385 @@ +"""``NfeObject``: an immutable, typed view over the JSON the API sent. + +Design (decision D1, "lean D"): + +* An ``NfeObject`` **is** a ``Mapping[str, Any]`` over the decoded JSON. Nothing is copied out + or filtered, so every field — including ones this SDK does not know yet — is available by its + wire name: ``invoice["flowStatus"]``. +* Reading by key returns the wire value, except that a nested mapping comes back as an + ``NfeObject`` and a list comes back as a *new* list whose mappings are ``NfeObject`` too. + Mutating what you get never changes the object. +* A few typed snake_case properties exist **only** where the SDK corrects or types something: + ids, statuses, dates (aware ``datetime``), money and rates (``Decimal``) and documents + (CNPJ/CPF as normalised ``str``). They are declarative descriptors (``StrField(...)``, ...) + that remember their wire key, which a test checks against the OpenAPI specs. +* There is no ``__getattr__`` magic: ``obj.items()`` is always the ``Mapping`` method, even + when the payload has an ``items`` field. +""" + +from __future__ import annotations + +import copy +import json +import math +import re +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Generic, TypeVar, overload + +from .._core.headers import Headers + +T = TypeVar("T") +M = TypeVar("M", bound="NfeObject") + +_SENSITIVE_KEY_RE = re.compile(r"secret|password|apikey|api_key|token", re.IGNORECASE) + + +@dataclass(frozen=True) +class ResponseInfo: + """Metadata of the HTTP response an object or page came from.""" + + status_code: int + headers: Headers + + @property + def request_id(self) -> str | None: + """``x-request-id`` — quote it when contacting NFE.io support.""" + return self.headers.get("x-request-id") + + +def _wrap(value: Any) -> Any: + if isinstance(value, dict): + return NfeObject._from_wire(value) + if isinstance(value, list): + return [_wrap(item) for item in value] + return value + + +def _plain(value: Any) -> Any: + if isinstance(value, NfeObject): + return copy.deepcopy(value._data) + if isinstance(value, Mapping): + out: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError(f"NfeObject keys must be strings, got {type(key).__name__}") + out[key] = _plain(item) + return out + if isinstance(value, (list, tuple)): + return [_plain(item) for item in value] + return copy.deepcopy(value) + + +def _masked(value: Any) -> Any: + if isinstance(value, dict): + return { + key: ("" if _SENSITIVE_KEY_RE.search(key) and item else _masked(item)) + for key, item in value.items() + } + if isinstance(value, list): + return [_masked(item) for item in value] + return value + + +class NfeObject(Mapping[str, Any]): + """Immutable mapping over an API JSON object, with typed properties on subclasses.""" + + __slots__ = ("_data", "_last_response") + + _data: dict[str, Any] + _last_response: ResponseInfo | None + + def __init__( + self, data: Mapping[str, Any] | None = None, *, last_response: ResponseInfo | None = None + ) -> None: + object.__setattr__(self, "_data", _plain(data or {})) + object.__setattr__(self, "_last_response", last_response) + + @classmethod + def _from_wire( + cls: type[M], data: dict[str, Any], last_response: ResponseInfo | None = None + ) -> M: + """Wrap a freshly decoded dict without copying (internal use only).""" + obj = cls.__new__(cls) + object.__setattr__(obj, "_data", data) + object.__setattr__(obj, "_last_response", last_response) + return obj + + def __setattr__(self, name: str, value: Any) -> None: + raise AttributeError(f"{type(self).__name__} is immutable") + + def __delattr__(self, name: str) -> None: + raise AttributeError(f"{type(self).__name__} is immutable") + + # -- Mapping protocol ------------------------------------------------------------------ + + def __getitem__(self, key: str) -> Any: + return _wrap(self._data[key]) + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __contains__(self, key: object) -> bool: + return key in self._data + + def __eq__(self, other: object) -> bool: + if isinstance(other, NfeObject): + return self._data == other._data + if isinstance(other, Mapping): + try: + return bool(self._data == _plain(other)) + except TypeError: + return False + return NotImplemented + + __hash__ = None # type: ignore[assignment] + + # -- extras ---------------------------------------------------------------------------- + + def to_dict(self) -> dict[str, Any]: + """Deep copy of the wire JSON (plain dicts/lists, JSON-serialisable).""" + return copy.deepcopy(self._data) + + @property + def last_response(self) -> ResponseInfo | None: + """Status, headers and ``request_id`` of the response this object came from.""" + return self._last_response + + def __repr__(self) -> str: + body = json.dumps(_masked(self._data), ensure_ascii=False, default=str) + return f"{type(self).__name__}({body})" + + def __copy__(self: M) -> M: + return self + + def __deepcopy__(self: M, memo: dict[int, Any]) -> M: + return type(self)._from_wire(copy.deepcopy(self._data, memo), self._last_response) + + def __reduce__(self) -> tuple[Any, ...]: + return (_restore, (type(self), self._data)) + + +def _restore(cls: type[M], data: dict[str, Any]) -> M: + return cls._from_wire(data) + + +# -------------------------------------------------------------------------------------------- +# Typed fields +# -------------------------------------------------------------------------------------------- + +Converter = Callable[[Any, Mapping[str, Any]], T | None] + + +class Field(Generic[T]): + """Declarative read-only property bound to a wire key (with optional fallback keys).""" + + __slots__ = ("convert", "fallback_keys", "key", "name") + + def __init__(self, key: str, convert: Converter[T], *fallback_keys: str) -> None: + self.key = key + self.fallback_keys = fallback_keys + self.convert = convert + self.name = key + + def __set_name__(self, owner: type, name: str) -> None: + self.name = name + + @property + def wire_keys(self) -> tuple[str, ...]: + return (self.key, *self.fallback_keys) + + @overload + def __get__(self, obj: None, objtype: type | None = None) -> Field[T]: ... + + @overload + def __get__(self, obj: NfeObject, objtype: type | None = None) -> T | None: ... + + def __get__(self, obj: NfeObject | None, objtype: type | None = None) -> Field[T] | T | None: + if obj is None: + return self + data = obj._data + for key in self.wire_keys: + raw = data.get(key) + if raw is not None: + return self.convert(raw, data) + return None + + def __set__(self, obj: NfeObject, value: Any) -> None: + raise AttributeError(f"{self.name} is read-only") + + +def iter_fields(cls: type[NfeObject]) -> Iterator[tuple[str, Field[Any]]]: + """Yield ``(attribute name, Field)`` for every typed property of a model class.""" + seen: set[str] = set() + for klass in cls.__mro__: + for name, value in vars(klass).items(): + if isinstance(value, Field) and name not in seen: + seen.add(name) + yield name, value + + +# -- converters -------------------------------------------------------------------------------- + + +def to_str(raw: Any, _data: Mapping[str, Any] | None = None) -> str | None: + if isinstance(raw, str): + return raw + if isinstance(raw, int) and not isinstance(raw, bool): + return str(raw) + return None + + +def to_decimal(raw: Any, _data: Mapping[str, Any] | None = None) -> Decimal | None: + """``float`` → ``Decimal(repr(float))`` (recovers the JSON literal up to 15-17 digits).""" + if isinstance(raw, bool): + return None + if isinstance(raw, int): + return Decimal(raw) + if isinstance(raw, float): + return Decimal(repr(raw)) if math.isfinite(raw) else None + if isinstance(raw, str): + try: + value = Decimal(raw.strip()) + except InvalidOperation: + return None + return value if value.is_finite() else None + return None + + +_DATETIME_RE = re.compile( + r"^(?P\d{4})-(?P\d{2})-(?P\d{2})" + r"(?:[T ](?P\d{2}):(?P\d{2})(?::(?P\d{2})(?:[.,](?P\d{1,9}))?)?)?" + r"\s*(?PZ|z|[+-]\d{2}(?::?\d{2})?)?$" +) + + +def parse_datetime(text: str) -> datetime | None: + """Tolerant ISO 8601 parser: ``Z``, offsets, 0-9 fractional digits, date only. + + Values without an offset are interpreted as UTC. Returns ``None`` when unparseable. + """ + match = _DATETIME_RE.fullmatch(text.strip()) + if not match: + return None + g = match.groupdict() + frac = (g["f"] or "")[:6].ljust(6, "0") + tz_text = g["tz"] + tz: timezone + if tz_text is None or tz_text in ("Z", "z"): + tz = timezone.utc + else: + sign = -1 if tz_text[0] == "-" else 1 + digits = tz_text[1:].replace(":", "") + hours = int(digits[:2]) + minutes = int(digits[2:4]) if len(digits) > 2 else 0 + if hours > 23 or minutes > 59: + return None + tz = timezone(sign * timedelta(hours=hours, minutes=minutes)) + try: + return datetime( + int(g["y"]), + int(g["mo"]), + int(g["d"]), + int(g["h"] or 0), + int(g["mi"] or 0), + int(g["s"] or 0), + int(frac), + tzinfo=tz, + ) + except ValueError: + return None + + +def to_datetime(raw: Any, _data: Mapping[str, Any] | None = None) -> datetime | None: + if isinstance(raw, str): + return parse_datetime(raw) + return None + + +def to_date(raw: Any, _data: Mapping[str, Any] | None = None) -> date | None: + parsed = to_datetime(raw) + return parsed.date() if parsed is not None else None + + +_DOC_CLEAN_RE = re.compile(r"[.\-/\s]") +_DOC_RE = re.compile(r"[0-9A-Z]+") + + +def normalize_document(raw: Any, kind: str, type_hint: Any = None) -> str | None: + """Normalise a CNPJ/CPF that may arrive as int (leading zeros lost), masked or raw string. + + ``kind`` is ``"cnpj"``, ``"cpf"``, ``"cep"`` or ``"auto"``. For ``"auto"`` the sibling + ``type`` field (``LegalEntity``/``NaturalPerson``) decides; without it, more than 11 + characters means CNPJ. + """ + if isinstance(raw, bool): + return None + if isinstance(raw, int): + if raw < 0: + return None + text = str(raw) + elif isinstance(raw, str): + text = _DOC_CLEAN_RE.sub("", raw).upper() + else: + return None + if not text or not _DOC_RE.fullmatch(text): + return None + if kind == "auto": + hint = type_hint.lower() if isinstance(type_hint, str) else "" + if "legal" in hint or "company" in hint: + kind = "cnpj" + elif "natural" in hint or "person" in hint: + kind = "cpf" + else: + kind = "cnpj" if len(text) > 11 else "cpf" + size = _DOC_SIZES.get(kind, 0) + return text.zfill(size) if len(text) <= size else text + + +_DOC_SIZES = {"cnpj": 14, "cpf": 11, "cep": 8} + + +def StrField(key: str, *fallback_keys: str) -> Field[str]: + return Field(key, to_str, *fallback_keys) + + +def DecimalField(key: str, *fallback_keys: str) -> Field[Decimal]: + return Field(key, to_decimal, *fallback_keys) + + +def DateTimeField(key: str, *fallback_keys: str) -> Field[datetime]: + return Field(key, to_datetime, *fallback_keys) + + +def DocumentField(key: str, kind: str, type_key: str | None = None) -> Field[str]: + def convert(raw: Any, data: Mapping[str, Any]) -> str | None: + return normalize_document(raw, kind, data.get(type_key) if type_key else None) + + return Field(key, convert) + + +def StrListField(key: str) -> Field[list[str]]: + def convert(raw: Any, _data: Mapping[str, Any]) -> list[str] | None: + if not isinstance(raw, list): + return None + out: list[str] = [] + for item in raw: + if isinstance(item, str): + out.append(item) + elif isinstance(item, dict) and isinstance(item.get("id"), str): + out.append(item["id"]) + return out + + return Field(key, convert) + + +def ObjectField(key: str, cls: type[M]) -> Field[M]: + def convert(raw: Any, _data: Mapping[str, Any]) -> M | None: + return cls._from_wire(raw) if isinstance(raw, dict) else None + + return Field(key, convert) diff --git a/src/nfeio/pagination.py b/src/nfeio/pagination.py new file mode 100644 index 0000000..8195afb --- /dev/null +++ b/src/nfeio/pagination.py @@ -0,0 +1,244 @@ +"""Paginated results. + +* :class:`OffsetPage` — API v1 lists (``pageIndex`` is **1-based**). The API sends no totals, + so ``has_more`` is ``len(data) == page_count`` (at worst one extra request that comes back + empty). +* :class:`CursorPage` — API v2 lists (``startingAfter``/``endingBefore``, ``hasMore``). + +``auto_paging_iter()`` walks this page and the following ones lazily and always stops at an +empty page, even if the API says ``hasMore: true``. There is deliberately no "load everything" +helper. ``Async*`` variants are returned by :class:`~nfeio.AsyncNfeClient`. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from ._core.ops import Op +from .errors import UnexpectedResponseError +from .models import NfeObject, ResponseInfo + +__all__ = ["AsyncCursorPage", "AsyncOffsetPage", "CursorPage", "OffsetPage"] + +T = TypeVar("T", bound=NfeObject) +S = TypeVar("S") + +#: Maximum page size accepted by the API lists in v0.1. +MAX_PAGE_SIZE = 50 + + +@dataclass(frozen=True) +class OffsetState(Generic[T]): + items: list[T] + last_response: ResponseInfo + page_index: int + page_count: int + fetch: Callable[[int], Op[OffsetState[T]]] + + @property + def has_more(self) -> bool: + return len(self.items) > 0 and len(self.items) >= self.page_count + + @property + def first_id(self) -> object: + return self.items[0].get("id") if self.items else None + + +@dataclass(frozen=True) +class CursorState(Generic[T]): + items: list[T] + last_response: ResponseInfo + has_more: bool + limit: int + backwards: bool + fetch: Callable[[str | None, str | None, int], Op[CursorState[T]]] + #: Cursor (``startingAfter``/``endingBefore``) used to fetch this page. + cursor: str | None = None + + def next_op(self, limit: int) -> Op[CursorState[T]] | None: + if not self.has_more or not self.items: + return None + edge = self.items[0] if self.backwards else self.items[-1] + next_cursor = edge.get("id") + if not isinstance(next_cursor, str): + return None + if next_cursor == self.cursor: + raise UnexpectedResponseError( + "cursor pagination did not advance (the API returned the same cursor again)" + ) + if self.backwards: + return self.fetch(None, next_cursor, limit) + return self.fetch(next_cursor, None, limit) + + +def _check_advanced(previous: object, page_first: object) -> None: + if previous is not None and previous == page_first: + raise UnexpectedResponseError( + "offset pagination did not advance (the API returned the same page again)" + ) + + +SyncRunner = Callable[[Op[Any]], Any] +AsyncRunner = Callable[[Op[Any]], Awaitable[Any]] + + +class _PageBase(Generic[T]): + __slots__ = ("_runner", "_state") + + _state: OffsetState[T] | CursorState[T] + _runner: Any + + @property + def data(self) -> list[T]: + """Items of this page (a new list on every access).""" + return list(self._state.items) + + @property + def has_more(self) -> bool: + return self._state.has_more + + @property + def last_response(self) -> ResponseInfo: + return self._state.last_response + + def __len__(self) -> int: + return len(self._state.items) + + def __iter__(self) -> Iterator[T]: + """Iterate over **this page only**; use ``auto_paging_iter()`` for all pages.""" + return iter(list(self._state.items)) + + def __repr__(self) -> str: + return f"<{type(self).__name__} items={len(self)} has_more={self.has_more}>" + + +class OffsetPage(_PageBase[T]): + """A page of an offset (``pageIndex``/``pageCount``) list.""" + + __slots__ = () + _state: OffsetState[T] + + def __init__(self, state: OffsetState[T], runner: SyncRunner) -> None: + self._state = state + self._runner = runner + + @property + def page_index(self) -> int: + return self._state.page_index + + @property + def page_count(self) -> int: + return self._state.page_count + + def next_page(self) -> OffsetPage[T] | None: + """Fetch the next page (same ``page_count``), or ``None`` if this one is the last.""" + if not self.has_more: + return None + state: OffsetState[T] = self._runner(self._state.fetch(self._state.page_index + 1)) + return OffsetPage(state, self._runner) + + def auto_paging_iter(self) -> Iterator[T]: + """Yield every item of this page and the following ones, fetching lazily.""" + page: OffsetPage[T] | None = self + while page is not None and len(page) > 0: + yield from page._state.items + previous = page._state.first_id + page = page.next_page() + if page is not None and len(page) > 0: + _check_advanced(previous, page._state.first_id) + + +class AsyncOffsetPage(_PageBase[T]): + """Async variant of :class:`OffsetPage`.""" + + __slots__ = () + _state: OffsetState[T] + + def __init__(self, state: OffsetState[T], runner: AsyncRunner) -> None: + self._state = state + self._runner = runner + + @property + def page_index(self) -> int: + return self._state.page_index + + @property + def page_count(self) -> int: + return self._state.page_count + + async def next_page(self) -> AsyncOffsetPage[T] | None: + if not self.has_more: + return None + state: OffsetState[T] = await self._runner(self._state.fetch(self._state.page_index + 1)) + return AsyncOffsetPage(state, self._runner) + + async def auto_paging_iter(self) -> AsyncIterator[T]: + page: AsyncOffsetPage[T] | None = self + while page is not None and len(page) > 0: + for item in page._state.items: + yield item + previous = page._state.first_id + page = await page.next_page() + if page is not None and len(page) > 0: + _check_advanced(previous, page._state.first_id) + + +class CursorPage(_PageBase[T]): + """A page of a cursor (``startingAfter``/``endingBefore``) list.""" + + __slots__ = () + _state: CursorState[T] + + def __init__(self, state: CursorState[T], runner: SyncRunner) -> None: + self._state = state + self._runner = runner + + @property + def limit(self) -> int: + return self._state.limit + + def next_page(self) -> CursorPage[T] | None: + """Fetch the next page in the same direction (same ``limit``), or ``None``.""" + op = self._state.next_op(self._state.limit) + if op is None: + return None + return CursorPage(self._runner(op), self._runner) + + def auto_paging_iter(self) -> Iterator[T]: + """Yield every item, fetching following pages with ``limit=50``.""" + page: CursorPage[T] | None = self + while page is not None and len(page) > 0: + yield from page._state.items + op = page._state.next_op(MAX_PAGE_SIZE) + page = CursorPage(self._runner(op), self._runner) if op is not None else None + + +class AsyncCursorPage(_PageBase[T]): + """Async variant of :class:`CursorPage`.""" + + __slots__ = () + _state: CursorState[T] + + def __init__(self, state: CursorState[T], runner: AsyncRunner) -> None: + self._state = state + self._runner = runner + + @property + def limit(self) -> int: + return self._state.limit + + async def next_page(self) -> AsyncCursorPage[T] | None: + op = self._state.next_op(self._state.limit) + if op is None: + return None + return AsyncCursorPage(await self._runner(op), self._runner) + + async def auto_paging_iter(self) -> AsyncIterator[T]: + page: AsyncCursorPage[T] | None = self + while page is not None and len(page) > 0: + for item in page._state.items: + yield item + op = page._state.next_op(MAX_PAGE_SIZE) + page = AsyncCursorPage(await self._runner(op), self._runner) if op else None diff --git a/src/nfeio/py.typed b/src/nfeio/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/nfeio/resources/__init__.py b/src/nfeio/resources/__init__.py new file mode 100644 index 0000000..bb16978 --- /dev/null +++ b/src/nfeio/resources/__init__.py @@ -0,0 +1 @@ +"""Resource services exposed as attributes of the clients (``client.service_invoices``, ...).""" diff --git a/src/nfeio/resources/_base.py b/src/nfeio/resources/_base.py new file mode 100644 index 0000000..0a558d7 --- /dev/null +++ b/src/nfeio/resources/_base.py @@ -0,0 +1,56 @@ +"""Shared plumbing of resource services (sync and async facades over the same ops).""" + +from __future__ import annotations + +from typing import Any, Protocol, TypeVar + +from .._config import ClientConfig +from .._core.ops import Op + +T = TypeVar("T") + + +class SyncHost(Protocol): + _config: ClientConfig + + def _run(self, op: Op[T]) -> T: ... + + +class AsyncHost(Protocol): + _config: ClientConfig + + async def _run(self, op: Op[T]) -> T: ... + + +class SyncService: + __slots__ = ("_host",) + + def __init__(self, host: SyncHost) -> None: + self._host = host + + @property + def _cfg(self) -> ClientConfig: + return self._host._config + + def _run(self, op: Op[T]) -> T: + return self._host._run(op) + + def _runner(self, op: Op[Any]) -> Any: + return self._host._run(op) + + +class AsyncService: + __slots__ = ("_host",) + + def __init__(self, host: AsyncHost) -> None: + self._host = host + + @property + def _cfg(self) -> ClientConfig: + return self._host._config + + async def _run(self, op: Op[T]) -> T: + return await self._host._run(op) + + async def _runner(self, op: Op[Any]) -> Any: + return await self._host._run(op) diff --git a/src/nfeio/resources/certificates.py b/src/nfeio/resources/certificates.py new file mode 100644 index 0000000..846a145 --- /dev/null +++ b/src/nfeio/resources/certificates.py @@ -0,0 +1,151 @@ +"""Digital certificates (A1) of a company: upload (multipart) and status.""" + +from __future__ import annotations + +import builtins +import io +import os +from pathlib import Path +from typing import BinaryIO, Union + +from .._config import ApiFamily, ClientConfig, RequestOptions +from .._core import multipart, paths +from .._core.ops import Op +from .._core.requestor import expect_list, expect_object, request, response_info +from ..errors import InvalidParameterError +from ..models import Certificate +from ._base import AsyncService, SyncService + +FAMILY = ApiFamily.ACCOUNT +MAX_CERTIFICATE_BYTES = 1024 * 1024 + +#: A PFX/P12 as bytes, a filesystem path, or a binary file object. +CertificateFile = Union[bytes, bytearray, memoryview, str, "os.PathLike[str]", BinaryIO] + + +def _certificates_path(company_id: str) -> str: + return f"/v2/companies/{paths.opaque_id(company_id, 'company_id')}/certificates" + + +def _read_file(file: CertificateFile, filename: str | None) -> tuple[bytes, str]: + if isinstance(file, (bytes, bytearray, memoryview)): + content = bytes(file) + name = filename or "certificate.pfx" + elif isinstance(file, (str, os.PathLike)): + path = Path(file) + try: + size = path.stat().st_size + except OSError as exc: + raise InvalidParameterError( + f"cannot read certificate file: {exc.strerror}", param="file" + ) from None + if size > MAX_CERTIFICATE_BYTES: + raise InvalidParameterError("certificate file is larger than 1 MiB", param="file") + content = path.read_bytes() + name = filename or path.name + elif isinstance(file, io.IOBase) or hasattr(file, "read"): + data = file.read(MAX_CERTIFICATE_BYTES + 1) + if not isinstance(data, (bytes, bytearray)): + raise InvalidParameterError("certificate file object must be opened in binary mode") + content = bytes(data) + raw_name = getattr(file, "name", None) + name = filename or (Path(raw_name).name if isinstance(raw_name, str) else "certificate.pfx") + else: + raise InvalidParameterError( + "file must be bytes, a path or a binary file object", param="file" + ) + if not content: + raise InvalidParameterError("certificate file is empty", param="file") + if len(content) > MAX_CERTIFICATE_BYTES: + raise InvalidParameterError("certificate file is larger than 1 MiB", param="file") + return content, name + + +def upload_op( + cfg: ClientConfig, + company_id: str, + file: CertificateFile, + password: str, + filename: str | None, + options: RequestOptions | None, +) -> Op[Certificate]: + path = _certificates_path(company_id) + if not isinstance(password, str) or not password: + raise InvalidParameterError("password must be a non-empty string", param="password") + content, name = _read_file(file, filename) + body, content_type = multipart.encode( + [("password", password)], + [multipart.FilePart("file", name, content, "application/x-pkcs12")], + ) + response = yield from request( + cfg, + "POST", + FAMILY, + path, + body=body, + content_type=content_type, + options=options, + secrets=(password,), + ) + data = expect_object(response, unwrap="certificate") + return Certificate._from_wire(data, response_info(response)) + + +def list_op( + cfg: ClientConfig, company_id: str, options: RequestOptions | None +) -> Op[list[Certificate]]: + response = yield from request( + cfg, "GET", FAMILY, _certificates_path(company_id), options=options + ) + _, items = expect_list(response, "certificates") + info = response_info(response) + return [Certificate._from_wire(item, info) for item in items if isinstance(item, dict)] + + +class CertificatesService(SyncService): + """``client.certificates`` — company digital certificates (synchronous).""" + + __slots__ = () + + def upload( + self, + company_id: str, + file: CertificateFile, + password: str, + *, + filename: str | None = None, + options: RequestOptions | None = None, + ) -> Certificate: + """Upload an A1 certificate (PFX/P12, at most 1 MiB) as multipart field ``file``. + + The password is never logged nor included in error messages. Not retried on 5xx. + """ + return self._run(upload_op(self._cfg, company_id, file, password, filename, options)) + + def list( + self, company_id: str, *, options: RequestOptions | None = None + ) -> builtins.list[Certificate]: + """Certificates of the company (empty list when there is none).""" + return self._run(list_op(self._cfg, company_id, options)) + + +class AsyncCertificatesService(AsyncService): + """``client.certificates`` — company digital certificates (asynchronous).""" + + __slots__ = () + + async def upload( + self, + company_id: str, + file: CertificateFile, + password: str, + *, + filename: str | None = None, + options: RequestOptions | None = None, + ) -> Certificate: + return await self._run(upload_op(self._cfg, company_id, file, password, filename, options)) + + async def list( + self, company_id: str, *, options: RequestOptions | None = None + ) -> builtins.list[Certificate]: + return await self._run(list_op(self._cfg, company_id, options)) diff --git a/src/nfeio/resources/companies.py b/src/nfeio/resources/companies.py new file mode 100644 index 0000000..d227098 --- /dev/null +++ b/src/nfeio/resources/companies.py @@ -0,0 +1,182 @@ +"""Companies (contribuintes), API v2 on ``https://api.nfse.io/v2/companies`` (cursor pages).""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .._config import ApiFamily, ClientConfig, RequestOptions +from .._core import paths +from .._core.ops import Op +from .._core.requestor import decode_json, expect_list, expect_object, request, response_info +from ..errors import InvalidParameterError +from ..models import Company +from ..pagination import MAX_PAGE_SIZE, AsyncCursorPage, CursorPage, CursorState +from ..types import CompanyParams +from ._base import AsyncService, SyncService + +FAMILY = ApiFamily.ACCOUNT +BASE = "/v2/companies" + + +def _company_path(company_id: str) -> str: + return f"{BASE}/{paths.opaque_id(company_id, 'company_id')}" + + +def _check_limit(limit: int) -> int: + # limit=0 returns an empty list with hasMore=true (endless loop): reject locally. + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_PAGE_SIZE: + raise InvalidParameterError(f"limit must be between 1 and {MAX_PAGE_SIZE}", param="limit") + return limit + + +def _body(params: CompanyParams | Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(params, Mapping): + raise InvalidParameterError("params must be a mapping (the company)", param="params") + return {"company": dict(params)} + + +def list_op( + cfg: ClientConfig, + starting_after: str | None, + ending_before: str | None, + limit: int, + options: RequestOptions | None, +) -> Op[CursorState[Company]]: + _check_limit(limit) + if starting_after is not None and ending_before is not None: + raise InvalidParameterError( + "pass starting_after or ending_before, not both", param="starting_after" + ) + query: dict[str, str | int | None] = {"limit": limit} + if starting_after is not None: + query["startingAfter"] = paths.opaque_id(starting_after, "starting_after") + if ending_before is not None: + query["endingBefore"] = paths.opaque_id(ending_before, "ending_before") + response = yield from request(cfg, "GET", FAMILY, BASE, query=query, options=options) + envelope, items = expect_list(response, "companies") + info = response_info(response) + companies = [Company._from_wire(item, info) for item in items if isinstance(item, dict)] + + def fetch(after: str | None, before: str | None, size: int) -> Op[CursorState[Company]]: + return list_op(cfg, after, before, size, options) + + return CursorState( + companies, + info, + bool(envelope.get("hasMore")), + limit, + ending_before is not None, + fetch, + ending_before if ending_before is not None else starting_after, + ) + + +def retrieve_op(cfg: ClientConfig, company_id: str, options: RequestOptions | None) -> Op[Company]: + response = yield from request(cfg, "GET", FAMILY, _company_path(company_id), options=options) + return Company._from_wire(expect_object(response, unwrap="company"), response_info(response)) + + +def create_op( + cfg: ClientConfig, params: CompanyParams | Mapping[str, Any], options: RequestOptions | None +) -> Op[Company]: + body = _body(params) + response = yield from request(cfg, "POST", FAMILY, BASE, json=body, options=options) + return Company._from_wire(expect_object(response, unwrap="company"), response_info(response)) + + +def update_op( + cfg: ClientConfig, + company_id: str, + params: CompanyParams | Mapping[str, Any], + options: RequestOptions | None, +) -> Op[Company]: + path = _company_path(company_id) + body = _body(params) + response = yield from request(cfg, "PUT", FAMILY, path, json=body, options=options) + return Company._from_wire(expect_object(response, unwrap="company"), response_info(response)) + + +def delete_op(cfg: ClientConfig, company_id: str, options: RequestOptions | None) -> Op[None]: + response = yield from request(cfg, "DELETE", FAMILY, _company_path(company_id), options=options) + decode_json(response, allow_empty=True) + return None + + +class CompaniesService(SyncService): + """``client.companies`` — Companies API v2 (synchronous).""" + + __slots__ = () + + def list( + self, + *, + limit: int = 10, + starting_after: str | None = None, + ending_before: str | None = None, + options: RequestOptions | None = None, + ) -> CursorPage[Company]: + """List companies by cursor (``limit`` 1-50). ``auto_paging_iter()`` walks them all.""" + state = self._run(list_op(self._cfg, starting_after, ending_before, limit, options)) + return CursorPage(state, self._runner) + + def retrieve(self, company_id: str, *, options: RequestOptions | None = None) -> Company: + return self._run(retrieve_op(self._cfg, company_id, options)) + + def create( + self, params: CompanyParams | Mapping[str, Any], *, options: RequestOptions | None = None + ) -> Company: + """Create a company (body sent as ``{"company": params}``).""" + return self._run(create_op(self._cfg, params, options)) + + def update( + self, + company_id: str, + params: CompanyParams | Mapping[str, Any], + *, + options: RequestOptions | None = None, + ) -> Company: + """Replace a company (full update: send every field you want to keep).""" + return self._run(update_op(self._cfg, company_id, params, options)) + + def delete(self, company_id: str, *, options: RequestOptions | None = None) -> None: + """Delete a company. The API does a **soft delete**: it answers 204 and the company + stays retrievable (and listed) with ``status == "Inactive"``.""" + self._run(delete_op(self._cfg, company_id, options)) + + +class AsyncCompaniesService(AsyncService): + """``client.companies`` — Companies API v2 (asynchronous).""" + + __slots__ = () + + async def list( + self, + *, + limit: int = 10, + starting_after: str | None = None, + ending_before: str | None = None, + options: RequestOptions | None = None, + ) -> AsyncCursorPage[Company]: + state = await self._run(list_op(self._cfg, starting_after, ending_before, limit, options)) + return AsyncCursorPage(state, self._runner) + + async def retrieve(self, company_id: str, *, options: RequestOptions | None = None) -> Company: + return await self._run(retrieve_op(self._cfg, company_id, options)) + + async def create( + self, params: CompanyParams | Mapping[str, Any], *, options: RequestOptions | None = None + ) -> Company: + return await self._run(create_op(self._cfg, params, options)) + + async def update( + self, + company_id: str, + params: CompanyParams | Mapping[str, Any], + *, + options: RequestOptions | None = None, + ) -> Company: + return await self._run(update_op(self._cfg, company_id, params, options)) + + async def delete(self, company_id: str, *, options: RequestOptions | None = None) -> None: + await self._run(delete_op(self._cfg, company_id, options)) diff --git a/src/nfeio/resources/lookups.py b/src/nfeio/resources/lookups.py new file mode 100644 index 0000000..d7859e4 --- /dev/null +++ b/src/nfeio/resources/lookups.py @@ -0,0 +1,118 @@ +"""Data lookups (CNPJ v3, CPF, CEP). They use ``data_api_key`` and their own hosts. + +Documents are validated locally (check digits) before any request: lookups may be billed. +""" + +from __future__ import annotations + +from datetime import date + +from .._config import ApiFamily, ClientConfig, RequestOptions +from .._core import paths +from .._core.ops import Op +from .._core.requestor import expect_object, request, response_info +from ..models import Address, LegalEntity, NaturalPerson +from ._base import AsyncService, SyncService + + +def cnpj_op( + cfg: ClientConfig, + cnpj: str, + update_address: bool | None, + update_city_code: bool | None, + options: RequestOptions | None, +) -> Op[LegalEntity]: + path = f"/v3/legalentities/basicInfo/{paths.cnpj(cnpj)}" + query = {"updateAddress": update_address, "updateCityCode": update_city_code} + response = yield from request( + cfg, "GET", ApiFamily.LEGAL_ENTITY, path, query=query, options=options + ) + data = expect_object(response, unwrap="legalEntity") + return LegalEntity._from_wire(data, response_info(response)) + + +def cnpj_state_taxes_op( + cfg: ClientConfig, cnpj: str, state: str, options: RequestOptions | None +) -> Op[LegalEntity]: + path = f"/v3/legalentities/stateTaxInfo/{paths.uf(state)}/{paths.cnpj(cnpj)}" + response = yield from request(cfg, "GET", ApiFamily.LEGAL_ENTITY, path, options=options) + data = expect_object(response, unwrap="legalEntity") + return LegalEntity._from_wire(data, response_info(response)) + + +def cpf_op( + cfg: ClientConfig, cpf: str, birth_date: date | str, options: RequestOptions | None +) -> Op[NaturalPerson]: + path = f"/v1/naturalperson/status/{paths.cpf(cpf)}/{paths.iso_date(birth_date, 'birth_date')}" + response = yield from request(cfg, "GET", ApiFamily.NATURAL_PERSON, path, options=options) + data = expect_object(response, unwrap="naturalPerson") + return NaturalPerson._from_wire(data, response_info(response)) + + +def cep_op(cfg: ClientConfig, cep: str, options: RequestOptions | None) -> Op[Address]: + path = f"/v2/addresses/{paths.cep(cep)}" + response = yield from request(cfg, "GET", ApiFamily.ADDRESS, path, options=options) + data = expect_object(response, unwrap="address") + return Address._from_wire(data, response_info(response)) + + +class LookupsService(SyncService): + """``client.lookups`` — CNPJ, CPF and CEP lookups (synchronous, ``data_api_key``).""" + + __slots__ = () + + def cnpj( + self, + cnpj: str, + *, + update_address: bool | None = None, + update_city_code: bool | None = None, + options: RequestOptions | None = None, + ) -> LegalEntity: + """Company registration data by CNPJ (numeric or alphanumeric, masked or not).""" + return self._run(cnpj_op(self._cfg, cnpj, update_address, update_city_code, options)) + + def cnpj_state_taxes( + self, cnpj: str, state: str, *, options: RequestOptions | None = None + ) -> LegalEntity: + """State enrollments (inscrições estaduais) of a CNPJ in a UF (``stateTaxes``).""" + return self._run(cnpj_state_taxes_op(self._cfg, cnpj, state, options)) + + def cpf( + self, cpf: str, birth_date: date | str, *, options: RequestOptions | None = None + ) -> NaturalPerson: + """CPF status. A CPF/birth date mismatch is reported by the API as ``NotFoundError``.""" + return self._run(cpf_op(self._cfg, cpf, birth_date, options)) + + def cep(self, cep: str, *, options: RequestOptions | None = None) -> Address: + """Address by CEP (8 digits, hyphen optional).""" + return self._run(cep_op(self._cfg, cep, options)) + + +class AsyncLookupsService(AsyncService): + """``client.lookups`` — CNPJ, CPF and CEP lookups (asynchronous, ``data_api_key``).""" + + __slots__ = () + + async def cnpj( + self, + cnpj: str, + *, + update_address: bool | None = None, + update_city_code: bool | None = None, + options: RequestOptions | None = None, + ) -> LegalEntity: + return await self._run(cnpj_op(self._cfg, cnpj, update_address, update_city_code, options)) + + async def cnpj_state_taxes( + self, cnpj: str, state: str, *, options: RequestOptions | None = None + ) -> LegalEntity: + return await self._run(cnpj_state_taxes_op(self._cfg, cnpj, state, options)) + + async def cpf( + self, cpf: str, birth_date: date | str, *, options: RequestOptions | None = None + ) -> NaturalPerson: + return await self._run(cpf_op(self._cfg, cpf, birth_date, options)) + + async def cep(self, cep: str, *, options: RequestOptions | None = None) -> Address: + return await self._run(cep_op(self._cfg, cep, options)) diff --git a/src/nfeio/resources/service_invoices.py b/src/nfeio/resources/service_invoices.py new file mode 100644 index 0000000..943a64b --- /dev/null +++ b/src/nfeio/resources/service_invoices.py @@ -0,0 +1,819 @@ +"""NFS-e (service invoices), API v1 on ``https://api.nfe.io``. + +Issuance and cancellation are asynchronous: the API answers ``202`` and the invoice moves +through ``flowStatus`` values until a terminal one. ``wait``/``create_and_wait``/ +``cancel_and_wait`` poll ``GET /serviceinvoices/{id}`` with backoff (there is no ``/status`` +route in GET). + +**Never retry an issuance on your own after an ambiguous failure.** The API can create the +invoice and still answer 500/504. Use ``external_id`` and, when an error has +``outcome_unknown=True`` (or is ``DuplicateExternalIdError``), reconcile with +``find_by_external_id(company_id, external_id, wait=30)``. +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping +from datetime import date +from typing import Any + +from .._config import ApiFamily, ClientConfig, RequestOptions +from .._core import paths +from .._core.ops import Now, Op, Sleep +from .._core.requestor import ( + decode_json, + expect_list, + expect_object, + follow_download, + request, + response_info, +) +from .._core.transport import HttpResponse +from ..errors import ( + InvalidParameterError, + InvoiceProcessingError, + NotFoundError, + PollingTimeoutError, + UnexpectedResponseError, +) +from ..models import ServiceInvoice +from ..pagination import AsyncOffsetPage, OffsetPage, OffsetState +from ..types import FlowStatus, ServiceInvoiceCreateParams +from ._base import AsyncService, SyncService + +FAMILY = ApiFamily.SERVICE_INVOICES +#: (min, max) of ``pageCount`` per route — the API rejects values outside (probe 2026-10-08). +PAGE_COUNT_LIMITS = {"service_invoices": (1, 50)} + +_DOWNLOADS = { + "pdf": ("pdf", "application/pdf, */*"), + "xml": ("xml", "application/xml, */*"), + "cancellation_xml": ("cancellation-xml", "application/xml, */*"), +} + +_CREATE_SUCCESS = frozenset({FlowStatus.ISSUED, FlowStatus.CANCELLED}) +_CREATE_FAILURE = frozenset({FlowStatus.ISSUE_FAILED, FlowStatus.ERROR}) +_CANCEL_SUCCESS = frozenset({FlowStatus.CANCELLED}) +_CANCEL_FAILURE = frozenset({FlowStatus.CANCEL_FAILED, FlowStatus.ERROR}) + + +def _base_path(company_id: str) -> str: + return f"/v1/companies/{paths.opaque_id(company_id, 'company_id')}/serviceinvoices" + + +def _invoice_path(company_id: str, invoice_id: str) -> str: + return f"{_base_path(company_id)}/{paths.opaque_id(invoice_id, 'invoice_id')}" + + +def _check_wait_params( + timeout: float, initial_interval: float, max_interval: float, backoff: float, grace: float +) -> None: + for name, value, allow_zero in ( + ("timeout", timeout, False), + ("initial_interval", initial_interval, False), + ("max_interval", max_interval, False), + ("not_found_grace", grace, True), + ): + valid = ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(value) + and (value >= 0 if allow_zero else value > 0) + ) + if not valid: + raise InvalidParameterError(f"{name} must be a finite positive number", param=name) + if isinstance(backoff, bool) or not isinstance(backoff, (int, float)) or not 1 <= backoff <= 10: + raise InvalidParameterError("backoff must be between 1 and 10", param="backoff") + + +def _accepted( + response: HttpResponse, company_id: str, invoice_id: str | None = None +) -> ServiceInvoice: + """Build the (possibly partial) invoice from a 200/201/202/204 answer. + + 202 bodies (``{id, environment, flowStatus}``) are preserved. When the body has no id, it + comes from the **path** of ``Location`` (whose scheme/host are never used: the API sends + ``http://``). + """ + data: dict[str, Any] = {} + parsed = decode_json(response, allow_empty=True) if response.body.strip() else None + if isinstance(parsed, dict): + data = parsed + found = data.get("id") if isinstance(data.get("id"), str) else None + if found is None: + found = _id_from_location(response, company_id) + if found is None: + found = invoice_id + if found is None: + raise UnexpectedResponseError( + "the API accepted the request but returned no invoice id (no 'id' in the body " + "and no usable Location header)", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + data.setdefault("id", found) + return ServiceInvoice._from_wire(data, response_info(response)) + + +_LOCATION_RE = re.compile( + r"/v1/companies/(?P[A-Za-z0-9_-]{1,64})/serviceinvoices/(?P[A-Za-z0-9_-]{1,64})/?" +) + + +def _id_from_location(response: HttpResponse, company_id: str) -> str | None: + from urllib.parse import urlsplit + + location = response.headers.get("location") + if not location: + return None + match = _LOCATION_RE.fullmatch(urlsplit(location.strip()).path) + if match is None or match.group("company") != company_id: + return None + return match.group("id") + + +# -- ops --------------------------------------------------------------------------------------- + + +def create_op( + cfg: ClientConfig, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + external_id: str | None, + options: RequestOptions | None, +) -> Op[ServiceInvoice]: + path = _base_path(company_id) + if not isinstance(params, Mapping): + raise InvalidParameterError("params must be a mapping (the request body)", param="params") + body = dict(params) + wire_external_id = body.get("externalId") + if external_id is not None: + paths.check_free_text(external_id, "external_id") + if wire_external_id is not None and wire_external_id != external_id: + raise InvalidParameterError( + "external_id and params['externalId'] differ; pass only one", + param="external_id", + ) + body["externalId"] = external_id + elif isinstance(wire_external_id, str): + external_id = wire_external_id + response = yield from request( + cfg, "POST", FAMILY, path, json=body, options=options, external_id=external_id + ) + if response.status_code not in (200, 201, 202): + raise UnexpectedResponseError( + f"unexpected status {response.status_code} when issuing a service invoice", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + return _accepted(response, company_id) + + +def list_op( + cfg: ClientConfig, + company_id: str, + page_index: int, + page_count: int, + filters: dict[str, str | None], + options: RequestOptions | None, +) -> Op[OffsetState[ServiceInvoice]]: + path = _base_path(company_id) + low, high = PAGE_COUNT_LIMITS["service_invoices"] + if isinstance(page_index, bool) or not isinstance(page_index, int) or page_index < 1: + raise InvalidParameterError( + "page_index starts at 1 (the API pagination is 1-based)", param="page_index" + ) + if ( + isinstance(page_count, bool) + or not isinstance(page_count, int) + or not (low <= page_count <= high) + ): + raise InvalidParameterError( + f"page_count must be between {low} and {high}", param="page_count" + ) + query: dict[str, str | int | None] = {"pageIndex": page_index, "pageCount": page_count} + query.update(filters) + response = yield from request(cfg, "GET", FAMILY, path, query=query, options=options) + _, items = expect_list(response, "serviceInvoices") + info = response_info(response) + invoices = [ServiceInvoice._from_wire(item, info) for item in items if isinstance(item, dict)] + + def fetch(next_index: int) -> Op[OffsetState[ServiceInvoice]]: + return list_op(cfg, company_id, next_index, page_count, filters, options) + + return OffsetState(invoices, info, page_index, page_count, fetch) + + +def retrieve_op( + cfg: ClientConfig, company_id: str, invoice_id: str, options: RequestOptions | None +) -> Op[ServiceInvoice]: + response = yield from request( + cfg, "GET", FAMILY, _invoice_path(company_id, invoice_id), options=options + ) + return ServiceInvoice._from_wire(expect_object(response), response_info(response)) + + +def find_by_external_id_op( + cfg: ClientConfig, + company_id: str, + external_id: str, + wait: float, + options: RequestOptions | None, +) -> Op[ServiceInvoice | None]: + path = ( + f"{_base_path(company_id)}/external/{paths.free_text_segment(external_id, 'external_id')}" + ) + if ( + isinstance(wait, bool) + or not isinstance(wait, (int, float)) + or not (math.isfinite(wait) and wait >= 0) + ): + raise InvalidParameterError("wait must be a finite number >= 0", param="wait") + start: float = yield Now() + delay = 1.0 + while True: + response = yield from request(cfg, "GET", FAMILY, path, options=options) + data = decode_json(response) + info = response_info(response) + # Only an explicit empty list means "no invoice": anything else unexpected must not be + # read as "safe to issue again" by a reconciliation flow. + items = data.get("serviceInvoices") if isinstance(data, dict) else None + if isinstance(items, list): + first = next((item for item in items if isinstance(item, dict)), None) + if first is not None: + return ServiceInvoice._from_wire(first, info) + elif isinstance(data, dict) and isinstance(data.get("id"), str): + return ServiceInvoice._from_wire(data, info) # object form, as the spec documents + else: + raise UnexpectedResponseError( + "unexpected answer to the externalId lookup (expected a serviceInvoices list)", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + now: float = yield Now() + remaining = wait - (now - start) + if remaining <= 0: + return None + yield Sleep(min(delay, remaining)) + delay = min(delay * 1.5, 5.0) + + +def cancel_op( + cfg: ClientConfig, company_id: str, invoice_id: str, options: RequestOptions | None +) -> Op[ServiceInvoice]: + path = _invoice_path(company_id, invoice_id) + response = yield from request(cfg, "DELETE", FAMILY, path, options=options) + if response.status_code not in (200, 201, 202, 204): + raise UnexpectedResponseError( + f"unexpected status {response.status_code} when cancelling a service invoice", + status_code=response.status_code, + body=response.body, + headers=response.headers, + ) + return _accepted(response, company_id, invoice_id) + + +def send_email_op( + cfg: ClientConfig, company_id: str, invoice_id: str, options: RequestOptions | None +) -> Op[None]: + path = f"{_invoice_path(company_id, invoice_id)}/sendemail" + yield from request(cfg, "PUT", FAMILY, path, options=options) + return None + + +def download_op( + cfg: ClientConfig, + company_id: str, + invoice_id: str, + kind: str, + options: RequestOptions | None, +) -> Op[bytes]: + suffix, accept = _DOWNLOADS[kind] + path = f"{_invoice_path(company_id, invoice_id)}/{suffix}" + response = yield from request( + cfg, "GET", FAMILY, path, accept=accept, options=options, allow_redirect=True + ) + response = yield from follow_download( + cfg, + response, + family=FAMILY, + origin_url=cfg.base_urls[FAMILY] + path, + accept=accept, + options=options, + ) + if response.status_code != 200: + raise UnexpectedResponseError( + f"unexpected status {response.status_code} when downloading {kind}", + status_code=response.status_code, + headers=response.headers, + ) + return response.body + + +def wait_op( + cfg: ClientConfig, + company_id: str, + invoice_id: str, + *, + timeout: float, + initial_interval: float, + max_interval: float, + backoff: float, + not_found_grace: float, + raise_on_failure: bool, + options: RequestOptions | None, + success: frozenset[str] = FlowStatus.SUCCESS, + failure: frozenset[str] = FlowStatus.FAILURE, +) -> Op[ServiceInvoice]: + _check_wait_params(timeout, initial_interval, max_interval, backoff, not_found_grace) + paths.opaque_id(invoice_id, "invoice_id") + start: float = yield Now() + interval = float(initial_interval) + last: ServiceInvoice | None = None + while True: + try: + invoice = yield from retrieve_op(cfg, company_id, invoice_id, options) + except NotFoundError: + elapsed: float = (yield Now()) - start + if elapsed >= not_found_grace: + raise + else: + last = invoice + status = invoice.flow_status + if status in success: + return invoice + if status in failure: + if not raise_on_failure: + return invoice + raise InvoiceProcessingError( + f"service invoice {invoice_id} ended in {status}: " + f"{invoice.flow_message or 'no flowMessage'}", + invoice=invoice, + flow_status=status, + flow_message=invoice.flow_message, + ) + now: float = yield Now() + remaining = timeout - (now - start) + if remaining <= 0: + observed = last.flow_status if last is not None else None + raise PollingTimeoutError( + f"service invoice {invoice_id} did not reach a terminal status in " + f"{timeout:g}s (last flowStatus: {observed})", + invoice=last, + flow_status=observed, + elapsed=now - start, + ) + yield Sleep(min(interval, remaining)) + interval = min(interval * backoff, max_interval) + + +def create_and_wait_op( + cfg: ClientConfig, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + external_id: str | None, + wait_kwargs: dict[str, Any], + options: RequestOptions | None, +) -> Op[ServiceInvoice]: + invoice = yield from create_op(cfg, company_id, params, external_id, options) + if invoice.flow_status in _CREATE_SUCCESS: + return invoice + if invoice.id is None: # pragma: no cover - _accepted always sets an id + raise UnexpectedResponseError("issued invoice has no id") + result = yield from wait_op( + cfg, + company_id, + invoice.id, + options=options, + success=_CREATE_SUCCESS, + failure=_CREATE_FAILURE, + **wait_kwargs, + ) + return result + + +def cancel_and_wait_op( + cfg: ClientConfig, + company_id: str, + invoice_id: str, + wait_kwargs: dict[str, Any], + options: RequestOptions | None, +) -> Op[ServiceInvoice]: + invoice = yield from cancel_op(cfg, company_id, invoice_id, options) + if invoice.flow_status in _CANCEL_SUCCESS: + return invoice + result = yield from wait_op( + cfg, + company_id, + invoice_id, + options=options, + success=_CANCEL_SUCCESS, + failure=_CANCEL_FAILURE, + **wait_kwargs, + ) + return result + + +def _filters( + issued_begin: date | str | None, + issued_end: date | str | None, + created_begin: date | str | None, + created_end: date | str | None, +) -> dict[str, str | None]: + out: dict[str, str | None] = {} + for wire, param, value in ( + ("issuedBegin", "issued_begin", issued_begin), + ("issuedEnd", "issued_end", issued_end), + ("createdBegin", "created_begin", created_begin), + ("createdEnd", "created_end", created_end), + ): + out[wire] = paths.iso_date(value, param) if value is not None else None + return out + + +def _wait_kwargs( + timeout: float, + initial_interval: float, + max_interval: float, + backoff: float, + not_found_grace: float, + raise_on_failure: bool, +) -> dict[str, Any]: + return { + "timeout": timeout, + "initial_interval": initial_interval, + "max_interval": max_interval, + "backoff": backoff, + "not_found_grace": not_found_grace, + "raise_on_failure": raise_on_failure, + } + + +# -- facades ----------------------------------------------------------------------------------- + + +class ServiceInvoicesService(SyncService): + """``client.service_invoices`` — NFS-e operations (synchronous).""" + + __slots__ = () + + def create( + self, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + *, + external_id: str | None = None, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + """Issue an NFS-e. Usually returns ``202`` with a partial invoice (``id``, + ``flow_status``); call :meth:`wait` or use :meth:`create_and_wait`. + + ``external_id`` (strongly recommended) is your dedupe key, sent as ``externalId``. + This method never retries after the request may have reached the API. + """ + return self._run(create_op(self._cfg, company_id, params, external_id, options)) + + def list( + self, + company_id: str, + *, + page_index: int = 1, + page_count: int = 10, + issued_begin: date | str | None = None, + issued_end: date | str | None = None, + created_begin: date | str | None = None, + created_end: date | str | None = None, + options: RequestOptions | None = None, + ) -> OffsetPage[ServiceInvoice]: + """List invoices (``page_index`` starts at 1, ``page_count`` 1-50).""" + filters = _filters(issued_begin, issued_end, created_begin, created_end) + state = self._run(list_op(self._cfg, company_id, page_index, page_count, filters, options)) + return OffsetPage(state, self._runner) + + def retrieve( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> ServiceInvoice: + return self._run(retrieve_op(self._cfg, company_id, invoice_id, options)) + + def find_by_external_id( + self, + company_id: str, + external_id: str, + *, + wait: float = 0.0, + options: RequestOptions | None = None, + ) -> ServiceInvoice | None: + """Find the invoice issued with ``external_id``; ``None`` if there is none. + + Right after a ``202`` the lookup may lag a few seconds: pass ``wait=30`` to retry + with backoff until found or until ``wait`` seconds elapse. + """ + return self._run(find_by_external_id_op(self._cfg, company_id, external_id, wait, options)) + + def cancel( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> ServiceInvoice: + """Request cancellation (asynchronous: usually ``202``).""" + return self._run(cancel_op(self._cfg, company_id, invoice_id, options)) + + def send_email( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> None: + """Re-send the invoice e-mail to the borrower.""" + self._run(send_email_op(self._cfg, company_id, invoice_id, options)) + + def download_pdf( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> bytes: + """PDF bytes. The API redirects to a pre-signed URL, followed without credentials.""" + return self._run(download_op(self._cfg, company_id, invoice_id, "pdf", options)) + + def download_xml( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> bytes: + """XML bytes, untouched (NFS-e XML starts with `` bytes: + """Cancellation XML bytes (only some providers have it; otherwise ``NotFoundError``).""" + return self._run( + download_op(self._cfg, company_id, invoice_id, "cancellation_xml", options) + ) + + def wait( + self, + company_id: str, + invoice_id: str, + *, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + """Poll until ``flowStatus`` is terminal (``Issued``/``Cancelled`` or a failure). + + Raises ``InvoiceProcessingError`` on ``IssueFailed``/``CancelFailed``/``Error`` + (unless ``raise_on_failure=False``) and ``PollingTimeoutError`` after ``timeout``. + """ + return self._run( + wait_op( + self._cfg, + company_id, + invoice_id, + options=options, + **_wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + ) + ) + + def create_and_wait( + self, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + *, + external_id: str | None = None, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + """:meth:`create` followed by :meth:`wait` until ``Issued`` (or failure).""" + return self._run( + create_and_wait_op( + self._cfg, + company_id, + params, + external_id, + _wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + options, + ) + ) + + def cancel_and_wait( + self, + company_id: str, + invoice_id: str, + *, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + """:meth:`cancel` followed by :meth:`wait` until ``Cancelled`` (or failure).""" + return self._run( + cancel_and_wait_op( + self._cfg, + company_id, + invoice_id, + _wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + options, + ) + ) + + +class AsyncServiceInvoicesService(AsyncService): + """``client.service_invoices`` — NFS-e operations (asynchronous). Same behaviour as + :class:`ServiceInvoicesService`.""" + + __slots__ = () + + async def create( + self, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + *, + external_id: str | None = None, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + return await self._run(create_op(self._cfg, company_id, params, external_id, options)) + + async def list( + self, + company_id: str, + *, + page_index: int = 1, + page_count: int = 10, + issued_begin: date | str | None = None, + issued_end: date | str | None = None, + created_begin: date | str | None = None, + created_end: date | str | None = None, + options: RequestOptions | None = None, + ) -> AsyncOffsetPage[ServiceInvoice]: + filters = _filters(issued_begin, issued_end, created_begin, created_end) + state = await self._run( + list_op(self._cfg, company_id, page_index, page_count, filters, options) + ) + return AsyncOffsetPage(state, self._runner) + + async def retrieve( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> ServiceInvoice: + return await self._run(retrieve_op(self._cfg, company_id, invoice_id, options)) + + async def find_by_external_id( + self, + company_id: str, + external_id: str, + *, + wait: float = 0.0, + options: RequestOptions | None = None, + ) -> ServiceInvoice | None: + return await self._run( + find_by_external_id_op(self._cfg, company_id, external_id, wait, options) + ) + + async def cancel( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> ServiceInvoice: + return await self._run(cancel_op(self._cfg, company_id, invoice_id, options)) + + async def send_email( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> None: + await self._run(send_email_op(self._cfg, company_id, invoice_id, options)) + + async def download_pdf( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> bytes: + return await self._run(download_op(self._cfg, company_id, invoice_id, "pdf", options)) + + async def download_xml( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> bytes: + return await self._run(download_op(self._cfg, company_id, invoice_id, "xml", options)) + + async def download_cancellation_xml( + self, company_id: str, invoice_id: str, *, options: RequestOptions | None = None + ) -> bytes: + return await self._run( + download_op(self._cfg, company_id, invoice_id, "cancellation_xml", options) + ) + + async def wait( + self, + company_id: str, + invoice_id: str, + *, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + return await self._run( + wait_op( + self._cfg, + company_id, + invoice_id, + options=options, + **_wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + ) + ) + + async def create_and_wait( + self, + company_id: str, + params: ServiceInvoiceCreateParams | Mapping[str, Any], + *, + external_id: str | None = None, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + return await self._run( + create_and_wait_op( + self._cfg, + company_id, + params, + external_id, + _wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + options, + ) + ) + + async def cancel_and_wait( + self, + company_id: str, + invoice_id: str, + *, + timeout: float = 120.0, + initial_interval: float = 1.0, + max_interval: float = 10.0, + backoff: float = 1.5, + not_found_grace: float = 10.0, + raise_on_failure: bool = True, + options: RequestOptions | None = None, + ) -> ServiceInvoice: + return await self._run( + cancel_and_wait_op( + self._cfg, + company_id, + invoice_id, + _wait_kwargs( + timeout, + initial_interval, + max_interval, + backoff, + not_found_grace, + raise_on_failure, + ), + options, + ) + ) diff --git a/src/nfeio/resources/webhooks.py b/src/nfeio/resources/webhooks.py new file mode 100644 index 0000000..e9a2255 --- /dev/null +++ b/src/nfeio/resources/webhooks.py @@ -0,0 +1,180 @@ +"""Account webhooks, ``https://api.nfse.io/v2/webhooks``. + +Signature verification of deliveries lives in :mod:`nfeio.webhooks` (no client needed). +There is deliberately no "delete all webhooks" operation. +""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from typing import Any + +from .._config import ApiFamily, ClientConfig, RequestOptions +from .._core import paths +from .._core.ops import Op +from .._core.requestor import decode_json, expect_list, expect_object, request, response_info +from ..errors import InvalidParameterError +from ..models import Webhook, WebhookEventType +from ..types import WebhookParams +from ._base import AsyncService, SyncService + +FAMILY = ApiFamily.ACCOUNT +BASE = "/v2/webhooks" + + +def _hook_path(webhook_id: str) -> str: + return f"{BASE}/{paths.opaque_id(webhook_id, 'webhook_id')}" + + +def _body(params: WebhookParams | Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(params, Mapping): + raise InvalidParameterError("params must be a mapping (the webhook)", param="params") + return {"webHook": dict(params)} + + +def list_op(cfg: ClientConfig, options: RequestOptions | None) -> Op[list[Webhook]]: + response = yield from request(cfg, "GET", FAMILY, BASE, options=options) + _, items = expect_list(response, "webHooks") + info = response_info(response) + return [Webhook._from_wire(item, info) for item in items if isinstance(item, dict)] + + +def retrieve_op(cfg: ClientConfig, webhook_id: str, options: RequestOptions | None) -> Op[Webhook]: + response = yield from request(cfg, "GET", FAMILY, _hook_path(webhook_id), options=options) + return Webhook._from_wire(expect_object(response, unwrap="webHook"), response_info(response)) + + +def create_op( + cfg: ClientConfig, params: WebhookParams | Mapping[str, Any], options: RequestOptions | None +) -> Op[Webhook]: + body = _body(params) + secret = params.get("secret") + response = yield from request( + cfg, + "POST", + FAMILY, + BASE, + json=body, + options=options, + secrets=(secret,) if isinstance(secret, str) else (), + ) + return Webhook._from_wire(expect_object(response, unwrap="webHook"), response_info(response)) + + +def update_op( + cfg: ClientConfig, + webhook_id: str, + params: WebhookParams | Mapping[str, Any], + options: RequestOptions | None, +) -> Op[Webhook]: + path = _hook_path(webhook_id) + body = _body(params) + secret = params.get("secret") + response = yield from request( + cfg, + "PUT", + FAMILY, + path, + json=body, + options=options, + secrets=(secret,) if isinstance(secret, str) else (), + ) + return Webhook._from_wire(expect_object(response, unwrap="webHook"), response_info(response)) + + +def delete_op(cfg: ClientConfig, webhook_id: str, options: RequestOptions | None) -> Op[None]: + response = yield from request(cfg, "DELETE", FAMILY, _hook_path(webhook_id), options=options) + decode_json(response, allow_empty=True) + return None + + +def ping_op(cfg: ClientConfig, webhook_id: str, options: RequestOptions | None) -> Op[None]: + path = f"{_hook_path(webhook_id)}/pings" + yield from request(cfg, "PUT", FAMILY, path, options=options) + return None + + +def event_types_op(cfg: ClientConfig, options: RequestOptions | None) -> Op[list[WebhookEventType]]: + response = yield from request(cfg, "GET", FAMILY, f"{BASE}/eventtypes", options=options) + _, items = expect_list(response, "eventTypes") + info = response_info(response) + return [WebhookEventType._from_wire(item, info) for item in items if isinstance(item, dict)] + + +class WebhooksService(SyncService): + """``client.webhooks`` — account webhooks (synchronous).""" + + __slots__ = () + + def list(self, *, options: RequestOptions | None = None) -> builtins.list[Webhook]: + return self._run(list_op(self._cfg, options)) + + def retrieve(self, webhook_id: str, *, options: RequestOptions | None = None) -> Webhook: + return self._run(retrieve_op(self._cfg, webhook_id, options)) + + def create( + self, params: WebhookParams | Mapping[str, Any], *, options: RequestOptions | None = None + ) -> Webhook: + """Create a webhook (body sent as ``{"webHook": params}``).""" + return self._run(create_op(self._cfg, params, options)) + + def update( + self, + webhook_id: str, + params: WebhookParams | Mapping[str, Any], + *, + options: RequestOptions | None = None, + ) -> Webhook: + """**Full replacement**, no merge: omitting ``status`` deactivates the webhook. + Retrieve, change and send the whole object.""" + return self._run(update_op(self._cfg, webhook_id, params, options)) + + def delete(self, webhook_id: str, *, options: RequestOptions | None = None) -> None: + self._run(delete_op(self._cfg, webhook_id, options)) + + def ping(self, webhook_id: str, *, options: RequestOptions | None = None) -> None: + """Ask the API to send a signed ``ping`` delivery to the webhook URI.""" + self._run(ping_op(self._cfg, webhook_id, options)) + + def event_types( + self, *, options: RequestOptions | None = None + ) -> builtins.list[WebhookEventType]: + return self._run(event_types_op(self._cfg, options)) + + +class AsyncWebhooksService(AsyncService): + """``client.webhooks`` — account webhooks (asynchronous).""" + + __slots__ = () + + async def list(self, *, options: RequestOptions | None = None) -> builtins.list[Webhook]: + return await self._run(list_op(self._cfg, options)) + + async def retrieve(self, webhook_id: str, *, options: RequestOptions | None = None) -> Webhook: + return await self._run(retrieve_op(self._cfg, webhook_id, options)) + + async def create( + self, params: WebhookParams | Mapping[str, Any], *, options: RequestOptions | None = None + ) -> Webhook: + return await self._run(create_op(self._cfg, params, options)) + + async def update( + self, + webhook_id: str, + params: WebhookParams | Mapping[str, Any], + *, + options: RequestOptions | None = None, + ) -> Webhook: + return await self._run(update_op(self._cfg, webhook_id, params, options)) + + async def delete(self, webhook_id: str, *, options: RequestOptions | None = None) -> None: + await self._run(delete_op(self._cfg, webhook_id, options)) + + async def ping(self, webhook_id: str, *, options: RequestOptions | None = None) -> None: + await self._run(ping_op(self._cfg, webhook_id, options)) + + async def event_types( + self, *, options: RequestOptions | None = None + ) -> builtins.list[WebhookEventType]: + return await self._run(event_types_op(self._cfg, options)) diff --git a/src/nfeio/transport.py b/src/nfeio/transport.py new file mode 100644 index 0000000..ed7b802 --- /dev/null +++ b/src/nfeio/transport.py @@ -0,0 +1,24 @@ +"""Public transport API: implement :class:`Transport`/:class:`AsyncTransport` to plug another +HTTP stack. Transports must not follow redirects and must raise +:class:`~nfeio.errors.APIConnectionError` with the right +:class:`~nfeio.errors.FailurePhase` on network failures.""" + +from ._core.headers import Headers +from ._core.transport import ( + AsyncTransport, + HttpClientTransport, + HttpRequest, + HttpResponse, + ThreadedAsyncTransport, + Transport, +) + +__all__ = [ + "AsyncTransport", + "Headers", + "HttpClientTransport", + "HttpRequest", + "HttpResponse", + "ThreadedAsyncTransport", + "Transport", +] diff --git a/src/nfeio/types.py b/src/nfeio/types.py new file mode 100644 index 0000000..bde9233 --- /dev/null +++ b/src/nfeio/types.py @@ -0,0 +1,196 @@ +"""Public constants and request-body types. + +Request bodies use the **wire keys** (camelCase), exactly as in the NFE.io API documentation +(decision D2): copy an example from the docs and it works. Every ``TypedDict`` here is +``total=False`` because required fields vary by city/regime; the API validates them. Keys not +listed are still sent as-is at runtime (only the type checker will complain). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from decimal import Decimal +from typing import Any, Final, TypedDict + +__all__ = [ + "AddressParams", + "BorrowerParams", + "CityParams", + "CompanyAddressParams", + "CompanyParams", + "FlowStatus", + "Number", + "ServiceInvoiceCreateParams", + "WebhookParams", +] + +#: Monetary values and rates accepted in request bodies (``Decimal`` is sent without loss). +Number = int | float | Decimal + + +class FlowStatus: + """Known ``flowStatus`` values of a service invoice. The set is open: compare strings. + + Unknown values returned by the API are treated as *not terminal* by ``wait``. + """ + + ISSUED: Final = "Issued" + CANCELLED: Final = "Cancelled" + ISSUE_FAILED: Final = "IssueFailed" + CANCEL_FAILED: Final = "CancelFailed" + ERROR: Final = "Error" + PULL_FROM_CITY_HALL: Final = "PullFromCityHall" + WAITING_CALCULATE_TAXES: Final = "WaitingCalculateTaxes" + WAITING_DEFINE_RPS_NUMBER: Final = "WaitingDefineRpsNumber" + WAITING_SEND: Final = "WaitingSend" + WAITING_SEND_CANCEL: Final = "WaitingSendCancel" + WAITING_RETURN: Final = "WaitingReturn" + WAITING_RETURN_CANCEL: Final = "WaitingReturnCancel" + WAITING_DOWNLOAD: Final = "WaitingDownload" + + #: Terminal statuses meaning success. + SUCCESS: Final = frozenset({ISSUED, CANCELLED}) + #: Terminal statuses meaning failure (``wait`` raises ``InvoiceProcessingError``). + FAILURE: Final = frozenset({ISSUE_FAILED, CANCEL_FAILED, ERROR}) + TERMINAL: Final = SUCCESS | FAILURE + + +class CityParams(TypedDict, total=False): + code: str + name: str + + +class AddressParams(TypedDict, total=False): + country: str + postalCode: str + street: str + number: str + additionalInformation: str + district: str + city: CityParams + state: str + + +class BorrowerParams(TypedDict, total=False): + """Tomador. ``federalTaxNumber`` accepts int or str (str keeps leading zeros).""" + + type: str + name: str + federalTaxNumber: int | str + municipalTaxNumber: str + stateTaxNumber: str + taxRegime: str + caepf: str + phoneNumber: str + email: str + noTaxIdReason: str + address: AddressParams + + +class ServiceInvoiceCreateParams(TypedDict, total=False): + """Body of ``POST /v1/companies/{company_id}/serviceinvoices`` (NFS-e v1). + + Prefer the ``external_id=`` argument of ``create`` over the ``externalId`` key. + """ + + borrower: BorrowerParams + externalId: str + cityServiceCode: str + federalServiceCode: str + cnaeCode: str + nbsCode: str + description: str + servicesAmount: Number + rpsSerialNumber: str + issuedOn: str + rpsNumber: int + taxationType: str + issRate: Number + issTaxAmount: Number + deductionsAmount: Number + discountUnconditionedAmount: Number + discountConditionedAmount: Number + irAmountWithheld: Number + pisAmountWithheld: Number + cofinsAmountWithheld: Number + csllAmountWithheld: Number + inssAmountWithheld: Number + issAmountWithheld: Number + othersAmountWithheld: Number + approximateTax: Mapping[str, Any] + additionalInformation: str + location: Mapping[str, Any] + activityEvent: Mapping[str, Any] + ncmCode: str + paidAmount: Number + accrualOn: str + cstPisCofins: str + pisCofinsBaseTax: Number + pisRate: Number + pisAmount: Number + cofinsRate: Number + cofinsAmount: Number + csllAmount: Number + csllRate: Number + inssRate: Number + ipiRate: Number + ipiAmount: Number + immunityType: str + retentionType: str + isEarlyInstallmentPayment: bool + intermediary: BorrowerParams + recipient: BorrowerParams + referenceSubstitution: Mapping[str, Any] + lease: Mapping[str, Any] + construction: Mapping[str, Any] + realEstate: Mapping[str, Any] + foreignTrade: Mapping[str, Any] + deduction: Mapping[str, Any] + benefit: Mapping[str, Any] + suspension: Mapping[str, Any] + serviceAmountDetails: Mapping[str, Any] + additionalInformationGroup: Mapping[str, Any] + approximateTotals: Mapping[str, Any] + ibsCbs: Mapping[str, Any] + + +class CompanyAddressParams(TypedDict, total=False): + state: str + city: CityParams + district: str + additionalInformation: str + street: str + number: str + postalCode: str + country: str + + +class CompanyParams(TypedDict, total=False): + """Body of ``POST``/``PUT /v2/companies`` (sent wrapped in ``{"company": ...}``). + + ``taxRegime``: ``SimplesNacional``, ``LucroPresumido``, ``LucroReal``, + ``SimplesNacionalExcessoSublimite``, ``MicroempreendedorIndividual``, ``Isento``, ``None``. + """ + + name: str + accountId: str + tradeName: str + federalTaxNumber: int | str + municipalTaxNumber: str + taxRegime: str + address: CompanyAddressParams + id: str + + +class WebhookParams(TypedDict, total=False): + """Body of ``POST``/``PUT /v2/webhooks`` (sent wrapped in ``{"webHook": ...}``). + + ``update`` is a full replacement: omitting ``status`` deactivates the webhook. + """ + + uri: str + secret: str + contentType: str + insecureSsl: bool + status: str + filters: Sequence[str] diff --git a/src/nfeio/webhooks.py b/src/nfeio/webhooks.py new file mode 100644 index 0000000..6a9728b --- /dev/null +++ b/src/nfeio/webhooks.py @@ -0,0 +1,200 @@ +"""Receive NFE.io webhook deliveries safely (no client needed). + +NFE.io signs each delivery with ``X-Hub-Signature: sha1=`` where ``HEX`` is +``HMAC-SHA1(secret, raw_body)`` in upper case. Always verify against the **raw request bytes** +(never a re-serialised ``json.dumps`` of a parsed dict). + +Deliveries are at-least-once: deduplicate by ``X-Hook-Id`` (:attr:`WebhookEvent.hook_id`). +The protocol has no signed timestamp, so there is no replay window to check. + +Example (Flask):: + + from nfeio.webhooks import construct_event + from nfeio.errors import SignatureVerificationError + + @app.post("/nfeio/webhook") + def nfeio_webhook(): + try: + event = construct_event(request.get_data(), request.headers, SECRET) + except SignatureVerificationError: + return "", 400 + ... +""" + +from __future__ import annotations + +import hashlib +import hmac +import string +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from ._core import jsonutil +from .errors import InvalidParameterError, SignatureVerificationError +from .models import NfeObject, ServiceInvoice + +__all__ = [ + "HOOK_EVENT_HEADER", + "HOOK_ID_HEADER", + "MAX_PAYLOAD_BYTES", + "SIGNATURE_HEADER", + "WebhookEvent", + "construct_event", + "verify_signature", +] + +SIGNATURE_HEADER = "X-Hub-Signature" +HOOK_ID_HEADER = "X-Hook-Id" +HOOK_EVENT_HEADER = "X-Hook-Event" +MAX_PAYLOAD_BYTES = 5 * 1024 * 1024 + +_HEX = frozenset(string.hexdigits) +_PREFIX = "sha1=" + + +def _as_bytes(value: object) -> bytes | None: + if isinstance(value, bytes): + return value + if isinstance(value, (bytearray, memoryview)): + return bytes(value) + if isinstance(value, str): + return value.encode("utf-8") + return None + + +def _first(value: object) -> object: + if isinstance(value, (list, tuple)): + return value[0] if value else None + return value + + +def verify_signature( + payload: bytes | bytearray | memoryview | str, + signature: str | Sequence[str] | None, + secret: str | bytes, +) -> bool: + """Return ``True`` only if ``signature`` is ``sha1=<40 hex>`` matching + ``HMAC-SHA1(secret, payload)`` (hex compared case-insensitively, in constant time). + + Never raises: malformed input, another algorithm, empty secret or unexpected types all + return ``False``. + + Args: + payload: the raw request body (``str`` is encoded as UTF-8). + signature: the ``X-Hub-Signature`` header value (a list uses its first item). + secret: the webhook secret configured in NFE.io. + """ + try: + body = _as_bytes(payload) + key = _as_bytes(secret) + header = _first(signature) + if body is None or not key or not isinstance(header, str): + return False + header = header.strip() + if len(header) != len(_PREFIX) + 40 or header[: len(_PREFIX)].lower() != _PREFIX: + return False + received = header[len(_PREFIX) :] + if not all(ch in _HEX for ch in received): + return False + expected = hmac.new(key, body, hashlib.sha1).hexdigest() + return hmac.compare_digest(expected, received.lower()) + except Exception: # never raise from a verification helper + return False + + +@dataclass(frozen=True, repr=False) +class WebhookEvent: + """A verified webhook delivery. + + Attributes: + action: ``body["action"]`` (e.g. ``"issued_successfully"``, ``"ping"``). + event_type: ``X-Hook-Event`` header (e.g. ``"service_invoice"``), if present. + hook_id: ``X-Hook-Id`` header — the idempotency key of the delivery. + data: the payload object (``ServiceInvoice`` for service invoice events; the + ``webHook`` object for pings). + body: the whole decoded body. + raw: the raw bytes received. + """ + + action: str | None + event_type: str | None + hook_id: str | None + data: NfeObject + body: NfeObject + raw: bytes + + def __repr__(self) -> str: + # Never show the body: ping deliveries carry the webhook secret in clear text. + return ( + f"WebhookEvent(action={self.action!r}, event_type={self.event_type!r}, " + f"hook_id={self.hook_id!r})" + ) + + +def _header(headers: Mapping[str, Any], name: str) -> str | None: + wanted = name.lower() + for key, value in headers.items(): + if isinstance(key, str) and key.lower() == wanted: + first = _first(value) + return first if isinstance(first, str) else None + return None + + +def construct_event( + payload: bytes | bytearray | memoryview | str, + headers: Mapping[str, Any] | str, + secret: str | bytes, +) -> WebhookEvent: + """Verify the signature, then parse the delivery. + + Args: + payload: the raw request body. + headers: the request headers (any mapping, case-insensitive lookup) or directly the + ``X-Hub-Signature`` value. + secret: the webhook secret. + + Raises: + SignatureVerificationError: the signature is missing or does not match (the body is + not parsed in that case). + InvalidParameterError: the body is larger than 5 MiB or is not a JSON object. + """ + body = _as_bytes(payload) + if body is None: + raise InvalidParameterError("payload must be bytes or str", param="payload") + if len(body) > MAX_PAYLOAD_BYTES: + raise InvalidParameterError("webhook payload exceeds 5 MiB", param="payload") + if isinstance(headers, str): + signature: str | None = headers + hook_id = event_type = None + else: + signature = _header(headers, SIGNATURE_HEADER) + hook_id = _header(headers, HOOK_ID_HEADER) + event_type = _header(headers, HOOK_EVENT_HEADER) + if not verify_signature(body, signature, secret): + raise SignatureVerificationError("webhook signature verification failed") + try: + decoded = jsonutil.loads(body) + except (UnicodeDecodeError, ValueError, RecursionError): + raise InvalidParameterError("webhook body is not valid JSON", param="payload") from None + if not isinstance(decoded, dict): + raise InvalidParameterError("webhook body is not a JSON object", param="payload") + action = decoded.get("action") if isinstance(decoded.get("action"), str) else None + inner: Any = decoded.get("payload") + if not isinstance(inner, dict): + inner = decoded.get("webHook") + if not isinstance(inner, dict): + inner = decoded + data: NfeObject + if (event_type or "").lower().startswith("service_invoice") and inner is not decoded: + data = ServiceInvoice._from_wire(inner) + else: + data = NfeObject._from_wire(inner) + return WebhookEvent( + action=action, + event_type=event_type, + hook_id=hook_id, + data=data, + body=NfeObject._from_wire(decoded), + raw=body, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3dc656 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import pytest + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("nfeio-live", "NFE.io integration tests (opt-in)") + group.addoption( + "--run-integration", + action="store_true", + help="run tests marked 'live' against the real API (same as NFE_RUN_INTEGRATION=1)", + ) + group.addoption( + "--live-write", + action="store_true", + help="also run 'live_write' tests that write to the API (same as NFE_LIVE_WRITE=1)", + ) diff --git a/tests/fixtures/error-envelopes.json b/tests/fixtures/error-envelopes.json new file mode 100644 index 0000000..dab7e7e --- /dev/null +++ b/tests/fixtures/error-envelopes.json @@ -0,0 +1,20 @@ +{ + "_source": "Corpos de erro reais observados na sonda de leitura de 2026-10-08 (scripts/probes/out, gitignored). Ids e traceIds substituídos por valores sintéticos; texto das mensagens mantido como veio da API.", + "cases": [ + {"label": "401 corpo vazio", "status": 401, "body": "", "expect_class": "AuthenticationError", "expect_message_contains": "api_key", "expect_code": null}, + {"label": "403 corpo vazio (chave de dados em rota fiscal)", "status": 403, "body": "", "expect_class": "PermissionDeniedError", "expect_message_contains": "api_key", "expect_code": null}, + {"label": "404 rota não servida", "status": 404, "body": "", "expect_class": "NotFoundError", "expect_message_contains": "route may not exist", "expect_code": null}, + {"label": "string JSON crua (companies v1)", "status": 400, "body": "\"pageIndex must be greater or equal to 1\"", "expect_class": "InvalidRequestError", "expect_message": "pageIndex must be greater or equal to 1", "expect_code": null}, + {"label": "string JSON crua (pageCount)", "status": 400, "body": "\"pageCount must be between 1 and 50\"", "expect_class": "InvalidRequestError", "expect_message": "pageCount must be between 1 and 50", "expect_code": null}, + {"label": "string JSON crua (cancellation-xml)", "status": 404, "body": "\"Cancellation event XML not available for this service invoice (National environment only; legacy providers have no cancellation event XML, or the invoice has not been cancelled).\"", "expect_class": "NotFoundError", "expect_message_contains": "National environment only", "expect_code": null}, + {"label": "errors com code (CPF)", "status": 404, "body": "{\"errors\":[{\"code\":40401,\"message\":\"not found\"}]}", "expect_class": "NotFoundError", "expect_message": "not found", "expect_code": 40401}, + {"label": "errors com code (companies v2)", "status": 404, "body": "{\"errors\":[{\"code\":40401,\"message\":\"company id not found\"}]}", "expect_class": "NotFoundError", "expect_message": "company id not found", "expect_code": 40401}, + {"label": "errors sem code (CNPJ)", "status": 400, "body": "{\"errors\":[{\"message\":\"cnpj is null, white space or not valid\"}]}", "expect_class": "InvalidRequestError", "expect_message": "cnpj is null, white space or not valid", "expect_code": null}, + {"label": "errors 40001 (CEP)", "status": 400, "body": "{\"errors\":[{\"code\":40001,\"message\":\"postalCode with value '00000000' is not valid\"}]}", "expect_class": "InvalidRequestError", "expect_message": "postalCode with value '00000000' is not valid", "expect_code": 40001}, + {"label": "ProblemDetails de validação", "status": 400, "body": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.1\",\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"pageIndex\":[\"The value 'abc' is not valid.\"]},\"traceId\":\"00-11111111111111111111111111111111-2222222222222222-01\"}", "expect_class": "InvalidRequestError", "expect_message_contains": "pageIndex: The value 'abc' is not valid.", "expect_code": null, "expect_trace_id": "00-11111111111111111111111111111111-2222222222222222-01"}, + {"label": "ProblemDetails 404 sem errors", "status": 404, "body": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.5.5\",\"title\":\"Not Found\",\"status\":404,\"traceId\":\"00-33333333333333333333333333333333-4444444444444444-01\"}", "expect_class": "NotFoundError", "expect_message": "Not Found", "expect_code": null, "expect_trace_id": "00-33333333333333333333333333333333-4444444444444444-01"}, + {"label": "string de nota inexistente", "status": 404, "body": "\"service invoice with id (000000000000000000000000) was not found\"", "expect_class": "NotFoundError", "expect_message": "service invoice with id (000000000000000000000000) was not found", "expect_code": null}, + {"label": "ProblemDetails 500 do upload", "status": 500, "body": "{\"title\":\"An error occurred while processing your request.\",\"status\":500}", "expect_class": "ServerError", "expect_message": "An error occurred while processing your request.", "expect_code": null}, + {"label": "code/message plano", "status": 409, "body": "{\"code\":\"conflict\",\"message\":\"already exists\"}", "expect_class": "ConflictError", "expect_message": "already exists", "expect_code": "conflict"} + ] +} diff --git a/tests/fixtures/live-contracts/README.md b/tests/fixtures/live-contracts/README.md new file mode 100644 index 0000000..54161e4 --- /dev/null +++ b/tests/fixtures/live-contracts/README.md @@ -0,0 +1,19 @@ +# Fixtures de contrato ao vivo (SDK Python) + +Capturadas em 2026-10-08 pelos testes `tests/live/` (fase 2 da change `add-sdk-foundation-v0-1`). + +**Envelope real, corpo sintético.** Status, cabeçalhos relevantes (`Content-Type`, `Location`) e a +FORMA do corpo vêm de respostas reais da API. Ids, CNPJ, CPF, `externalId`, nomes e URLs +pré-assinadas foram trocados por valores fabricados. A evidência crua redigida fica em +`tests/live/out/` (fora do git). + +Consumidas por `tests/unit/test_live_contracts.py`, que reexecuta cada troca no SDK com o +transporte falso e verifica o comportamento esperado. + +| Arquivo | O que prova | +|---|---| +| `service-invoice-lifecycle.json` | emissão 202 + `Location` http, busca por `externalId`, duplicidade 400, download 302 sem credencial, cancelamento 202 + `Location`, XML de cancelamento 404 | +| `company-v2-crud.json` | criação 200 com envelope `company`, update 200, DELETE 204 = soft delete (`Inactive`) | +| `webhook-crud.json` | criação 201 (status ignorado, segredo ecoado), verificação da URI na criação (400 `40001`) | +| `certificate-upload-v2.json` | campo `file` aceito pela v2; arquivo inválido → 500 ProblemDetails | +| `auth-cross-key.json` | chave de dados em NFS-e → 403 vazio; chave principal em CEP → 403 vazio | diff --git a/tests/fixtures/live-contracts/auth-cross-key.json b/tests/fixtures/live-contracts/auth-cross-key.json new file mode 100644 index 0000000..6e38ae5 --- /dev/null +++ b/tests/fixtures/live-contracts/auth-cross-key.json @@ -0,0 +1,9 @@ +{ + "probe": "Chave trocada entre famílias (Authorization: sem prefixo)", + "capturedOn": "2026-10-08", + "source": "tests/live/test_live_read.py", + "exchanges": { + "dataKeyOnServiceInvoices": {"request": {"method": "GET", "pathShape": "/v1/companies/{companyId}/serviceinvoices"}, "response": {"status": 403, "rawBody": ""}}, + "mainKeyOnAddress": {"request": {"method": "GET", "host": "address.api.nfe.io", "pathShape": "/v2/addresses/{cep}"}, "response": {"status": 403, "rawBody": ""}} + } +} diff --git a/tests/fixtures/live-contracts/certificate-upload-v2.json b/tests/fixtures/live-contracts/certificate-upload-v2.json new file mode 100644 index 0000000..6d13eee --- /dev/null +++ b/tests/fixtures/live-contracts/certificate-upload-v2.json @@ -0,0 +1,8 @@ +{ + "probe": "Upload de certificado na v2 com arquivo inválido (empresa descartável; nenhum certificado real enviado)", + "capturedOn": "2026-10-08", + "source": "tests/live/test_live_write.py::test_disposable_company_crud", + "note": "Campo multipart 'file' + 'password'. A v2 aceitou o binding (não houve 400 'File field is required') e falhou ao processar o arquivo falso: 500 ProblemDetails, igual à v1 em 2026-09-01. O caminho de sucesso exige PFX válido e não foi exercitado (fica como teste pulado).", + "request": {"method": "POST", "pathShape": "/v2/companies/{companyId}/certificates", "multipartFields": ["password", "file"], "fileContentType": "application/x-pkcs12"}, + "response": {"status": 500, "headers": {"content-type": "application/json; charset=utf-8"}, "rawBody": "{\"type\":\"https://tools.ietf.org/html/rfc9110#section-15.6.1\",\"title\":\"An error occurred while processing your request.\",\"status\":500,\"traceId\":\"00-55555555555555555555555555555555-6666666666666666-00\"}"} +} diff --git a/tests/fixtures/live-contracts/company-v2-crud.json b/tests/fixtures/live-contracts/company-v2-crud.json new file mode 100644 index 0000000..7e082cb --- /dev/null +++ b/tests/fixtures/live-contracts/company-v2-crud.json @@ -0,0 +1,17 @@ +{ + "probe": "CRUD de empresa na API v2 (empresa descartável SDK-PY TESTE DESCARTAVEL)", + "capturedOn": "2026-10-08", + "source": "tests/live/test_live_write.py::test_disposable_company_crud", + "exchanges": { + "create": { + "request": {"method": "POST", "path": "/v2/companies", "body": {"company": {"name": "SDK-PY TESTE DESCARTAVEL", "tradeName": "SDK-PY TESTE DESCARTAVEL", "federalTaxNumber": 99000000000191, "taxRegime": "SimplesNacional", "address": {"country": "BRA", "postalCode": "80010000", "street": "Rua Teste", "number": "1", "district": "Centro", "state": "PR", "city": {"code": "4106902", "name": "Curitiba"}}}}}, + "response": {"status": 200, "note": "200, não 201; corpo embrulhado em company; federalTaxNumber volta inteiro.", "body": {"company": {"id": "{companyId}", "accountId": "{accountId}", "name": "SDK-PY TESTE DESCARTAVEL", "tradeName": "SDK-PY TESTE DESCARTAVEL", "federalTaxNumber": 99000000000191, "taxRegime": "SimplesNacional", "status": "Active", "municipalTaxes": [], "stateTaxes": [], "address": {"state": "PR", "city": {"code": "4106902", "name": "Curitiba"}}, "createdOn": "2026-10-08T21:01:42.000+00:00", "modifiedOn": "2026-10-08T21:01:42.000+00:00"}}} + }, + "update": {"request": {"method": "PUT", "path": "/v2/companies/{companyId}"}, "response": {"status": 200, "body": {"company": {"id": "{companyId}", "tradeName": "SDK-PY TESTE DESCARTAVEL ATUALIZADA", "status": "Active"}}}}, + "delete": {"request": {"method": "DELETE", "path": "/v2/companies/{companyId}"}, "response": {"status": 204, "rawBody": ""}}, + "retrieveAfterDelete": { + "note": "Soft delete: a empresa continua listada e consultável, com status Inactive. Um segundo DELETE também devolve 204.", + "response": {"status": 200, "body": {"company": {"id": "{companyId}", "status": "Inactive"}}} + } + } +} diff --git a/tests/fixtures/live-contracts/service-invoice-lifecycle.json b/tests/fixtures/live-contracts/service-invoice-lifecycle.json new file mode 100644 index 0000000..a40e8dc --- /dev/null +++ b/tests/fixtures/live-contracts/service-invoice-lifecycle.json @@ -0,0 +1,45 @@ +{ + "probe": "Ciclo de vida de NFS-e v1 em homologação (empresa de teste)", + "capturedOn": "2026-10-08", + "source": "tests/live/test_live_write.py::test_service_invoice_lifecycle", + "exchanges": { + "create": { + "request": {"method": "POST", "pathShape": "/v1/companies/{companyId}/serviceinvoices", "bodyKeys": ["borrower", "cityServiceCode", "description", "externalId", "servicesAmount"]}, + "response": { + "status": 202, + "headers": {"content-type": "application/json; charset=utf-8", "location": "http://api.nfe.io/v1/companies/{companyId}/serviceinvoices/{invoiceId}"}, + "note": "Corpo de 202 é uma nota completa (38 chaves), não só {id, environment, flowStatus} como no RTC de 2026-09-01. Location com esquema http.", + "body": {"id": "{invoiceId}", "externalId": "{externalId}", "environment": "Development", "flowStatus": "WaitingCalculateTaxes", "status": "None", "servicesAmount": 10.0, "cityServiceCode": "10677", "description": "Servico de teste", "borrower": {"type": "NaturalPerson", "name": "Tomador", "federalTaxNumber": 52998224725}, "provider": {"type": "LegalEntity", "federalTaxNumber": 191}} + } + }, + "findByExternalIdRightAfter202": { + "note": "Encontrada na primeira chamada (0,21 s após o 202): o atraso de indexação não apareceu hoje.", + "response": {"status": 200, "body": {"serviceInvoices": [{"id": "{invoiceId}", "externalId": "{externalId}", "flowStatus": "WaitingCalculateTaxes"}], "page": 1}} + }, + "duplicateExternalId": { + "response": {"status": 400, "headers": {"content-type": "application/json; charset=utf-8"}, "rawBody": "\"service invoice with external id ({externalId}) already exists\""} + }, + "waitUntilIssued": {"flowStatuses": ["WaitingCalculateTaxes", "Issued"], "pollRoute": "GET /v1/companies/{companyId}/serviceinvoices/{invoiceId}"}, + "downloadPdf": { + "first": {"status": 302, "headers": {"location": "https://api.nfse.io/v1/blob/download?b={b}&e={e}&s={s}"}, "authorizationSent": true}, + "redirect": {"status": 200, "headers": {"content-type": "application/pdf"}, "authorizationSent": false, "bodyHead": "%PDF-1.4", "bytes": 56223} + }, + "downloadXml": { + "first": {"status": 302}, + "redirect": {"status": 200, "headers": {"content-type": "application/xml"}, "authorizationSent": false, "bodyHead": "", "bytes": 1316} + }, + "cancel": { + "request": {"method": "DELETE", "pathShape": "/v1/companies/{companyId}/serviceinvoices/{invoiceId}"}, + "response": { + "status": 202, + "headers": {"content-type": "application/json; charset=utf-8", "location": "http://api.nfe.io/v1/companies/{companyId}/serviceinvoices/{invoiceId}"}, + "note": "Confirma a inferência: cancelamento é assíncrono (a spec diz 200 string).", + "body": {"id": "{invoiceId}", "flowStatus": "WaitingSendCancel", "status": "Issued"} + }, + "finalFlowStatus": "Cancelled" + }, + "cancellationXml": { + "response": {"status": 404, "rawBody": "\"Cancellation event XML not available for this service invoice (National environment only; legacy providers have no cancellation event XML, or the invoice has not been cancelled).\""} + } + } +} diff --git a/tests/fixtures/live-contracts/webhook-crud.json b/tests/fixtures/live-contracts/webhook-crud.json new file mode 100644 index 0000000..931e639 --- /dev/null +++ b/tests/fixtures/live-contracts/webhook-crud.json @@ -0,0 +1,18 @@ +{ + "probe": "CRUD de webhook da conta em api.nfse.io/v2/webhooks", + "capturedOn": "2026-10-08", + "source": "tests/live/test_live_write.py::test_webhook_crud", + "exchanges": { + "createWithUnreachableUri": { + "note": "A API chama a URI na criação; example.com respondeu 405 e a criação falhou. Nada foi criado.", + "request": {"method": "POST", "path": "/v2/webhooks", "body": {"webHook": {"uri": "https://example.com/nfeio-sdkpy-test", "secret": "{secret}", "contentType": "json", "insecureSsl": false, "status": "Inactive", "filters": ["service_invoice.issued_successfully"]}}}, + "response": {"status": 400, "headers": {"content-type": "application/json; charset=utf-8"}, "rawBody": "{\"errors\":[{\"code\":40001,\"message\":\"WebHook verification failed. Please ensure that the WebHook URI is valid and that the endpoint is accessible. Error encountered: 405\"}]}"} + }, + "create": { + "note": "201; status Inactive enviado na criação é ignorado (volta Active); o segredo é ecoado no corpo.", + "response": {"status": 201, "body": {"webHook": {"id": "{webhookId}", "uri": "https://httpbin.org/status/200?nfeio-sdkpy-test=1", "secret": "{secret}", "contentType": "json", "insecureSsl": false, "status": "Active", "filters": ["service_invoice.issued_successfully"], "createdOn": "2026-10-08T21:00:00+00:00", "modifiedOn": "2026-10-08T21:00:00+00:00"}}} + }, + "update": {"note": "PUT é substituição total; aqui status Inactive foi aplicado.", "response": {"status": 200, "body": {"webHook": {"id": "{webhookId}", "status": "Inactive"}}}}, + "delete": {"response": {"status": 204}} + } +} diff --git a/tests/fixtures/spec-keys.json b/tests/fixtures/spec-keys.json new file mode 100644 index 0000000..103b351 --- /dev/null +++ b/tests/fixtures/spec-keys.json @@ -0,0 +1,290 @@ +{ + "_generated_by": "scripts/spec_keys.py", + "keys": { + "Address": [ + "additionalInformation", + "city", + "country", + "district", + "number", + "numberMax", + "numberMin", + "postalCode", + "state", + "street", + "streetSuffix" + ], + "AddressParams": [ + "additionalInformation", + "city", + "country", + "district", + "number", + "postalCode", + "state", + "street" + ], + "BorrowerParams": [ + "address", + "caepf", + "email", + "federalTaxNumber", + "municipalTaxNumber", + "name", + "noTaxIdReason", + "phoneNumber", + "stateTaxNumber", + "taxRegime", + "type" + ], + "Certificate": [ + "modifiedOn", + "status", + "subject", + "taxId", + "taxPayerId", + "thumbprint", + "validUntil" + ], + "CityParams": [ + "code", + "name" + ], + "Company": [ + "accountId", + "address", + "createdOn", + "federalTaxNumber", + "id", + "modifiedOn", + "municipalTaxNumber", + "municipalTaxes", + "name", + "stateTaxes", + "status", + "taxRegime", + "tradeName" + ], + "CompanyAddressParams": [ + "additionalInformation", + "city", + "country", + "district", + "number", + "postalCode", + "state", + "street" + ], + "CompanyParams": [ + "accountId", + "address", + "federalTaxNumber", + "id", + "municipalTaxNumber", + "name", + "taxRegime", + "tradeName" + ], + "LegalEntity": [ + "address", + "economicActivities", + "email", + "federalTaxNumber", + "issuedOn", + "legalNature", + "name", + "openedOn", + "partners", + "phones", + "registrationUnit", + "responsableEntity", + "shareCapital", + "size", + "specialStatus", + "specialStatusOn", + "status", + "statusOn", + "statusReason", + "taxRegime", + "taxRegimeHistory", + "tradeName", + "unit" + ], + "NaturalPerson": [ + "birthOn", + "createdOn", + "federalTaxNumber", + "name", + "status" + ], + "Party.borrower": [ + "address", + "email", + "federalTaxNumber", + "id", + "name", + "parentId", + "phoneNumber" + ], + "Party.provider": [ + "address", + "authIssueValue", + "companyRegistryNumber", + "economicActivities", + "email", + "federalTaxDetermination", + "federalTaxNumber", + "id", + "issRate", + "legalNature", + "loginName", + "loginPassword", + "municipalTaxDetermination", + "municipalTaxNumber", + "name", + "openningDate", + "parentId", + "regionalTaxNumber", + "specialTaxRegime", + "taxRegime", + "tradeName" + ], + "ServiceInvoice": [ + "activityEvent", + "additionalInformation", + "amountNet", + "amountWithheld", + "approximateTax", + "baseTaxAmount", + "batchCheckNumber", + "batchNumber", + "borrower", + "cancelledOn", + "checkCode", + "cityServiceCode", + "cofinsAmountWithheld", + "createdOn", + "csllAmountWithheld", + "deductionsAmount", + "description", + "discountConditionedAmount", + "discountUnconditionedAmount", + "environment", + "externalId", + "federalServiceCode", + "flowMessage", + "flowStatus", + "id", + "inssAmountWithheld", + "irAmountWithheld", + "issAmountWithheld", + "issRate", + "issTaxAmount", + "issuedOn", + "location", + "modifiedOn", + "number", + "othersAmountWithheld", + "pisAmountWithheld", + "provider", + "rpsNumber", + "rpsSerialNumber", + "rpsStatus", + "rpsType", + "servicesAmount", + "status", + "taxationType" + ], + "ServiceInvoiceCreateParams": [ + "accrualOn", + "activityEvent", + "additionalInformation", + "additionalInformationGroup", + "approximateTax", + "approximateTotals", + "benefit", + "borrower", + "cityServiceCode", + "cnaeCode", + "cofinsAmount", + "cofinsAmountWithheld", + "cofinsRate", + "construction", + "csllAmount", + "csllAmountWithheld", + "csllRate", + "cstPisCofins", + "deduction", + "deductionsAmount", + "description", + "discountConditionedAmount", + "discountUnconditionedAmount", + "externalId", + "federalServiceCode", + "foreignTrade", + "ibsCbs", + "immunityType", + "inssAmountWithheld", + "inssRate", + "intermediary", + "ipiAmount", + "ipiRate", + "irAmountWithheld", + "isEarlyInstallmentPayment", + "issAmountWithheld", + "issRate", + "issTaxAmount", + "issuedOn", + "lease", + "location", + "nbsCode", + "ncmCode", + "othersAmountWithheld", + "paidAmount", + "pisAmount", + "pisAmountWithheld", + "pisCofinsBaseTax", + "pisRate", + "realEstate", + "recipient", + "referenceSubstitution", + "retentionType", + "rpsNumber", + "rpsSerialNumber", + "serviceAmountDetails", + "servicesAmount", + "suspension", + "taxationType" + ], + "Webhook": [ + "contentType", + "createdOn", + "filters", + "headers", + "id", + "insecureSsl", + "modifiedOn", + "properties", + "secret", + "status", + "uri" + ], + "WebhookEventType": [ + "description", + "id", + "status" + ], + "WebhookParams": [ + "contentType", + "createdOn", + "filters", + "headers", + "insecureSsl", + "modifiedOn", + "properties", + "secret", + "status", + "uri" + ] + } +} diff --git a/tests/fixtures/webhook-signatures.json b/tests/fixtures/webhook-signatures.json new file mode 100644 index 0000000..6e700cf --- /dev/null +++ b/tests/fixtures/webhook-signatures.json @@ -0,0 +1,38 @@ +{ + "_description": "Captured from live api.nfse.io PUT /v2/webhooks/{id}/pings against the sandbox account. Each fixture is a real NFE.io webhook delivery: body is the exact UTF-8 bytes that NFE.io computed the signature over; header_value is the X-Hub-Signature header verbatim. Regenerate with /tmp/capture-fixtures.js if NFE.io changes scheme.", + "_capturedAt": "see _capturedAtNote", + "_capturedAtNote": "captured during /opsx:apply session", + "_algorithm": "HMAC-SHA1(secret, utf8_bytes_of_body) → hex (UPPERCASE) → \"sha1=\" + hex", + "fixtures": [ + { + "label": "product_invoice", + "description": "Live ping captured 2026-06-11 01:21:17 from 40.78.80.242", + "secret": "probe-secret-32chars-minimum-length-1", + "body": "{\"action\":\"ping\",\"webHook\":{\"Id\":\"9dd4c3e9df4043adb11df22dcd9d8ce1\",\"Uri\":\"https://webhook.site/002aedc9-653d-49f9-8a81-9cc88dd741cb\",\"Secret\":\"probe-secret-32chars-minimum-length-1\",\"ContentType\":\"json\",\"InsecureSsl\":false,\"Status\":\"active\",\"Filters\":[\"product_invoice.issued_successfully\"],\"CreatedOn\":\"2026-06-11T01:21:16.6890073+00:00\",\"ModifiedOn\":\"2026-06-11T01:21:16.6890199+00:00\"}}", + "header_value": "sha1=22E979F2F1281DADB4F883975D951732DE5D396A", + "content_md5": "LG+mWL29pINbll/ddF3y3Q==", + "x_hook_id": "4efa03baf6014b788e591da82efbaba8", + "user_agent": "HookShoot/2.0" + }, + { + "label": "service_invoice", + "description": "Live ping captured 2026-06-11 01:21:24 from 40.78.80.242", + "secret": "probe-secret-32chars-minimum-length-1", + "body": "{\"action\":\"ping\",\"webHook\":{\"Id\":\"ad1010b0f66441419454e5e283cce64b\",\"Uri\":\"https://webhook.site/002aedc9-653d-49f9-8a81-9cc88dd741cb\",\"Secret\":\"probe-secret-32chars-minimum-length-1\",\"ContentType\":\"json\",\"InsecureSsl\":false,\"Status\":\"active\",\"Filters\":[\"service_invoice.issued_successfully\"],\"CreatedOn\":\"2026-06-11T01:21:22.9932699+00:00\",\"ModifiedOn\":\"2026-06-11T01:21:22.9932884+00:00\"}}", + "header_value": "sha1=E7EA893E16D32963590D3BE3682D0DEAE69DBFE0", + "content_md5": "3pAHWedkmOwsA0fWm+oCmw==", + "x_hook_id": "01efa9ea29204563a9e230c47bd601ed", + "user_agent": "HookShoot/2.0" + }, + { + "label": "mixed_filters", + "description": "Live ping captured 2026-06-11 01:21:31 from 40.78.80.242", + "secret": "probe-secret-32chars-minimum-length-1", + "body": "{\"action\":\"ping\",\"webHook\":{\"Id\":\"838e219848084fbf8946d3d9053eb20b\",\"Uri\":\"https://webhook.site/002aedc9-653d-49f9-8a81-9cc88dd741cb\",\"Secret\":\"probe-secret-32chars-minimum-length-1\",\"ContentType\":\"json\",\"InsecureSsl\":false,\"Status\":\"active\",\"Filters\":[\"product_invoice.issued_successfully\",\"service_invoice.issued_successfully\",\"consumer_invoice.issued_successfully\"],\"CreatedOn\":\"2026-06-11T01:21:29.528298+00:00\",\"ModifiedOn\":\"2026-06-11T01:21:29.5283077+00:00\"}}", + "header_value": "sha1=7C0CA49581E5C2E5DF76588CF99CB38BC133E931", + "content_md5": "2VsMq8ZMIgPqi+HyyYWDFA==", + "x_hook_id": "623403cb4b3a4dd28055574ade84e9b0", + "user_agent": "HookShoot/2.0" + } + ] +} diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..a357de3 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,180 @@ +"""Test doubles: scripted transports, a fake clock and client factories (no network, no sleep).""" + +from __future__ import annotations + +import dataclasses +import json +from collections.abc import Callable, Mapping +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from nfeio import AsyncNfeClient, NfeClient +from nfeio.transport import Headers, HttpRequest, HttpResponse + +API_KEY = "test_main_key_0123456789abcdef" +DATA_KEY = "test_data_key_fedcba9876543210" + +Scripted = HttpResponse | BaseException | Callable[[HttpRequest], HttpResponse] + + +def resp( + status: int = 200, + json_body: Any = None, + *, + body: bytes | None = None, + headers: Mapping[str, str] | None = None, +) -> HttpResponse: + hdrs = {"x-request-id": "0HTEST:00000001"} + if json_body is not None: + body = json.dumps(json_body).encode() + hdrs["Content-Type"] = "application/json; charset=utf-8" + hdrs.update(headers or {}) + return HttpResponse(status_code=status, headers=Headers(hdrs), body=body or b"") + + +class FakeTransport: + """Pops scripted responses (or raises scripted exceptions) and records every request.""" + + def __init__(self, *script: Scripted) -> None: + self.script: list[Scripted] = list(script) + self.requests: list[HttpRequest] = [] + self.closed = False + + def add(self, *script: Scripted) -> None: + self.script.extend(script) + + def send(self, request: HttpRequest) -> HttpResponse: + self.requests.append(request) + if not self.script: + raise AssertionError(f"unexpected request: {request!r}") + item = self.script.pop(0) + if isinstance(item, BaseException): + raise item + if callable(item): + return item(request) + return item + + def close(self) -> None: + self.closed = True + + # -- inspection helpers -- + @property + def last(self) -> HttpRequest: + return self.requests[-1] + + def paths(self) -> list[str]: + return [urlsplit(r.url).path for r in self.requests] + + def query(self, index: int = -1) -> dict[str, list[str]]: + return parse_qs(urlsplit(self.requests[index].url).query) + + def json_body(self, index: int = -1) -> Any: + body = self.requests[index].body + assert body is not None + return json.loads(body) + + +class FakeAsyncTransport: + def __init__(self, sync: FakeTransport) -> None: + self.sync = sync + self.closed = False + + async def send(self, request: HttpRequest) -> HttpResponse: + return self.sync.send(request) + + async def aclose(self) -> None: + self.closed = True + + +class FakeClock: + """Monotonic clock advanced only by (fake) sleeps.""" + + def __init__(self) -> None: + self.now = 1000.0 + self.sleeps: list[float] = [] + + def monotonic(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + async def asleep(self, seconds: float) -> None: + self.sleep(seconds) + + +def _fixed_random() -> float: + return 0.5 # jitter factor 0.7 + 0.6 * 0.5 = 1.0 -> deterministic delays + + +def make_client( + transport: FakeTransport, clock: FakeClock | None = None, **kwargs: Any +) -> NfeClient: + kwargs.setdefault("api_key", API_KEY) + kwargs.setdefault("data_api_key", DATA_KEY) + client = NfeClient(transport=transport, **kwargs) + client._config = dataclasses.replace(client._config, random=_fixed_random) + clock = clock or FakeClock() + client._sleep = clock.sleep + client._monotonic = clock.monotonic + return client + + +def make_async_client( + transport: FakeTransport, clock: FakeClock | None = None, **kwargs: Any +) -> AsyncNfeClient: + kwargs.setdefault("api_key", API_KEY) + kwargs.setdefault("data_api_key", DATA_KEY) + client = AsyncNfeClient(transport=FakeAsyncTransport(transport), **kwargs) + client._config = dataclasses.replace(client._config, random=_fixed_random) + clock = clock or FakeClock() + client._sleep = clock.asleep + client._monotonic = clock.monotonic + return client + + +COMPANY_ID = "0123456789abcdef0123456789abcdef" +INVOICE_ID = "fedcba9876543210fedcba98" + + +def invoice_json(**overrides: Any) -> dict[str, Any]: + """A redacted service invoice with the wire shape observed on 2026-10-08.""" + data: dict[str, Any] = { + "id": INVOICE_ID, + "environment": "Development", + "flowStatus": "Issued", + "flowMessage": None, + "status": "Issued", + "number": 123, + "checkCode": "ABCD1234", + "rpsNumber": 77, + "rpsSerialNumber": "IO", + "description": "Serviço de teste", + "cityServiceCode": "2690", + "servicesAmount": 1234.56, + "baseTaxAmount": 1234.56, + "deductionsAmount": 0.0, + "issRate": 0.02, + "issTaxAmount": 24.69, + "amountNet": 1234.56, + "issuedOn": "2026-09-01T02:55:33.418+00:00", + "createdOn": "2026-09-01T02:55:30.1234567Z", + "modifiedOn": "2026-09-01T02:56:00+00:00", + "borrower": { + "id": "b0rr0w3r", + "type": "NaturalPerson", + "name": "Cliente Teste", + "federalTaxNumber": 52998224725, + "email": "cliente@example.com", + "address": {"city": {"code": "3550308", "name": "São Paulo"}, "state": "SP"}, + }, + "provider": { + "id": COMPANY_ID, + "type": "LegalEntity", + "name": "Empresa Teste", + "federalTaxNumber": 191, + }, + } + data.update(overrides) + return data diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/live/conftest.py b/tests/live/conftest.py new file mode 100644 index 0000000..380efc1 --- /dev/null +++ b/tests/live/conftest.py @@ -0,0 +1,183 @@ +"""Opt-in integration tests against the real NFE.io API. + +* Read tests run only with ``NFE_RUN_INTEGRATION=1`` (or ``pytest --run-integration``) and the + keys from the environment or the repo ``.env`` (parsed here with the stdlib, never printed). + The opt-in itself is never read from ``.env``. +* Write tests additionally need ``NFE_LIVE_WRITE=1`` (or ``--live-write``). NFS-e writes are + locked to ``NFE_COMPANY_ID``; the only other writes are one disposable company and test + webhooks, both deleted in ``finally`` blocks. +* Every exchange is recorded **redacted** to ``tests/live/out/`` (gitignored) as raw evidence; + curated, synthetic fixtures go to ``tests/fixtures/live-contracts/``. +""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import pytest + +from nfeio import NfeClient +from nfeio._core.redact import log_path +from nfeio.transport import HttpClientTransport, HttpRequest, HttpResponse + +ROOT = Path(__file__).resolve().parents[2] +OUT = Path(__file__).resolve().parent / "out" + + +def _load_dotenv() -> dict[str, str]: + path = ROOT / ".env" + values: dict[str, str] = {} + if not path.exists(): + return values + for raw in path.read_text("utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def env(name: str) -> str: + return os.environ.get(name) or _load_dotenv().get(name, "") + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + run = os.environ.get("NFE_RUN_INTEGRATION") == "1" or config.getoption("--run-integration") + write = os.environ.get("NFE_LIVE_WRITE") == "1" or config.getoption("--live-write") + for item in items: + if "live" in item.keywords: + if not run: + item.add_marker(pytest.mark.skip(reason="set NFE_RUN_INTEGRATION=1 to run")) + elif not env("NFE_API_KEY"): + item.add_marker(pytest.mark.skip(reason="NFE_API_KEY not available")) + if "live_write" in item.keywords and not write: + item.add_marker(pytest.mark.skip(reason="set NFE_LIVE_WRITE=1 to run writes")) + + +class Recorder: + """Records exchanges with ids, documents and secrets replaced by placeholders.""" + + def __init__(self, secrets: list[str]) -> None: + self.secrets = [s for s in secrets if s] + self.entries: list[dict[str, Any]] = [] + + def redact(self, text: str) -> str: + for secret in self.secrets: + text = text.replace(secret, "") + text = re.sub(r"\b[0-9a-f]{32}\b", "", text) + text = re.sub(r"\b[0-9a-f]{24}\b", "", text) + text = re.sub(r"\b\d{11,14}\b", "", text) + text = re.sub(r"([?&])(s|e|b|sig|signature|token)=[^&\s\"]+", r"\1\2=", text) + return text + + def redact_value(self, value: Any) -> Any: + if isinstance(value, dict): + return {k: self.redact_value(v) for k, v in value.items()} + if isinstance(value, list): + return [self.redact_value(v) for v in value] + if isinstance(value, str): + return self.redact(value) + if isinstance(value, int) and not isinstance(value, bool) and value >= 10**8: + return "" + return value + + def record(self, request: HttpRequest, response: HttpResponse | None, error: str = "") -> None: + parts = urlsplit(request.url) + body: Any = None + if response is not None and response.body: + ctype = response.headers.get("content-type", "") + if "json" in ctype: + try: + body = self.redact_value(json.loads(response.body.decode("utf-8-sig"))) + except ValueError: + body = {"_text": self.redact(response.body[:500].decode("utf-8", "replace"))} + else: + head = response.body[:16] + body = {"_bytes": len(response.body), "_head": head.decode("latin-1")} + self.entries.append( + { + "method": request.method, + "host": parts.hostname, + "path": log_path(parts.path), + "authorization_sent": "Authorization" in request.headers, + "status": response.status_code if response is not None else None, + "headers": { + k.lower(): self.redact(v) + for k, v in (response.headers.items() if response is not None else []) + if k.lower() in {"content-type", "location", "retry-after", "content-length"} + } + | ( + {"x-request-id": ""} + if response is not None and "x-request-id" in response.headers + else {} + ), + "body": body, + "error": error, + } + ) + + def save(self, name: str) -> Path: + OUT.mkdir(parents=True, exist_ok=True) + path = OUT / f"{name}.json" + text = json.dumps(self.entries, ensure_ascii=False, indent=2) + for secret in self.secrets: + assert secret not in text, "secret leaked into the evidence file" + path.write_text(text, "utf-8") + return path + + +class RecordingTransport(HttpClientTransport): + def __init__(self, recorder: Recorder) -> None: + super().__init__() + self.recorder = recorder + + def send(self, request: HttpRequest) -> HttpResponse: + try: + response = super().send(request) + except Exception as exc: + self.recorder.record(request, None, error=f"{type(exc).__name__}: {exc}") + raise + self.recorder.record(request, response) + return response + + +@pytest.fixture(scope="session") +def company_id() -> str: + value = env("NFE_COMPANY_ID") + if not value: + pytest.skip("NFE_COMPANY_ID not available") + return value + + +@pytest.fixture +def recorder(request: pytest.FixtureRequest) -> Iterator[Recorder]: + rec = Recorder([env("NFE_API_KEY"), env("NFE_DATA_API_KEY"), env("NFE_COMPANY_ID")]) + yield rec + rec.save(request.node.name) + + +@pytest.fixture +def client(recorder: Recorder) -> Iterator[NfeClient]: + transport = RecordingTransport(recorder) + c = NfeClient( + api_key=env("NFE_API_KEY"), + data_api_key=env("NFE_DATA_API_KEY") or None, + transport=transport, + app_info=("nfeio-sdk-python-live-tests", "0"), + ) + yield c + c.close() + + +def assert_write_company(company: str) -> None: + """Hard lock: NFS-e writes only on the test company configured in NFE_COMPANY_ID.""" + allowed = env("NFE_COMPANY_ID") + if not allowed or company != allowed: + raise RuntimeError("refusing to write NFS-e outside NFE_COMPANY_ID") diff --git a/tests/live/test_live_read.py b/tests/live/test_live_read.py new file mode 100644 index 0000000..e8f9141 --- /dev/null +++ b/tests/live/test_live_read.py @@ -0,0 +1,113 @@ +"""Read-only integration tests (NFE_RUN_INTEGRATION=1).""" + +from __future__ import annotations + +import pytest + +from nfeio import ( + AsyncNfeClient, + NfeClient, + NotFoundError, + PermissionDeniedError, + RequestOptions, +) +from tests.live.conftest import env + +pytestmark = pytest.mark.live + + +def test_auth_by_family(client: NfeClient) -> None: + assert isinstance(client.webhooks.list(), list) + if not env("NFE_DATA_API_KEY"): + pytest.skip("NFE_DATA_API_KEY not available") + address = client.lookups.cep("01310-100") + assert address.postal_code == "01310100" + assert address["city"]["code"] == "3550308" + with pytest.raises(PermissionDeniedError, match="data_api_key"): + client.lookups.cep("01310100", options=RequestOptions(api_key=env("NFE_API_KEY"))) + + +def test_service_invoices_reject_data_key(client: NfeClient, company_id: str) -> None: + if not env("NFE_DATA_API_KEY"): + pytest.skip("NFE_DATA_API_KEY not available") + with pytest.raises(PermissionDeniedError) as info: + client.service_invoices.list( + company_id, options=RequestOptions(api_key=env("NFE_DATA_API_KEY")) + ) + assert info.value.request_id + + +def test_companies_v2_full_sweep(client: NfeClient) -> None: + first = client.companies.list(limit=50) + seen: list[str] = [] + for company in first.auto_paging_iter(): + assert company.id + seen.append(company.id) + assert len(seen) == len(set(seen)), "duplicated companies while paging" + assert len(seen) > 50 + + +def test_company_retrieve(client: NfeClient, company_id: str) -> None: + company = client.companies.retrieve(company_id) + assert company.id == company_id + assert company.federal_tax_number is not None and len(company.federal_tax_number) == 14 + assert company.created_on is not None and company.created_on.tzinfo is not None + with pytest.raises(NotFoundError): + client.companies.retrieve("0" * 32) + + +def test_service_invoices_read(client: NfeClient, company_id: str) -> None: + page = client.service_invoices.list(company_id, page_count=5) + assert page.last_response.request_id + issued = next((inv for inv in page if inv.flow_status == "Issued"), None) + assert len(page) <= 5 + if issued is None: + pytest.skip("no issued invoice to read") + assert issued.id is not None + again = client.service_invoices.retrieve(company_id, issued.id) + assert again.id == issued.id + assert again.services_amount is not None + pdf = client.service_invoices.download_pdf(company_id, issued.id) + assert pdf.startswith(b"%PDF") + xml = client.service_invoices.download_xml(company_id, issued.id) + assert xml.lstrip().startswith(b"<") + assert client.service_invoices.find_by_external_id(company_id, "sdkpy-missing-0000") is None + with pytest.raises(NotFoundError): + client.service_invoices.retrieve(company_id, "0" * 24) + + +def test_certificates_read(client: NfeClient, company_id: str) -> None: + certs = client.certificates.list(company_id) + assert certs, "test company should have a certificate" + assert certs[0].expires_on is not None + + +def test_webhooks_read(client: NfeClient) -> None: + hooks = client.webhooks.list() + for hook in hooks: + assert hook.id + types = client.webhooks.event_types() + assert any(t.id == "service_invoice.issued_successfully" for t in types) + + +def test_lookups(client: NfeClient) -> None: + if not env("NFE_DATA_API_KEY"): + pytest.skip("NFE_DATA_API_KEY not available") + entity = client.lookups.cnpj("00.000.000/0001-91") + assert entity.federal_tax_number == "00000000000191" + assert entity["name"] + taxes = client.lookups.cnpj_state_taxes("00000000000191", "DF") + assert taxes.federal_tax_number == "00000000000191" + with pytest.raises(NotFoundError) as info: + client.lookups.cpf("52998224725", "1990-01-01") + assert info.value.error_code == 40401 + + +async def test_async_client_live() -> None: + async with AsyncNfeClient( + api_key=env("NFE_API_KEY"), data_api_key=env("NFE_DATA_API_KEY") or None + ) as client: + hooks = await client.webhooks.list() + assert isinstance(hooks, list) + page = await client.companies.list(limit=2) + assert len(page) == 2 diff --git a/tests/live/test_live_write.py b/tests/live/test_live_write.py new file mode 100644 index 0000000..f6208eb --- /dev/null +++ b/tests/live/test_live_write.py @@ -0,0 +1,383 @@ +"""Write integration tests (``--run-integration --live-write`` or the NFE_* env opt-ins). + +Scope authorised for phase 2, and nothing else: + a) NFS-e in homologation **only** on NFE_COMPANY_ID, unique ``externalId`` ``sdkpy-``, + at most ~5 invoices in total (this module issues 2 and cancels both); + b) create and delete ONE disposable company (``SDK-PY TESTE DESCARTAVEL ``); + c) create and delete test webhooks pointing to a harmless URL (see WEBHOOK_URI). +No real certificate is uploaded. Every write has a cleanup path that also covers ambiguous +failures (the resource is looked up and removed even if the create call raised). +""" + +from __future__ import annotations + +import json +import random +import time +import uuid +from datetime import date, datetime, timezone +from decimal import Decimal +from typing import Any + +import pytest + +from nfeio import ( + APIError, + AsyncNfeClient, + DuplicateExternalIdError, + NfeClient, + NfeError, + ServiceInvoice, +) +from tests.live.conftest import OUT, assert_write_company, env + +pytestmark = [pytest.mark.live, pytest.mark.live_write] + +EVIDENCE: dict[str, Any] = {} +# example.com fails the API verification call on create (405 -> 400 code 40001), so the test +# uses a public echo endpoint that answers 2xx and stores nothing. The query marks our hooks. +WEBHOOK_URI = "https://httpbin.org/status/200?nfeio-sdkpy-test=1" + + +def _evidence(key: str, value: Any) -> None: + EVIDENCE[key] = value + OUT.mkdir(parents=True, exist_ok=True) + (OUT / "write-summary.json").write_text( + json.dumps(EVIDENCE, indent=2, ensure_ascii=False, default=str), "utf-8" + ) + + +def _redact_id(value: str | None) -> str: + return f"{value[:4]}…{value[-4:]}" if value else "None" + + +def _invoice_params() -> dict[str, Any]: + return { + "borrower": { + "type": "NaturalPerson", + "name": "SDK-PY Teste Tomador", + "federalTaxNumber": 52998224725, + "email": "sdkpy-test@example.com", + }, + "cityServiceCode": "10677", + "description": "Servico de teste SDK Python v0.1 (homologacao)", + "servicesAmount": Decimal("10.00"), + } + + +def _skip_if_certificate_expired(client: NfeClient, company_id: str) -> None: + certs = client.certificates.list(company_id) + if not certs or all(cert.is_expired() for cert in certs): + pytest.skip("test company certificate is missing or expired") + + +def _cancel_safely(client: NfeClient, company_id: str, invoice_id: str) -> ServiceInvoice: + """Wait for a terminal issuance status, then cancel and wait for ``Cancelled``.""" + current = client.service_invoices.wait( + company_id, invoice_id, timeout=180, raise_on_failure=False + ) + if current.flow_status != "Issued": + return current + return client.service_invoices.cancel_and_wait( + company_id, invoice_id, timeout=180, raise_on_failure=False + ) + + +def test_service_invoice_lifecycle(client: NfeClient, company_id: str) -> None: + assert_write_company(company_id) + _skip_if_certificate_expired(client, company_id) + external_id = f"sdkpy-{uuid.uuid4()}" + issued_id: str | None = None + try: + created = client.service_invoices.create( + company_id, _invoice_params(), external_id=external_id + ) + issued_id = created.id + assert issued_id + assert created.last_response is not None + _evidence( + "nfse_create", + { + "status": created.last_response.status_code, + "location_present": "location" in created.last_response.headers, + "location_scheme": created.last_response.headers.get("location", "").split(":")[0], + "body_keys": sorted(created.keys()), + "flowStatus": created.flow_status, + "invoice_id": _redact_id(issued_id), + }, + ) + + started = time.monotonic() + immediate = client.service_invoices.find_by_external_id(company_id, external_id) + found = immediate or client.service_invoices.find_by_external_id( + company_id, external_id, wait=30 + ) + _evidence( + "nfse_find_by_external_id", + { + "found_immediately": immediate is not None, + "found": found is not None, + "seconds_until_found": round(time.monotonic() - started, 2), + }, + ) + assert found is not None and found.id == issued_id + + try: + duplicate = client.service_invoices.create( + company_id, _invoice_params(), external_id=external_id + ) + except DuplicateExternalIdError as dup: + _evidence( + "nfse_duplicate", + { + "status": dup.status_code, + "error_code": dup.error_code, + "message": dup.message.replace(external_id, ""), + }, + ) + else: # the API accepted a duplicate: clean it up and fail loudly + if duplicate.id: + _cancel_safely(client, company_id, duplicate.id) + pytest.fail("API accepted a duplicated externalId") + + issued = client.service_invoices.wait( + company_id, issued_id, timeout=180, raise_on_failure=False + ) + _evidence( + "nfse_wait", {"flowStatus": issued.flow_status, "flowMessage": issued.flow_message} + ) + assert issued.flow_status == "Issued" + assert issued.services_amount == Decimal("10.00") + assert issued.borrower is not None + assert issued.borrower.federal_tax_number == "52998224725" + + pdf = client.service_invoices.download_pdf(company_id, issued_id) + xml = client.service_invoices.download_xml(company_id, issued_id) + _evidence( + "nfse_downloads", + { + "pdf_head": pdf[:8].decode("latin-1"), + "pdf_bytes": len(pdf), + "xml_head": xml[:12].decode("utf-8", "replace"), + "xml_bytes": len(xml), + }, + ) + assert pdf.startswith(b"%PDF") + + cancelled = client.service_invoices.cancel(company_id, issued_id) + _evidence( + "nfse_cancel", + { + "status": cancelled.last_response.status_code if cancelled.last_response else None, + "location_present": bool( + cancelled.last_response and "location" in cancelled.last_response.headers + ), + "body_keys": sorted(cancelled.keys()), + "flowStatus": cancelled.flow_status, + }, + ) + final = client.service_invoices.wait( + company_id, issued_id, timeout=180, raise_on_failure=False + ) + deadline = time.monotonic() + 180 + while final.flow_status != "Cancelled" and time.monotonic() < deadline: + time.sleep(3) + final = client.service_invoices.retrieve(company_id, issued_id) + _evidence("nfse_cancel_final", {"flowStatus": final.flow_status}) + assert final.flow_status == "Cancelled" + try: + xml = client.service_invoices.download_cancellation_xml(company_id, issued_id) + _evidence("nfse_cancellation_xml", {"bytes": len(xml)}) + except APIError as exc: + _evidence( + "nfse_cancellation_xml", + {"error": type(exc).__name__, "status": exc.status_code, "message": exc.message}, + ) + finally: + if issued_id is None: # ambiguous create: reconcile by externalId (recipe D5) + leftover = client.service_invoices.find_by_external_id(company_id, external_id, wait=30) + issued_id = leftover.id if leftover is not None else None + if issued_id is not None: + state = client.service_invoices.retrieve(company_id, issued_id) + if state.flow_status != "Cancelled": + _cancel_safely(client, company_id, issued_id) + + +async def test_async_create_and_cancel_and_wait(company_id: str) -> None: + assert_write_company(company_id) + async with AsyncNfeClient(api_key=env("NFE_API_KEY")) as client: + certs = await client.certificates.list(company_id) + if not certs or all(cert.is_expired() for cert in certs): + pytest.skip("test company certificate is missing or expired") + external_id = f"sdkpy-{uuid.uuid4()}" + invoice_id: str | None = None + try: + created = await client.service_invoices.create( + company_id, _invoice_params(), external_id=external_id + ) + invoice_id = created.id + assert invoice_id + issued = await client.service_invoices.wait( + company_id, invoice_id, timeout=180, raise_on_failure=False + ) + assert issued.flow_status == "Issued" + cancelled = await client.service_invoices.cancel_and_wait( + company_id, invoice_id, timeout=180 + ) + _evidence( + "nfse_async", + { + "issued": issued.flow_status, + "cancelled": cancelled.flow_status, + "invoice_id": _redact_id(invoice_id), + }, + ) + assert cancelled.flow_status == "Cancelled" + finally: + if invoice_id is None: + found = await client.service_invoices.find_by_external_id( + company_id, external_id, wait=30 + ) + invoice_id = found.id if found is not None else None + if invoice_id is not None: + state = await client.service_invoices.retrieve(company_id, invoice_id) + if state.flow_status == "Issued": + await client.service_invoices.cancel_and_wait( + company_id, invoice_id, timeout=180, raise_on_failure=False + ) + + +def _disposable_cnpj() -> str: + """A random CNPJ with valid check digits, branch 0001, starting with 99 (test only).""" + from nfeio._core.paths import _cnpj_check_digit + + rng = random.SystemRandom() + body = "99" + "".join(str(rng.randrange(10)) for _ in range(6)) + "0001" + first = _cnpj_check_digit(body) + second = _cnpj_check_digit(body + str(first)) + return f"{body}{first}{second}" + + +def _find_company(client: NfeClient, name: str, cnpj: str) -> str | None: + for company in client.companies.list(limit=50).auto_paging_iter(): + if company["name"] == name and company.federal_tax_number == cnpj: + return company.id + return None + + +def test_disposable_company_crud(client: NfeClient) -> None: + today = date.today().isoformat() + name = f"SDK-PY TESTE DESCARTAVEL {today}" + cnpj = _disposable_cnpj() + params: dict[str, Any] = { + "name": name, + "tradeName": name, + "federalTaxNumber": int(cnpj), + "taxRegime": "SimplesNacional", + "address": { + "country": "BRA", + "postalCode": "80010000", + "street": "Rua Teste", + "number": "1", + "district": "Centro", + "state": "PR", + "city": {"code": "4106902", "name": "Curitiba"}, + }, + } + created_id: str | None = None + create_failed = False + try: + try: + created = client.companies.create(params) + except NfeError: + create_failed = True + raise + created_id = created.id + _evidence( + "company_create", + { + "status": created.last_response.status_code if created.last_response else None, + "company_id": _redact_id(created_id), + "body_keys": sorted(created.keys()), + "federalTaxNumber_type": type(created["federalTaxNumber"]).__name__, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + ) + assert created_id + assert created.federal_tax_number == cnpj + fetched = client.companies.retrieve(created_id) + assert fetched["name"] == name + updated = client.companies.update(created_id, {**params, "tradeName": name + " ATUALIZADA"}) + _evidence( + "company_update", + { + "status": updated.last_response.status_code if updated.last_response else None, + "tradeName_updated": updated["tradeName"] == name + " ATUALIZADA", + }, + ) + assert updated["tradeName"] == name + " ATUALIZADA" + try: + client.certificates.upload( + created_id, b"not-a-real-pfx", "not-a-real-password", filename="fake.pfx" + ) + _evidence("certificate_upload_garbage", {"result": "accepted (unexpected)"}) + except NfeError as exc: + _evidence( + "certificate_upload_garbage", + { + "error": type(exc).__name__, + "status": getattr(exc, "status_code", None), + "message": exc.message, + }, + ) + finally: + if created_id is None and create_failed: + created_id = _find_company(client, name, cnpj) + if created_id: + client.companies.delete(created_id) + _evidence("company_delete", {"company_id": _redact_id(created_id), "deleted": True}) + # DELETE /v2/companies/{id} is a soft delete (probe 2026-10-08): 204, then the company is + # still listed and retrievable with status "Inactive"; a second DELETE is also 204. + if created_id: + assert client.companies.retrieve(created_id).status == "Inactive" + + +def test_webhook_crud(client: NfeClient) -> None: + params: dict[str, Any] = { + "uri": WEBHOOK_URI, + "secret": f"sdkpy-{uuid.uuid4().hex}", + "contentType": "json", + "insecureSsl": False, + "status": "Inactive", + "filters": ["service_invoice.issued_successfully"], + } + hook_id: str | None = None + try: + created = client.webhooks.create(params) + hook_id = created.id + _evidence( + "webhook_create", + { + "status": created.last_response.status_code if created.last_response else None, + "body_keys": sorted(created.keys()), + "wire_status": created.status, + "contentType": created.content_type, + "secret_echoed": "secret" in created, + }, + ) + assert hook_id + fetched = client.webhooks.retrieve(hook_id) + assert fetched["uri"] == WEBHOOK_URI + updated = client.webhooks.update(hook_id, {**params, "uri": WEBHOOK_URI + "&v=2"}) + assert updated["uri"].endswith("&v=2") + _evidence("webhook_update", {"wire_status": updated.status}) + finally: + if hook_id is None: + hook_id = next( + (h.id for h in client.webhooks.list() if str(h["uri"]).startswith(WEBHOOK_URI)), + None, + ) + if hook_id: + client.webhooks.delete(hook_id) + _evidence("webhook_delete", {"deleted": True}) + assert all(not str(h["uri"]).startswith(WEBHOOK_URI) for h in client.webhooks.list()) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_async.py b/tests/unit/test_async.py new file mode 100644 index 0000000..dc28dea --- /dev/null +++ b/tests/unit/test_async.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import inspect +import threading +import time +from typing import Any + +import pytest + +from nfeio import ( + AsyncCursorPage, + AsyncNfeClient, + AsyncOffsetPage, + InvalidParameterError, + NfeClient, + ServerError, +) +from nfeio.transport import HttpRequest, HttpResponse, ThreadedAsyncTransport +from tests.helpers import ( + COMPANY_ID, + INVOICE_ID, + FakeClock, + FakeTransport, + invoice_json, + make_async_client, + make_client, + resp, +) + +RESOURCES = ("service_invoices", "companies", "certificates", "webhooks", "lookups") +SYNC_ONLY_RETURN = {"OffsetPage": "AsyncOffsetPage", "CursorPage": "AsyncCursorPage"} + + +def _public_methods(obj: Any) -> dict[str, Any]: + return { + name: getattr(obj, name) + for name in dir(obj) + if not name.startswith("_") and callable(getattr(obj, name)) + } + + +def test_signature_parity() -> None: + sync = NfeClient(api_key="k" * 10, transport=FakeTransport()) + asyn = AsyncNfeClient(api_key="k" * 10, transport=ThreadedAsyncTransport(FakeTransport())) + sync_init = inspect.signature(NfeClient.__init__).parameters + async_init = inspect.signature(AsyncNfeClient.__init__).parameters + assert list(sync_init) == list(async_init) + for name in sync_init: + if name != "transport": + assert sync_init[name].default == async_init[name].default, name + for resource in RESOURCES: + s_methods = _public_methods(getattr(sync, resource)) + a_methods = _public_methods(getattr(asyn, resource)) + assert set(s_methods) == set(a_methods), resource + for name, method in s_methods.items(): + a_method = a_methods[name] + assert inspect.iscoroutinefunction(a_method), f"{resource}.{name}" + s_sig, a_sig = inspect.signature(method), inspect.signature(a_method) + assert list(s_sig.parameters) == list(a_sig.parameters), f"{resource}.{name}" + for p_name, param in s_sig.parameters.items(): + a_param = a_sig.parameters[p_name] + assert param.default == a_param.default, f"{resource}.{name}.{p_name}" + assert param.kind == a_param.kind + assert param.annotation == a_param.annotation, f"{resource}.{name}.{p_name}" + s_ret, a_ret = str(s_sig.return_annotation), str(a_sig.return_annotation) + for s_name, a_name in SYNC_ONLY_RETURN.items(): + s_ret = s_ret.replace(s_name, a_name) + assert s_ret.replace("AsyncAsync", "Async") == a_ret, f"{resource}.{name}" + + +async def test_same_responses_same_behaviour() -> None: + script = [resp(503), resp(503), resp(200, {"webHooks": [{"id": "h1"}]})] + s_clock, a_clock = FakeClock(), FakeClock() + s_transport, a_transport = FakeTransport(*script), FakeTransport(*script) + sync_result = make_client(s_transport, s_clock).webhooks.list() + async_result = await make_async_client(a_transport, a_clock).webhooks.list() + assert sync_result == async_result + assert s_clock.sleeps == a_clock.sleeps == [1.0, 2.0] + assert [r.url for r in s_transport.requests] == [r.url for r in a_transport.requests] + + +async def test_async_errors_and_validation_identical() -> None: + transport = FakeTransport(resp(504)) + client = make_async_client(transport) + with pytest.raises(ServerError) as info: + await client.service_invoices.create(COMPANY_ID, {}, external_id="x") + assert info.value.outcome_unknown + with pytest.raises(InvalidParameterError): + await client.service_invoices.retrieve(COMPANY_ID, "../x") + + +async def test_async_pagination() -> None: + page1 = {"serviceInvoices": [invoice_json(id=f"i{n}") for n in range(2)], "page": 1} + page2 = {"serviceInvoices": [invoice_json(id="i2")], "page": 2} + transport = FakeTransport( + resp(200, page1), + resp(200, page2), + resp(200, {"hasMore": True, "companies": [{"id": "a"}]}), + resp(200, {"hasMore": False, "companies": [{"id": "b"}]}), + ) + client = make_async_client(transport) + page = await client.service_invoices.list(COMPANY_ID, page_count=2) + assert isinstance(page, AsyncOffsetPage) and page.page_index == 1 and page.page_count == 2 + assert [inv.id async for inv in page.auto_paging_iter()] == ["i0", "i1", "i2"] + cpage = await client.companies.list(limit=1) + assert isinstance(cpage, AsyncCursorPage) and cpage.limit == 1 + assert [c.id async for c in cpage.auto_paging_iter()] == ["a", "b"] + + +async def test_async_next_page_none() -> None: + transport = FakeTransport( + resp(200, {"serviceInvoices": [], "page": 1}), + resp(200, {"hasMore": False, "companies": []}), + ) + client = make_async_client(transport) + assert await (await client.service_invoices.list(COMPANY_ID)).next_page() is None + assert await (await client.companies.list()).next_page() is None + + +async def test_async_full_surface_smoke() -> None: + location = f"http://api.nfe.io/v1/companies/{COMPANY_ID}/serviceinvoices/{INVOICE_ID}" + issued = resp(200, invoice_json()) + cancelled = resp(200, invoice_json(flowStatus="Cancelled")) + transport = FakeTransport( + resp(202, {"id": INVOICE_ID, "flowStatus": "WaitingSend"}), + issued, # create_and_wait + resp(202, headers={"Location": location}), + cancelled, # cancel_and_wait + issued, + issued, # retrieve, wait + resp(200, {"serviceInvoices": [], "page": 1}), # find + resp(202, headers={"Location": location}), # cancel + resp(200), # send_email + resp(200, body=b"%PDF"), + resp(200, body=b""), + resp(200, body=b""), # downloads + resp(200, {"company": {"id": "c"}}), + resp(201, {"company": {"id": "c"}}), + resp(200, {"company": {"id": "c"}}), + resp(204), # companies + resp(200, {"certificate": {}}), + resp(200, {"certificates": []}), # certificates + resp(200, {"webHooks": []}), + resp(200, {"webHook": {"id": "h"}}), + resp(201, {"webHook": {"id": "h"}}), + resp(200, {"webHook": {"id": "h"}}), + resp(204), + resp(204), + resp(200, {"eventTypes": []}), # webhooks + resp(200, {"legalEntity": {}}), + resp(200, {"legalEntity": {}}), + resp(200, {"status": "Regular"}), + resp(200, {"address": {}}), # lookups + ) + c = make_async_client(transport) + si = c.service_invoices + assert (await si.create_and_wait(COMPANY_ID, {})).flow_status == "Issued" + assert (await si.cancel_and_wait(COMPANY_ID, INVOICE_ID)).flow_status == "Cancelled" + assert (await si.retrieve(COMPANY_ID, INVOICE_ID)).id == INVOICE_ID + assert (await si.wait(COMPANY_ID, INVOICE_ID)).id == INVOICE_ID + assert await si.find_by_external_id(COMPANY_ID, "e") is None + assert (await si.cancel(COMPANY_ID, INVOICE_ID)).id == INVOICE_ID + await si.send_email(COMPANY_ID, INVOICE_ID) + assert await si.download_pdf(COMPANY_ID, INVOICE_ID) == b"%PDF" + assert await si.download_xml(COMPANY_ID, INVOICE_ID) == b"" + assert await si.download_cancellation_xml(COMPANY_ID, INVOICE_ID) == b"" + assert (await c.companies.retrieve("c")).id == "c" + assert (await c.companies.create({"name": "x"})).id == "c" + assert (await c.companies.update("c", {"name": "y"})).id == "c" + await c.companies.delete("c") + await c.certificates.upload(COMPANY_ID, b"PFX", "pw") + assert await c.certificates.list(COMPANY_ID) == [] + assert await c.webhooks.list() == [] + assert (await c.webhooks.retrieve("h")).id == "h" + await c.webhooks.create({"uri": "https://x"}) + await c.webhooks.update("h", {"uri": "https://x"}) + await c.webhooks.delete("h") + await c.webhooks.ping("h") + assert await c.webhooks.event_types() == [] + await c.lookups.cnpj("00000000000191") + await c.lookups.cnpj_state_taxes("00000000000191", "SP") + assert (await c.lookups.cpf("52998224725", "1990-01-01")).status == "Regular" + await c.lookups.cep("01310100") + assert transport.script == [] + + +class _SlowTransport: + def __init__(self) -> None: + self.active = 0 + self.max_active = 0 + self.lock = threading.Lock() + + def send(self, request: HttpRequest) -> HttpResponse: + with self.lock: + self.active += 1 + self.max_active = max(self.max_active, self.active) + time.sleep(0.05) + with self.lock: + self.active -= 1 + return resp(200, {"webHooks": []}) + + def close(self) -> None: + pass + + +async def test_threaded_transport_does_not_block_loop() -> None: + slow = _SlowTransport() + client = AsyncNfeClient(api_key="k" * 10, transport=ThreadedAsyncTransport(slow)) + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + for _ in range(10): + await asyncio.sleep(0.005) + ticks += 1 + + results = await asyncio.gather(ticker(), *[client.webhooks.list() for _ in range(10)]) + assert ticks == 10 + assert results[1:] == [[]] * 10 + assert slow.max_active > 1 + assert ThreadedAsyncTransport(slow).sync_transport is slow + + +async def test_async_context_manager_closes() -> None: + transport = FakeTransport() + async with make_async_client(transport) as client: + assert "AsyncNfeClient" in repr(client) + assert client._transport.closed # type: ignore[attr-defined] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..ca87dfc --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import math +import ssl + +import pytest + +from nfeio import ApiFamily, ConfigurationError, NfeClient, RequestOptions, Timeout +from nfeio._config import ClientConfig, SecretStr, build_ssl_context, build_user_agent, mask_secret +from tests.helpers import API_KEY, DATA_KEY, FakeTransport, make_client, resp + + +def test_two_clients_keep_their_own_keys() -> None: + t1, t2 = FakeTransport(resp(200, {"webHooks": []})), FakeTransport(resp(200, {"webHooks": []})) + make_client(t1, api_key="key-AAAAAAAAAAAA").webhooks.list() + make_client(t2, api_key="key-BBBBBBBBBBBB").webhooks.list() + assert t1.last.headers["Authorization"] == "key-AAAAAAAAAAAA" + assert t2.last.headers["Authorization"] == "key-BBBBBBBBBBBB" + + +def test_resources_are_attributes() -> None: + client = make_client(FakeTransport()) + for name in ("service_invoices", "companies", "certificates", "webhooks", "lookups"): + assert hasattr(client, name) + + +def test_env_keys(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NFE_API_KEY", "env-main-key-123456") + monkeypatch.setenv("NFE_DATA_API_KEY", "env-data-key-654321") + cfg = ClientConfig.build() + assert cfg.key_for(ApiFamily.SERVICE_INVOICES, None) == "env-main-key-123456" + assert cfg.key_for(ApiFamily.ADDRESS, None) == "env-data-key-654321" + + +def test_lookup_without_data_key_fails_before_io(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NFE_DATA_API_KEY", raising=False) + transport = FakeTransport() + client = NfeClient(api_key=API_KEY, transport=transport) + with pytest.raises(ConfigurationError, match="data_api_key"): + client.lookups.cep("01310-100") + assert transport.requests == [] + + +def test_no_key_at_all(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NFE_API_KEY", raising=False) + transport = FakeTransport() + client = NfeClient(transport=transport) + with pytest.raises(ConfigurationError, match="NFE_API_KEY"): + client.service_invoices.list("abc") + assert transport.requests == [] + + +def test_main_key_used_for_fiscal_and_data_key_for_lookups() -> None: + transport = FakeTransport( + resp(200, {"serviceInvoices": [], "page": 1}), + resp(200, {"address": {"postalCode": "01310100"}}), + ) + client = make_client(transport) + client.service_invoices.list("abc") + client.lookups.cep("01310100") + assert transport.requests[0].headers["Authorization"] == API_KEY + assert transport.requests[1].headers["Authorization"] == DATA_KEY + assert API_KEY not in transport.requests[0].url + + +def test_base_url_override_and_https_only() -> None: + transport = FakeTransport( + resp(200, {"serviceInvoices": [], "page": 1}), resp(200, {"webHooks": []}) + ) + client = make_client(transport, base_urls={"service_invoices": "https://localhost:8443/"}) + client.service_invoices.list("abc") + client.webhooks.list() + assert transport.requests[0].url.startswith("https://localhost:8443/v1/companies/abc/") + assert transport.requests[1].url.startswith("https://api.nfse.io/v2/webhooks") + with pytest.raises(ConfigurationError, match="https"): + NfeClient(api_key="x" * 10, base_urls={"service_invoices": "http://api.nfe.io"}) + with pytest.raises(ConfigurationError, match="unknown API family"): + NfeClient(api_key="x" * 10, base_urls={"nope": "https://x"}) + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, base_urls={"address": "https://user:pw@host"}) + + +@pytest.mark.parametrize("bad", [None, 0, -1, math.inf, math.nan, "10", True]) +def test_timeout_must_be_finite(bad: object) -> None: + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, timeout=bad) # type: ignore[arg-type] + + +def test_timeout_coercion() -> None: + assert Timeout.coerce(90) == Timeout(connect=10.0, read=90.0, total=90.0) + assert Timeout.coerce(5) == Timeout(connect=5.0, read=5.0, total=5.0) + custom = Timeout(connect=1, read=2, total=3) + assert Timeout.coerce(custom) is custom + with pytest.raises(ConfigurationError): + Timeout(connect=0) + + +def test_per_call_timeout_and_retries() -> None: + transport = FakeTransport(resp(503), resp(200, {"webHooks": []})) + client = make_client(transport) + from nfeio import ServerError + + with pytest.raises(ServerError): + client.webhooks.list(options=RequestOptions(timeout=90, max_retries=0)) + assert len(transport.requests) == 1 + assert transport.requests[0].timeout.read == 90 + client.webhooks.list() + assert transport.requests[1].timeout == Timeout() + + +@pytest.mark.parametrize("name", ["Authorization", "user-agent", "Content-Type"]) +def test_protected_headers(name: str) -> None: + with pytest.raises(ConfigurationError): + RequestOptions(extra_headers={name: "x"}) + + +def test_extra_headers_and_idempotency_key_are_sent() -> None: + transport = FakeTransport(resp(200, {"webHooks": []})) + make_client(transport).webhooks.list( + options=RequestOptions(idempotency_key="abc-1", extra_headers={"X-Trace": "t1"}) + ) + assert transport.last.headers["Idempotency-Key"] == "abc-1" + assert transport.last.headers["X-Trace"] == "t1" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"idempotency_key": "a\r\nb"}, + {"idempotency_key": ""}, + {"extra_headers": {"X-A": "v\n"}}, + {"extra_headers": {"bad name": "v"}}, + {"max_retries": -1}, + {"max_retries": 11}, + {"api_key": ""}, + ], +) +def test_request_options_validation(kwargs: dict[str, object]) -> None: + with pytest.raises(ConfigurationError): + RequestOptions(**kwargs) # type: ignore[arg-type] + + +def test_per_call_api_key_override() -> None: + transport = FakeTransport(resp(200, {"webHooks": []})) + make_client(transport).webhooks.list(options=RequestOptions(api_key="override-key-999")) + assert transport.last.headers["Authorization"] == "override-key-999" + + +def test_secret_masking() -> None: + assert mask_secret("abcdefghijkl1234") == "****1234" + assert mask_secret("short") == "****" + secret = SecretStr("abcdefghijkl1234") + assert "abcdefghijkl" not in repr(secret) and "abcdefghijkl" not in str(secret) + assert secret.get_secret_value() == "abcdefghijkl1234" + import pickle + + with pytest.raises(TypeError): + pickle.dumps(secret) + client = NfeClient(api_key=API_KEY, data_api_key=DATA_KEY, transport=FakeTransport()) + text = repr(client) + repr(client._config) + repr(RequestOptions(api_key=API_KEY)) + assert API_KEY not in text and DATA_KEY not in text + assert API_KEY[-4:] in text + + +def test_user_agent_format(monkeypatch: pytest.MonkeyPatch) -> None: + import platform + import sys + + from nfeio import __version__ + + monkeypatch.setattr(platform, "system", lambda: "Linux") + expected = ( + f"nfe-io-python/{__version__} python/{sys.version_info[0]}.{sys.version_info[1]} linux" + ) + assert build_user_agent(None) == expected + assert build_user_agent(("odoo-l10n_br_nfse_nfeio", "18.0.1.0")).endswith( + " odoo-l10n_br_nfse_nfeio/18.0.1.0" + ) + transport = FakeTransport(resp(200, {"webHooks": []})) + make_client(transport, app_info=("erp", "1.0")).webhooks.list() + assert transport.last.headers["User-Agent"].endswith(" erp/1.0") + assert transport.last.headers["Accept-Encoding"] == "identity" + + +@pytest.mark.parametrize("app_info", [("a b", "1"), ("a", "1\r\n"), ("only",), "str"]) +def test_app_info_validation(app_info: object) -> None: + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, app_info=app_info) # type: ignore[arg-type] + + +def test_ssl_context_must_verify() -> None: + insecure = ssl.create_default_context() + insecure.check_hostname = False + insecure.verify_mode = ssl.CERT_NONE + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, ssl_context=insecure) + no_hostname = ssl.create_default_context() + no_hostname.check_hostname = False + with pytest.raises(ConfigurationError): + build_ssl_context(no_hostname) + ok = build_ssl_context(ssl.create_default_context()) + assert ok.minimum_version >= ssl.TLSVersion.TLSv1_2 + with pytest.raises(ConfigurationError): + build_ssl_context(ca_bundle="/nonexistent/ca.pem") + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, transport=FakeTransport(), ssl_context=ok) + + +def test_other_config_validation() -> None: + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, max_response_bytes=10) + with pytest.raises(ConfigurationError): + NfeClient(api_key="x" * 10, max_retries=True) + with pytest.raises(ConfigurationError): + NfeClient(api_key="") + with pytest.raises(ConfigurationError): + NfeClient(api_key="abc\ndef") + + +def test_context_manager_closes_transport() -> None: + transport = FakeTransport() + with NfeClient(api_key="x" * 10, transport=transport): + pass + assert transport.closed diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 0000000..b55518b --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +import nfeio.errors as errors_module +from nfeio import ( + APIConnectionError, + APIError, + AuthenticationError, + ConfigurationError, + ConflictError, + DuplicateExternalIdError, + FailurePhase, + InvalidParameterError, + InvalidRequestError, + NfeError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ServerError, + UnexpectedResponseError, +) +from nfeio._config import ApiFamily +from nfeio._core.error_mapping import error_from_response, extract_error, sanitize +from tests.helpers import API_KEY, COMPANY_ID, FakeTransport, make_client, resp + +FIXTURES = json.loads( + (Path(__file__).parent.parent / "fixtures" / "error-envelopes.json").read_text("utf-8") +)["cases"] + + +def _map(status: int, body: bytes, method: str = "GET", **headers: str) -> APIError: + return error_from_response( + resp(status, body=body, headers={"x-request-id": "abc:1", **headers}), + family=ApiFamily.SERVICE_INVOICES, + method=method, + ) + + +@pytest.mark.parametrize("case", FIXTURES, ids=[c["label"] for c in FIXTURES]) +def test_real_error_envelopes(case: dict[str, Any]) -> None: + err = _map(case["status"], case["body"].encode()) + assert type(err).__name__ == case["expect_class"] + assert err.status_code == case["status"] + assert err.request_id == "abc:1" + assert err.message + if "expect_message" in case: + assert err.message == case["expect_message"] + if "expect_message_contains" in case: + assert case["expect_message_contains"] in err.message + assert err.error_code == case["expect_code"] + if "expect_trace_id" in case: + assert err.trace_id == case["expect_trace_id"] + assert err.body == case["body"].encode() + + +@pytest.mark.parametrize( + ("status", "cls"), + [ + (400, InvalidRequestError), + (401, AuthenticationError), + (403, PermissionDeniedError), + (404, NotFoundError), + (405, InvalidRequestError), + (408, ServerError), + (409, ConflictError), + (410, InvalidRequestError), + (415, InvalidRequestError), + (422, InvalidRequestError), + (429, RateLimitError), + (500, ServerError), + (502, ServerError), + (503, ServerError), + (504, ServerError), + ], +) +def test_status_mapping(status: int, cls: type[APIError]) -> None: + err = _map(status, b"") + assert type(err) is cls + assert isinstance(err, NfeError) + assert err.message + + +def test_hierarchy() -> None: + assert issubclass(ConfigurationError, ValueError) + assert issubclass(InvalidParameterError, ValueError) + assert issubclass(DuplicateExternalIdError, InvalidRequestError) + for name in errors_module.__all__: + obj = getattr(errors_module, name) + if isinstance(obj, type) and issubclass(obj, Exception): + assert issubclass(obj, NfeError), name + + +def test_permission_hint_by_family() -> None: + fiscal = error_from_response(resp(403), family=ApiFamily.SERVICE_INVOICES, method="GET") + data = error_from_response(resp(403), family=ApiFamily.LEGAL_ENTITY, method="GET") + assert "api_key" in fiscal.message and "data_api_key" not in fiscal.message + assert "data_api_key" in data.message + + +def test_cnpj_lookup_403_mentions_data_key() -> None: + client = make_client(FakeTransport(resp(403))) + with pytest.raises(PermissionDeniedError, match="data_api_key"): + client.lookups.cnpj("00000000000191") + + +def test_duplicate_external_id_detection() -> None: + for text in [ + '"service invoice with external id (pedido-1) already exists"', + '"Service Invoice with External Id (pedido-1) ALREADY EXISTS"', + '{"errors":[{"message":"service invoice with externalId (pedido-1) already exists"}]}', + ]: + err = _map(400, text.encode(), method="POST") + assert isinstance(err, DuplicateExternalIdError), text + assert err.external_id == "pedido-1" + assert not err.outcome_unknown + + +def test_outcome_unknown_only_for_post_ambiguous() -> None: + assert _map(500, b"", method="POST").outcome_unknown + assert _map(504, b"", method="POST").outcome_unknown + assert _map(429, b"", method="POST").outcome_unknown + assert not _map(400, b"", method="POST").outcome_unknown + assert not _map(500, b"", method="GET").outcome_unknown + + +def test_rate_limit_retry_after() -> None: + err = _map(429, b"", **{"Retry-After": "3600"}) + assert isinstance(err, RateLimitError) + assert err.retry_after == 3600 + + +def test_message_sanitised_and_truncated() -> None: + err = _map(400, json.dumps("bad\x00\x1b[31m" + "x" * 5000).encode()) + assert "\x00" not in err.message and "\x1b" not in err.message + assert len(err.message) <= 1000 + assert len(err.body) > 5000 + assert sanitize("a\r\n b") == "a b" + + +def test_extract_edge_cases() -> None: + assert extract_error(b"", 500) == ("", None, None) + assert extract_error(b"\xff\xfe", 500) == ("", None, None) + assert extract_error(b"Bad gateway", 502)[0] == "Bad gateway" + assert extract_error(b"[1, 2]", 400) == ("", None, None) + assert extract_error(b'{"errors": ["plain"]}', 400)[0] == "plain" + assert extract_error(b'{"errors": {"": ["no field"]}}', 400)[0] == "no field" + + +def test_str_and_repr_have_no_key() -> None: + client = make_client(FakeTransport(resp(401))) + with pytest.raises(AuthenticationError) as info: + client.service_invoices.list(COMPANY_ID) + err = info.value + for text in (str(err), repr(err), repr(err.headers), str(vars(err))): + assert API_KEY not in text + assert "status=401" in str(err) + assert "request_id=" in str(err) + + +def test_echoed_key_is_scrubbed() -> None: + body = json.dumps(f"invalid key {API_KEY}").encode() + client = make_client(FakeTransport(resp(400, body=body))) + with pytest.raises(InvalidRequestError) as info: + client.webhooks.list() + assert API_KEY not in info.value.message + assert API_KEY.encode() not in info.value.body + + +def test_json_body_property() -> None: + err = _map(404, b'{"errors":[{"code":1,"message":"m"}]}') + assert err.json_body == {"errors": [{"code": 1, "message": "m"}]} + assert _map(404, b"").json_body is None + assert _map(502, b"").json_body is None + + +def test_connection_error_str() -> None: + err = APIConnectionError("boom", phase=FailurePhase.MAYBE_SENT, outcome_unknown=True) + assert "maybe_sent" in str(err) and "outcome_unknown=True" in str(err) + assert "boom" in repr(err) + unexpected = UnexpectedResponseError("x", status_code=200, headers={"x-request-id": "r"}) + assert unexpected.request_id == "r" diff --git a/tests/unit/test_hardening.py b/tests/unit/test_hardening.py new file mode 100644 index 0000000..fc76026 --- /dev/null +++ b/tests/unit/test_hardening.py @@ -0,0 +1,128 @@ +"""Regressions for the hardening found in the final security review (2026-10-08).""" + +from __future__ import annotations + +import hashlib +import hmac +import json + +import pytest + +from nfeio import InvalidParameterError, UnexpectedResponseError +from nfeio._core import jsonutil +from nfeio._core.error_mapping import extract_error +from nfeio.webhooks import construct_event +from tests.helpers import COMPANY_ID, INVOICE_ID, FakeTransport, invoice_json, make_client, resp + + +@pytest.mark.parametrize( + "answer", + [ + resp(200, body=b""), + resp(200, {"unexpected": True}), + resp(200, ["not", "an", "object"]), + resp(302, headers={"Location": "https://elsewhere.example/x"}), + ], +) +def test_external_lookup_never_reads_garbage_as_not_found(answer: object) -> None: + """A reconciliation must not conclude "safe to issue again" from an unexpected answer.""" + transport = FakeTransport(answer) # type: ignore[arg-type] + with pytest.raises(UnexpectedResponseError): + make_client(transport).service_invoices.find_by_external_id(COMPANY_ID, "p-1", wait=30) + assert len(transport.requests) == 1 + + +def test_redirect_outside_downloads_is_an_error() -> None: + transport = FakeTransport(resp(302, headers={"Location": "https://api.nfse.io/x"})) + with pytest.raises(UnexpectedResponseError, match="redirect"): + make_client(transport).service_invoices.retrieve(COMPANY_ID, INVOICE_ID) + assert len(transport.requests) == 1 + + +def test_deeply_nested_json_stays_inside_the_hierarchy() -> None: + nested = b"[" * 100_000 + b"]" * 100_000 + transport = FakeTransport(resp(200, body=nested), resp(400, body=nested)) + client = make_client(transport) + with pytest.raises(UnexpectedResponseError): + client.webhooks.list() + from nfeio import InvalidRequestError + + with pytest.raises(InvalidRequestError) as info: + client.webhooks.list() + assert info.value.json_body is None + + +def _nested(depth: int) -> str: + return "[" * depth + "]" * depth + + +def test_json_depth_limit_is_explicit() -> None: + """The limit does not depend on the interpreter: 129 levels decode fine with ``json``.""" + limit = jsonutil.MAX_JSON_DEPTH + assert jsonutil.loads(_nested(limit).encode()) == json.loads(_nested(limit)) + json.loads(_nested(limit + 1)) + with pytest.raises(ValueError, match="nested deeper than 128"): + jsonutil.loads(_nested(limit + 1).encode()) + objects = '{"a":' * limit + "1" + "}" * limit + assert jsonutil.loads(objects) is not None + with pytest.raises(ValueError, match="nested deeper"): + jsonutil.loads('{"a":' * limit + "[1]" + "}" * limit) + + +def test_json_depth_ignores_brackets_inside_strings() -> None: + noise = "[{" * 500 + assert jsonutil.loads(json.dumps({"text": noise, "list": [noise]})) == { + "text": noise, + "list": [noise], + } + # An escaped quote does not close the string, so the brackets after it still do not count. + escaped = '{"text": "say \\"' + noise + '\\" ok"}' + assert jsonutil.loads(escaped)["text"] == 'say "' + noise + '" ok' + # An escaped backslash does close it: the brackets after it are real nesting. + real = '["\\\\", ' * (jsonutil.MAX_JSON_DEPTH + 1) + "1" + "]" * (jsonutil.MAX_JSON_DEPTH + 1) + with pytest.raises(ValueError, match="nested deeper"): + jsonutil.loads(real) + + +def test_json_depth_check_with_unterminated_string_is_linear() -> None: + """An unterminated string full of escaped quotes is scanned once, not once per quote.""" + hostile = "[" * 200 + '"' + '\\"' * 500_000 + with pytest.raises(ValueError, match="nested deeper"): + jsonutil.loads(hostile) + with pytest.raises(json.JSONDecodeError, match="Unterminated string"): + jsonutil.loads("[" * 100 + '"' + '\\"' * 500_000) + + +def test_deeply_nested_error_body_and_webhook() -> None: + deep = _nested(jsonutil.MAX_JSON_DEPTH + 1).encode() + message, code, trace_id = extract_error(deep, 400) + assert message.startswith("[[[") and code is None and trace_id is None + secret = "s3cr3t" + signature = "sha1=" + hmac.new(secret.encode(), deep, hashlib.sha1).hexdigest() + with pytest.raises(InvalidParameterError, match="not valid JSON"): + construct_event(deep, signature, secret) + + +def test_offset_pagination_that_does_not_advance() -> None: + same = {"serviceInvoices": [invoice_json(id="a"), invoice_json(id="b")], "page": 1} + transport = FakeTransport(resp(200, same), resp(200, same)) + page = make_client(transport).service_invoices.list(COMPANY_ID, page_count=2) + with pytest.raises(UnexpectedResponseError, match="did not advance"): + list(page.auto_paging_iter()) + + +def test_cursor_pagination_that_does_not_advance() -> None: + transport = FakeTransport(resp(200, {"hasMore": True, "companies": [{"id": "x"}]})) + page = make_client(transport).companies.list(limit=1, starting_after="x") + with pytest.raises(UnexpectedResponseError, match="did not advance"): + list(page.auto_paging_iter()) + + +async def test_async_offset_pagination_that_does_not_advance() -> None: + from tests.helpers import make_async_client + + same = {"serviceInvoices": [invoice_json(id="a")], "page": 1} + transport = FakeTransport(resp(200, same), resp(200, same)) + page = await make_async_client(transport).service_invoices.list(COMPANY_ID, page_count=1) + with pytest.raises(UnexpectedResponseError, match="did not advance"): + [item async for item in page.auto_paging_iter()] diff --git a/tests/unit/test_headers.py b/tests/unit/test_headers.py new file mode 100644 index 0000000..64cd096 --- /dev/null +++ b/tests/unit/test_headers.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from nfeio.transport import Headers + + +def test_case_insensitive_and_repeated() -> None: + headers = Headers([("Set-Cookie", "a=1"), ("set-cookie", "b=2"), ("X-Request-Id", "r1")]) + assert headers["SET-COOKIE"] == "a=1, b=2" + assert "x-request-id" in headers + assert not headers.__contains__(5) + assert list(headers) == ["Set-Cookie", "X-Request-Id"] + assert len(headers) == 2 + assert headers.get("missing") is None + + +def test_equality_and_repr_redaction() -> None: + headers = Headers({"Authorization": "secret-key-123", "Accept": "x"}) + assert headers == {"authorization": "secret-key-123", "ACCEPT": "x"} + assert headers != {"Accept": "y"} + assert (headers == 3) is False + assert "secret-key-123" not in repr(headers) + assert "" in repr(headers) + assert Headers() == {} diff --git a/tests/unit/test_live_contracts.py b/tests/unit/test_live_contracts.py new file mode 100644 index 0000000..f7d06b7 --- /dev/null +++ b/tests/unit/test_live_contracts.py @@ -0,0 +1,148 @@ +"""Replay the redacted live-contract fixtures (2026-10-08) through the SDK, without network.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from nfeio import ( + DuplicateExternalIdError, + InvalidRequestError, + NotFoundError, + PermissionDeniedError, + ServerError, +) +from nfeio.transport import HttpResponse +from tests.helpers import COMPANY_ID, INVOICE_ID, FakeTransport, make_client, resp + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "live-contracts" + + +def _load(name: str) -> dict[str, Any]: + data = json.loads((FIXTURES / name).read_text("utf-8")) + return data # type: ignore[no-any-return] + + +def _fill(value: Any) -> Any: + text = json.dumps(value) + for placeholder, real in { + "{invoiceId}": INVOICE_ID, + "{companyId}": COMPANY_ID, + "{externalId}": "sdkpy-ext-1", + "{webhookId}": "0123456789abcdef0123456789abcdef", + "{accountId}": "acc", + "{secret}": "whsec", + }.items(): + text = text.replace(placeholder, real) + return json.loads(text) + + +def _replay(exchange: dict[str, Any]) -> HttpResponse: + response = _fill(exchange["response"]) + headers = response.get("headers", {}) + if "rawBody" in response: + return resp(response["status"], body=response["rawBody"].encode(), headers=headers) + return resp(response["status"], response.get("body"), headers=headers) + + +def test_service_invoice_lifecycle_contract() -> None: + ex = _load("service-invoice-lifecycle.json")["exchanges"] + issued = resp(200, _fill({**ex["create"]["response"]["body"], "flowStatus": "Issued"})) + cancelled = resp(200, _fill({**ex["create"]["response"]["body"], "flowStatus": "Cancelled"})) + transport = FakeTransport( + _replay(ex["create"]), + _replay(ex["findByExternalIdRightAfter202"]), + _replay(ex["duplicateExternalId"]), + issued, + resp(302, headers=_fill(ex["downloadPdf"]["first"]["headers"])), + resp(200, body=b"%PDF-1.4 synthetic", headers={"content-type": "application/pdf"}), + _replay(ex["cancel"]), + cancelled, + _replay(ex["cancellationXml"]), + ) + client = make_client(transport) + si = client.service_invoices + created = si.create(COMPANY_ID, {"servicesAmount": 10}, external_id="sdkpy-ext-1") + assert created.id == INVOICE_ID and created.flow_status == "WaitingCalculateTaxes" + assert created.borrower is not None + assert created.borrower.federal_tax_number == "52998224725" + assert created.provider is not None + assert created.provider.federal_tax_number == "00000000000191" + found = si.find_by_external_id(COMPANY_ID, "sdkpy-ext-1") + assert found is not None and found.id == INVOICE_ID + with pytest.raises(DuplicateExternalIdError) as dup: + si.create(COMPANY_ID, {}, external_id="sdkpy-ext-1") + assert dup.value.external_id == "sdkpy-ext-1" + assert si.wait(COMPANY_ID, INVOICE_ID).flow_status == "Issued" + assert si.download_pdf(COMPANY_ID, INVOICE_ID).startswith(b"%PDF") + assert "Authorization" not in transport.requests[5].headers + assert si.cancel_and_wait(COMPANY_ID, INVOICE_ID).flow_status == "Cancelled" + assert transport.requests[6].method == "DELETE" + with pytest.raises(NotFoundError, match="National environment only"): + si.download_cancellation_xml(COMPANY_ID, INVOICE_ID) + + +def test_company_v2_contract() -> None: + ex = _load("company-v2-crud.json")["exchanges"] + transport = FakeTransport( + _replay(ex["create"]), + _replay(ex["update"]), + _replay(ex["delete"]), + _replay(ex["retrieveAfterDelete"]), + ) + client = make_client(transport) + created = client.companies.create(_fill(ex["create"]["request"]["body"]["company"])) + assert created.id == COMPANY_ID + assert created.federal_tax_number == "99000000000191" + assert created.status == "Active" + assert transport.json_body(0) == _fill(ex["create"]["request"]["body"]) + assert client.companies.update(COMPANY_ID, {"name": "x"})["tradeName"].endswith("ATUALIZADA") + client.companies.delete(COMPANY_ID) + assert client.companies.retrieve(COMPANY_ID).status == "Inactive" + + +def test_webhook_contract() -> None: + ex = _load("webhook-crud.json")["exchanges"] + transport = FakeTransport( + _replay(ex["createWithUnreachableUri"]), + _replay(ex["create"]), + _replay(ex["update"]), + _replay(ex["delete"]), + ) + client = make_client(transport) + with pytest.raises(InvalidRequestError) as info: + client.webhooks.create({"uri": "https://example.com/x"}) + assert info.value.error_code == 40001 + assert "verification failed" in info.value.message + hook = client.webhooks.create({"uri": "https://httpbin.org/status/200"}) + assert hook.status == "Active" and hook.content_type == "json" + assert "whsec" not in repr(hook) + assert client.webhooks.update(hook.id or "", {"status": "Inactive"}).status == "Inactive" + client.webhooks.delete(hook.id or "") + + +def test_certificate_upload_contract() -> None: + fixture = _load("certificate-upload-v2.json") + transport = FakeTransport(_replay(fixture)) + with pytest.raises(ServerError) as info: + make_client(transport).certificates.upload(COMPANY_ID, b"not-a-pfx", "pw-123") + assert info.value.outcome_unknown + assert len(transport.requests) == 1 + body = transport.last.body or b"" + for field in fixture["request"]["multipartFields"]: + assert f'name="{field}"'.encode() in body + + +def test_cross_key_contract() -> None: + ex = _load("auth-cross-key.json")["exchanges"] + transport = FakeTransport( + _replay(ex["dataKeyOnServiceInvoices"]), _replay(ex["mainKeyOnAddress"]) + ) + client = make_client(transport) + with pytest.raises(PermissionDeniedError, match="uses api_key"): + client.service_invoices.list(COMPANY_ID) + with pytest.raises(PermissionDeniedError, match="data_api_key"): + client.lookups.cep("01310100") diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..0f337da --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import copy +import json +import pickle +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from nfeio import Certificate, Company, NfeObject, ServiceInvoice +from nfeio.models import Address, LegalEntity, Party +from nfeio.models._base import normalize_document, parse_datetime, to_decimal +from tests.helpers import invoice_json + + +def test_unknown_field_preserved() -> None: + inv = ServiceInvoice(invoice_json(novoCampo=1)) + assert inv["novoCampo"] == 1 + assert inv.to_dict()["novoCampo"] == 1 + + +def test_typed_properties() -> None: + inv = ServiceInvoice(invoice_json()) + assert inv.id == "fedcba9876543210fedcba98" + assert inv.flow_status == "Issued" + assert inv.flow_message is None + assert inv.services_amount == Decimal("1234.56") + assert inv.iss_rate == Decimal("0.02") + assert inv.iss_tax_amount == Decimal("24.69") + assert inv.issued_on == datetime(2026, 9, 1, 2, 55, 33, 418000, tzinfo=timezone.utc) + assert inv.created_on == datetime(2026, 9, 1, 2, 55, 30, 123456, tzinfo=timezone.utc) + assert inv.cancelled_on is None + assert inv.borrower is not None and inv.borrower.federal_tax_number == "52998224725" + assert inv.provider is not None and inv.provider.federal_tax_number == "00000000000191" + assert isinstance(inv.borrower, Party) + + +def test_nested_by_key_is_nfeobject_and_to_dict_is_plain() -> None: + inv = ServiceInvoice(invoice_json()) + borrower = inv["borrower"] + assert isinstance(borrower, NfeObject) and not isinstance(borrower, Party) + assert inv["borrower"]["address"]["city"]["code"] == "3550308" + plain = inv.to_dict() + assert type(plain["borrower"]) is dict + json.dumps(plain) + + +def test_mapping_method_names_are_not_shadowed() -> None: + obj = NfeObject({"items": [{"code": "1"}], "keys": "k", "get": "g"}) + assert obj["items"][0]["code"] == "1" + assert callable(obj.items) and ("items", obj["items"]) in list(obj.items()) + assert obj.get("keys") == "k" + assert set(obj.keys()) == {"items", "keys", "get"} + with pytest.raises(AttributeError): + obj.flowStatus # type: ignore[attr-defined] # noqa: B018 - no __getattr__ magic + + +def test_immutability() -> None: + obj = NfeObject({"filters": ["a"], "nested": {"x": 1}}) + filters = obj["filters"] + filters.append("b") + assert obj["filters"] == ["a"] + assert obj.to_dict()["filters"] == ["a"] + with pytest.raises(TypeError): + obj["x"] = 1 # type: ignore[index] + with pytest.raises(AttributeError): + obj.foo = 1 + with pytest.raises(AttributeError): + ServiceInvoice({}).id = "x" + source = {"a": {"b": 1}} + built = NfeObject(source) + source["a"]["b"] = 2 + assert built["a"]["b"] == 1 + out = built.to_dict() + out["a"]["b"] = 3 + assert built["a"]["b"] == 1 + + +def test_equality_and_copy() -> None: + a = NfeObject({"x": {"y": [1, 2]}}) + assert a == {"x": {"y": [1, 2]}} + assert a == NfeObject({"x": {"y": [1, 2]}}) + assert a != {"x": 1} + assert a != 5 + assert a == {"x": NfeObject({"y": [1, 2]})} + assert copy.copy(a) is a + assert copy.deepcopy(a) == a + # Round-trip of an object created right here (trusted data); the SDK never unpickles. + restored = pickle.loads(pickle.dumps(ServiceInvoice(invoice_json()))) # noqa: S301 + assert isinstance(restored, ServiceInvoice) and restored.flow_status == "Issued" + with pytest.raises(TypeError): + hash(a) + assert len(a) == 1 and "x" in a and list(a) == ["x"] + + +def test_repr_masks_secrets() -> None: + obj = NfeObject({"webHook": {"Secret": "super-secret-value", "uri": "https://x"}}) + company = Company({"loginPassword": "p@ss", "name": "ACME"}) + assert "super-secret-value" not in repr(obj) + assert "p@ss" not in repr(company) + assert "ACME" in repr(company) + assert obj.to_dict()["webHook"]["Secret"] == "super-secret-value" + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("2026-09-01T02:55:33.418+00:00", datetime(2026, 9, 1, 2, 55, 33, 418000, timezone.utc)), + ("2026-09-01T02:55:33Z", datetime(2026, 9, 1, 2, 55, 33, tzinfo=timezone.utc)), + ( + "2026-06-11T01:21:16.6890073+00:00", + datetime(2026, 6, 11, 1, 21, 16, 689007, timezone.utc), + ), + ("2026-09-01", datetime(2026, 9, 1, tzinfo=timezone.utc)), + ("2026-09-01T10:00:00", datetime(2026, 9, 1, 10, tzinfo=timezone.utc)), + ("2026-09-01T10:00:00-03:00", datetime(2026, 9, 1, 13, tzinfo=timezone.utc)), + ("2026-09-01T10:00:00-0300", datetime(2026, 9, 1, 13, tzinfo=timezone.utc)), + ("2026-09-01 10:00", datetime(2026, 9, 1, 10, tzinfo=timezone.utc)), + ], +) +def test_parse_datetime(text: str, expected: datetime) -> None: + parsed = parse_datetime(text) + assert parsed == expected + assert parsed is not None and parsed.tzinfo is not None + + +@pytest.mark.parametrize( + "text", ["", "ontem", "2026-13-01", "2026-09-01T25:00:00", "2026-09-01T10:00+25:00"] +) +def test_parse_datetime_invalid(text: str) -> None: + assert parse_datetime(text) is None + inv = ServiceInvoice({"createdOn": text}) + assert inv.created_on is None + assert inv["createdOn"] == text + + +def test_decimal_conversion() -> None: + assert to_decimal(1234.56) == Decimal("1234.56") + assert to_decimal(0.1) == Decimal("0.1") + assert to_decimal(100) == Decimal(100) + assert to_decimal("10.50") == Decimal("10.50") + for bad in [True, None, "abc", float("inf"), "NaN", [1]]: + assert to_decimal(bad) is None + + +def test_document_normalisation() -> None: + assert normalize_document(191, "cnpj") == "00000000000191" + assert normalize_document("00.000.000/0001-91", "cnpj") == "00000000000191" + assert normalize_document(52998224725, "cpf") == "52998224725" + assert normalize_document(1234, "auto", "LegalEntity") == "00000000001234" + assert normalize_document(1234, "auto", "NaturalPerson") == "00000001234" + assert normalize_document(12345678000195, "auto") == "12345678000195" + assert normalize_document("12ABC34501DE35", "auto") == "12ABC34501DE35" + assert normalize_document("01310-100", "cep") == "01310100" + for bad in [True, -1, "", "#$", 1.5, None]: + assert normalize_document(bad, "auto") is None + assert Company({"federalTaxNumber": 191}).federal_tax_number == "00000000000191" + assert ( + LegalEntity({"federalTaxNumber": "00000000000191"}).federal_tax_number == "00000000000191" + ) + assert Address({"postalCode": "01310-100"}).postal_code == "01310100" + + +def test_company_does_not_expose_municipal_tax_number() -> None: + company = Company( + { + "id": "c1", + "municipalTaxNumber": "0a1b2c3d4e5f60718293a4b5c6d7e8f9", + "municipalTaxes": ["2693cd58b26f404aa3b434fc4035d1af"], + "stateTaxes": [{"id": "st1"}, 3], + } + ) + assert not hasattr(company, "municipal_tax_number") + assert company["municipalTaxNumber"] == "0a1b2c3d4e5f60718293a4b5c6d7e8f9" + assert company.municipal_tax_ids == ["2693cd58b26f404aa3b434fc4035d1af"] + assert company.state_tax_ids == ["st1"] + assert Company({"municipalTaxes": "x"}).municipal_tax_ids is None + + +def test_certificate_expiration() -> None: + cert = Certificate({"validUntil": "2026-11-03T16:18:00+00:00", "taxId": "00000000000191"}) + assert cert.expires_on == datetime(2026, 11, 3, 16, 18, tzinfo=timezone.utc) + assert cert.is_expired(at=datetime(2026, 12, 1, tzinfo=timezone.utc)) + assert not cert.is_expired(at=datetime(2026, 10, 8, tzinfo=timezone.utc)) + assert not cert.is_expired(at=datetime(2026, 10, 8)) # naive = UTC + assert Certificate({"expiresOn": "2030-01-01T00:00:00Z"}).expires_on is not None + assert Certificate({}).is_expired() + future = datetime.now(timezone.utc) + timedelta(days=30) + assert not Certificate({"validUntil": future.isoformat()}).is_expired() + assert cert.tax_id == "00000000000191" + + +def test_field_descriptor_on_class() -> None: + from nfeio.models._base import Field, iter_fields + + assert isinstance(ServiceInvoice.flow_status, Field) + names = dict(iter_fields(ServiceInvoice)) + assert names["flow_status"].wire_keys == ("flowStatus",) + assert dict(iter_fields(Certificate))["expires_on"].wire_keys == ("validUntil", "expiresOn") diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py new file mode 100644 index 0000000..788e0dc --- /dev/null +++ b/tests/unit/test_pagination.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +import pytest + +from nfeio import InvalidParameterError, OffsetPage +from tests.helpers import COMPANY_ID, FakeTransport, invoice_json, make_client, resp + + +def _invoices(start: int, count: int) -> dict[str, Any]: + return { + "serviceInvoices": [invoice_json(id=f"inv{start + i:05d}") for i in range(count)], + "page": 1, + } + + +def _companies(ids: list[str], has_more: bool) -> dict[str, Any]: + return {"hasMore": has_more, "companies": [{"id": i} for i in ids]} + + +def test_first_page_query_and_has_more() -> None: + transport = FakeTransport(resp(200, _invoices(0, 2))) + page = make_client(transport).service_invoices.list(COMPANY_ID, page_index=1, page_count=2) + assert isinstance(page, OffsetPage) + assert transport.query() == {"pageIndex": ["1"], "pageCount": ["2"]} + assert page.has_more and len(page) == 2 and page.page_index == 1 and page.page_count == 2 + assert page.last_response.request_id == "0HTEST:00000001" + assert [i.id for i in page] == ["inv00000", "inv00001"] + assert "OffsetPage" in repr(page) + + +def test_last_page() -> None: + page = make_client(FakeTransport(resp(200, _invoices(0, 1)))).service_invoices.list( + COMPANY_ID, page_count=2 + ) + assert not page.has_more + assert page.next_page() is None + + +def test_filters_are_sent_as_iso_dates() -> None: + transport = FakeTransport(resp(200, _invoices(0, 0))) + make_client(transport).service_invoices.list( + COMPANY_ID, issued_begin=date(2026, 1, 1), issued_end="2026-12-31" + ) + query = transport.query() + assert query["issuedBegin"] == ["2026-01-01"] and query["issuedEnd"] == ["2026-12-31"] + assert "createdBegin" not in query + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"page_index": 0}, "starts at 1"), + ({"page_count": 100}, "50"), + ({"page_count": 0}, "between 1 and 50"), + ({"page_index": True}, "starts at 1"), + ], +) +def test_local_page_validation(kwargs: dict[str, Any], match: str) -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError, match=match): + make_client(transport).service_invoices.list(COMPANY_ID, **kwargs) + assert transport.requests == [] + + +def test_auto_paging_iter_offset_120_items() -> None: + transport = FakeTransport( + resp(200, _invoices(0, 50)), resp(200, _invoices(50, 50)), resp(200, _invoices(100, 20)) + ) + page = make_client(transport).service_invoices.list(COMPANY_ID, page_count=50) + ids = [inv.id for inv in page.auto_paging_iter()] + assert ids == [f"inv{i:05d}" for i in range(120)] + assert [transport.query(i)["pageIndex"] for i in range(3)] == [["1"], ["2"], ["3"]] + assert all(transport.query(i)["pageCount"] == ["50"] for i in range(3)) + + +def test_auto_paging_stops_on_full_then_empty_page() -> None: + transport = FakeTransport(resp(200, _invoices(0, 2)), resp(200, _invoices(2, 0))) + page = make_client(transport).service_invoices.list(COMPANY_ID, page_count=2) + assert len(list(page.auto_paging_iter())) == 2 + assert len(transport.requests) == 2 + + +def test_cursor_page_query_and_forward_iteration() -> None: + transport = FakeTransport( + resp(200, _companies(["a", "b"], True)), + resp(200, _companies(["c"], False)), + ) + page = make_client(transport).companies.list(limit=2, starting_after="X") + assert transport.query() == {"limit": ["2"], "startingAfter": ["X"]} + assert page.has_more and page.limit == 2 + assert [c.id for c in page.auto_paging_iter()] == ["a", "b", "c"] + assert transport.query(1) == {"limit": ["50"], "startingAfter": ["b"]} + + +def test_cursor_next_page_keeps_limit_and_backwards() -> None: + transport = FakeTransport( + resp(200, _companies(["m", "n"], True)), resp(200, _companies(["k", "l"], False)) + ) + page = make_client(transport).companies.list(limit=2, ending_before="o") + nxt = page.next_page() + assert nxt is not None and [c.id for c in nxt] == ["k", "l"] + assert transport.query(1) == {"limit": ["2"], "endingBefore": ["m"]} + assert nxt.next_page() is None + + +def test_cursor_has_more_true_with_empty_list_stops() -> None: + transport = FakeTransport(resp(200, _companies([], True))) + page = make_client(transport).companies.list() + assert list(page.auto_paging_iter()) == [] + assert page.next_page() is None + assert len(transport.requests) == 1 + + +@pytest.mark.parametrize("limit", [0, 51, -1, True]) +def test_cursor_limit_validation(limit: int) -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError): + make_client(transport).companies.list(limit=limit) + assert transport.requests == [] + + +def test_cursor_rejects_both_directions_and_bad_ids() -> None: + client = make_client(FakeTransport()) + with pytest.raises(InvalidParameterError): + client.companies.list(starting_after="a", ending_before="b") + with pytest.raises(InvalidParameterError): + client.companies.list(starting_after="../x") + + +def test_unexpected_list_shape() -> None: + from nfeio import UnexpectedResponseError + + with pytest.raises(UnexpectedResponseError): + make_client(FakeTransport(resp(200, {"other": []}))).service_invoices.list(COMPANY_ID) + with pytest.raises(UnexpectedResponseError): + make_client(FakeTransport(resp(200, body=b""))).companies.list() diff --git a/tests/unit/test_paths_json_multipart.py b/tests/unit/test_paths_json_multipart.py new file mode 100644 index 0000000..c300c06 --- /dev/null +++ b/tests/unit/test_paths_json_multipart.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest + +from nfeio import InvalidParameterError +from nfeio._core import jsonutil, multipart, paths +from tests.helpers import COMPANY_ID, FakeTransport, make_client + + +@pytest.mark.parametrize( + "value", + ["../../v2/webhooks", "..", ".", "", "a/b", "a b", "x" * 65, "abc%2F", "a\nb", 123, None], + ids=[ + "traversal", + "dotdot", + "dot", + "empty", + "slash", + "space", + "65-chars", + "percent-encoded", + "newline", + "int", + "none", + ], +) +def test_opaque_id_rejects(value: object) -> None: + with pytest.raises(InvalidParameterError): + paths.opaque_id(value, "invoice_id") + + +@pytest.mark.parametrize("value", ["5f0e3b1a2c4d5e6f7a8b9c0d", COMPANY_ID, "A-b_9"]) +def test_opaque_id_accepts(value: str) -> None: + assert paths.opaque_id(value, "id") == value + + +def test_path_traversal_never_reaches_transport() -> None: + transport = FakeTransport() + client = make_client(transport) + with pytest.raises(InvalidParameterError): + client.service_invoices.retrieve(COMPANY_ID, "../../v2/webhooks") + with pytest.raises(InvalidParameterError): + client.service_invoices.retrieve("../x", "abc") + with pytest.raises(InvalidParameterError): + client.webhooks.delete("a/b") + assert transport.requests == [] + + +@pytest.mark.parametrize( + ("value", "encoded"), + [("pedido/123", "pedido%2F123"), ("WOO NFE/110", "WOO%20NFE%2F110"), ("a?b#c", "a%3Fb%23c")], +) +def test_free_text_segment(value: str, encoded: str) -> None: + assert paths.free_text_segment(value, "external_id") == encoded + + +@pytest.mark.parametrize( + "value", + ["", ".", "..", "a\x00b", "tab\there", "x" * 256, 5], + ids=["empty", "dot", "dotdot", "nul", "tab", "256-chars", "int"], +) +def test_free_text_rejects(value: object) -> None: + with pytest.raises(InvalidParameterError): + paths.free_text_segment(value, "external_id") + + +@pytest.mark.parametrize( + ("value", "normalized"), + [ + ("00.000.000/0001-91", "00000000000191"), + ("00000000000191", "00000000000191"), + (191, "00000000000191"), + ("12.ABC.345/01DE-35", "12ABC34501DE35"), + ("12abc34501de35", "12ABC34501DE35"), + ], +) +def test_cnpj_valid(value: object, normalized: str) -> None: + assert paths.cnpj(value) == normalized + + +@pytest.mark.parametrize( + "value", ["00000000000100", "00000000000000", "123", "12ABC34501DE36", "", None, True] +) +def test_cnpj_invalid(value: object) -> None: + with pytest.raises(InvalidParameterError): + paths.cnpj(value) + + +def test_cpf() -> None: + assert paths.cpf("529.982.247-25") == "52998224725" + for bad in ["52998224724", "11111111111", "123", "abc", None]: + with pytest.raises(InvalidParameterError): + paths.cpf(bad) + + +def test_cep_uf_date() -> None: + assert paths.cep("01310-100") == "01310100" + with pytest.raises(InvalidParameterError): + paths.cep("1310-100") + assert paths.uf(" sp ") == "SP" + with pytest.raises(InvalidParameterError): + paths.uf("XX") + assert paths.iso_date(date(2026, 1, 2), "d") == "2026-01-02" + assert paths.iso_date(datetime(2026, 1, 2, 3, tzinfo=timezone.utc), "d") == "2026-01-02" + assert paths.iso_date("1990-01-31", "d") == "1990-01-31" + for bad in ["01/01/1990", "1990-02-30", "1990-1-1", 19900101]: + with pytest.raises(InvalidParameterError): + paths.iso_date(bad, "d") + + +def test_json_decimal_is_exact() -> None: + body = jsonutil.dumps({"servicesAmount": Decimal("100.10"), "n": [Decimal("1E+2"), 1.5]}) + assert body == b'{"servicesAmount":100.10,"n":[100,1.5]}' + assert json.loads(body)["servicesAmount"] == pytest.approx(100.10) + + +def test_json_rejects_garbage() -> None: + for bad in [{"a": float("nan")}, {"a": Decimal("NaN")}, {1: "x"}, {"a": object()}]: + with pytest.raises(InvalidParameterError): + jsonutil.dumps(bad) + assert jsonutil.dumps({"d": date(2026, 1, 2)}) == b'{"d":"2026-01-02"}' + assert jsonutil.dumps({"s": "ção"}) == '{"s":"ção"}'.encode() + + +def test_multipart_body_is_exact() -> None: + body, ctype = multipart.encode( + [("password", "s3nh@")], + [multipart.FilePart("file", 'evil"\r\nname.pfx', b"\x00\x01PFX", "application/x-pkcs12")], + ) + boundary = ctype.split("boundary=", 1)[1] + assert ctype.startswith("multipart/form-data; boundary=nfeio-") + assert len(boundary) == len("nfeio-") + 32 + expected = ( + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="password"\r\n\r\n' + "s3nh@\r\n" + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="file"; filename="evil___name.pfx"\r\n' + "Content-Type: application/x-pkcs12\r\n\r\n" + ).encode() + + b"\x00\x01PFX\r\n" + + f"--{boundary}--\r\n".encode() + ) + assert body == expected + assert multipart.sanitize_filename('a"b\\c\r\nd é') == "a_b_c__d _" + with pytest.raises(ValueError, match="field name"): + multipart.encode([('bad"name', "x")], []) diff --git a/tests/unit/test_readme.py b/tests/unit/test_readme.py new file mode 100644 index 0000000..b1a4297 --- /dev/null +++ b/tests/unit/test_readme.py @@ -0,0 +1,83 @@ +"""README examples: every Python block compiles; the quick start and the reconciliation recipe +run against the fake transport.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest + +import nfeio +from nfeio import APIConnectionError, FailurePhase, InvalidRequestError +from tests.helpers import COMPANY_ID, INVOICE_ID, FakeTransport, invoice_json, make_client, resp + +README = (Path(__file__).resolve().parents[2] / "README.md").read_text("utf-8") +BLOCKS: list[str] = re.findall(r"```python\n(.*?)```", README, re.DOTALL) + + +def _block(containing: str) -> str: + return next(block for block in BLOCKS if containing in block) + + +@pytest.mark.parametrize("index", range(len(BLOCKS))) +def test_python_blocks_compile(index: int) -> None: + compile(BLOCKS[index], f"README.md#block{index}", "exec") + + +def test_quick_start_runs(monkeypatch: pytest.MonkeyPatch) -> None: + location = f"http://api.nfe.io/v1/companies/ID_DA_EMPRESA/serviceinvoices/{INVOICE_ID}" + transport = FakeTransport( + resp(202, {"id": INVOICE_ID, "flowStatus": "WaitingSend"}, headers={"Location": location}), + resp(200, invoice_json()), + resp(302, headers={"Location": "https://api.nfse.io/v1/blob/download?s=1"}), + resp(200, body=b"%PDF-1.4"), + ) + monkeypatch.setattr(nfeio, "NfeClient", lambda **_: make_client(transport)) + namespace: dict[str, Any] = {} + exec(_block("create_and_wait(\n company_id"), namespace) # noqa: S102 - README example + assert namespace["pdf"] == b"%PDF-1.4" + assert transport.json_body(0)["externalId"] == "pedido-1001" + + +def _recipe() -> Any: + namespace: dict[str, Any] = {} + exec(_block("def emitir"), namespace) # noqa: S102 - README example under test + return namespace["emitir"] + + +PEDIDO = {"id": 7, "nfse": {"servicesAmount": 1}} + + +def test_recipe_success() -> None: + transport = FakeTransport(resp(202, {"id": INVOICE_ID, "flowStatus": "WaitingSend"})) + assert _recipe()(make_client(transport), COMPANY_ID, PEDIDO).id == INVOICE_ID + + +def test_recipe_reconciles_ambiguous_failure() -> None: + found = resp(200, {"serviceInvoices": [invoice_json()], "page": 1}) + for failure in (resp(504), APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT)): + transport = FakeTransport(failure, found) + nota = _recipe()(make_client(transport), COMPANY_ID, PEDIDO) + assert nota.id == INVOICE_ID + assert [r.method for r in transport.requests] == ["POST", "GET"] + assert transport.requests[1].url.endswith("/external/pedido-7") + + +def test_recipe_reconciles_duplicate() -> None: + duplicate = resp(400, body=b'"service invoice with external id (pedido-7) already exists"') + found = resp(200, {"serviceInvoices": [invoice_json()], "page": 1}) + transport = FakeTransport(duplicate, found) + assert _recipe()(make_client(transport), COMPANY_ID, PEDIDO).id == INVOICE_ID + + +def test_recipe_propagates_validation_errors_and_missing_invoice() -> None: + transport = FakeTransport(resp(422, body=b'"cityServiceCode is required"')) + with pytest.raises(InvalidRequestError): + _recipe()(make_client(transport), COMPANY_ID, PEDIDO) + empty = resp(200, {"serviceInvoices": [], "page": 1}) + transport = FakeTransport(resp(500), *[empty] * 40) + client = make_client(transport) + with pytest.raises(RuntimeError, match="seguro tentar de novo"): + _recipe()(client, COMPANY_ID, PEDIDO) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py new file mode 100644 index 0000000..14b74c0 --- /dev/null +++ b/tests/unit/test_resources.py @@ -0,0 +1,334 @@ +"""Companies v2, certificates, account webhooks and data lookups against scripted responses.""" + +from __future__ import annotations + +import io +from datetime import date +from pathlib import Path + +import pytest + +from nfeio import ( + Address, + Certificate, + Company, + InvalidParameterError, + LegalEntity, + NaturalPerson, + NotFoundError, + ServerError, + Webhook, +) +from tests.helpers import API_KEY, COMPANY_ID, DATA_KEY, FakeTransport, make_client, resp + +COMPANY = { + "id": COMPANY_ID, + "accountId": "acc1", + "name": "SDK-PY TESTE", + "federalTaxNumber": 191, + "taxRegime": "SimplesNacional", + "status": "Active", + "municipalTaxes": [], + "stateTaxes": [], + "createdOn": "2026-10-08T12:00:00.123+00:00", +} + +# -- companies --------------------------------------------------------------------------------- + + +def test_company_crud_envelopes() -> None: + transport = FakeTransport( + resp(201, {"company": COMPANY}), + resp(200, {"company": COMPANY}), + resp(200, {"company": {**COMPANY, "name": "NOVO"}}), + resp(204), + ) + client = make_client(transport) + params = { + "name": "SDK-PY TESTE", + "federalTaxNumber": 191, + "taxRegime": "SimplesNacional", + "address": {"state": "SP", "city": {"code": "3550308", "name": "São Paulo"}}, + } + created = client.companies.create(params) + assert isinstance(created, Company) and created.id == COMPANY_ID + assert created.federal_tax_number == "00000000000191" + assert transport.json_body(0) == {"company": params} + assert transport.requests[0].url == "https://api.nfse.io/v2/companies" + assert client.companies.retrieve(COMPANY_ID).account_id == "acc1" + updated = client.companies.update(COMPANY_ID, {"name": "NOVO"}) + assert updated["name"] == "NOVO" + assert transport.requests[2].method == "PUT" + assert transport.json_body(2) == {"company": {"name": "NOVO"}} + client.companies.delete(COMPANY_ID) + assert transport.requests[3].method == "DELETE" + assert transport.paths()[3] == f"/v2/companies/{COMPANY_ID}" + assert all(r.headers["Authorization"] == API_KEY for r in transport.requests) + + +def test_company_not_found_code() -> None: + body = b'{"errors":[{"code":40401,"message":"company id not found"}]}' + with pytest.raises(NotFoundError) as info: + make_client(FakeTransport(resp(404, body=body))).companies.retrieve(COMPANY_ID) + assert info.value.error_code == 40401 + + +def test_company_params_must_be_mapping() -> None: + with pytest.raises(InvalidParameterError): + make_client(FakeTransport()).companies.create(["x"]) # type: ignore[arg-type] + + +def test_company_tolerates_unwrapped_object() -> None: + company = make_client(FakeTransport(resp(200, COMPANY))).companies.retrieve(COMPANY_ID) + assert company.id == COMPANY_ID + + +# -- certificates ------------------------------------------------------------------------------ + + +def _parts(body: bytes) -> bytes: + return body + + +def test_certificate_upload_from_path(tmp_path: Path) -> None: + pfx = tmp_path / "cert.pfx" + pfx.write_bytes(b"\x30\x82PFXDATA") + cert_json = { + "certificate": { + "thumbprint": "AB12", + "status": "Active", + "validUntil": "2027-01-01T00:00:00Z", + } + } + transport = FakeTransport(resp(200, cert_json)) + cert = make_client(transport).certificates.upload(COMPANY_ID, pfx, "s3cr3t-pw") + assert isinstance(cert, Certificate) and cert.thumbprint == "AB12" + req = transport.last + assert req.url == f"https://api.nfse.io/v2/companies/{COMPANY_ID}/certificates" + assert req.headers["Content-Type"].startswith("multipart/form-data; boundary=") + body = req.body or b"" + assert b'name="file"; filename="cert.pfx"' in body + assert b"Content-Type: application/x-pkcs12" in body + assert b"\x30\x82PFXDATA" in body + assert b'name="password"\r\n\r\ns3cr3t-pw\r\n' in body + + +def test_certificate_upload_from_bytes_and_fileobj() -> None: + transport = FakeTransport(resp(200, {"certificate": {}}), resp(200, {"certificate": {}})) + client = make_client(transport) + client.certificates.upload(COMPANY_ID, b"PFX", "pw-123", filename="a.p12") + assert b'filename="a.p12"' in (transport.last.body or b"") + client.certificates.upload(COMPANY_ID, io.BytesIO(b"PFX2"), "pw-123") + assert b"PFX2" in (transport.last.body or b"") + + +@pytest.mark.parametrize( + ("file", "password"), + [ + (b"", "pw"), + (b"x" * (1024 * 1024 + 1), "pw"), + (b"x", ""), + (123, "pw"), + ("/nonexistent/x.pfx", "pw"), + ], + # Short ids: pytest puts the id in PYTEST_CURRENT_TEST, and Windows rejects environment + # variables longer than 32,767 characters (the 1 MiB value would be the id). + ids=["empty-file", "file-over-1mib", "empty-password", "not-bytes-or-path", "missing-path"], +) +def test_certificate_local_validation(file: object, password: str) -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError): + make_client(transport).certificates.upload(COMPANY_ID, file, password) # type: ignore[arg-type] + assert transport.requests == [] + + +def test_certificate_text_fileobj_rejected() -> None: + with pytest.raises(InvalidParameterError): + make_client(FakeTransport()).certificates.upload(COMPANY_ID, io.StringIO("x"), "pw") # type: ignore[arg-type] + + +def test_certificate_upload_500_not_retried_and_password_hidden() -> None: + body = b'{"title":"An error occurred while processing your request. pw=s3cr3t-pw","status":500}' + transport = FakeTransport(resp(500, body=body)) + with pytest.raises(ServerError) as info: + make_client(transport).certificates.upload(COMPANY_ID, b"PFX", "s3cr3t-pw") + assert len(transport.requests) == 1 + err = info.value + for text in (str(err), repr(err), err.message): + assert "s3cr3t-pw" not in text + assert b"s3cr3t-pw" not in err.body + + +def test_certificate_list() -> None: + transport = FakeTransport( + resp( + 200, {"certificates": [{"thumbprint": "A", "validUntil": "2026-11-03T16:18:00+00:00"}]} + ), + resp(200, {"certificates": []}), + ) + client = make_client(transport) + certs = client.certificates.list(COMPANY_ID) + assert len(certs) == 1 and certs[0].expires_on is not None + assert client.certificates.list(COMPANY_ID) == [] + + +# -- webhooks ---------------------------------------------------------------------------------- + +HOOK_ID = "9dd4c3e9df4043adb11df22dcd9d8ce1" +HOOK: dict[str, object] = { + "id": HOOK_ID, + "uri": "https://example.com/nfeio-sdkpy-test", + "contentType": "json", + "insecureSsl": False, + "status": "Active", + "filters": ["service_invoice.issued_successfully"], + "createdOn": "2026-01-01T00:00:00Z", +} + + +def test_webhook_crud() -> None: + transport = FakeTransport( + resp(200, {"webHooks": [HOOK]}), + resp(200, {"webHook": HOOK}), + resp(201, {"webHook": HOOK}), + resp(200, {"webHook": HOOK}), + resp(204), + resp(204), + ) + client = make_client(transport) + hooks = client.webhooks.list() + assert isinstance(hooks[0], Webhook) + assert hooks[0].content_type == "json" and hooks[0].status == "Active" + assert client.webhooks.retrieve(HOOK_ID).id == HOOK_ID + params = { + "uri": "https://erp.exemplo/hook", + "secret": "abc", + "filters": ["service_invoice.issued_successfully"], + } + client.webhooks.create(params) + assert transport.json_body(2) == {"webHook": params} + client.webhooks.update(HOOK_ID, {"uri": "https://novo"}) + assert transport.json_body(3) == {"webHook": {"uri": "https://novo"}} + assert transport.requests[3].method == "PUT" + client.webhooks.delete(HOOK_ID) + client.webhooks.ping(HOOK_ID) + assert transport.paths()[5] == f"/v2/webhooks/{HOOK['id']}/pings" + assert transport.requests[5].method == "PUT" + assert all(r.url.startswith("https://api.nfse.io/v2/webhooks") for r in transport.requests) + + +def test_webhook_event_types_free_text_ids() -> None: + types = [{"id": f"evt.{i}"} for i in range(46)] + [ + {"id": "legal_entity_taxpayer:updated_sucessfully"} + ] + transport = FakeTransport(resp(200, {"eventTypes": types})) + result = make_client(transport).webhooks.event_types() + assert len(result) == 47 and result[-1].id == "legal_entity_taxpayer:updated_sucessfully" + assert transport.paths() == ["/v2/webhooks/eventtypes"] + + +def test_no_delete_all_webhooks() -> None: + client = make_client(FakeTransport()) + assert not hasattr(client.webhooks, "delete_all") + with pytest.raises(TypeError): + client.webhooks.delete() # type: ignore[call-arg] + + +# -- lookups ----------------------------------------------------------------------------------- + + +def test_cnpj_lookup() -> None: + entity = { + "legalEntity": { + "federalTaxNumber": "00000000000191", + "name": "BANCO", + "shareCapital": 1000.5, + "status": "Active", + "openedOn": "1966-08-01T00:00:00Z", + } + } + transport = FakeTransport(resp(200, entity), resp(200, entity)) + client = make_client(transport) + result = client.lookups.cnpj("00.000.000/0001-91") + assert isinstance(result, LegalEntity) + assert result.federal_tax_number == "00000000000191" + assert str(result.share_capital) == "1000.5" + assert result.opened_on is not None and result.opened_on.year == 1966 + req = transport.requests[0] + assert req.url == "https://legalentity.api.nfe.io/v3/legalentities/basicInfo/00000000000191" + assert req.headers["Authorization"] == DATA_KEY + client.lookups.cnpj("12.ABC.345/01DE-35", update_address=False, update_city_code=True) + assert transport.requests[1].url.endswith( + "/basicInfo/12ABC34501DE35?updateAddress=false&updateCityCode=true" + ) + + +def test_cnpj_invalid_never_requested() -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError): + make_client(transport).lookups.cnpj("00000000000100") + with pytest.raises(InvalidParameterError): + make_client(transport).lookups.cnpj_state_taxes("00000000000191", "XX") + assert transport.requests == [] + + +def test_cnpj_state_taxes() -> None: + transport = FakeTransport( + resp( + 200, + { + "legalEntity": { + "federalTaxNumber": "00000000000191", + "stateTaxes": [{"taxNumber": "1"}], + } + }, + ) + ) + result = make_client(transport).lookups.cnpj_state_taxes("00000000000191", "sp") + assert result["stateTaxes"][0]["taxNumber"] == "1" + assert transport.paths() == ["/v3/legalentities/stateTaxInfo/SP/00000000000191"] + + +def test_cpf_lookup() -> None: + transport = FakeTransport( + resp( + 200, + { + "federalTaxNumber": "52998224725", + "status": "Regular", + "birthOn": "1990-01-01T00:00:00", + }, + ), + resp(404, body=b'{"errors":[{"code":40401,"message":"not found"}]}'), + ) + client = make_client(transport) + person = client.lookups.cpf("529.982.247-25", date(1990, 1, 1)) + assert isinstance(person, NaturalPerson) and person.status == "Regular" + assert person.birth_on is not None and person.birth_on.year == 1990 + assert transport.requests[0].url == ( + "https://naturalperson.api.nfe.io/v1/naturalperson/status/52998224725/1990-01-01" + ) + with pytest.raises(NotFoundError) as info: + client.lookups.cpf("52998224725", "1990-01-02") + assert info.value.error_code == 40401 + with pytest.raises(InvalidParameterError): + client.lookups.cpf("52998224725", "01/01/1990") + + +def test_cep_lookup() -> None: + body = { + "address": { + "state": "SP", + "city": {"code": "3550308", "name": "São Paulo"}, + "postalCode": "01310-100", + "street": "Paulista", + } + } + transport = FakeTransport(resp(200, body)) + address = make_client(transport).lookups.cep("01310-100") + assert isinstance(address, Address) + assert transport.requests[0].url == "https://address.api.nfe.io/v2/addresses/01310100" + assert address["city"]["code"] == "3550308" + assert address["state"] == "SP" and address["city"]["name"] == "São Paulo" + assert address.postal_code == "01310100" + assert not hasattr(make_client(FakeTransport()).lookups, "search") diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py new file mode 100644 index 0000000..0a08dc0 --- /dev/null +++ b/tests/unit/test_retry.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime + +import pytest + +from nfeio import ( + APIConnectionError, + APITimeoutError, + FailurePhase, + RateLimitError, + RequestOptions, + ServerError, +) +from nfeio._core.retry import parse_retry_after, should_retry_failure, should_retry_status +from tests.helpers import ( + API_KEY, + COMPANY_ID, + FakeClock, + FakeTransport, + invoice_json, + make_client, + resp, +) + +RETRYABLE = [408, 429, 500, 502, 503, 504] +NOT_RETRYABLE = [400, 401, 403, 404, 405, 409, 422, 501, 505] + + +@pytest.mark.parametrize("method", ["GET", "HEAD", "PUT", "DELETE"]) +@pytest.mark.parametrize("status", RETRYABLE) +def test_idempotent_methods_retry_transient_status(method: str, status: int) -> None: + assert should_retry_status(method, status) + + +@pytest.mark.parametrize("status", RETRYABLE + NOT_RETRYABLE) +def test_post_never_retries_on_status(status: int) -> None: + assert not should_retry_status("POST", status) + + +@pytest.mark.parametrize("status", NOT_RETRYABLE) +def test_other_4xx_not_retried(status: int) -> None: + assert not should_retry_status("GET", status) + + +def test_failure_phase_table() -> None: + assert should_retry_failure("GET", FailurePhase.MAYBE_SENT) + assert should_retry_failure("GET", FailurePhase.NOT_ESTABLISHED) + assert should_retry_failure("POST", FailurePhase.NOT_ESTABLISHED) + assert not should_retry_failure("POST", FailurePhase.MAYBE_SENT) + + +def test_get_503_then_200() -> None: + clock = FakeClock() + transport = FakeTransport(resp(503), resp(200, {"webHooks": []})) + assert make_client(transport, clock).webhooks.list() == [] + assert len(transport.requests) == 2 + assert clock.sleeps == [1.0] + + +def test_backoff_sequence_and_limit() -> None: + clock = FakeClock() + transport = FakeTransport(resp(503), resp(503), resp(503), resp(503)) + with pytest.raises(ServerError): + make_client(transport, clock).webhooks.list() + assert len(transport.requests) == 4 # 1 + max_retries=3 + assert clock.sleeps == [1.0, 2.0, 4.0] + + +def test_backoff_caps_at_max_delay() -> None: + clock = FakeClock() + transport = FakeTransport(*[resp(502)] * 7) + client = make_client(transport, clock, max_retries=6, retry_max_delay=5.0) + with pytest.raises(ServerError): + client.webhooks.list() + assert clock.sleeps == [1.0, 2.0, 4.0, 5.0, 5.0, 5.0] + + +def test_jitter_bounds() -> None: + from nfeio._config import ClientConfig + from nfeio._core.retry import backoff_delay + + low = ClientConfig.build(api_key="k" * 10, random=lambda: 0.0) + high = ClientConfig.build(api_key="k" * 10, random=lambda: 0.999999) + assert backoff_delay(1, low) == pytest.approx(0.7) + assert backoff_delay(2, high) == pytest.approx(2.6, rel=1e-3) + + +def test_max_retries_zero() -> None: + transport = FakeTransport(resp(503)) + with pytest.raises(ServerError): + make_client(transport, max_retries=0).webhooks.list() + assert len(transport.requests) == 1 + + +def test_post_issuance_504_not_retried_and_outcome_unknown() -> None: + transport = FakeTransport(resp(504)) + with pytest.raises(ServerError) as info: + make_client(transport).service_invoices.create( + COMPANY_ID, {"servicesAmount": 1}, external_id="pedido-2" + ) + assert len(transport.requests) == 1 + assert info.value.outcome_unknown is True + assert info.value.external_id == "pedido-2" + + +def test_post_500_with_idempotency_key_not_retried() -> None: + transport = FakeTransport(resp(500)) + with pytest.raises(ServerError): + make_client(transport).service_invoices.create( + COMPANY_ID, {}, options=RequestOptions(idempotency_key="idem-1") + ) + assert len(transport.requests) == 1 + + +def test_post_retried_when_connection_not_established() -> None: + clock = FakeClock() + transport = FakeTransport( + APIConnectionError("refused", phase=FailurePhase.NOT_ESTABLISHED), + resp(202, {"id": "abc123", "flowStatus": "WaitingSend"}), + ) + invoice = make_client(transport, clock).service_invoices.create(COMPANY_ID, {}) + assert invoice.id == "abc123" + assert len(transport.requests) == 2 + assert clock.sleeps == [1.0] + + +def test_post_read_timeout_not_retried() -> None: + transport = FakeTransport(APITimeoutError("read timeout", phase=FailurePhase.MAYBE_SENT)) + with pytest.raises(APITimeoutError) as info: + make_client(transport).service_invoices.create(COMPANY_ID, {}, external_id="p-3") + assert len(transport.requests) == 1 + assert info.value.outcome_unknown is True + assert info.value.external_id == "p-3" + + +def test_get_connection_errors_retried_then_raised_without_outcome_flag() -> None: + transport = FakeTransport(*[APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT)] * 4) + with pytest.raises(APIConnectionError) as info: + make_client(transport).webhooks.list() + assert len(transport.requests) == 4 + assert info.value.outcome_unknown is False + + +def test_os_error_from_custom_transport_is_wrapped() -> None: + transport = FakeTransport(ConnectionResetError("x"), resp(200, {"webHooks": []})) + assert make_client(transport).webhooks.list() == [] + + +def test_post_429_not_retried() -> None: + transport = FakeTransport(resp(429, headers={"Retry-After": "2"})) + with pytest.raises(RateLimitError) as info: + make_client(transport).service_invoices.create(COMPANY_ID, {}) + assert info.value.retry_after == 2 + assert len(transport.requests) == 1 + + +def test_retry_after_seconds_honoured() -> None: + clock = FakeClock() + transport = FakeTransport(resp(429, headers={"Retry-After": "7"}), resp(200, {"webHooks": []})) + make_client(transport, clock).webhooks.list() + assert clock.sleeps == [7.0] + + +def test_retry_after_http_date() -> None: + now = datetime(2026, 10, 8, 12, 0, 0, tzinfo=timezone.utc) + header = format_datetime(now + timedelta(seconds=5), usegmt=True) + assert parse_retry_after(header, now) == pytest.approx(5.0) + assert parse_retry_after("garbage", now) is None + assert parse_retry_after("", now) is None + assert parse_retry_after(None, now) is None + assert parse_retry_after(format_datetime(now - timedelta(seconds=5), usegmt=True), now) == 0 + + +def test_retry_after_too_long_raises_without_waiting() -> None: + clock = FakeClock() + transport = FakeTransport(resp(429, headers={"Retry-After": "3600"})) + with pytest.raises(RateLimitError) as info: + make_client(transport, clock).webhooks.list() + assert info.value.retry_after == 3600 + assert clock.sleeps == [] + assert len(transport.requests) == 1 + + +def test_retry_log_has_no_secrets(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="nfeio") + transport = FakeTransport(resp(502), resp(200, {"webHooks": []})) + make_client(transport).webhooks.list() + retry_logs = [r for r in caplog.records if "retry" in r.getMessage()] + assert retry_logs + message = retry_logs[0].getMessage() + assert "502" in message and "attempt 1" in message and "0HTEST" in message + assert all(API_KEY not in r.getMessage() for r in caplog.records) + + +def test_find_by_external_id_is_retryable_get() -> None: + transport = FakeTransport( + resp(503), resp(200, {"serviceInvoices": [invoice_json()], "page": 1}) + ) + found = make_client(transport).service_invoices.find_by_external_id(COMPANY_ID, "x") + assert found is not None diff --git a/tests/unit/test_security_and_packaging.py b/tests/unit/test_security_and_packaging.py new file mode 100644 index 0000000..ca1a555 --- /dev/null +++ b/tests/unit/test_security_and_packaging.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import ast +import importlib.metadata +import logging +import re +from collections.abc import Callable +from pathlib import Path + +import pytest + +import nfeio +from nfeio import APIConnectionError, FailurePhase, NfeError, RequestOptions +from tests.helpers import API_KEY, COMPANY_ID, DATA_KEY, FakeTransport, make_client, resp + +ROOT = Path(__file__).resolve().parents[2] +SRC = ROOT / "src" / "nfeio" +CERT_PASSWORD = "cert-password-XYZ987" + + +def test_secret_leak_scan(caplog: pytest.LogCaptureFixture) -> None: + """Success, 401, 500 and a network failure with DEBUG logs: no key or password anywhere.""" + caplog.set_level(logging.DEBUG) + transport = FakeTransport( + resp(200, {"webHooks": [{"id": "h", "secret": "hook-secret-1234"}]}), + resp(401), + resp(500, body=b'{"title":"boom"}'), + resp(500), + APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT), + APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT), + APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT), + APIConnectionError("reset", phase=FailurePhase.MAYBE_SENT), + resp(500, body=b'{"title":"upload failed"}'), + resp(403), + ) + client = make_client(transport, max_retries=3) + texts: list[str] = [repr(client), repr(client._config), repr(RequestOptions(api_key=API_KEY))] + hooks = client.webhooks.list() + texts += [repr(hooks), repr(hooks[0])] + calls: list[Callable[[], object]] = [ + lambda: client.service_invoices.list(COMPANY_ID), + lambda: client.service_invoices.create(COMPANY_ID, {}, external_id="e"), + lambda: client.webhooks.retrieve("h"), + lambda: client.certificates.upload(COMPANY_ID, b"PFX", CERT_PASSWORD), + lambda: client.lookups.cep("01310100"), + ] + for call in calls: + with pytest.raises(NfeError) as info: + call() + err = info.value + texts += [str(err), repr(err), repr(vars(err)), repr(err.args)] + texts += [repr(r) for r in transport.requests] + texts += [record.getMessage() for record in caplog.records] + blob = "\n".join(texts) + for secret in (API_KEY, DATA_KEY, CERT_PASSWORD, "hook-secret-1234"): + assert secret not in blob, secret + assert any("nfeio" in r.name for r in caplog.records) + + +def test_zero_runtime_dependencies() -> None: + pyproject = (ROOT / "pyproject.toml").read_text("utf-8") + assert re.search(r"^dependencies = \[\]$", pyproject, re.MULTILINE) + requires = importlib.metadata.requires("nfe-io") or [] + assert [r for r in requires if "extra ==" not in r] == [] + + +def test_version_single_source() -> None: + version = nfeio.__version__ + assert importlib.metadata.version("nfe-io") == version + offenders = [] + for path in SRC.rglob("*.py"): + if path.name == "_version.py": + continue + tree = ast.parse(path.read_text("utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + standalone = rf"(? None: + assert (SRC / "py.typed").exists() + assert (ROOT / "LICENSE").read_text("utf-8").startswith("MIT License") + assert "Private Vulnerability Reporting" in (ROOT / "SECURITY.md").read_text("utf-8") + + +def test_no_dangerous_calls_in_package() -> None: + forbidden = re.compile( + r"\b(pickle\.loads?|eval\(|exec\(|yaml\.load|marshal\.loads?|shell=True)" + ) + hits = [ + f"{p.relative_to(ROOT)}" + for p in SRC.rglob("*.py") + if forbidden.search(p.read_text("utf-8")) + ] + assert hits == [] + + +def test_public_api_surface() -> None: + for name in nfeio.__all__: + assert hasattr(nfeio, name), name + assert not hasattr(nfeio, "api_key") # no global state + + +@pytest.mark.parametrize("value", ["abc\n", "abc\r\n", "\nabc"]) +def test_validators_reject_trailing_newline(value: str) -> None: + from nfeio import ConfigurationError, InvalidParameterError + from nfeio._core import paths + + with pytest.raises(InvalidParameterError): + paths.opaque_id(value, "id") + # Documents strip whitespace as a mask separator: the value that reaches the path is clean. + assert paths.cnpj("00000000000191\n") == "00000000000191" + with pytest.raises(InvalidParameterError): + paths.iso_date("1990-01-01\n", "birth_date") + with pytest.raises(ConfigurationError): + RequestOptions(extra_headers={"X-A\n": "v"}) + + +def test_logs_and_reprs_have_no_personal_data(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger="nfeio") + transport = FakeTransport( + resp(503), + resp(200, {"status": "Regular"}), + resp(200, {"serviceInvoices": [], "page": 1}), + ) + client = make_client(transport) + client.lookups.cpf("52998224725", "1990-01-31") + client.service_invoices.find_by_external_id(COMPANY_ID, "pedido-joao-silva") + lines = [r.getMessage() for r in caplog.records] + [repr(r) for r in transport.requests] + blob = "\n".join(lines) + for personal in ("52998224725", "1990-01-31", "pedido-joao-silva", COMPANY_ID): + assert personal not in blob + assert "/v1/naturalperson/status/*/*" in blob diff --git a/tests/unit/test_service_invoices.py b/tests/unit/test_service_invoices.py new file mode 100644 index 0000000..104c51e --- /dev/null +++ b/tests/unit/test_service_invoices.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +import pytest + +from nfeio import ( + DuplicateExternalIdError, + FlowStatus, + InvalidParameterError, + InvoiceProcessingError, + NotFoundError, + PollingTimeoutError, + ServerError, + UnexpectedResponseError, +) +from tests.helpers import ( + API_KEY, + COMPANY_ID, + INVOICE_ID, + FakeClock, + FakeTransport, + invoice_json, + make_client, + resp, +) + +LOCATION = f"http://api.nfe.io/v1/companies/{COMPANY_ID}/serviceinvoices/{INVOICE_ID}" +BASE = f"/v1/companies/{COMPANY_ID}/serviceinvoices" + + +def _accepted(**body: Any) -> Any: + return resp(202, body or None, headers={"Location": LOCATION}) + + +# -- create ---------------------------------------------------------------------------------- + + +def test_create_202_keeps_body() -> None: + transport = FakeTransport( + _accepted(id=INVOICE_ID, environment="Development", flowStatus="WaitingCalculateTaxes") + ) + params = {"cityServiceCode": "2690", "description": "x", "servicesAmount": Decimal("100.10")} + inv = make_client(transport).service_invoices.create(COMPANY_ID, params, external_id="p-1") + assert inv.id == INVOICE_ID + assert inv.flow_status == "WaitingCalculateTaxes" + assert inv["environment"] == "Development" + assert inv.last_response is not None and inv.last_response.status_code == 202 + req = transport.last + assert req.method == "POST" and req.url == f"https://api.nfe.io{BASE}" + assert req.headers["Content-Type"] == "application/json; charset=utf-8" + assert b'"servicesAmount":100.10' in (req.body or b"") + assert transport.json_body()["externalId"] == "p-1" + + +def test_create_202_empty_body_uses_location_path_only() -> None: + transport = FakeTransport(_accepted()) + inv = make_client(transport).service_invoices.create(COMPANY_ID, {}) + assert inv.id == INVOICE_ID + assert len(transport.requests) == 1 # Location never fetched + + +def test_create_202_location_for_other_company_is_ignored() -> None: + other = "http://evil.example/v1/companies/other/serviceinvoices/abc" + transport = FakeTransport(resp(202, headers={"Location": other})) + with pytest.raises(UnexpectedResponseError, match="no invoice id"): + make_client(transport).service_invoices.create(COMPANY_ID, {}) + + +def test_create_202_without_any_id() -> None: + with pytest.raises(UnexpectedResponseError): + make_client(FakeTransport(resp(202))).service_invoices.create(COMPANY_ID, {}) + + +def test_create_201_immediate() -> None: + transport = FakeTransport(resp(201, invoice_json())) + inv = make_client(transport).service_invoices.create(COMPANY_ID, {}) + assert inv.flow_status == "Issued" + + +def test_create_external_id_conflict() -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError): + make_client(transport).service_invoices.create( + COMPANY_ID, {"externalId": "B"}, external_id="A" + ) + with pytest.raises(InvalidParameterError): + make_client(transport).service_invoices.create(COMPANY_ID, [], external_id="A") # type: ignore[arg-type] + with pytest.raises(InvalidParameterError): + make_client(transport).service_invoices.create(COMPANY_ID, {}, external_id="") + assert transport.requests == [] + + +def test_create_external_id_from_params_is_tracked() -> None: + transport = FakeTransport(resp(500)) + with pytest.raises(ServerError) as info: + make_client(transport).service_invoices.create(COMPANY_ID, {"externalId": "from-body"}) + assert info.value.external_id == "from-body" + assert info.value.outcome_unknown + + +def test_duplicate_external_id() -> None: + body = b'"service invoice with external id (pedido-1) already exists"' + transport = FakeTransport(resp(400, body=body)) + with pytest.raises(DuplicateExternalIdError) as info: + make_client(transport).service_invoices.create(COMPANY_ID, {}, external_id="pedido-1") + assert info.value.external_id == "pedido-1" + + +def test_create_unexpected_status() -> None: + with pytest.raises(UnexpectedResponseError): + make_client(FakeTransport(resp(204))).service_invoices.create(COMPANY_ID, {}) + + +# -- retrieve / find / cancel / email ---------------------------------------------------------- + + +def test_retrieve_and_not_found() -> None: + transport = FakeTransport( + resp(200, invoice_json()), + resp(404, body=b'"service invoice with id (X) was not found"'), + ) + client = make_client(transport) + assert client.service_invoices.retrieve(COMPANY_ID, INVOICE_ID).id == INVOICE_ID + assert transport.paths()[0] == f"{BASE}/{INVOICE_ID}" + with pytest.raises(NotFoundError, match="was not found"): + client.service_invoices.retrieve(COMPANY_ID, "X") + + +def test_find_by_external_id_found_missing_and_encoding() -> None: + transport = FakeTransport( + resp(200, {"serviceInvoices": [invoice_json(externalId="WOO NFE/110")], "page": 1}), + resp(200, {"serviceInvoices": [], "page": 1}), + resp(200, invoice_json()), + ) + client = make_client(transport) + found = client.service_invoices.find_by_external_id(COMPANY_ID, "WOO NFE/110") + assert found is not None and found.external_id == "WOO NFE/110" + assert transport.requests[0].url.endswith("/external/WOO%20NFE%2F110") + assert client.service_invoices.find_by_external_id(COMPANY_ID, "missing") is None + assert client.service_invoices.find_by_external_id(COMPANY_ID, "obj") is not None + + +def test_find_by_external_id_waits_for_indexing() -> None: + clock = FakeClock() + empty = resp(200, {"serviceInvoices": [], "page": 1}) + transport = FakeTransport( + empty, empty, resp(200, {"serviceInvoices": [invoice_json()], "page": 1}) + ) + found = make_client(transport, clock).service_invoices.find_by_external_id( + COMPANY_ID, "p", wait=30 + ) + assert found is not None + assert clock.sleeps == [1.0, 1.5] + + +def test_find_by_external_id_wait_expires() -> None: + clock = FakeClock() + empty = resp(200, {"serviceInvoices": [], "page": 1}) + transport = FakeTransport(*[empty] * 20) + assert ( + make_client(transport, clock).service_invoices.find_by_external_id(COMPANY_ID, "p", wait=10) + is None + ) + assert sum(clock.sleeps) == pytest.approx(10) + with pytest.raises(InvalidParameterError): + make_client(transport).service_invoices.find_by_external_id(COMPANY_ID, "p", wait=-1) + + +def test_cancel_variants() -> None: + transport = FakeTransport( + resp( + 202, + {"id": INVOICE_ID, "flowStatus": "WaitingSendCancel"}, + headers={"Location": LOCATION}, + ), + resp(202, headers={"Location": LOCATION}), + resp(200, body=b'"cancelled"'), + resp(204), + ) + client = make_client(transport) + first = client.service_invoices.cancel(COMPANY_ID, INVOICE_ID) + assert first.flow_status == "WaitingSendCancel" + assert transport.last.method == "DELETE" + for _ in range(3): + assert client.service_invoices.cancel(COMPANY_ID, INVOICE_ID).id == INVOICE_ID + + +def test_send_email() -> None: + transport = FakeTransport(resp(200)) + make_client(transport).service_invoices.send_email(COMPANY_ID, INVOICE_ID) + assert transport.last.method == "PUT" + assert transport.paths() == [f"{BASE}/{INVOICE_ID}/sendemail"] + assert transport.last.body is None + + +# -- downloads --------------------------------------------------------------------------------- + + +def test_download_pdf_follows_redirect_without_credentials() -> None: + presigned = "https://api.nfse.io/v1/blob/download?b=x&e=y&s=sig" + transport = FakeTransport( + resp(302, headers={"Location": presigned}), + resp(200, body=b"%PDF-1.4 ...", headers={"Content-Type": "application/pdf"}), + ) + pdf = make_client(transport).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + assert pdf.startswith(b"%PDF") + first, second = transport.requests + assert first.headers["Authorization"] == API_KEY + assert first.url.endswith(f"{INVOICE_ID}/pdf") + assert second.url == presigned + assert "Authorization" not in second.headers + assert all(API_KEY not in v for v in second.headers.values()) + + +def test_download_xml_untouched() -> None: + xml = b"1" + transport = FakeTransport( + resp(302, headers={"Location": "https://api.nfse.io/v1/blob/download?s=1"}), + resp(200, body=xml), + ) + assert make_client(transport).service_invoices.download_xml(COMPANY_ID, INVOICE_ID) == xml + + +def test_download_relative_redirect_and_direct_200() -> None: + transport = FakeTransport( + resp(302, headers={"Location": "/v1/blob/download?s=1"}), + resp(200, body=b""), + resp(200, body=b"%PDF"), + ) + client = make_client(transport) + assert client.service_invoices.download_xml(COMPANY_ID, INVOICE_ID) == b"" + assert transport.requests[1].url == "https://api.nfe.io/v1/blob/download?s=1" + assert client.service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) == b"%PDF" + + +def test_download_refuses_http_redirect() -> None: + transport = FakeTransport(resp(302, headers={"Location": "http://api.nfse.io/blob"})) + with pytest.raises(UnexpectedResponseError, match="https"): + make_client(transport).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + assert len(transport.requests) == 1 + + +def test_download_too_many_redirects_and_missing_location() -> None: + hop = resp(302, headers={"Location": "https://a.example/x"}) + transport = FakeTransport(hop, hop, hop, hop) + with pytest.raises(UnexpectedResponseError, match="too many"): + make_client(transport).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + with pytest.raises(UnexpectedResponseError, match="Location"): + make_client(FakeTransport(resp(302))).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + + +def test_download_presigned_error_and_odd_status() -> None: + transport = FakeTransport( + resp(302, headers={"Location": "https://blob.example/x"}), resp(403, body=b"") + ) + from nfeio import PermissionDeniedError + + with pytest.raises(PermissionDeniedError): + make_client(transport).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + with pytest.raises(UnexpectedResponseError): + make_client(FakeTransport(resp(204))).service_invoices.download_pdf(COMPANY_ID, INVOICE_ID) + + +def test_cancellation_xml_not_available() -> None: + body = b'"Cancellation event XML not available (National environment only)"' + transport = FakeTransport(resp(404, body=body)) + with pytest.raises(NotFoundError, match="National environment only"): + make_client(transport).service_invoices.download_cancellation_xml(COMPANY_ID, INVOICE_ID) + assert transport.last.url.endswith("/cancellation-xml") + + +# -- polling ----------------------------------------------------------------------------------- + + +def _status(status: str, message: str | None = None) -> Any: + return resp(200, invoice_json(flowStatus=status, flowMessage=message)) + + +def test_wait_until_issued_with_backoff() -> None: + clock = FakeClock() + transport = FakeTransport( + _status("WaitingCalculateTaxes"), + _status("WaitingSend"), + _status("WaitingReturn"), + _status("Issued"), + ) + inv = make_client(transport, clock).service_invoices.wait(COMPANY_ID, INVOICE_ID) + assert inv.flow_status == FlowStatus.ISSUED + assert clock.sleeps == [1.0, 1.5, 2.25] + assert all(p == f"{BASE}/{INVOICE_ID}" for p in transport.paths()) + assert not any(p.endswith("/status") for p in transport.paths()) + + +def test_wait_interval_capped() -> None: + clock = FakeClock() + transport = FakeTransport(*[_status("WaitingSend")] * 8, _status("Issued")) + make_client(transport, clock).service_invoices.wait( + COMPANY_ID, INVOICE_ID, timeout=500, max_interval=3.0 + ) + assert max(clock.sleeps) == 3.0 + + +def test_wait_timeout() -> None: + clock = FakeClock() + transport = FakeTransport(*[_status("WaitingReturn")] * 50) + with pytest.raises(PollingTimeoutError) as info: + make_client(transport, clock).service_invoices.wait(COMPANY_ID, INVOICE_ID, timeout=20) + assert info.value.flow_status == "WaitingReturn" + assert info.value.invoice is not None + assert sum(clock.sleeps) == pytest.approx(20) + + +def test_wait_failure_raises_with_flow_message() -> None: + transport = FakeTransport(_status("IssueFailed", "max retry reached; city hall rejected")) + with pytest.raises(InvoiceProcessingError) as info: + make_client(transport).service_invoices.wait(COMPANY_ID, INVOICE_ID) + assert info.value.flow_status == "IssueFailed" + assert info.value.flow_message == "max retry reached; city hall rejected" + transport.add(_status("Error")) + inv = make_client(transport).service_invoices.wait( + COMPANY_ID, INVOICE_ID, raise_on_failure=False + ) + assert inv.flow_status == "Error" + + +def test_wait_unknown_status_is_not_terminal() -> None: + transport = FakeTransport(_status("WaitingSomethingNew"), _status("Issued")) + inv = make_client(transport).service_invoices.wait(COMPANY_ID, INVOICE_ID) + assert inv.flow_status == "Issued" + assert len(transport.requests) == 2 + + +def test_wait_tolerates_initial_404() -> None: + clock = FakeClock() + not_found = resp(404, body=b'"service invoice with id (x) was not found"') + transport = FakeTransport(not_found, _status("Issued")) + assert make_client(transport, clock).service_invoices.wait(COMPANY_ID, INVOICE_ID).id + + +def test_wait_404_after_grace_propagates() -> None: + clock = FakeClock() + not_found = resp(404, body=b'"not found"') + transport = FakeTransport(*[not_found] * 20) + with pytest.raises(NotFoundError): + make_client(transport, clock).service_invoices.wait( + COMPANY_ID, INVOICE_ID, not_found_grace=3 + ) + assert sum(clock.sleeps) >= 3 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"timeout": 0}, + {"timeout": float("inf")}, + {"initial_interval": -1}, + {"backoff": 0.5}, + {"backoff": True}, + {"not_found_grace": -1}, + {"max_interval": "1"}, + ], +) +def test_wait_param_validation(kwargs: dict[str, Any]) -> None: + transport = FakeTransport() + with pytest.raises(InvalidParameterError): + make_client(transport).service_invoices.wait(COMPANY_ID, INVOICE_ID, **kwargs) + assert transport.requests == [] + + +def test_create_and_wait() -> None: + clock = FakeClock() + transport = FakeTransport( + _accepted(id=INVOICE_ID, flowStatus="WaitingCalculateTaxes"), + _status("WaitingSend"), + _status("Issued"), + ) + inv = make_client(transport, clock).service_invoices.create_and_wait( + COMPANY_ID, {"servicesAmount": 1}, external_id="p-9" + ) + assert inv.flow_status == "Issued" + assert [r.method for r in transport.requests] == ["POST", "GET", "GET"] + + +def test_create_and_wait_immediate_and_failure_propagation() -> None: + transport = FakeTransport(resp(201, invoice_json())) + assert make_client(transport).service_invoices.create_and_wait(COMPANY_ID, {}).id + transport = FakeTransport(resp(504)) + with pytest.raises(ServerError) as info: + make_client(transport).service_invoices.create_and_wait(COMPANY_ID, {}, external_id="e") + assert info.value.outcome_unknown and info.value.external_id == "e" + assert len(transport.requests) == 1 + + +def test_cancel_and_wait_ignores_issued_until_cancelled() -> None: + transport = FakeTransport( + resp(202, headers={"Location": LOCATION}), + _status("Issued"), + _status("WaitingReturnCancel"), + _status("Cancelled"), + ) + inv = make_client(transport).service_invoices.cancel_and_wait(COMPANY_ID, INVOICE_ID) + assert inv.flow_status == "Cancelled" + assert len(transport.requests) == 4 + + +def test_cancel_and_wait_failure_and_immediate() -> None: + transport = FakeTransport(resp(202, headers={"Location": LOCATION}), _status("CancelFailed")) + with pytest.raises(InvoiceProcessingError): + make_client(transport).service_invoices.cancel_and_wait(COMPANY_ID, INVOICE_ID) + transport = FakeTransport(resp(200, invoice_json(flowStatus="Cancelled"))) + inv = make_client(transport).service_invoices.cancel_and_wait(COMPANY_ID, INVOICE_ID) + assert inv.flow_status == "Cancelled" diff --git a/tests/unit/test_spec_alignment.py b/tests/unit/test_spec_alignment.py new file mode 100644 index 0000000..8afe3b0 --- /dev/null +++ b/tests/unit/test_spec_alignment.py @@ -0,0 +1,114 @@ +"""Every typed property and request ``TypedDict`` key must exist in the OpenAPI schema of its +route, or be listed below as a divergence proven on the wire (with its evidence). + +The schema keys come from ``tests/fixtures/spec-keys.json``, generated by +``scripts/spec_keys.py`` from ``nfeio-docs/static/api`` (anchored on paths, not operationIds). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from nfeio import models, types +from nfeio.models._base import NfeObject, iter_fields + +ROOT = Path(__file__).resolve().parents[2] +SPEC_KEYS: dict[str, list[str]] = json.loads( + (ROOT / "tests" / "fixtures" / "spec-keys.json").read_text("utf-8") +)["keys"] + +#: Wire keys missing from the spec but observed on the wire (design §16). +PROVEN_DIVERGENCES: dict[str, dict[str, str]] = { + "Certificate": { + "providerType": "SONDA 2026-10-08 §5 / design §16 #23 — present in every certificate", + "expiresOn": "SONDA §4 / design §16 #24 — companies list uses expiresOn, not validUntil", + }, +} + +MODEL_SCHEMAS: dict[type[NfeObject], list[str]] = { + models.ServiceInvoice: ["ServiceInvoice"], + models.Party: ["Party.borrower", "Party.provider"], + models.Company: ["Company"], + models.Certificate: ["Certificate"], + models.Webhook: ["Webhook"], + models.WebhookEventType: ["WebhookEventType"], + models.LegalEntity: ["LegalEntity"], + models.NaturalPerson: ["NaturalPerson"], + models.Address: ["Address"], +} + +#: TypedDict -> (schema, must be complete?) +PARAM_SCHEMAS: dict[Any, tuple[str, bool]] = { + types.ServiceInvoiceCreateParams: ("ServiceInvoiceCreateParams", True), + types.BorrowerParams: ("BorrowerParams", True), + types.AddressParams: ("AddressParams", True), + types.CityParams: ("CityParams", True), + types.CompanyParams: ("CompanyParams", True), + types.CompanyAddressParams: ("CompanyAddressParams", True), + # createdOn/modifiedOn are read-only; headers/properties are undocumented on the wire. + types.WebhookParams: ("WebhookParams", False), +} + + +@pytest.mark.parametrize("model", list(MODEL_SCHEMAS), ids=lambda m: m.__name__) +def test_typed_properties_point_to_spec_keys(model: type[NfeObject]) -> None: + allowed: set[str] = set() + for schema in MODEL_SCHEMAS[model]: + allowed |= set(SPEC_KEYS[schema]) + allowed |= set(PROVEN_DIVERGENCES.get(model.__name__, {})) + fields = list(iter_fields(model)) + assert fields, model + for name, field in fields: + for key in field.wire_keys: + assert key in allowed, f"{model.__name__}.{name} -> {key!r} is not in the spec" + + +def test_every_model_is_checked() -> None: + public = { + obj + for obj in vars(models).values() + if isinstance(obj, type) and issubclass(obj, NfeObject) and obj is not NfeObject + } + assert public == set(MODEL_SCHEMAS) + + +@pytest.mark.parametrize("typed", list(PARAM_SCHEMAS), ids=lambda t: t.__name__) +def test_request_typeddicts_match_spec(typed: Any) -> None: + schema, complete = PARAM_SCHEMAS[typed] + spec = set(SPEC_KEYS[schema]) + mine = set(typed.__annotations__) + assert mine <= spec, f"{typed.__name__} has keys not in the spec: {sorted(mine - spec)}" + if complete: + assert mine == spec, f"{typed.__name__} misses spec keys: {sorted(spec - mine)}" + + +def test_phantom_property_is_detected() -> None: + from nfeio.models._base import StrField + + class Phantom(models.ServiceInvoice): + __slots__ = () + total_amount = StrField("totalAmount") # the classic phantom field + + allowed = set(SPEC_KEYS["ServiceInvoice"]) + missing = [f.key for _, f in iter_fields(Phantom) if f.key not in allowed] + assert missing == ["totalAmount"] + + +@pytest.mark.skipif( + not (ROOT / "nfeio-docs" / "static" / "api" / "nf-servico-v1.yaml").exists(), + reason="nfeio-docs checkout not available (snapshot is used instead)", +) +def test_snapshot_is_fresh() -> None: + result = subprocess.run( # noqa: S603 - fixed arguments + [sys.executable, str(ROOT / "scripts" / "spec_keys.py"), "--check"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py new file mode 100644 index 0000000..d75833a --- /dev/null +++ b/tests/unit/test_transport.py @@ -0,0 +1,272 @@ +"""The stdlib transport against real local sockets (no internet).""" + +from __future__ import annotations + +import http.server +import shutil +import socket +import ssl +import subprocess +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import pytest + +from nfeio import ( + APIConnectionError, + APITimeoutError, + AsyncNfeClient, + ConfigurationError, + FailurePhase, + NfeClient, + ResponseTooLargeError, + Timeout, +) +from nfeio.transport import HttpClientTransport, HttpRequest, ThreadedAsyncTransport + + +class _Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args: Any) -> None: + pass + + def _serve(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + self.server.seen.append((self.command, self.path, dict(self.headers), body)) # type: ignore[attr-defined] + mode = self.path.split("?")[0].strip("/") + if mode == "slow": + time.sleep(1.0) + if mode == "drop": + payload = b"{}" + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self.close_connection = True + return + if mode == "close": + self.close_connection = True + self.connection.close() + return + if mode == "big": + payload = b"x" * 300_000 + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() # no Content-Length: forces chunk-by-chunk accounting + self.close_connection = True + self.wfile.write(payload) + return + if mode == "declared-big": + self.send_response(200) + self.send_header("Content-Length", "999999999") + self.end_headers() + self.close_connection = True + return + payload = b'{"webHooks": []}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.send_header("x-request-id", "local:1") + self.end_headers() + self.wfile.write(payload) + + do_GET = do_POST = do_PUT = do_DELETE = _serve + + +@contextmanager +def _server(tls: ssl.SSLContext | None = None) -> Iterator[tuple[str, Any]]: + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server.seen = [] # type: ignore[attr-defined] + server.daemon_threads = True + if tls is not None: + server.socket = tls.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + scheme = "https" if tls else "http" + try: + yield f"{scheme}://127.0.0.1:{server.server_address[1]}", server + finally: + server.shutdown() + server.server_close() + + +_OPEN: list[HttpClientTransport] = [] + + +def _t(**kwargs: Any) -> HttpClientTransport: + transport = HttpClientTransport(**kwargs) + _OPEN.append(transport) + return transport + + +@pytest.fixture(autouse=True) +def _close_transports() -> Iterator[None]: + yield + while _OPEN: + _OPEN.pop().close() + + +def _req(url: str, method: str = "GET", **kwargs: Any) -> HttpRequest: + kwargs.setdefault("timeout", Timeout(connect=2, read=2, total=5)) + return HttpRequest(method=method, url=url, headers={"Accept": "application/json"}, **kwargs) + + +def test_get_and_keepalive_pool() -> None: + transport = _t() + with _server() as (base, server): + r1 = transport.send(_req(base + "/ok")) + transport.send(_req(base + "/ok?x=1")) + assert r1.status_code == 200 and r1.body == b'{"webHooks": []}' + assert r1.headers["X-REQUEST-ID"] == "local:1" + assert transport.idle_connections() == 1 + assert server.seen[1][1] == "/ok?x=1" + assert server.seen[0][2]["Accept-Encoding"] == "identity" + transport.send(_req(base + "/ok", method="POST", body=b"{}")) + assert transport.idle_connections() == 1 + transport.close() + assert transport.idle_connections() == 0 + with pytest.raises(ConfigurationError): + transport.send(_req(base + "/ok")) + + +def test_stale_pooled_connection_is_replayed_for_get() -> None: + transport = _t() + with _server() as (base, server): + # "/drop" answers normally (keep-alive, no "Connection: close") and then the server + # silently closes the socket, leaving a dead connection in the client pool. + assert transport.send(_req(base + "/drop")).status_code == 200 + assert transport.idle_connections() == 1 + time.sleep(0.05) + assert transport.send(_req(base + "/ok")).status_code == 200 + assert [path for _, path, _, _ in server.seen] == ["/drop", "/ok"] + + +def test_connection_refused_is_not_established() -> None: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + with pytest.raises(APIConnectionError) as info: + _t().send(_req(f"http://127.0.0.1:{port}/x")) + assert info.value.phase is FailurePhase.NOT_ESTABLISHED + + +def test_dns_failure_is_not_established(monkeypatch: pytest.MonkeyPatch) -> None: + def no_dns(*_: Any, **__: Any) -> Any: + raise socket.gaierror(socket.EAI_NONAME, "Name or service not known") + + monkeypatch.setattr(socket, "getaddrinfo", no_dns) + with pytest.raises(APIConnectionError) as info: + _t().send(_req("http://nfeio-sdk-test.invalid/x")) + assert info.value.phase is FailurePhase.NOT_ESTABLISHED + + +def test_read_timeout_is_maybe_sent() -> None: + with _server() as (base, _), pytest.raises(APITimeoutError) as info: + _t().send(_req(base + "/slow", method="POST", body=b"{}", timeout=Timeout(1, 0.2, 5))) + assert info.value.phase is FailurePhase.MAYBE_SENT + + +def test_total_deadline() -> None: + with _server() as (base, _), pytest.raises(APITimeoutError): + _t().send(_req(base + "/slow", timeout=Timeout(1, 5, 0.3))) + + +def test_server_closes_connection_is_maybe_sent() -> None: + with _server() as (base, _), pytest.raises(APIConnectionError) as info: + _t().send(_req(base + "/close", method="POST", body=b"{}")) + assert info.value.phase is FailurePhase.MAYBE_SENT + + +def test_response_size_limit() -> None: + with _server() as (base, _): + with pytest.raises(ResponseTooLargeError): + _t().send(_req(base + "/big", max_response_bytes=100_000)) + with pytest.raises(ResponseTooLargeError): + _t().send(_req(base + "/declared-big", max_response_bytes=100_000)) + ok = _t().send(_req(base + "/big", max_response_bytes=400_000)) + assert len(ok.body) == 300_000 + + +def test_unsupported_scheme() -> None: + with pytest.raises(ConfigurationError): + _t().send(_req("ftp://example.com/x")) + + +def test_repr_hides_authorization() -> None: + req = HttpRequest( + method="GET", + url="https://api.nfe.io/v1/x?s=secret", + headers={"Authorization": "k3y-123456"}, + ) + assert "k3y-123456" not in repr(req) and "s=secret" not in repr(req) + + +@pytest.fixture(scope="module") +def self_signed(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + if shutil.which("openssl") is None: + pytest.skip("openssl not available to create a test certificate") + directory = tmp_path_factory.mktemp("tls") + cert, key = directory / "cert.pem", directory / "key.pem" + openssl = shutil.which("openssl") + assert openssl is not None + subprocess.run( # noqa: S603 - fixed arguments, test only + [ + openssl, + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=127.0.0.1", + "-addext", + "subjectAltName=IP:127.0.0.1", + "-keyout", + str(key), + "-out", + str(cert), + ], + check=True, + capture_output=True, + ) + return cert, key + + +def _server_tls(cert: Path, key: Path) -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(cert, key) + return ctx + + +def test_self_signed_rejected_then_trusted_via_ca_bundle(self_signed: tuple[Path, Path]) -> None: + cert, key = self_signed + with _server(_server_tls(cert, key)) as (base, _): + with pytest.raises(APIConnectionError) as info: + _t().send(_req(base + "/ok")) + assert info.value.phase is FailurePhase.NOT_ESTABLISHED + trusted = _t(ca_bundle=cert) + assert trusted.send(_req(base + "/ok")).status_code == 200 + client = NfeClient(api_key="k" * 12, base_urls={"account": base}, ca_bundle=cert) + assert client.webhooks.list() == [] + client.close() + + +async def test_async_client_over_real_socket(self_signed: tuple[Path, Path]) -> None: + cert, key = self_signed + with _server(_server_tls(cert, key)) as (base, server): + async with AsyncNfeClient( + api_key="k" * 12, base_urls={"account": base}, ca_bundle=cert + ) as client: + assert await client.webhooks.list() == [] + assert server.seen[0][2]["Authorization"] == "k" * 12 + transport = ThreadedAsyncTransport(HttpClientTransport()) + await transport.aclose() diff --git a/tests/unit/test_webhook_signature.py b/tests/unit/test_webhook_signature.py new file mode 100644 index 0000000..4eaaeec --- /dev/null +++ b/tests/unit/test_webhook_signature.py @@ -0,0 +1,140 @@ +"""Signature verification with the 3 real ping deliveries captured from api.nfse.io (2026-06-11) +plus the negative cases of the Node SDK spec ``webhook-signature-verification``.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from pathlib import Path +from typing import Any + +import pytest + +from nfeio import InvalidParameterError, ServiceInvoice, SignatureVerificationError +from nfeio.webhooks import construct_event, verify_signature + +VECTORS: list[dict[str, Any]] = json.loads( + (Path(__file__).parent.parent / "fixtures" / "webhook-signatures.json").read_text("utf-8") +)["fixtures"] +SECRET = "whsec-test-0123456789" + + +def _sign(body: bytes, secret: str = SECRET) -> str: + return "sha1=" + hmac.new(secret.encode(), body, hashlib.sha1).hexdigest().upper() + + +@pytest.mark.parametrize("vector", VECTORS, ids=[v["label"] for v in VECTORS]) +def test_real_deliveries(vector: dict[str, Any]) -> None: + body = vector["body"].encode("utf-8") + assert verify_signature(body, vector["header_value"], vector["secret"]) is True + assert verify_signature(vector["body"], vector["header_value"], vector["secret"]) is True + assert verify_signature(body, vector["header_value"].lower(), vector["secret"]) is True + assert verify_signature(body, "SHA1=" + vector["header_value"][5:], vector["secret"]) + assert verify_signature(body, [vector["header_value"]], vector["secret"]) + assert verify_signature(bytearray(body), vector["header_value"], vector["secret"].encode()) + + +@pytest.mark.parametrize("vector", VECTORS, ids=[v["label"] for v in VECTORS]) +def test_tampered_body(vector: dict[str, Any]) -> None: + body = bytearray(vector["body"].encode("utf-8")) + body[10] ^= 0x01 + assert verify_signature(bytes(body), vector["header_value"], vector["secret"]) is False + + +def test_negative_inputs_never_raise() -> None: + body = b'{"action":"ping"}' + good = _sign(body) + hexpart = good[5:] + cases: list[tuple[Any, Any, Any]] = [ + (body, "sha256=" + hexpart, SECRET), + (body, hexpart, SECRET), + (body, "sha1=abc", SECRET), + (body, "sha1=" + "z" * 40, SECRET), + (body, "sha1=" + "é" * 40, SECRET), + (body, None, SECRET), + (body, "", SECRET), + (body, [], SECRET), + (body, good, ""), + (body, good, b""), + (body, good, None), + (None, good, SECRET), + (12345, good, SECRET), + (body, 12345, SECRET), + (body, good + "00", SECRET), + (body, _sign(body, "other-secret"), SECRET), + ] + for payload, signature, secret in cases: + assert verify_signature(payload, signature, secret) is False, (signature, secret) + assert verify_signature(body, good, SECRET) is True + assert verify_signature(body, " " + good + " ", SECRET) is True + + +def test_uses_compare_digest(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str]] = [] + real = hmac.compare_digest + + def spy(a: str, b: str) -> bool: + calls.append((a, b)) + return real(a, b) + + monkeypatch.setattr(hmac, "compare_digest", spy) + body = b"x" + verify_signature(body, _sign(body), SECRET) + assert len(calls) == 1 + + +def test_construct_event_service_invoice() -> None: + payload = json.dumps( + {"action": "issued_successfully", "payload": {"id": "inv1", "flowStatus": "Issued"}} + ).encode() + headers = { + "x-hub-signature": _sign(payload), + "x-hook-id": "4efa03baf6014b788e591da82efbaba8", + "X-Hook-Event": "service_invoice", + } + event = construct_event(payload, headers, SECRET) + assert event.action == "issued_successfully" + assert event.event_type == "service_invoice" + assert event.hook_id == "4efa03baf6014b788e591da82efbaba8" + assert isinstance(event.data, ServiceInvoice) + assert event.data.flow_status == "Issued" + assert event.raw == payload + assert event.body["action"] == "issued_successfully" + + +def test_construct_event_ping_and_repr_hides_secret() -> None: + vector = VECTORS[1] + body = vector["body"].encode() + event = construct_event(body, {"X-Hub-Signature": [vector["header_value"]]}, vector["secret"]) + assert event.action == "ping" + assert event.data["Id"] == "ad1010b0f66441419454e5e283cce64b" + assert vector["secret"] not in repr(event) + assert vector["secret"] not in repr(event.data) + by_value = construct_event(body, vector["header_value"], vector["secret"]) + assert by_value.hook_id is None and by_value.event_type is None + + +def test_construct_event_rejects_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_: Any, **__: Any) -> Any: + raise AssertionError("body must not be parsed before verification") + + monkeypatch.setattr(json, "loads", boom) + with pytest.raises(SignatureVerificationError): + construct_event(b'{"a":1}', {"X-Hub-Signature": "sha1=" + "0" * 40}, SECRET) + with pytest.raises(SignatureVerificationError): + construct_event(b'{"a":1}', {}, SECRET) + + +def test_construct_event_limits_and_shapes() -> None: + big = b"{" + b" " * (5 * 1024 * 1024) + b"}" + with pytest.raises(InvalidParameterError): + construct_event(big, _sign(big), SECRET) + for body in (b"not json", b"[1,2]"): + with pytest.raises(InvalidParameterError): + construct_event(body, _sign(body), SECRET) + with pytest.raises(InvalidParameterError): + construct_event(None, "x", SECRET) # type: ignore[arg-type] + plain = b'{"id":"x"}' + event = construct_event(plain, _sign(plain), SECRET) + assert event.action is None and event.data["id"] == "x" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f2f33f0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1644 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore", version = "5.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "stevedore", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, + { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, + { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", hash = "sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186", size = 196517, upload-time = "2026-09-29T02:33:52.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/aa/5b6b09f835791045282dc5d08431db599a5f4743a69fe2f6670045a2cd85/msgpack-1.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec0030361cc861ac699b2ef1c695b741fa145c88f8667fa3d7e3f73deeb648a3", size = 90927, upload-time = "2026-09-29T02:31:28.286Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/7b288e9133bd1ba92ca0ca4e7f2a4cfc53cf467d99d8d2f57b9939908fac/msgpack-1.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1efdd9181cb1b719ee46865f368a927f1c0c65d577798340b1194545b7515a", size = 89798, upload-time = "2026-09-29T02:31:30.028Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/5c3dbc450d14645dcec987970692d6ab24008cc33d2155474b1d818486f9/msgpack-1.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c309a7abae1d14ba29a8bd0ddbd704a5e469d8e9bd9c3dee0e4ff53d7ae01d56", size = 450687, upload-time = "2026-09-29T02:31:32.407Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/ea60a8fd0d9e0897fce823e9fd9bf6742567784b35c7eee8f4a18a56eb19/msgpack-1.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bf390259cb25a6a1cd197c65810999b811f64cd38683251538bcc5a1e41f7d3", size = 459808, upload-time = "2026-09-29T02:31:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/42140e6afdac8e94bfedae4cfb67ee004b6ad5c4cadd024df42f759bf3b5/msgpack-1.2.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39b6986c19e1f2dfa549d185dba6ccf1de2e4c0ba10d8cfc0048935b1c5f9109", size = 423845, upload-time = "2026-09-29T02:31:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/19/7b/cd54f27b59dfbdc438a12361fbb6798b66d377a978f946bc9512598290e9/msgpack-1.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fcc6800daac4922960f6eeb7a0dda3dd4105e0bf7bce0e83ebc465a78cb7bdba", size = 445608, upload-time = "2026-09-29T02:31:37.65Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/52bc0dc44cc9f7c2339b632f93d02f8badc78cfb0bb070f2a50a51945e53/msgpack-1.2.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:968583e956d0427878050b371308c5f8647088732ef3e66a117dbe1192ec91e0", size = 421721, upload-time = "2026-09-29T02:31:39.151Z" }, + { url = "https://files.pythonhosted.org/packages/89/e6/451c9a42274fb2be82d8ba8b76a5219c613e20f8de1da521d10cb758a9ef/msgpack-1.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1d6bcec3dbbdb89ca385d3a73e63ceae7b841fa0d7ca7c676f1a7bfe7fb2cdb8", size = 460430, upload-time = "2026-09-29T02:31:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/57/bb/663e3100327b58caaa5fb66379e557a2717dac08bb586f22f885756bee47/msgpack-1.2.3-cp310-cp310-win32.whl", hash = "sha256:a6b63917d60d6df451f328bd6afba8565e33c4afe1f62ec4ad758b78731c827b", size = 67987, upload-time = "2026-09-29T02:31:42.157Z" }, + { url = "https://files.pythonhosted.org/packages/28/7a/a00d5d7abc5601099260e0d0af8fadc54fbfac2191315aa56eaee3641d9d/msgpack-1.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:4c0780095871ecc49a58b2ff6b1b43b25214704da67646557ca287a3f49fb2dd", size = 75572, upload-time = "2026-09-29T02:31:43.544Z" }, + { url = "https://files.pythonhosted.org/packages/2a/95/b9c651ccb9d720b2e2c8d537954dff528ab869a03bf89598145716db823c/msgpack-1.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af", size = 90404, upload-time = "2026-09-29T02:31:44.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/cd/fc9e2e367e80f1493e2ec5f610dda558b344eeede296f88976db133e8f2c/msgpack-1.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226", size = 89683, upload-time = "2026-09-29T02:31:46.413Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/1028485c6886c1c117f777cc9b053e541eff0fedb3292dfb1da95040edb5/msgpack-1.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac", size = 465347, upload-time = "2026-09-29T02:31:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/aa/83/800570e6a22376eb8d599920f70aead4779a63611696f567477c4e85a70f/msgpack-1.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55", size = 477820, upload-time = "2026-09-29T02:31:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/817e4a2052f848d3fb67726908d6e4e7c19f68ee7c19553a82ce7b0ed415/msgpack-1.2.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62", size = 436656, upload-time = "2026-09-29T02:31:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/040cc55dde6a7d92057baac8d1fc9cfb9f4fd4162900e2ec16dc33917a7d/msgpack-1.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a", size = 460939, upload-time = "2026-09-29T02:31:53.026Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/4dc007bdef930eed247346773bc0189b710078961d3218d5ee7ba59f322c/msgpack-1.2.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c", size = 433608, upload-time = "2026-09-29T02:31:54.981Z" }, + { url = "https://files.pythonhosted.org/packages/c0/97/a1b944046f283ec89445cb2a982c42233b5b07cc630f9be739f4f1d469a3/msgpack-1.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4", size = 477373, upload-time = "2026-09-29T02:31:56.713Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/ab411d0d172743732ab2503f4c32a22dd1a7d1436a6feecbb160e4b6376a/msgpack-1.2.3-cp311-cp311-win32.whl", hash = "sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9", size = 67514, upload-time = "2026-09-29T02:31:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/6f0cb2b84e484e96278455c26870196d025bb0cec312b226a663f1fa9000/msgpack-1.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46", size = 75850, upload-time = "2026-09-29T02:31:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/f99e13a2c1d3f5a1dcaa5aab27f474e8c4358188bbc68ad79fecb0d1aefe/msgpack-1.2.3-cp311-cp311-win_arm64.whl", hash = "sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd", size = 72338, upload-time = "2026-09-29T02:32:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/af/12/4d7c6d6203416d9fbf0f59ebaa805e70fb929b93a41b611bc821ec5964a0/msgpack-1.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43", size = 91577, upload-time = "2026-09-29T02:32:02.141Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c7/8576ad39f4ca42ddad26f68eb8621d2d0a60501193d480f504bd9d7f36c4/msgpack-1.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f", size = 90027, upload-time = "2026-09-29T02:32:03.508Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3a/aa9c580aea1314529a0f3562461479780b0d254b064f0880956bfbcc74a8/msgpack-1.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06", size = 460343, upload-time = "2026-09-29T02:32:04.906Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618", size = 472998, upload-time = "2026-09-29T02:32:06.69Z" }, + { url = "https://files.pythonhosted.org/packages/7b/41/915c81fe6df2d3cbdb0dece4f1a5cd313e1cd2abd9f501d0f50c0582517e/msgpack-1.2.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb", size = 423216, upload-time = "2026-09-29T02:32:08.739Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/7dda8b1039abfd9bba4c5068172c67135c9e33089f503512db9226f23c24/msgpack-1.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb", size = 451218, upload-time = "2026-09-29T02:32:10.517Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/ce995c1ed4a0522b7f2d034bc2034fd63005f240b945961b70fb56fbaf3d/msgpack-1.2.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb", size = 422453, upload-time = "2026-09-29T02:32:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/ce191fb87e2650d0166b34c437e499ee4a7f9db9c1eb164f41725eb6160e/msgpack-1.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438", size = 469003, upload-time = "2026-09-29T02:32:13.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/539123407fe200fb16609c835675496fbeb6017ace9fc93909f0613223ae/msgpack-1.2.3-cp312-cp312-win32.whl", hash = "sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1", size = 68303, upload-time = "2026-09-29T02:32:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/331b45f9b86fbda6b9e103244d189068e51f726d8c40021ed66e1f2c415e/msgpack-1.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d", size = 76744, upload-time = "2026-09-29T02:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/13/9f/fb572dc42b9fac06c7ea848aaee6e140d84469743bd1402bc07089fc4566/msgpack-1.2.3-cp312-cp312-win_arm64.whl", hash = "sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751", size = 71580, upload-time = "2026-09-29T02:32:17.617Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8b/3824d65e912e925d09ce30d9130fa9970d6d2855d7888b13639a6604967f/msgpack-1.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8", size = 91728, upload-time = "2026-09-29T02:32:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/df7f2c9ebb94760113debbcea2bd3afe5fdab88a4f7bec1b618755517460/msgpack-1.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709", size = 89955, upload-time = "2026-09-29T02:32:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/e5fc57136e8bacccb2b39627dea2cd546540a06181e22fe6db90e15b3ae4/msgpack-1.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca", size = 454930, upload-time = "2026-09-29T02:32:21.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/30/c394d37898db9212d1693456cdf363c7e1a097d0b63e10664007f3df3ec1/msgpack-1.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb", size = 466866, upload-time = "2026-09-29T02:32:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/1e4ddf6f6b829b3ee6c530c79dfae89cb609d2b0eedb5e0ae716851c52d1/msgpack-1.2.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5", size = 418715, upload-time = "2026-09-29T02:32:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/11/a5/f460ba6d7a12d4301002f3efbb8f841e8bdc9c5fc98d771689677a352885/msgpack-1.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37", size = 446489, upload-time = "2026-09-29T02:32:26.988Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/adface88db909bed321c85dd673655152d4a514c67e1f0800eb51c777d07/msgpack-1.2.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d", size = 416998, upload-time = "2026-09-29T02:32:28.606Z" }, + { url = "https://files.pythonhosted.org/packages/36/00/5bb3a239ccfc3763c4d0fa49b13b1b7010b00182c499ab3c1fecfe6294bc/msgpack-1.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853", size = 463288, upload-time = "2026-09-29T02:32:30.375Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/456df77f00d701df9d6980ffb80291bce6e4e2e112e25a4dfae216f0715a/msgpack-1.2.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890", size = 53347, upload-time = "2026-09-29T02:32:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ce780be666f89b77cdb855daa9ec62e87bb7f69e9f403e4a5d83a2b2208f/msgpack-1.2.3-cp313-cp313-win32.whl", hash = "sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f", size = 68258, upload-time = "2026-09-29T02:32:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/51/06/c3def9bc4db283103c5901b302ee2a4305cb1e69729244f94d9bd8f8e8e7/msgpack-1.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a", size = 76569, upload-time = "2026-09-29T02:32:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/cef344073858b80adb92d6ea342e20b0eae7a8f6fe70281b69cf03707270/msgpack-1.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047", size = 71530, upload-time = "2026-09-29T02:32:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/f777f74e38731c428857933c8011596f2d2f3160c821152f23b6ffba862f/msgpack-1.2.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8", size = 92042, upload-time = "2026-09-29T02:32:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4", size = 90578, upload-time = "2026-09-29T02:32:38.883Z" }, + { url = "https://files.pythonhosted.org/packages/ea/11/6d78ce5a9a58bf9ba7b1b6a8f649173b030e6770c8019cf330b91825ee5d/msgpack-1.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220", size = 454352, upload-time = "2026-09-29T02:32:40.34Z" }, + { url = "https://files.pythonhosted.org/packages/3d/08/feb9a196269ba7809f44f9117d9e4a601c41c313f6144fd0c337293a5488/msgpack-1.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58", size = 462562, upload-time = "2026-09-29T02:32:42.176Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/3a674f366def24140b103d1ffd4fd27b3d912a13e47da67422afa16bebb3/msgpack-1.2.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620", size = 418134, upload-time = "2026-09-29T02:32:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/82/944e71f280577490d99a3951cbce21aa4cbe04e7ab42cb373fd668af883c/msgpack-1.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30", size = 445937, upload-time = "2026-09-29T02:32:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ec/feddd629c4a3edf1395313680450c525086cceab56dec0d4de9da9ccb618/msgpack-1.2.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c", size = 416450, upload-time = "2026-09-29T02:32:47.558Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/263a10f8c4613ba0713f48cbda7695ac8dd6d6fab2fcbc9168f03f23a94d/msgpack-1.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207", size = 459546, upload-time = "2026-09-29T02:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/1e/21/addcfa1e583cfc8a22fbdc57526621b5decd7ad676ae12e9150b7be1be5d/msgpack-1.2.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150", size = 53462, upload-time = "2026-09-29T02:32:50.708Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2c/3cb5c8524a1335ee27ca952c7ab78d375a16fea8e18ae3767ba0c880416c/msgpack-1.2.3-cp314-cp314-win32.whl", hash = "sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec", size = 70294, upload-time = "2026-09-29T02:32:52.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/9172ff3cdb85d160ad06df5e2708a5fce7682982a5eee8d31869b9f69d2e/msgpack-1.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab", size = 77778, upload-time = "2026-09-29T02:32:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/04/e8/b4c23178bcf605ae17cec48a75530dd69d49b0a5a6f5f4df5c47d59f746e/msgpack-1.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290", size = 73794, upload-time = "2026-09-29T02:32:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/66/b1/92704be352c4f428b7e0a0e0fb210cb1aa2b1c42c102b8dc22d34b82fac0/msgpack-1.2.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1", size = 93721, upload-time = "2026-09-29T02:32:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/9c91f1e86cadcbc100b3780fd429c3715648704032a612e77a00646ebe79/msgpack-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18", size = 94256, upload-time = "2026-09-29T02:32:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/270f9725921ae88a29d37a774a77ac24f0ef1411fc960a63f5a4665e81b4/msgpack-1.2.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f", size = 471673, upload-time = "2026-09-29T02:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/48/b8/eaa8d930f72dc1d1dd79511dc2ccf965922b059f2f0ed3b30aebac8c4b11/msgpack-1.2.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a", size = 466257, upload-time = "2026-09-29T02:33:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/97adc805037bc7e24c4e2f711bbcd3b28be8ec9aea3e778f18208cfbdb46/msgpack-1.2.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc", size = 418484, upload-time = "2026-09-29T02:33:03.402Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7e/1c53302606fe436ab48ba539ebafafe4a6a9efe12c4f04dc7eb36912d93e/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f", size = 454064, upload-time = "2026-09-29T02:33:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/00/2d/9ee0170f638907b396c15c6cd26b3e54f869159efc6206683acfd8f696e1/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e", size = 417901, upload-time = "2026-09-29T02:33:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d2/905c84490a75cd15a27065407cd085d201f7d392e1e0411f49f03fd31ade/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db", size = 459896, upload-time = "2026-09-29T02:33:08.361Z" }, + { url = "https://files.pythonhosted.org/packages/37/cd/4ce5809b9ab3b114d7cca64863e436820fa1614b49d55ccb93d49824ac2d/msgpack-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e", size = 75983, upload-time = "2026-09-29T02:33:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/8a/31/853bb580744c24be0dbd8b090c3e6987dce466a1fc840fe50c0ac2ef9044/msgpack-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9", size = 83757, upload-time = "2026-09-29T02:33:11.441Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/9f1b2ee484414eef9e21ee2b2b23b482bb71433ab9bac1da03cbda15ebf5/msgpack-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd", size = 78128, upload-time = "2026-09-29T02:33:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/47/b8/50db4235407c3802f622b4ccdf65c6fe1e48d3c3eab6981fa6a9a5e53f11/msgpack-1.2.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c", size = 92111, upload-time = "2026-09-29T02:33:14.476Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949", size = 90583, upload-time = "2026-09-29T02:33:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/8cc02f767c3bc94d2649c954d28dea935ce9398eb9c93ce2444bb9474cc1/msgpack-1.2.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5", size = 454751, upload-time = "2026-09-29T02:33:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49", size = 463597, upload-time = "2026-09-29T02:33:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a5/e7c261abf75783c07dcac89951cb31dd0c123bf02fbdeda0c67303e698d8/msgpack-1.2.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab", size = 422661, upload-time = "2026-09-29T02:33:21.093Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/466d5133f9e1c2e232e15e304f715b62f6f0e28332d18e37d975fe174315/msgpack-1.2.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012", size = 445188, upload-time = "2026-09-29T02:33:22.877Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b4/33e7ad987ee2f4b3d449a6cbf28f574ed222987ca7f65ad277072646ac5e/msgpack-1.2.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377", size = 420451, upload-time = "2026-09-29T02:33:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/34/2c/9d8be0d6c16e7e6131cd7da20257dd3da65473e3e6df0c00572fb10a195c/msgpack-1.2.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd", size = 460624, upload-time = "2026-09-29T02:33:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3a04783582c6f44f398cbfcf5f07a111192126ec4e63edf7f5640143bf64/msgpack-1.2.3-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098", size = 53474, upload-time = "2026-09-29T02:33:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/db07359851644e258609d84f8e4fe0030ef448c108e20afe73f2a3bf539c/msgpack-1.2.3-cp315-cp315-win32.whl", hash = "sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0", size = 70344, upload-time = "2026-09-29T02:33:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/cf5584d2f2a2e4465d5896a855a3e75a34a20ab172360b3d42ad862dd1ce/msgpack-1.2.3-cp315-cp315-win_amd64.whl", hash = "sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a", size = 77800, upload-time = "2026-09-29T02:33:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/63/f9/518ad4e8a580027b507eafdd26de7aae661a714e43d7c111c212482e4a1b/msgpack-1.2.3-cp315-cp315-win_arm64.whl", hash = "sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d", size = 73871, upload-time = "2026-09-29T02:33:32.406Z" }, + { url = "https://files.pythonhosted.org/packages/a4/79/254d4c9ad642b2a3ba84e646787892b34cc815eb36c9976f67a1c4f38515/msgpack-1.2.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124", size = 93370, upload-time = "2026-09-29T02:33:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/5a2ba167646a25e84eaa8894e12935351e4331b80c28a9237ce6fe8d375f/msgpack-1.2.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173", size = 93959, upload-time = "2026-09-29T02:33:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/2b44612e55f7cf5d5e4b580294959b4429bbbcb1991177888e3e18668137/msgpack-1.2.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007", size = 467921, upload-time = "2026-09-29T02:33:37.023Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6e/3309798ed1c11d7fcfdc7b946642685b0ff1588477925bc0d26bee7dcaae/msgpack-1.2.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e", size = 467310, upload-time = "2026-09-29T02:33:38.799Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/9c799f489fa4146de4e00cfe9fee17afe33d8012f88ddffffea94f7c4700/msgpack-1.2.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6", size = 420178, upload-time = "2026-09-29T02:33:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/5850dc9cafcd2ea315692e65db0e222d20923dd55f44adf35061003de27e/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0", size = 450248, upload-time = "2026-09-29T02:33:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d2/b4c806e3497fe21f0b353568266aec14ff735d092aea672de7b2955db03f/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471", size = 418431, upload-time = "2026-09-29T02:33:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/f4ecc3ddac4d551bf2f3cdb283ec546dcc826fe7c500074be61aa273e08a/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa", size = 457543, upload-time = "2026-09-29T02:33:45.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/1c821d8386fae5cecc5fcaacf3de3947ff0a23f16bb481b5532b5868372a/msgpack-1.2.3-cp315-cp315t-win32.whl", hash = "sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a", size = 75820, upload-time = "2026-09-29T02:33:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/41e2f7343a3764a9c1fb10c79f9a6a05db9df93dedd76401d1b511f5a685/msgpack-1.2.3-cp315-cp315t-win_amd64.whl", hash = "sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3", size = 83345, upload-time = "2026-09-29T02:33:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/78c9ac93933e2ecd51d807021f78c03ffe98640f33951a8be159f5fd55bc/mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2", size = 14408618, upload-time = "2026-10-01T20:39:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4f/5f5f4429fd5a4218ce6268164a66afeed75c7e2ce5522a4d163faaa5fe85/mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23", size = 14552749, upload-time = "2026-10-01T20:40:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/fa1c319cd51db7a6027b46117460d5a361c6bba0c0f0f0670a2dab8ce2a5/mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080", size = 15505846, upload-time = "2026-10-01T20:39:15.475Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/14c02690776fb04e9fa60b81173cb9ae6315e0745fad3813b303737f2f0c/mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f", size = 15823059, upload-time = "2026-10-01T20:39:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/76/d8/d29afa819f8cb765018d6e2773e390a41ea4592f75abe417135969646857/mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb", size = 11312927, upload-time = "2026-10-01T20:39:28.509Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c8/966dec4d80303855d824e6bf67fe1da812d88adfe4d75e24d8545dab4826/mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220", size = 10879252, upload-time = "2026-10-01T20:38:41.545Z" }, + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nfe-io" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "twine" }, + { name = "types-pyyaml" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.6" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "pip-audit", specifier = ">=2.7" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.7" }, + { name = "twine", specifier = ">=6.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "nh3" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/08/47dfc28aa16ef0dbc38dd7c6e4fe24d124c3ed3a722a4a3e5021584a588c/pip_api-0.0.35.tar.gz", hash = "sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363", size = 123884, upload-time = "2026-09-08T19:35:12.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2e/d424c5b5877be862dd9a2c8b7e9ddb13382bb25e132e6dd50cc09c0a7dde/pip_api-0.0.35-py3-none-any.whl", hash = "sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894", size = 123943, upload-time = "2026-09-08T19:35:11.537Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pycparser" +version = "3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "readme-renderer" +version = "46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-08-28T15:18:32.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-08-28T15:18:31.132Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stevedore" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, +] + +[[package]] +name = "stevedore" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "twine" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/23/655a1802fe8041302c959774ca7c80b53bc24737ff3ef45cb50ef11bd96c/zipp-4.1.1.tar.gz", hash = "sha256:7ebb7a44c021b29fd8dbd7cce6812d0d7b5b454521f93cc71af6ccd155aaa70b", size = 27649, upload-time = "2026-10-03T17:03:03.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/98/df615823cd9419131ce19fba00de53a663794369e198aade064a244b385d/zipp-4.1.1-py3-none-any.whl", hash = "sha256:8979f52d874162f485ff2981e3891f3a3317b7a3dd43ff1e1775b9304f307a9c", size = 10582, upload-time = "2026-10-03T17:03:02.506Z" }, +]