From 032151af3460afb916b071ecd074d5ee69b2caa8 Mon Sep 17 00:00:00 2001 From: jonatanritter Date: Wed, 22 Jul 2026 15:05:10 -0300 Subject: [PATCH] feat: extract pure fiscal core from sowai-auto (parity-preserving copy) Copies the 6 pure fiscal modules (domains, chave_acesso, tpag, presets, resolver, xml_builder) from sowai-auto's backend/app/modules/fiscal/ into this standalone lib, with only the two mechanical changes required to drop the app.* dependency: import prefix rename, and TaxRule (ORM) -> TaxRuleLike (structural Protocol) in the resolver. No other line changes -- byte-level parity with the auto is the point. Ports the pure test suites (domains, chave_acesso, resolver with a local FakeTaxRule satisfying TaxRuleLike, xml_builder) plus a new test_presets_data covering the PRESETS dict directly (the auto's equivalent test exercises the apply-preset HTTP/DB flow, which isn't part of the extracted pure core). 55 tests pass locally via `uv run pytest`, no Postgres/k8s required. --- .gitignore | 6 + README.md | 45 +++ pyproject.toml | 21 ++ src/sowai_fiscal/__init__.py | 2 + src/sowai_fiscal/chave_acesso.py | 102 ++++++ src/sowai_fiscal/domains.py | 140 ++++++++ src/sowai_fiscal/presets.py | 98 ++++++ src/sowai_fiscal/resolver.py | 227 +++++++++++++ src/sowai_fiscal/tpag.py | 23 ++ src/sowai_fiscal/types.py | 29 ++ src/sowai_fiscal/xml_builder.py | 563 +++++++++++++++++++++++++++++++ tests/test_chave_acesso.py | 113 +++++++ tests/test_domains.py | 73 ++++ tests/test_presets_data.py | 69 ++++ tests/test_resolver.py | 217 ++++++++++++ tests/test_xml_builder.py | 303 +++++++++++++++++ uv.lock | 382 +++++++++++++++++++++ 17 files changed, 2413 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/sowai_fiscal/__init__.py create mode 100644 src/sowai_fiscal/chave_acesso.py create mode 100644 src/sowai_fiscal/domains.py create mode 100644 src/sowai_fiscal/presets.py create mode 100644 src/sowai_fiscal/resolver.py create mode 100644 src/sowai_fiscal/tpag.py create mode 100644 src/sowai_fiscal/types.py create mode 100644 src/sowai_fiscal/xml_builder.py create mode 100644 tests/test_chave_acesso.py create mode 100644 tests/test_domains.py create mode 100644 tests/test_presets_data.py create mode 100644 tests/test_resolver.py create mode 100644 tests/test_xml_builder.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ae5ae5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e5e26d --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# sowai-fiscal + +Núcleo fiscal puro da SowAI: motor de regras tributário (imposto-como-dado), +chave de acesso de NF-e (módulo 11) e builder do XML NF-e 55 (leiaute NT +2025.002 v1.40, bindings `nfelib` 2.5.x). Extraído do produto `sowai-auto` +(F1 do plano `sowai-fiscal-svc`) como lib versionada — zero I/O, zero +SQLAlchemy, zero FastAPI, zero acoplamento a nenhum produto: a interface com +o ORM do consumidor é o `Protocol` estrutural `sowai_fiscal.types.TaxRuleLike`. + +## Módulos + +- `domains` — catálogo `TaxDomain`/`DOMAIN_FIELDS`/`MATCHER_WEIGHTS` + + transposição de CFOP intra→interestadual. +- `chave_acesso` — DV módulo 11, sorteio de `cNF`, montagem da chave de 44 + dígitos. +- `tpag` — tabela de códigos `tPag` (MOC, grupo YA02). +- `presets` — presets fiscais seed-editáveis (perfis reais extraídos do + iCode da Thiago Auto Center). +- `resolver` — `resolve_fiscal`: `FiscalOperation` × regras (`TaxRuleLike`) + → `FiscalResult`. Puro, determinístico, fail-closed. +- `xml_builder` — `build_nfe`: `DadosEmissao` (já resolvido) → objeto `Nfe` + (bindings `nfelib`). +- `types` — `TaxRuleLike`, o `Protocol` que desacopla o resolver do ORM do + produto. +- `golden_helpers` — `dados_from_json`/`serialize_infnfe`, usados pelo + corpus golden (`goldens/`) e pelo teste de paridade do produto consumidor. + +## Corpus golden + +`src/sowai_fiscal/goldens/caso_.{input.json,expected.xml}` — pares +determinísticos (`dh_emi`/`cnf`/`chave_acesso` pinados no input) que travam +o leiaute XML pré-assinatura gerado a partir de um `DadosEmissao`. São dados +de pacote (lidos via `importlib.resources`), para que o produto consumidor +compare byte a byte contra os MESMOS arquivos da lib instalada — nenhuma +cópia que possa divergir. Regenerar com `uv run python scripts/gen_goldens.py` +apenas quando uma NT mudar o leiaute DE PROPÓSITO. + +## Testes + +```bash +uv sync +uv run pytest +``` + +100% local — sem Postgres, sem k8s. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..445c0ba --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "sowai-fiscal" +version = "0.1.0" +description = "Núcleo fiscal SowAI: motor de regras (imposto-como-dado), chave de acesso, builder de XML NF-e 4.00/NT 2025.002 v1.40" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.7", + "nfelib>=2.5.2", + "lxml>=5.2", + "xsdata>=24.0", +] + +[dependency-groups] +dev = ["pytest>=8", "pytest-asyncio>=0.24"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sowai_fiscal"] diff --git a/src/sowai_fiscal/__init__.py b/src/sowai_fiscal/__init__.py new file mode 100644 index 0000000..372bb6c --- /dev/null +++ b/src/sowai_fiscal/__init__.py @@ -0,0 +1,2 @@ +"""sowai_fiscal — núcleo fiscal puro da SowAI (motor de regras, chave de +acesso, builder de XML NF-e). Ver README.md para o mapa dos módulos.""" diff --git a/src/sowai_fiscal/chave_acesso.py b/src/sowai_fiscal/chave_acesso.py new file mode 100644 index 0000000..efdb713 --- /dev/null +++ b/src/sowai_fiscal/chave_acesso.py @@ -0,0 +1,102 @@ +"""Chave de acesso da NF-e (funções PURAS -- zero I/O, zero sessão). + +Estrutura (44 dígitos): cUF(2) + AAMM(4) + CNPJ(14) + mod(2) + série(3) + +nNF(9) + tpEmis(1) + cNF(8) + DV(1). + +cNF é sorteado UMA vez por documento e PERSISTE (`FiscalDocument. +codigo_numerico`, Task 4): retry de transmissão usa a MESMA chave -- +re-sortear cNF é o que fabrica a Rejeição 539 (duplicidade com chave +diferente). Regra NT 2019.001: cNF != nNF. +""" +import secrets + +_PESOS = (2, 3, 4, 5, 6, 7, 8, 9) + + +def dv_modulo11(chave43: str) -> str: + """Dígito verificador módulo 11 da chave de acesso. + + Pesos 2..9 aplicados da direita para a esquerda (repetindo em ciclos de + 8); soma dos produtos; resto = soma % 11; DV = 0 se resto in (0, 1), + senão DV = 11 - resto. Validado contra chaves reais -- ver + `tests/modules/fiscal/test_chave_acesso.py`. + """ + if len(chave43) != 43 or not chave43.isdigit(): + raise ValueError("chave sem DV deve ter 43 dígitos numéricos") + soma = 0 + for i, digito in enumerate(reversed(chave43)): + soma += int(digito) * _PESOS[i % 8] + resto = soma % 11 + return "0" if resto in (0, 1) else str(11 - resto) + + +def gerar_cnf(numero_nnf: int) -> str: + """Sorteia o código numérico (cNF) de 8 dígitos, criptograficamente + aleatório, garantindo cNF != nNF (regra NT 2019.001 -- SEFAZ rejeita + quando os dois coincidem). + + F3a (review 2026-07-16): também rejeita `cNF == "00000000"`. All-zeros + é um valor técnicamente válido pelo formato (8 dígitos), mas é o chute + óbvio de qualquer gerador ingênuo/mockado -- aceitá-lo aqui abriria + espaço para uma chave previsível se algum outro ponto do código algum + dia trocasse `secrets.randbelow` por algo mais fraco sem essa rede de + segurança. Nunca colide com `numero_nnf` de propósito (nNF sempre >= 1, + nunca 0 -- `montar_chave_acesso` já exige `numero >= 1`), então esta + checagem nunca é a mesma da de `cNF != nNF` acima.""" + while True: + cnf = f"{secrets.randbelow(100_000_000):08d}" + if int(cnf) != numero_nnf and int(cnf) != 0: + return cnf + + +def montar_chave_acesso( + *, + uf_ibge: str, + aamm: str, + cnpj: str, + modelo: str, + serie: int, + numero: int, + tp_emis: str, + cnf: str, +) -> str: + """Monta a chave de acesso de 44 dígitos a partir dos componentes já + formatados/validados pelo chamador (o orquestrador de emissão, Task 6). + Pura: não consulta banco, não decide nenhum dos valores -- só concatena + e calcula o DV. + + F1 (review 2026-07-16): valida CADA componente individualmente (tamanho + exato + só dígitos), não apenas o tamanho AGREGADO de 43 dígitos do + `chave43` final. Só checar o total é insuficiente -- dois componentes + errados que se COMPENSAM em dígitos (ex.: um `cnpj` de 13 dígitos + um + `cnf` de 9, em vez dos 14+8 corretos) somam exatamente 43 e passavam + batendo pela checagem antiga, produzindo uma chave de 44 dígitos + SINTATICAMENTE válida (DV correto) mas que aponta para o CNPJ/emitente + ERRADO -- um bug silencioso que só se manifestaria como uma rejeição + (ou pior, uma autorização indevida) na SEFAZ, não em teste algum. Cada + checagem abaixo isola o componente específico que a violaria.""" + if len(uf_ibge) != 2 or not uf_ibge.isdigit(): + raise ValueError(f"uf_ibge deve ter 2 dígitos numéricos: {uf_ibge!r}") + if len(aamm) != 4 or not aamm.isdigit(): + raise ValueError(f"aamm deve ter 4 dígitos numéricos: {aamm!r}") + if len(cnpj) != 14 or not cnpj.isdigit(): + raise ValueError(f"cnpj deve ter 14 dígitos numéricos: {cnpj!r}") + if len(modelo) != 2 or not modelo.isdigit(): + raise ValueError(f"modelo deve ter 2 dígitos numéricos: {modelo!r}") + if len(tp_emis) != 1 or not tp_emis.isdigit(): + raise ValueError(f"tp_emis deve ter 1 dígito numérico: {tp_emis!r}") + if len(cnf) != 8 or not cnf.isdigit(): + raise ValueError(f"cnf deve ter 8 dígitos numéricos: {cnf!r}") + if not (0 <= serie <= 999): + raise ValueError(f"serie fora do intervalo 0-999: {serie}") + if not (1 <= numero <= 999_999_999): + raise ValueError(f"numero fora do intervalo 1-999999999: {numero}") + + chave43 = f"{uf_ibge}{aamm}{cnpj}{modelo}{serie:03d}{numero:09d}{tp_emis}{cnf}" + # Invariante garantido pelas checagens de componente acima -- mantido + # como cinto-e-suspensório (nunca deve disparar; se disparar, é sinal + # de que uma checagem de componente ficou desalinhada com o format + # string abaixo dela). + if len(chave43) != 43: + raise ValueError(f"componentes somam {len(chave43)} dígitos, esperado 43") + return chave43 + dv_modulo11(chave43) diff --git a/src/sowai_fiscal/domains.py b/src/sowai_fiscal/domains.py new file mode 100644 index 0000000..a687369 --- /dev/null +++ b/src/sowai_fiscal/domains.py @@ -0,0 +1,140 @@ +"""Catálogo de domínios tributários do motor fiscal (Bloco B). + +`tax_domain` é `String(20)` no banco DE PROPÓSITO — validado por ESTE enum +Python na borda Pydantic. Adicionar um tributo (IBS/CBS/qualquer futuro) é +um membro novo aqui + uma entrada em DOMAIN_FIELDS: sem ALTER TYPE, sem +migration, sem o gotcha enum-NAME-vs-value que já quebrou produção neste +projeto (ver project_enum_migration_gotcha). Este é o "imposto-como-dado" +literal da Opção 1 do spec. +""" +import enum +from dataclasses import dataclass + + +class TaxDomain(str, enum.Enum): + ICMS = "icms" + ICMSST = "icmsst" + IPI = "ipi" + PIS = "pis" + COFINS = "cofins" + DIFAL = "difal" + FCP = "fcp" + IBS = "ibs" + CBS = "cbs" + ISS = "iss" + + +@dataclass(frozen=True) +class DomainFieldSpec: + """O que o form da UI mostra/exige para uma regra deste domínio. + + Nomes de campo = colunas de resultado da TaxRule. O frontend NÃO + hard-coda isto — consome via GET /fiscal/tax-domains. + + `campos_um_de`: grupos "exatamente um de" (review do frontend, + 2026-07-15) — para ICMS/ICMS-ST a situação tributária é exatamente um + de {cst, csosn} (qual, depende do CRT da filial: Simples → CSOSN, + Normal → CST). Sem isto no catálogo, o form dinâmico não tem como + exigir situação tributária e o usuário come um 422 que o form não + previu. O validator das regras consome ESTES grupos — uma fonte só. + """ + + label: str + campos_aplicaveis: tuple[str, ...] + campos_obrigatorios: tuple[str, ...] + campos_um_de: tuple[tuple[str, ...], ...] = () + + +_SITUACAO = ("cst", "csosn") # exatamente um dos dois, conforme o CRT +_VALORES = ("base_calc_percent", "aliquota") + +DOMAIN_FIELDS: dict[str, DomainFieldSpec] = { + TaxDomain.ICMS.value: DomainFieldSpec( + label="ICMS", + campos_aplicaveis=(*_SITUACAO, "cfop", *_VALORES, "codigo_beneficio"), + campos_obrigatorios=("cfop",), + campos_um_de=(_SITUACAO,), + ), + TaxDomain.ICMSST.value: DomainFieldSpec( + label="ICMS-ST", + campos_aplicaveis=(*_SITUACAO, "cfop", *_VALORES, "mva", "aliquota_st", "codigo_beneficio"), + campos_obrigatorios=("cfop",), + campos_um_de=(_SITUACAO,), + ), + TaxDomain.IPI.value: DomainFieldSpec( + label="IPI", + campos_aplicaveis=("cst", *_VALORES, "codigo_beneficio"), + campos_obrigatorios=("cst",), + ), + TaxDomain.PIS.value: DomainFieldSpec( + label="PIS", campos_aplicaveis=("cst", *_VALORES), campos_obrigatorios=("cst",), + ), + TaxDomain.COFINS.value: DomainFieldSpec( + label="COFINS", campos_aplicaveis=("cst", *_VALORES), campos_obrigatorios=("cst",), + ), + TaxDomain.DIFAL.value: DomainFieldSpec( + label="DIFAL", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",), + ), + TaxDomain.FCP.value: DomainFieldSpec( + label="FCP", campos_aplicaveis=("fcp_percent",), campos_obrigatorios=("fcp_percent",), + ), + # Reforma tributária: domínios ACEITOS hoje (regra pode ser cadastrada), + # cálculo entra no roadmap R1 do spec quando SEFAZ publicar o leiaute. + TaxDomain.IBS.value: DomainFieldSpec( + label="IBS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",), + ), + TaxDomain.CBS.value: DomainFieldSpec( + label="CBS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",), + ), + TaxDomain.ISS.value: DomainFieldSpec( + label="ISS", campos_aplicaveis=_VALORES, campos_obrigatorios=("aliquota",), + ), +} + +# Especificidade ponderada (spec, seção resolvedor): potências de 2 em ordem +# ESTRITA — cada matcher supera a soma de todos os mais fracos (64 > 63), +# então nenhuma combinação de matchers genéricos vence um mais seletivo. +# Caveat registrado no spec: ncm > cest; se um preset futuro criar regra-NCM +# que sombreie regra-CEST-de-ST, revisitar estes dois. +# +# M5 (auditoria Fable Bloco B) — pegadinha dos prefixos NCM ANINHADOS: o +# peso de `ncm_prefix` é fixo (64) independente do COMPRIMENTO do prefixo — +# uma regra com `ncm_prefix="87"` e outra com `ncm_prefix="8708"` casando a +# MESMA peça (NCM 87089990) EMPATAM em peso (ambas ganham 64), mesmo a +# segunda sendo estritamente mais específica. Isto é AMBIGUIDADE DELIBERADA +# (vira `AmbiguousRuleError`, fail-closed, nunca escolha silenciosa do +# prefixo mais longo) — o resolvedor NÃO faz "longest-prefix-wins" como um +# roteador de IP faria. A UI de cadastro de regras deve orientar o tenant a +# usar prefixos NCM DISJUNTOS (não aninhados) dentro do mesmo domínio/ +# situação, ou aceitar o 409 de ambiguidade como sinal de conflito a +# resolver manualmente. +MATCHER_WEIGHTS: dict[str, int] = { + "ncm_prefix": 64, + "cest": 32, + "consumidor_final": 16, + "indicador_ie": 8, + "uf_destino_tipo": 4, + "tipo_operacao": 2, + "crt": 1, +} + +# Transposição CFOP intra→interestadual. A tabela explícita existe porque a +# regra geral (trocar 5→6 / 1→2) está ERRADA para ST: 5405 → 6403, não 6405. +# CFOPs de ST/exceção DEVEM estar aqui; o fallback genérico cobre o resto. +TRANSPOSICAO_CFOP: dict[str, str] = { + "5102": "6102", + "5405": "6403", + "1202": "2202", +} + +_GENERIC_FIRST_DIGIT = {"5": "6", "1": "2"} + + +def transpose_cfop(cfop: str) -> str: + """CFOP-base intra-estadual → interestadual (requisito herdado #3 da 1b).""" + if cfop in TRANSPOSICAO_CFOP: + return TRANSPOSICAO_CFOP[cfop] + first = cfop[0] + if first in _GENERIC_FIRST_DIGIT: + return _GENERIC_FIRST_DIGIT[first] + cfop[1:] + return cfop diff --git a/src/sowai_fiscal/presets.py b/src/sowai_fiscal/presets.py new file mode 100644 index 0000000..0dd8957 --- /dev/null +++ b/src/sowai_fiscal/presets.py @@ -0,0 +1,98 @@ +"""Presets fiscais seed-editáveis (decisão batida #3/#4 do spec): as regras +são COPIADAS como TaxRule reais do tenant, que edita/apaga à vontade. + +Valores REAIS extraídos do iCode da Thiago +(thoughts/2026-07-13-icode-config-fiscal-extraida.md) — perfis "Padrão" +(cód 110) e "Óleos/ST" (cód 108). NÃO inventar valores aqui. + +PIS/COFINS do Óleos: CST 04 (monofásico — revenda a alíquota zero). O +`~49/52` da extração tinha um til de incerteza; óleo lubrificante revendido +é monofásico POR LEGISLAÇÃO (não é regra do cliente), logo CST 04. +⚠️ Confirmar contra a aba PIS/COFINS do perfil 108 no iCode antes do +primeiro tenant novo usar este preset em emissão real (tarefa registrada). + +NENHUM preset carrega `origem`: origem é sempre do Part (review do +frontend, 2026-07-14). +""" +from dataclasses import dataclass, field +from decimal import Decimal + + +@dataclass(frozen=True) +class PresetRule: + tax_domain: str + crt: str | None = None + tipo_operacao: str | None = None + cst: str | None = None + csosn: str | None = None + cfop: str | None = None + aliquota: Decimal | None = None + + +@dataclass(frozen=True) +class FiscalPreset: + key: str + name: str + description: str + # M2 (auditoria Fable Bloco B): CRT-alvo do preset ("1" = Simples + # Nacional para os dois presets extraídos do iCode) -- exposto no + # catálogo (`GET /fiscal/presets`) pra UI avisar quando o CRT da filial + # não bate com o preset que o tenant está aplicando. + regime_alvo: str = "1" + rules: tuple[PresetRule, ...] = field(default_factory=tuple) + + +PRESETS: dict[str, FiscalPreset] = { + "autopecas_simples_padrao": FiscalPreset( + key="autopecas_simples_padrao", + name="Autopeças — Simples Nacional (Padrão)", + description=( + "Venda de peças no Simples: CSOSN 102, CFOP 5102 (interno; o motor " + "transpõe para 6102 fora do estado), PIS/COFINS CST 08 alíquota 0, " + "IPI não destacado. Devolução (entrada): CSOSN 102, CFOP 1202/2202." + ), + rules=( + # ICMS escopado a `tipo_operacao="venda"` (review 2026-07-15, + # IMPORTANTE 1): sem escopo, esta regra também casava devolução + # (tipo_operacao=NULL casa qualquer) e devolvia CFOP de SAÍDA + # (5102/6102) para uma operação de ENTRADA — fail-open num motor + # fail-closed. A regra de devolução abaixo é a única que cobre + # tipo_operacao="devolucao" para este preset. + PresetRule( + tax_domain="icms", crt="1", tipo_operacao="venda", + csosn="102", cfop="5102", aliquota=Decimal("0"), + ), + PresetRule( + tax_domain="icms", crt="1", tipo_operacao="devolucao", + csosn="102", cfop="1202", aliquota=Decimal("0"), + ), + # PIS/COFINS ficam sem escopo de tipo_operacao: os mesmos CSTs + # valem para venda e devolução. + PresetRule(tax_domain="pis", cst="08", aliquota=Decimal("0")), + PresetRule(tax_domain="cofins", cst="08", aliquota=Decimal("0")), + ), + ), + "autopecas_simples_st": FiscalPreset( + key="autopecas_simples_st", + name="Autopeças — Simples Nacional (Óleos/ST)", + description=( + "Peça/óleo com ICMS-ST retido pelo fornecedor: CSOSN 500, CFOP 5405 " + "(interno; 6403 fora do estado), PIS/COFINS CST 04 (monofásico). " + "Devolução (entrada): CSOSN 500, CFOP 1202/2202." + ), + rules=( + # Mesmo escopo de tipo_operacao que o preset Padrão — ver + # comentário acima. + PresetRule( + tax_domain="icmsst", crt="1", tipo_operacao="venda", + csosn="500", cfop="5405", + ), + PresetRule( + tax_domain="icmsst", crt="1", tipo_operacao="devolucao", + csosn="500", cfop="1202", + ), + PresetRule(tax_domain="pis", cst="04", aliquota=Decimal("0")), + PresetRule(tax_domain="cofins", cst="04", aliquota=Decimal("0")), + ), + ), +} diff --git a/src/sowai_fiscal/resolver.py b/src/sowai_fiscal/resolver.py new file mode 100644 index 0000000..afdcb5c --- /dev/null +++ b/src/sowai_fiscal/resolver.py @@ -0,0 +1,227 @@ +"""Resolvedor fiscal puro do Bloco B: FiscalOperation × TaxRule → FiscalResult. + +Função pura, determinística, ZERO I/O — recebe as regras já carregadas. +Quem toca o banco é fiscal/service.py. Fail-closed: sem regra para um +domínio OBRIGATÓRIO (icms/icmsst com CFOP + situação tributária), levanta +FiscalConfigError — nunca emite imposto adivinhado (decisão batida #2 do +spec). `origem` é ecoada do ITEM (Part.icms_origem) e jamais de regra. +""" +import uuid +from decimal import ROUND_HALF_UP, Decimal + +from pydantic import BaseModel + +from sowai_fiscal.domains import MATCHER_WEIGHTS, TaxDomain, transpose_cfop +from sowai_fiscal.types import TaxRuleLike + +_CENT = Decimal("0.01") + + +class FiscalItem(BaseModel): + tax_profile_id: uuid.UUID + ncm: str + cest: str | None + origem: str | None + # M1 (auditoria Fable Bloco B): input completo que o spec lista (costura + # do R4/ISS) -- nenhuma regra o consome hoje, ecoado do + # `Part.tipo_fiscal` pelo simulate. + tipo_fiscal: str | None = None + quantity: Decimal + unit_price: Decimal + + +class FiscalOperation(BaseModel): + crt: str + uf_origem: str + uf_destino: str + indicador_ie: str + consumidor_final: bool + tipo_operacao: str + item: FiscalItem + + @property + def uf_destino_tipo(self) -> str: + return "interna" if self.uf_origem == self.uf_destino else "interestadual" + + +class TributoLinha(BaseModel): + tax_domain: str + cst: str | None + csosn: str | None + base_calc: Decimal + base_calc_percent: Decimal + aliquota: Decimal | None + valor: Decimal + mva: Decimal | None + aliquota_st: Decimal | None + fcp_percent: Decimal | None + codigo_beneficio: str | None + rule_id: uuid.UUID + + +class FiscalResult(BaseModel): + cfop: str + cst: str | None + csosn: str | None + origem: str | None + consumidor_final: bool + indicador_ie: str + tributos: list[TributoLinha] + + +class FiscalConfigError(Exception): + """Fail-closed: configuração fiscal faltante — a emissão DEVE bloquear.""" + + def __init__(self, missing: list[str]): + self.missing = missing + super().__init__( + "Configuração fiscal ausente para: " + ", ".join(missing) + ) + + +class AmbiguousRuleError(Exception): + """Duas regras casam com o MESMO peso — erro de configuração, nunca + escolha silenciosa (decisão do spec, review do frontend).""" + + def __init__(self, tax_domain: str, rule_ids: list[uuid.UUID]): + self.tax_domain = tax_domain + self.rule_ids = rule_ids + super().__init__( + f"Regras ambíguas para o domínio {tax_domain!r}: " + + ", ".join(str(r) for r in rule_ids) + ) + + +def _matches(rule: TaxRuleLike, op: FiscalOperation) -> int | None: + """Peso da regra para a operação, ou None se algum matcher não casa.""" + weight = 0 + checks: list[tuple[str, bool]] = [ + ("crt", rule.crt is None or rule.crt == op.crt), + ("uf_destino_tipo", rule.uf_destino_tipo is None or rule.uf_destino_tipo == op.uf_destino_tipo), + ("consumidor_final", rule.consumidor_final is None or rule.consumidor_final == op.consumidor_final), + ("indicador_ie", rule.indicador_ie is None or rule.indicador_ie == op.indicador_ie), + ("tipo_operacao", rule.tipo_operacao is None or rule.tipo_operacao == op.tipo_operacao), + ("ncm_prefix", rule.ncm_prefix is None or op.item.ncm.startswith(rule.ncm_prefix)), + ("cest", rule.cest is None or rule.cest == op.item.cest), + ] + for campo, ok in checks: + if not ok: + return None + if getattr(rule, campo) is not None: + weight += MATCHER_WEIGHTS[campo] + return weight + + +def _pick(rules: list[TaxRuleLike], domain: str, op: FiscalOperation) -> TaxRuleLike | None: + candidates: list[tuple[int, TaxRuleLike]] = [] + for rule in rules: + if rule.tax_domain != domain or rule.deleted_at is not None: + continue + weight = _matches(rule, op) + if weight is not None: + candidates.append((weight, rule)) + if not candidates: + return None + top = max(w for w, _ in candidates) + winners = [r for w, r in candidates if w == top] + if len(winners) > 1: + raise AmbiguousRuleError(domain, [r.id for r in winners]) + return winners[0] + + +def _linha(rule: TaxRuleLike, base: Decimal) -> TributoLinha: + pct = rule.base_calc_percent if rule.base_calc_percent is not None else Decimal("100") + base_efetiva = (base * pct / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP) + aliquota = rule.aliquota + if rule.tax_domain == TaxDomain.FCP.value: + # I1 (auditoria Fable Bloco B): o domínio `fcp` NÃO tem `aliquota` + # no catálogo (DOMAIN_FIELDS['fcp'] só declara `fcp_percent`) — o + # ramo genérico abaixo (aliquota is None → 0.00) fazia toda linha + # FCP sair com valor zerado, um preview que mente. Domínio-explícito + # de propósito: NÃO um fallback genérico "aliquota None usa + # fcp_percent", que surpreenderia outros domínios (ex.: PIS/COFINS + # CST 08/04, onde aliquota None É zero de verdade). + taxa = rule.fcp_percent if rule.fcp_percent is not None else Decimal("0") + valor = (base_efetiva * taxa / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP) + else: + valor = ( + (base_efetiva * aliquota / Decimal("100")).quantize(_CENT, rounding=ROUND_HALF_UP) + if aliquota is not None + else Decimal("0.00") + ) + return TributoLinha( + tax_domain=rule.tax_domain, + cst=rule.cst, csosn=rule.csosn, + base_calc=base_efetiva, base_calc_percent=pct, + aliquota=aliquota, valor=valor, + mva=rule.mva, aliquota_st=rule.aliquota_st, + fcp_percent=rule.fcp_percent, codigo_beneficio=rule.codigo_beneficio, + rule_id=rule.id, + ) + + +def _linha_st(rule: TaxRuleLike, base: Decimal, valor_icms_proprio: Decimal) -> TributoLinha: + """ICMS-ST: base_st = base × (1 + MVA%); valor = base_st × aliq_st − ICMS próprio. + Para CSOSN 500 (ST retida) mva/aliquota_st são nulos → valor 0, correto.""" + linha = _linha(rule, base) + if rule.mva is not None and rule.aliquota_st is not None: + base_st = (base * (Decimal("100") + rule.mva) / Decimal("100")).quantize( + _CENT, rounding=ROUND_HALF_UP + ) + bruto = (base_st * rule.aliquota_st / Decimal("100")).quantize( + _CENT, rounding=ROUND_HALF_UP + ) + linha = linha.model_copy( + update={"base_calc": base_st, "valor": max(Decimal("0.00"), bruto - valor_icms_proprio)} + ) + return linha + + +def resolve_fiscal(rules: list[TaxRuleLike], op: FiscalOperation) -> FiscalResult: + base = (op.item.quantity * op.item.unit_price).quantize(_CENT, rounding=ROUND_HALF_UP) + + # 1. Âncora: ICMS ou ICMS-ST — deve existir, com CFOP + situação. + icms_rule = _pick(rules, TaxDomain.ICMS.value, op) + icmsst_rule = _pick(rules, TaxDomain.ICMSST.value, op) + anchor = icmsst_rule or icms_rule # ST é a situação mais específica quando ambos casam + missing: list[str] = [] + if anchor is None: + missing.append("icms (ou icmsst): nenhuma regra casa com a operação") + else: + if anchor.cfop is None: + missing.append(f"cfop na regra {anchor.id} ({anchor.tax_domain})") + if anchor.cst is None and anchor.csosn is None: + missing.append(f"cst/csosn na regra {anchor.id} ({anchor.tax_domain})") + if missing: + raise FiscalConfigError(missing) + + # 2. CFOP com transposição (requisito herdado #3). + cfop = anchor.cfop + if op.uf_destino_tipo == "interestadual": + cfop = transpose_cfop(cfop) + + # 3. Tributos: uma linha por domínio que tiver regra casando. + tributos: list[TributoLinha] = [] + icms_linha = _linha(icms_rule, base) if icms_rule is not None else None + if icms_linha is not None: + tributos.append(icms_linha) + if icmsst_rule is not None: + valor_proprio = icms_linha.valor if icms_linha is not None else Decimal("0.00") + tributos.append(_linha_st(icmsst_rule, base, valor_proprio)) + for domain in ( + TaxDomain.IPI, TaxDomain.PIS, TaxDomain.COFINS, + TaxDomain.DIFAL, TaxDomain.FCP, TaxDomain.IBS, TaxDomain.CBS, TaxDomain.ISS, + ): + rule = _pick(rules, domain.value, op) + if rule is not None: + tributos.append(_linha(rule, base)) + + return FiscalResult( + cfop=cfop, + cst=anchor.cst, + csosn=anchor.csosn, + origem=op.item.origem, # SEMPRE do item (Part) — nunca de regra + consumidor_final=op.consumidor_final, + indicador_ie=op.indicador_ie, + tributos=tributos, + ) diff --git a/src/sowai_fiscal/tpag.py b/src/sowai_fiscal/tpag.py new file mode 100644 index 0000000..53e42a4 --- /dev/null +++ b/src/sowai_fiscal/tpag.py @@ -0,0 +1,23 @@ +"""Códigos tPag da NF-e (tabela do MOC, grupo YA02). String no banco, +validação aqui -- um código novo (ex.: futuro meio de pagamento) é uma linha +neste dict, sem migration.""" + +TPAG_CODES: dict[str, str] = { + "01": "Dinheiro", + "02": "Cheque", + "03": "Cartão de Crédito", + "04": "Cartão de Débito", + "05": "Crédito Loja", + "10": "Vale Alimentação", + "11": "Vale Refeição", + "12": "Vale Presente", + "13": "Vale Combustível", + "14": "Duplicata Mercantil", + "15": "Boleto Bancário", + "16": "Depósito Bancário", + "17": "Pagamento Instantâneo (PIX)", + "18": "Transferência bancária, Carteira Digital", + "19": "Programa de fidelidade, Cashback, Crédito Virtual", + "90": "Sem pagamento", + "99": "Outros", +} diff --git a/src/sowai_fiscal/types.py b/src/sowai_fiscal/types.py new file mode 100644 index 0000000..f58dce8 --- /dev/null +++ b/src/sowai_fiscal/types.py @@ -0,0 +1,29 @@ +"""Interface estrutural da regra fiscal — o desacoplamento que permite a lib +existir. O produto passa seus objetos (no auto, o ORM TaxRule) e eles +satisfazem o Protocol por estrutura, sem herdar nada daqui.""" +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Protocol + + +class TaxRuleLike(Protocol): + id: uuid.UUID + tax_domain: str + crt: str | None + uf_destino_tipo: str | None + consumidor_final: bool | None + indicador_ie: str | None + tipo_operacao: str | None + ncm_prefix: str | None + cest: str | None + cst: str | None + csosn: str | None + cfop: str | None + base_calc_percent: Decimal | None + aliquota: Decimal | None + mva: Decimal | None + aliquota_st: Decimal | None + fcp_percent: Decimal | None + codigo_beneficio: str | None + deleted_at: datetime | None diff --git a/src/sowai_fiscal/xml_builder.py b/src/sowai_fiscal/xml_builder.py new file mode 100644 index 0000000..548772c --- /dev/null +++ b/src/sowai_fiscal/xml_builder.py @@ -0,0 +1,563 @@ +"""Montagem PURA do XML da NF-e 55 (leiaute NT 2025.002 v1.40) -- 1b.1 +Task 5. Recebe um `DadosEmissao` já COMPLETO (o orquestrador de emissão, +`emissao.py`/Task 6, é quem coleta/valida os dados de Branch/Person/Part/ +FiscalResult e monta este dataclass) e devolve um objeto `Nfe` (bindings +xsdata do pacote `nfelib`, os MESMOS usados por `estoque/nfe_import.py` +para PARSE -- aqui construímos os mesmos dataclasses em vez de lê-los). + +ZERO I/O, ZERO sessão de banco, ZERO decisão de negócio: este módulo não +sabe o que é uma Branch ou uma Sale, só sabe montar o XML a partir dos +campos já resolvidos. Fail-closed (409 `fiscal_config_missing` nomeando +o campo) é responsabilidade do ORQUESTRADOR -- este builder assume que +`DadosEmissao` está completo e, se não estiver (ex.: `None` onde um campo +obrigatório do leiaute era esperado), deixa o próprio xsdata/lxml estourar +uma exceção genérica (AttributeError/TypeError), não um 409 estruturado. + +Nomes dos campos vêm do binding REAL instalado (nfelib 2.5.2, inspecionado +diretamente no venv -- `nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00.Tnfe. +InfNfe` e seus subgrupos -- não chutados): `nfelib.nfe.bindings.v4_0. +nfe_v4_00.Nfe` é a raiz ``; `.infNFe` é o `Tnfe.InfNfe`. + +Mapeamento de situação tributária ICMS (CST/CSOSN -> grupo do binding): +DELIBERADAMENTE parcial -- cobre exatamente as situações alcançáveis pelos +presets hoje cadastrados (`fiscal.presets`, CSOSN 102/103/300/400 e 500) e +mais alguns CSTs de regime normal (00/40/41/50/60/90) de cobertura óbvia. +Uma situação fora desta tabela levanta `UnsupportedIcmsSituationError` -- +erro claro, não um XML silenciosamente errado -- e a tabela deve crescer +quando um preset/regra novo precisar de outra situação. +""" +from dataclasses import dataclass, field +from decimal import ROUND_HALF_UP, Decimal + +from nfelib.nfe.bindings.v4_0.leiaute_nfe_v4_00 import Tendereco, TenderEmi, Tnfe +from nfelib.nfe.bindings.v4_0.nfe_v4_00 import Nfe + +from sowai_fiscal.resolver import FiscalResult, TributoLinha + +_InfNfe = Tnfe.InfNfe +_Det = _InfNfe.Det +_Icms = _Det.Imposto.Icms +_Pis = _Det.Imposto.Pis +_Cofins = _Det.Imposto.Cofins + +_CENT = Decimal("0.01") + +# Texto oficial (NT NF-e -- ambiente de homologação): substitui o xNome do +# destinatário em toda nota emitida em homologação (tpAmb=2), independente +# do nome real informado. +TEXTO_HOMOLOGACAO = "NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL" + +_SEM_GTIN = "SEM GTIN" + + +class UnsupportedIcmsSituationError(ValueError): + """CST/CSOSN fora da tabela de mapeamento deste builder (ver docstring + do módulo) -- NÃO é um 409 de dado faltante (o motor resolveu uma + situação válida), é uma lacuna de COBERTURA deste builder. Deve ser + tratado como bug/gap a fechar quando aparecer, não silenciado.""" + + def __init__(self, cst: str | None, csosn: str | None): + self.cst = cst + self.csosn = csosn + super().__init__( + f"Situação tributária ICMS não suportada pelo builder: cst={cst!r} csosn={csosn!r}" + ) + + +# --- dataclasses de input (montados pelo orquestrador, Task 6) ------------- + + +@dataclass(frozen=True) +class EmitenteData: + cnpj: str + razao_social: str + nome_fantasia: str | None + ie: str + crt: str # "1"|"2"|"3" (CRT.value) + address_street: str + address_number: str + address_complement: str | None + address_district: str + address_city: str + address_state: str + address_zip: str + address_city_ibge_code: str + fone: str | None = None + + +@dataclass(frozen=True) +class DestinatarioData: + """`None` no orquestrador == consumidor final não identificado (o + grupo `` inteiro fica ausente -- opcional no schema).""" + + nome: str + cnpj: str | None + cpf: str | None + indicador_ie: str # "1"|"2"|"9" (DestIndIedest) + ie: str | None = None + address_street: str | None = None + address_number: str | None = None + address_complement: str | None = None + address_district: str | None = None + address_city: str | None = None + address_state: str | None = None + address_zip: str | None = None + address_city_ibge_code: str | None = None + email: str | None = None + + +@dataclass(frozen=True) +class ItemData: + codigo: str # cProd + descricao: str # xProd + ncm: str + cfop: str # já transposto pelo resolvedor (resolve_fiscal) + unidade_comercial: str # uCom + unidade_tributavel: str # uTrib + quantidade: Decimal + valor_unitario: Decimal + fiscal_result: FiscalResult + gtin: str | None = None # None -> "SEM GTIN" + cest: str | None = None + peso_liquido_kg: Decimal | None = None + peso_bruto_kg: Decimal | None = None + + +@dataclass(frozen=True) +class PagamentoData: + tpag: str + valor: Decimal + indpag: str = "0" # 0=à vista, 1=a prazo + + +@dataclass(frozen=True) +class DadosEmissao: + emitente: EmitenteData + itens: list[ItemData] + pagamento: PagamentoData + ambiente: str # "homologacao"|"producao" + chave_acesso: str # 44 dígitos, já montada (chave_acesso.montar_chave_acesso) + numero: int + serie: int + cnf: str + dh_emi: str # ISO 8601 com timezone, ex. "2026-07-16T10:00:00-03:00" + uf_destino_tipo: str # "interna"|"interestadual" (mesmo campo do resolvedor) + destinatario: DestinatarioData | None = None + nat_op: str = "Venda" + tp_emis: str = "1" + ind_final: str = "1" # 1 = consumidor final (venda de balcão, o caso comum da 1b.1) + ind_pres: str = "1" # 1 = operação presencial + fin_nfe: str = "1" # 1 = NF-e normal + ver_proc: str = "sowai-auto/1b.1" + + +# --- mapeamento CST/CSOSN -> grupo ICMS do binding -------------------------- + + +def _pct(linha: TributoLinha | None) -> str: + if linha is None or linha.aliquota is None: + return "0.0000" + return str(linha.aliquota.quantize(Decimal("0.0001"))) + + +def _val(linha: TributoLinha | None) -> str: + if linha is None: + return "0.00" + return str(linha.valor.quantize(_CENT, rounding=ROUND_HALF_UP)) + + +def _base(linha: TributoLinha | None) -> str: + if linha is None: + return "0.00" + return str(linha.base_calc.quantize(_CENT, rounding=ROUND_HALF_UP)) + + +_CSOSN_SEM_PERMISSAO_CREDITO = {"102", "103", "300", "400"} + + +def _build_icms(fiscal_result: FiscalResult, icms_linha, icmsst_linha) -> _Icms: + csosn = fiscal_result.csosn + cst = fiscal_result.cst + # I3 (review Opus, 2026-07-16): SEM fallback `or "0"` -- origem ausente + # aqui é erro de PROGRAMAÇÃO (a validação de completude do orquestrador, + # `emissao.emitir_documento`, barra peça sem `icms_origem` com 409 antes + # de chegar neste builder), nunca um default silencioso: emitir "0" + # (nacional) para uma peça importada é imposto errado na SEFAZ. + if fiscal_result.origem is None: + raise ValueError( + "fiscal_result.origem é None -- input incompleto para o builder; " + "peça sem icms_origem deveria ter sido barrada na validação de " + "completude da emissão (409 fiscal_config_missing)" + ) + orig = fiscal_result.origem + + if csosn is not None: + if csosn in _CSOSN_SEM_PERMISSAO_CREDITO: + return _Icms(ICMSSN102=_Icms.Icmssn102(orig=orig, CSOSN=csosn)) + if csosn == "500": + return _Icms( + ICMSSN500=_Icms.Icmssn500( + orig=orig, + CSOSN=csosn, + vBCSTRet=_base(icmsst_linha), + pST=_pct(icmsst_linha), + vICMSSTRet=_val(icmsst_linha), + ) + ) + if csosn == "900": + return _Icms( + ICMSSN900=_Icms.Icmssn900( + orig=orig, + CSOSN=csosn, + modBC="0", + vBC=_base(icms_linha), + pICMS=_pct(icms_linha), + vICMS=_val(icms_linha), + ) + ) + raise UnsupportedIcmsSituationError(cst, csosn) + + if cst is not None: + if cst == "00": + return _Icms( + ICMS00=_Icms.Icms00( + orig=orig, CST=cst, modBC="0", + vBC=_base(icms_linha), pICMS=_pct(icms_linha), vICMS=_val(icms_linha), + ) + ) + if cst in ("40", "41", "50"): + return _Icms(ICMS40=_Icms.Icms40(orig=orig, CST=cst)) + if cst == "60": + return _Icms( + ICMS60=_Icms.Icms60( + orig=orig, CST=cst, + vBCSTRet=_base(icmsst_linha), pST=_pct(icmsst_linha), + vICMSSTRet=_val(icmsst_linha), + ) + ) + if cst == "90": + return _Icms( + ICMS90=_Icms.Icms90( + orig=orig, CST=cst, modBC="0", + vBC=_base(icms_linha), pICMS=_pct(icms_linha), vICMS=_val(icms_linha), + vBCST=_base(icmsst_linha), pICMSST=_pct(icmsst_linha), vICMSST=_val(icmsst_linha), + ) + ) + raise UnsupportedIcmsSituationError(cst, csosn) + + raise UnsupportedIcmsSituationError(cst, csosn) + + +# CST 04-09 -> "NT" (não tributado/isento/suspenso -- só CST, sem base/aliq); +# 01/02 -> Aliq (percentual); 03 -> Qtde; qualquer outro -> Outr ("99"), +# mesma tabela para PIS e COFINS (os dois têm as mesmas 4 famílias de grupo). +_PIS_COFINS_NT_CSTS = {"04", "05", "06", "07", "08", "09"} + + +def _build_pis(linha: TributoLinha | None) -> _Pis: + if linha is None or linha.cst is None: + return _Pis(PISNT=_Pis.Pisnt(CST="08")) + cst = linha.cst + if cst in _PIS_COFINS_NT_CSTS: + return _Pis(PISNT=_Pis.Pisnt(CST=cst)) + if cst in ("01", "02"): + return _Pis( + PISAliq=_Pis.Pisaliq(CST=cst, vBC=_base(linha), pPIS=_pct(linha), vPIS=_val(linha)) + ) + return _Pis(PISOutr=_Pis.Pisoutr(CST="99", vBC=_base(linha), pPIS=_pct(linha), vPIS=_val(linha))) + + +def _build_cofins(linha: TributoLinha | None) -> _Cofins: + if linha is None or linha.cst is None: + return _Cofins(COFINSNT=_Cofins.Cofinsnt(CST="08")) + cst = linha.cst + if cst in _PIS_COFINS_NT_CSTS: + return _Cofins(COFINSNT=_Cofins.Cofinsnt(CST=cst)) + if cst in ("01", "02"): + return _Cofins( + COFINSAliq=_Cofins.Cofinsaliq( + CST=cst, vBC=_base(linha), pCOFINS=_pct(linha), vCOFINS=_val(linha) + ) + ) + return _Cofins( + COFINSOutr=_Cofins.Cofinsoutr( + CST="99", vBC=_base(linha), pCOFINS=_pct(linha), vCOFINS=_val(linha) + ) + ) + + +def _linha_por_dominio(fiscal_result: FiscalResult, dominio: str) -> TributoLinha | None: + for linha in fiscal_result.tributos: + if linha.tax_domain == dominio: + return linha + return None + + +# M4 (auditoria Fable, 2026-07-16): SEM fonte real -- ver o docstring de +# `_build_ibscbs`. Módulo-level (não literais inline) de propósito, para o +# nome do valor já denunciar no call-site que é fabricado. +_PLACEHOLDER_CST_SEM_FONTE_REAL = "000" +_PLACEHOLDER_CCLASSTRIB_SEM_FONTE_REAL = "000001" + + +def _build_ibscbs(fiscal_result: FiscalResult): + """Grupo UB (IBS/CBS, RTC v1.40) -- só presente quando o `FiscalResult` + tiver linhas `ibs`/`cbs` (CRT 1 em 2026 nunca tem -- R1 do motor exige + isso a partir de 04/01/2027, ver spec). Best-effort ESTRUTURAL: os + valores de `vBC`/`pIBSUF`/`vIBSUF`/`pCBS`/`vCBS` vêm das linhas + resolvidas (REAIS -- `aliquota`/`valor`/`base_calc` do `TributoLinha`, + configurados pelo tenant na regra), mas `CST`/`cClassTrib` abaixo são + FABRICADOS ("000"/"000001") -- não existe fonte real para nenhum dos + dois hoje: `DOMAIN_FIELDS['ibs'|'cbs']` nem aceita `cst`/ + `codigo_beneficio` no cadastro da regra, só `aliquota`/ + `base_calc_percent` (ver `fiscal.domains`). O grupo RTC completo + (gIBSUF/gIBSMun/gCBS com suas dezenas de sub-campos) também ainda não + tem um mapeamento tão maduro quanto o ICMS legado. + + M4 (auditoria Fable, 2026-07-16): esta função é PROVADAMENTE + inalcançável a partir da emissão real -- `fiscal.emissao. + emitir_documento` barra com 409 `fiscal_config_missing` ANTES de + montar o XML sempre que algum item resolve uma linha ibs/cbs + (exatamente o caso em que este `if` abaixo deixaria de devolver + `None`). Só existe aqui para o builder PURO continuar com o suporte + ESTRUTURAL testável isoladamente (`test_grupo_ibscbs_presente_com_ + linhas_ibs_cbs`) -- endurecer (CST/cClassTrib reais) junto com o R1 do + motor, quando o catálogo de domínios ganhar esses campos.""" + ibs_linha = _linha_por_dominio(fiscal_result, "ibs") + cbs_linha = _linha_por_dominio(fiscal_result, "cbs") + if ibs_linha is None and cbs_linha is None: + return None + + from nfelib.nfe.bindings.v4_0.dfe_tipos_basicos_v1_00 import Tcibs, TtribNfe + + base = ibs_linha or cbs_linha + g_ibs_uf = ( + Tcibs.GIbsuf(pIBSUF=_pct(ibs_linha), vIBSUF=_val(ibs_linha)) if ibs_linha else None + ) + g_cbs = Tcibs.GCbs(pCBS=_pct(cbs_linha), vCBS=_val(cbs_linha)) if cbs_linha else None + + gibscbs = Tcibs(vBC=_base(base), gIBSUF=g_ibs_uf, gCBS=g_cbs) + # M4: nomeados _PLACEHOLDER_* (não `CST`/`cClassTrib` soltos) de + # propósito -- deixa explícito, no próprio call-site, que estes DOIS + # valores não vêm de lugar nenhum real (ver o docstring acima). + return TtribNfe( + CST=_PLACEHOLDER_CST_SEM_FONTE_REAL, + cClassTrib=_PLACEHOLDER_CCLASSTRIB_SEM_FONTE_REAL, + gIBSCBS=gibscbs, + ) + + +def _build_det(item: ItemData, n_item: int) -> _Det: + fr = item.fiscal_result + icms_linha = _linha_por_dominio(fr, "icms") + icmsst_linha = _linha_por_dominio(fr, "icmsst") + pis_linha = _linha_por_dominio(fr, "pis") + cofins_linha = _linha_por_dominio(fr, "cofins") + + v_prod = (item.quantidade * item.valor_unitario).quantize(_CENT, rounding=ROUND_HALF_UP) + + prod = _Det.Prod( + cProd=item.codigo, + cEAN=item.gtin or _SEM_GTIN, + xProd=item.descricao, + NCM=item.ncm, + CEST=item.cest, + CFOP=item.cfop, + uCom=item.unidade_comercial, + qCom=str(item.quantidade), + vUnCom=str(item.valor_unitario), + vProd=str(v_prod), + cEANTrib=item.gtin or _SEM_GTIN, + uTrib=item.unidade_tributavel, + qTrib=str(item.quantidade), + vUnTrib=str(item.valor_unitario), + indTot="1", + ) + imposto = _Det.Imposto( + ICMS=_build_icms(fr, icms_linha, icmsst_linha), + PIS=_build_pis(pis_linha), + COFINS=_build_cofins(cofins_linha), + IBSCBS=_build_ibscbs(fr), + ) + return _Det(prod=prod, imposto=imposto, nItem=str(n_item)) + + +def soma_itens_quantizados(itens: list[ItemData]) -> Decimal: + """Fonte ÚNICA do somatório de produtos (I1, review Opus 2026-07-16): + soma dos `vProd` POR ITEM já quantizados (2 casas, ROUND_HALF_UP) -- + exatamente o valor que aparece em cada `` e, portanto, + o único somatório que fecha com `vProd`/`vNF` do ``. O `vPag` + do orquestrador (`emissao.py`) TEM que derivar daqui também: qualquer + outro caminho (ex.: somar `qtd*preço` cru e quantizar só o agregado) + diverge em 1 centavo quando um item cai em fração de centavo, e a SEFAZ + rejeita com "Valor do Pagamento difere do total".""" + total = sum( + (item.quantidade * item.valor_unitario).quantize(_CENT, rounding=ROUND_HALF_UP) + for item in itens + ) + return Decimal(total).quantize(_CENT, rounding=ROUND_HALF_UP) + + +def _sum_valor(itens: list[ItemData], dominio: str) -> Decimal: + total = Decimal("0.00") + for item in itens: + linha = _linha_por_dominio(item.fiscal_result, dominio) + if linha is not None: + total += linha.valor + return total.quantize(_CENT, rounding=ROUND_HALF_UP) + + +def _sum_base(itens: list[ItemData], dominio: str) -> Decimal: + total = Decimal("0.00") + for item in itens: + linha = _linha_por_dominio(item.fiscal_result, dominio) + if linha is not None: + total += linha.base_calc + return total.quantize(_CENT, rounding=ROUND_HALF_UP) + + +def _build_total(itens: list[ItemData]) -> _InfNfe.Total: + v_prod = soma_itens_quantizados(itens) + v_icms = _sum_valor(itens, "icms") + v_bc = _sum_base(itens, "icms") + v_st = _sum_valor(itens, "icmsst") + v_bcst = _sum_base(itens, "icmsst") + v_pis = _sum_valor(itens, "pis") + v_cofins = _sum_valor(itens, "cofins") + zero = "0.00" + + icms_tot = _InfNfe.Total.Icmstot( + vBC=str(v_bc), vICMS=str(v_icms), vICMSDeson=zero, vFCP=zero, + vBCST=str(v_bcst), vST=str(v_st), vFCPST=zero, vFCPSTRet=zero, + vProd=str(v_prod), vFrete=zero, vSeg=zero, vDesc=zero, + vII=zero, vIPI=zero, vIPIDevol=zero, vPIS=str(v_pis), vCOFINS=str(v_cofins), + vOutro=zero, vNF=str(v_prod), + ) + return _InfNfe.Total(ICMSTot=icms_tot) + + +def _build_ide(dados: DadosEmissao) -> _InfNfe.Ide: + id_dest = "1" if dados.uf_destino_tipo == "interna" else "2" + return _InfNfe.Ide( + cUF=dados.chave_acesso[0:2], + cNF=dados.cnf, + natOp=dados.nat_op, + mod="55", + serie=str(dados.serie), + nNF=str(dados.numero), + dhEmi=dados.dh_emi, + tpNF="1", + idDest=id_dest, + cMunFG=dados.emitente.address_city_ibge_code, + tpImp="1", + tpEmis=dados.tp_emis, + cDV=dados.chave_acesso[-1], + tpAmb="2" if dados.ambiente == "homologacao" else "1", + finNFe=dados.fin_nfe, + indFinal=dados.ind_final, + indPres=dados.ind_pres, + procEmi="0", + verProc=dados.ver_proc, + ) + + +def _build_emit(emitente: EmitenteData) -> _InfNfe.Emit: + ender = TenderEmi( + xLgr=emitente.address_street, + nro=emitente.address_number, + xCpl=emitente.address_complement, + xBairro=emitente.address_district, + cMun=emitente.address_city_ibge_code, + xMun=emitente.address_city, + UF=emitente.address_state, + CEP=emitente.address_zip, + cPais="1058", + xPais="Brasil", + fone=emitente.fone, + ) + return _InfNfe.Emit( + CNPJ=emitente.cnpj, + xNome=emitente.razao_social, + xFant=emitente.nome_fantasia, + enderEmit=ender, + IE=emitente.ie, + CRT=emitente.crt, + ) + + +def _build_dest(dados: DadosEmissao) -> _InfNfe.Dest | None: + dest = dados.destinatario + if dest is None: + return None + + x_nome = TEXTO_HOMOLOGACAO if dados.ambiente == "homologacao" else dest.nome + + ender = None + if dest.address_street is not None: + ender = Tendereco( + xLgr=dest.address_street, + nro=dest.address_number, + xCpl=dest.address_complement, + xBairro=dest.address_district, + cMun=dest.address_city_ibge_code, + xMun=dest.address_city, + UF=dest.address_state, + CEP=dest.address_zip, + cPais="1058", + xPais="Brasil", + ) + + return _InfNfe.Dest( + CNPJ=dest.cnpj, + CPF=dest.cpf, + xNome=x_nome, + enderDest=ender, + indIEDest=dest.indicador_ie, + IE=dest.ie, + email=dest.email, + ) + + +def _build_transp() -> _InfNfe.Transp: + # 1b.1: venda de balcão/PDV -- sem transportador/volume rastreado ainda + # (Sale/SaleItem não carregam esses dados). modFrete=9 "Sem Ocorrência + # de Transporte" é o valor correto para essa realidade, não um chute. + return _InfNfe.Transp(modFrete="9") + + +def _build_pag(pagamento: PagamentoData) -> _InfNfe.Pag: + det_pag = _InfNfe.Pag.DetPag( + indPag=pagamento.indpag, + tPag=pagamento.tpag, + vPag=str(pagamento.valor.quantize(_CENT, rounding=ROUND_HALF_UP)), + ) + return _InfNfe.Pag(detPag=[det_pag]) + + +def build_nfe(dados: DadosEmissao) -> Nfe: + """Monta o objeto `Nfe` (nfelib) completo a partir de `DadosEmissao` -- + PURA: nenhuma chamada de rede/banco, nenhuma decisão de negócio (tudo + que precisava de contexto -- CFOP, CST/CSOSN, ambiente, chave -- já + veio resolvido no input pelo orquestrador). O `Id` do `infNFe` é + `"NFe" + chave_acesso` (44 dígitos), exatamente o formato que + `erpbrasil.assinatura` referencia na assinatura (Task 6).""" + ide = _build_ide(dados) + emit = _build_emit(dados.emitente) + dest = _build_dest(dados) + dets = [_build_det(item, n) for n, item in enumerate(dados.itens, start=1)] + total = _build_total(dados.itens) + transp = _build_transp() + pag = _build_pag(dados.pagamento) + + inf_nfe = _InfNfe( + ide=ide, + emit=emit, + dest=dest, + det=dets, + total=total, + transp=transp, + pag=pag, + versao="4.00", + Id="NFe" + dados.chave_acesso, + ) + return Nfe(infNFe=inf_nfe) diff --git a/tests/test_chave_acesso.py b/tests/test_chave_acesso.py new file mode 100644 index 0000000..7054ce6 --- /dev/null +++ b/tests/test_chave_acesso.py @@ -0,0 +1,113 @@ +"""Chave de acesso NF-e: cUF(2)+AAMM(4)+CNPJ(14)+mod(2)+série(3)+nNF(9)+ +tpEmis(1)+cNF(8)+DV(1) = 44 dígitos. DV = módulo 11 (pesos 2..9, da direita +para a esquerda; resto 0 ou 1 -> DV 0). + +`dv_modulo11` é validado contra chaves REAIS (não inventadas) -- mas NÃO as +de `tests/modules/estoque/test_nfe_import.py` como o plano original +cogitava: aquele fixture é hand-written (docstring do próprio arquivo o diz) +e tem só UMA chave de 44 dígitos, cujo último dígito NÃO bate com o DV +módulo 11 (conferido por script antes de escrever este teste -- não é uma +chave real, é um ID de exemplo forjado só para exercitar o parser). Fonte +usada em vez disso: os XMLs de amostra que o próprio pacote `nfelib` +(dependência já pinada em pyproject.toml, versão instalada no pod +confirmada em 2.5.2 na Task 1 Step 0) empacota em +`nfelib/nfe/samples/v4_0/leiauteNFe/*.xml` -- os NOMES desses arquivos SÃO +as chaves de acesso de NF-e reais/realistas de exemplo, com DV +correto -- confirmado por decomposição manual antes de escrever este teste: +`35180834128745000152550010000476711079516696` (mod=55, DV correto=6) e +`35200159594315000157550010000000012062777161` (mod=55, DV correto=1, +também o `Id` do `infNFe` dentro de +`NFe35200159594315000157550010000000012062777161.xml`).""" +import pytest + +from sowai_fiscal.chave_acesso import dv_modulo11, gerar_cnf, montar_chave_acesso + + +def test_dv_modulo11_contra_chave_real_1_amostra_nfelib(): + chave44 = "35180834128745000152550010000476711079516696" + assert dv_modulo11(chave44[:43]) == chave44[43] + + +def test_dv_modulo11_contra_chave_real_2_amostra_nfelib(): + chave44 = "35200159594315000157550010000000012062777161" + assert dv_modulo11(chave44[:43]) == chave44[43] + + +def test_dv_modulo11_rejeita_chave_com_tamanho_errado(): + with pytest.raises(ValueError): + dv_modulo11("123") + + +def test_montar_chave_tem_44_digitos_e_dv_valido(): + chave = montar_chave_acesso( + uf_ibge="41", aamm="2607", cnpj="12345678000190", + modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321", + ) + assert len(chave) == 44 and chave.isdigit() + assert chave[-1] == dv_modulo11(chave[:43]) + assert chave[22:25] == "001" and chave[25:34] == "000001014" + + +def test_gerar_cnf_8_digitos_e_diferente_do_nnf(): + for _ in range(50): + cnf = gerar_cnf(numero_nnf=1014) + assert len(cnf) == 8 and cnf.isdigit() + assert int(cnf) != 1014 # regra NT2019.001: cNF != nNF + + +def test_gerar_cnf_nunca_all_zeros(): + # F3a (review 2026-07-16): all-zeros é 8 dígitos válidos no FORMATO mas + # é o chute óbvio de um gerador fraco -- nunca deve sair do sorteio, + # mesmo por acaso estatístico (1 em 10^8 por chamada; 200 chamadas aqui + # deixa a chance de falso-negativo desprezível). + for _ in range(200): + assert gerar_cnf(numero_nnf=1014) != "00000000" + + +def test_montar_chave_acesso_rejeita_componentes_que_se_compensam_no_total(): + # F1 (review 2026-07-16, Important): o bug real -- checar SÓ o tamanho + # agregado (43 dígitos) deixava passar um CNPJ de 13 dígitos (errado, + # deveria ter 14) somado a um cNF de 9 dígitos (errado, deveria ter 8): + # 13 + 9 = 22, o mesmo total de dígitos que o par correto 14 + 8 = 22 -- + # a chave "compensada" saía com 43 dígitos e um DV módulo-11 sintaticamente + # válido, mas apontando para o CNPJ ERRADO (um dígito do cNF vazando para + # dentro do que deveria ser o CNPJ). A validação por COMPONENTE (não só o + # total) tem que capturar isto. + with pytest.raises(ValueError, match="cnpj"): + montar_chave_acesso( + uf_ibge="41", aamm="2607", cnpj="1234567800019", # 13 dígitos, não 14 + modelo="55", serie=1, numero=1014, tp_emis="1", + cnf="876543210", # 9 dígitos, não 8 -- compensa o total em 43 + ) + + +@pytest.mark.parametrize( + "campo,valor", + [ + ("uf_ibge", "4"), + ("uf_ibge", "411"), + ("aamm", "260"), + ("cnpj", "1234567800019"), + ("cnpj", "123456780001900"), + ("modelo", "5"), + ("tp_emis", "12"), + ("cnf", "8765432"), + ("cnf", "876543210"), + ], +) +def test_montar_chave_acesso_rejeita_cada_componente_fora_do_tamanho(campo, valor): + kwargs = dict( + uf_ibge="41", aamm="2607", cnpj="12345678000190", + modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321", + ) + kwargs[campo] = valor + with pytest.raises(ValueError): + montar_chave_acesso(**kwargs) + + +def test_montar_chave_acesso_rejeita_componente_nao_numerico(): + with pytest.raises(ValueError, match="cnpj"): + montar_chave_acesso( + uf_ibge="41", aamm="2607", cnpj="1234567800019A", + modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321", + ) diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..3c71f00 --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,73 @@ +"""Bloco B (spec 2026-07-14): catálogo de domínios tributários. + +`tax_domain` é String no banco de propósito (imposto-como-dado): um domínio +novo (IBS/CBS/futuro) é um membro novo NO ENUM PYTHON, sem ALTER TYPE, sem +migration, sem o gotcha enum-NAME-vs-value. Estes testes travam o contrato +que o frontend consome via GET /fiscal/tax-domains e a tabela de pesos/ +transposição que o resolvedor usa. +""" +from sowai_fiscal.domains import ( + DOMAIN_FIELDS, + MATCHER_WEIGHTS, + TaxDomain, + transpose_cfop, +) + + +def test_tax_domain_has_the_reform_domains_without_any_migration(): + # A prova literal do "imposto-como-dado": IBS/CBS já são domínios aceitos + # hoje, e não existe NENHUM tipo enum Postgres para tax_domain. + assert TaxDomain.IBS.value == "ibs" + assert TaxDomain.CBS.value == "cbs" + assert {d.value for d in TaxDomain} >= { + "icms", "icmsst", "ipi", "pis", "cofins", "difal", "fcp", "ibs", "cbs", "iss", + } + + +def test_domain_fields_catalog_covers_every_domain(): + # O frontend monta o form por domínio a partir deste catálogo — cada + # domínio precisa declarar campos aplicáveis e obrigatórios. + for domain in TaxDomain: + spec = DOMAIN_FIELDS[domain.value] + assert spec.label + assert set(spec.campos_obrigatorios) <= set(spec.campos_aplicaveis) + for grupo in spec.campos_um_de: + assert set(grupo) <= set(spec.campos_aplicaveis) + assert not set(grupo) & set(spec.campos_obrigatorios) # um-de nunca também obrigatório + + +def test_icms_declares_the_exclusive_situacao_group(): + # A UI exige "situação tributária" (cst OU csosn) a partir DAQUI — sem + # este grupo no catálogo, o form dinâmico deixaria passar regra de ICMS + # sem situação e o usuário comeria um 422 imprevisto (review frontend). + assert ("cst", "csosn") in DOMAIN_FIELDS["icms"].campos_um_de + assert ("cst", "csosn") in DOMAIN_FIELDS["icmsst"].campos_um_de + + +def test_matcher_weights_are_strict_powers_of_two(): + # Cada peso supera a SOMA de todos os mais fracos (64 > 63): nenhuma + # combinação de matchers genéricos vence um mais seletivo. É a correção + # do review do frontend sobre "contagem de matchers". + ordered = sorted(MATCHER_WEIGHTS.values()) + for i, w in enumerate(ordered): + assert w > sum(ordered[:i]) + assert MATCHER_WEIGHTS["ncm_prefix"] == 64 + assert MATCHER_WEIGHTS["cest"] == 32 + assert MATCHER_WEIGHTS["consumidor_final"] == 16 + assert MATCHER_WEIGHTS["indicador_ie"] == 8 + assert MATCHER_WEIGHTS["uf_destino_tipo"] == 4 + assert MATCHER_WEIGHTS["tipo_operacao"] == 2 + assert MATCHER_WEIGHTS["crt"] == 1 + + +def test_transpose_cfop_uses_the_explicit_table_for_st(): + # 5405 → 6403, NÃO 6405: é por isso que a tabela explícita existe. + assert transpose_cfop("5405") == "6403" + assert transpose_cfop("5102") == "6102" + assert transpose_cfop("1202") == "2202" + + +def test_transpose_cfop_generic_fallback_swaps_the_first_digit(): + # CFOP fora da tabela: regra geral 5xxx→6xxx / 1xxx→2xxx. + assert transpose_cfop("5949") == "6949" + assert transpose_cfop("1949") == "2949" diff --git a/tests/test_presets_data.py b/tests/test_presets_data.py new file mode 100644 index 0000000..da4a96e --- /dev/null +++ b/tests/test_presets_data.py @@ -0,0 +1,69 @@ +"""Presets fiscais seed-editáveis: teste PURO do dict `PRESETS` (dados, +não a API `/tax-profiles/apply-preset/*` -- aquela é do produto, DB/HTTP, +não faz parte do núcleo puro extraído). Os valores REAIS vieram do iCode +da Thiago (perfis "Padrão" cód 110 e "Óleos/ST" cód 108) -- estes testes +travam o contrato de dados que o consumidor (auto) usa para semear +`TaxRule`/`TaxProfile`.""" +from decimal import Decimal + +from sowai_fiscal.presets import PRESETS, FiscalPreset, PresetRule + + +def test_presets_catalog_has_the_two_icode_profiles(): + assert {"autopecas_simples_padrao", "autopecas_simples_st"} <= PRESETS.keys() + + +def test_padrao_preset_has_venda_and_devolucao_icms_rules(): + preset = PRESETS["autopecas_simples_padrao"] + assert isinstance(preset, FiscalPreset) + assert preset.regime_alvo == "1" + + icms_rules = [r for r in preset.rules if r.tax_domain == "icms"] + assert len(icms_rules) == 2 + + venda = next(r for r in icms_rules if r.tipo_operacao == "venda") + assert venda.csosn == "102" + assert venda.cfop == "5102" + assert venda.aliquota == Decimal("0") + + devolucao = next(r for r in icms_rules if r.tipo_operacao == "devolucao") + assert devolucao.csosn == "102" + assert devolucao.cfop == "1202" + + +def test_padrao_preset_pis_cofins_cst_08(): + preset = PRESETS["autopecas_simples_padrao"] + pis = next(r for r in preset.rules if r.tax_domain == "pis") + cofins = next(r for r in preset.rules if r.tax_domain == "cofins") + assert pis.cst == "08" + assert cofins.cst == "08" + assert pis.aliquota == Decimal("0") + assert cofins.aliquota == Decimal("0") + + +def test_st_preset_has_venda_and_devolucao_icmsst_rules(): + preset = PRESETS["autopecas_simples_st"] + icmsst_rules = [r for r in preset.rules if r.tax_domain == "icmsst"] + assert len(icmsst_rules) == 2 + + venda = next(r for r in icmsst_rules if r.tipo_operacao == "venda") + assert venda.csosn == "500" + assert venda.cfop == "5405" + + devolucao = next(r for r in icmsst_rules if r.tipo_operacao == "devolucao") + assert devolucao.csosn == "500" + assert devolucao.cfop == "1202" + + +def test_st_preset_pis_cofins_cst_04_monofasico(): + preset = PRESETS["autopecas_simples_st"] + pis = next(r for r in preset.rules if r.tax_domain == "pis") + cofins = next(r for r in preset.rules if r.tax_domain == "cofins") + assert pis.cst == "04" + assert cofins.cst == "04" + + +def test_no_preset_rule_carries_origem(): + # NENHUM preset carrega `origem`: origem é sempre do Part (review do + # frontend, 2026-07-14) -- `PresetRule` não tem sequer o campo. + assert not hasattr(PresetRule(tax_domain="icms"), "origem") diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 0000000..8ca8752 --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,217 @@ +"""Resolvedor puro do Bloco B. Sem banco, sem I/O — TaxRule montada em +memória. Os dois perfis REAIS da Thiago (extração do iCode, +thoughts/2026-07-13-icode-config-fiscal-extraida.md) são casos de teste +literais: CSOSN 102 → 5102/6102; CSOSN 500 → 5405/6403.""" +import uuid +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +import pytest + +from sowai_fiscal.resolver import ( + AmbiguousRuleError, + FiscalConfigError, + FiscalItem, + FiscalOperation, + resolve_fiscal, +) + +_PROFILE = uuid.uuid4() +_ORG = uuid.uuid4() + + +@dataclass +class FakeTaxRule: + """Satisfaz `sowai_fiscal.types.TaxRuleLike` por estrutura — o + substituto, nos testes da lib, do ORM `TaxRule` que o auto usa.""" + + id: uuid.UUID + organization_id: uuid.UUID + tax_profile_id: uuid.UUID + tax_domain: str + crt: str | None = None + uf_destino_tipo: str | None = None + consumidor_final: bool | None = None + indicador_ie: str | None = None + tipo_operacao: str | None = None + ncm_prefix: str | None = None + cest: str | None = None + cst: str | None = None + csosn: str | None = None + cfop: str | None = None + base_calc_percent: Decimal | None = None + aliquota: Decimal | None = None + mva: Decimal | None = None + aliquota_st: Decimal | None = None + fcp_percent: Decimal | None = None + codigo_beneficio: str | None = None + deleted_at: datetime | None = None + + +def _rule(**kw) -> FakeTaxRule: + defaults = dict( + id=uuid.uuid4(), organization_id=_ORG, tax_profile_id=_PROFILE, + ) + return FakeTaxRule(**{**defaults, **kw}) + + +def _op(**kw) -> FiscalOperation: + defaults = dict( + crt="1", uf_origem="PR", uf_destino="PR", + indicador_ie="nao_contribuinte", consumidor_final=True, + tipo_operacao="venda", + item=FiscalItem( + tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0", + quantity=Decimal("2"), unit_price=Decimal("75.00"), + ), + ) + return FiscalOperation(**{**defaults, **kw}) + + +_PADRAO = [ # preset "Padrão" da Thiago (CSOSN 102) + _rule(tax_domain="icms", crt="1", csosn="102", cfop="5102", aliquota=Decimal("0")), + _rule(tax_domain="pis", cst="08", aliquota=Decimal("0")), + _rule(tax_domain="cofins", cst="08", aliquota=Decimal("0")), +] + + +def test_padrao_interna_resolves_5102_csosn_102(): + result = resolve_fiscal(_PADRAO, _op()) + assert result.cfop == "5102" + assert result.csosn == "102" + assert result.cst is None + assert result.origem == "0" # ecoado do ITEM (Part), nunca de regra + icms = next(t for t in result.tributos if t.tax_domain == "icms") + assert icms.base_calc == Decimal("150.00") + assert icms.valor == Decimal("0.00") + assert icms.rule_id == _PADRAO[0].id + + +def test_padrao_interestadual_transposes_to_6102(): + result = resolve_fiscal(_PADRAO, _op(uf_destino="SP")) + assert result.cfop == "6102" + + +def test_st_profile_resolves_5405_and_6403(): + st_rules = [_rule(tax_domain="icmsst", crt="1", csosn="500", cfop="5405")] + assert resolve_fiscal(st_rules, _op()).cfop == "5405" + assert resolve_fiscal(st_rules, _op(uf_destino="SP")).cfop == "6403" # NÃO 6405 + + +def test_more_selective_ncm_rule_beats_generic_rule_with_more_matchers(): + # A correção do review: NCM (peso 64) vence 3 matchers genéricos + # (crt=1 + uf=4 + indicador_ie=8 = 13). + generic = _rule( + tax_domain="icms", crt="1", uf_destino_tipo="interna", + indicador_ie="nao_contribuinte", csosn="102", cfop="5102", + ) + by_ncm = _rule(tax_domain="icms", ncm_prefix="8708", csosn="500", cfop="5405") + result = resolve_fiscal([generic, by_ncm], _op()) + assert result.csosn == "500" + + +def test_rule_with_non_matching_matcher_is_skipped(): + rule_sp = _rule(tax_domain="icms", uf_destino_tipo="interestadual", csosn="102", cfop="5102") + with pytest.raises(FiscalConfigError): + resolve_fiscal([rule_sp], _op()) # operação interna; a regra não casa + + +def test_tie_same_weight_raises_ambiguous(): + a = _rule(tax_domain="icms", crt="1", csosn="102", cfop="5102") + b = _rule(tax_domain="icms", crt="1", csosn="101", cfop="5102") + with pytest.raises(AmbiguousRuleError) as exc: + resolve_fiscal([a, b], _op()) + assert {a.id, b.id} == set(exc.value.rule_ids) + + +def test_fail_closed_lists_the_missing_domain(): + with pytest.raises(FiscalConfigError) as exc: + resolve_fiscal([], _op()) + assert "icms" in " ".join(exc.value.missing) + + +def test_optional_domain_absent_does_not_block(): + # Sem regra de FCP/IPI → simplesmente não entra no resultado. + result = resolve_fiscal(_PADRAO, _op()) + assert {t.tax_domain for t in result.tributos} == {"icms", "pis", "cofins"} + + +def test_st_value_math_with_mva(): + # base 100; MVA 40% → base_st 140; aliq_st 18% → 25.20 − ICMS próprio 12.00 = 13.20 + rules = [ + _rule(tax_domain="icms", cst="00", cfop="5102", aliquota=Decimal("12")), + _rule( + tax_domain="icmsst", cst="10", cfop="5405", + mva=Decimal("40"), aliquota_st=Decimal("18"), + ), + ] + op = _op(crt="3", item=FiscalItem( + tax_profile_id=_PROFILE, ncm="27101259", cest="0600100", origem="0", + quantity=Decimal("1"), unit_price=Decimal("100.00"), + )) + result = resolve_fiscal(rules, op) + st = next(t for t in result.tributos if t.tax_domain == "icmsst") + assert st.valor == Decimal("13.20") + + +def test_reducao_de_base(): + # base 100 com redução p/ 60% e alíquota 18% → 10.80 + rules = [_rule( + tax_domain="icms", cst="20", cfop="5102", + base_calc_percent=Decimal("60"), aliquota=Decimal("18"), + )] + result = resolve_fiscal(rules, _op(crt="3", item=FiscalItem( + tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0", + quantity=Decimal("1"), unit_price=Decimal("100.00"), + ))) + icms = next(t for t in result.tributos if t.tax_domain == "icms") + assert icms.valor == Decimal("10.80") + + +def test_anchor_matches_without_cfop_names_the_field(): + """M5 (review 2026-07-15): a âncora casa (tem csosn) mas não tem CFOP -- + `FiscalConfigError.missing` deve nomear o campo faltante, não só o + domínio.""" + rule = _rule(tax_domain="icms", csosn="102", cfop=None) + with pytest.raises(FiscalConfigError) as exc: + resolve_fiscal([rule], _op()) + assert "cfop" in " ".join(exc.value.missing) + + +def test_anchor_matches_without_cst_or_csosn_names_the_field(): + """M5 (review 2026-07-15): a âncora casa (tem cfop) mas não tem cst NEM + csosn -- `FiscalConfigError.missing` deve nomear cst/csosn.""" + rule = _rule(tax_domain="icms", cfop="5102", cst=None, csosn=None) + with pytest.raises(FiscalConfigError) as exc: + resolve_fiscal([rule], _op()) + assert "cst/csosn" in " ".join(exc.value.missing) + + +def test_deleted_rule_is_ignored(): + from datetime import datetime, timezone + + dead = _rule(tax_domain="icms", csosn="102", cfop="5102", + deleted_at=datetime.now(timezone.utc)) + with pytest.raises(FiscalConfigError): + resolve_fiscal([dead], _op()) + + +def test_fcp_calculates_from_fcp_percent(): + """I1 (auditoria Fable Bloco B): o domínio `fcp` não tem `aliquota` no + catálogo (só `fcp_percent`, DOMAIN_FIELDS['fcp']) -- `_linha` genérico + só multiplica `aliquota`, então toda linha FCP saía com `valor=0.00` + (preview que mente). Prova de detecção: SEM o fix deste teste, `valor` + vem 0.00 em vez de 2.00 -- FCP 2% sobre base 100.""" + rules = [ + *_PADRAO, + _rule(tax_domain="fcp", fcp_percent=Decimal("2")), + ] + op = _op(item=FiscalItem( + tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0", + quantity=Decimal("1"), unit_price=Decimal("100.00"), + )) + result = resolve_fiscal(rules, op) + fcp = next(t for t in result.tributos if t.tax_domain == "fcp") + assert fcp.valor == Decimal("2.00") + assert fcp.fcp_percent == Decimal("2") diff --git a/tests/test_xml_builder.py b/tests/test_xml_builder.py new file mode 100644 index 0000000..0399c79 --- /dev/null +++ b/tests/test_xml_builder.py @@ -0,0 +1,303 @@ +"""1b.1 Task 5: `xml_builder.build_nfe` -- montagem PURA do XML da NF-e 55 +(NT 2025.002 v1.40). Todos os testes montam `DadosEmissao` em memória +(nenhum banco, nenhum I/O) e inspecionam o objeto `Nfe` resultante +diretamente OU sua serialização via `xsdata.formats.dataclass.serializers. +XmlSerializer` (o mesmo serializador que `emissao.py`, Task 6, usará antes +de assinar).""" +import uuid +from decimal import Decimal + +import pytest +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + +from sowai_fiscal.resolver import FiscalResult, TributoLinha +from sowai_fiscal.xml_builder import ( + TEXTO_HOMOLOGACAO, + DadosEmissao, + DestinatarioData, + EmitenteData, + ItemData, + PagamentoData, + build_nfe, +) + +_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe" + + +def _emitente(**overrides) -> EmitenteData: + base = dict( + cnpj="12345678000190", + razao_social="EMPRESA TESTE LTDA", + nome_fantasia="Empresa Teste", + ie="1234567890", + crt="1", + address_street="Rua Teste", + address_number="100", + address_complement=None, + address_district="Centro", + address_city="Curitiba", + address_state="PR", + address_zip="80000000", + address_city_ibge_code="4106902", + ) + base.update(overrides) + return EmitenteData(**base) + + +def _destinatario(**overrides) -> DestinatarioData: + base = dict( + nome="CLIENTE TESTE LTDA", + cnpj="99887766000155", + cpf=None, + indicador_ie="9", + address_street="Av Cliente", + address_number="50", + address_district="Bairro", + address_city="Curitiba", + address_state="PR", + address_zip="80000000", + address_city_ibge_code="4106902", + ) + base.update(overrides) + return DestinatarioData(**base) + + +def _tributo(tax_domain: str, *, cst=None, csosn=None, aliquota=None, valor="0.00", base_calc="100.00") -> TributoLinha: + return TributoLinha( + tax_domain=tax_domain, + cst=cst, + csosn=csosn, + base_calc=Decimal(base_calc), + base_calc_percent=Decimal("100"), + aliquota=Decimal(aliquota) if aliquota is not None else None, + valor=Decimal(valor), + mva=None, + aliquota_st=None, + fcp_percent=None, + codigo_beneficio=None, + rule_id=uuid.uuid4(), + ) + + +def _fiscal_result(cfop: str, *, csosn="102", cst=None, tributos=None, origem="0") -> FiscalResult: + return FiscalResult( + cfop=cfop, + cst=cst, + csosn=csosn, + origem=origem, + consumidor_final=True, + indicador_ie="9", + tributos=tributos if tributos is not None else [ + _tributo("pis", cst="08"), + _tributo("cofins", cst="08"), + ], + ) + + +def _item(cfop: str, **overrides) -> ItemData: + base = dict( + codigo="PC001", + descricao="Filtro de oleo", + ncm="84212300", + cfop=cfop, + unidade_comercial="UN", + unidade_tributavel="UN", + quantidade=Decimal("2"), + valor_unitario=Decimal("50.00"), + fiscal_result=_fiscal_result(cfop), + ) + base.update(overrides) + return ItemData(**base) + + +def _pagamento(**overrides) -> PagamentoData: + base = dict(tpag="01", valor=Decimal("100.00")) + base.update(overrides) + return PagamentoData(**base) + + +def _dados(**overrides) -> DadosEmissao: + base = dict( + emitente=_emitente(), + itens=[_item("5102")], + pagamento=_pagamento(), + ambiente="homologacao", + chave_acesso="4" * 44, + numero=1014, + serie=1, + cnf="87654321", + dh_emi="2026-07-16T10:00:00-03:00", + uf_destino_tipo="interna", + destinatario=_destinatario(), + ) + base.update(overrides) + return DadosEmissao(**base) + + +def _render(nfe) -> str: + config = SerializerConfig(xml_declaration=False, indent=None) + return XmlSerializer(config=config).render(nfe, ns_map={None: _NFE_NAMESPACE}) + + +# --- venda Padrão intra-PR --------------------------------------------- + + +def test_venda_padrao_intra_estado_cfop_5102_csosn_102_e_origem_do_item(): + dados = _dados(uf_destino_tipo="interna", itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="102", origem="1"))]) + + nfe = build_nfe(dados) + + det = nfe.infNFe.det[0] + assert det.prod.CFOP == "5102" + assert det.imposto.ICMS.ICMSSN102.CSOSN == "102" + assert det.imposto.ICMS.ICMSSN102.orig == "1" # origem SEMPRE do item, nunca de regra + assert nfe.infNFe.ide.idDest == "1" + + +def test_totais_batem_com_a_soma_dos_itens_decimal_2_casas(): + itens = [ + _item("5102", quantidade=Decimal("2"), valor_unitario=Decimal("50.00")), + _item("5102", quantidade=Decimal("3"), valor_unitario=Decimal("10.005")), + ] + dados = _dados(itens=itens) + + nfe = build_nfe(dados) + + # 2*50.00 = 100.00 ; 3*10.005 = 30.015 -> arredonda p/ 30.02 (ROUND_HALF_UP) + assert nfe.infNFe.total.ICMSTot.vProd == "130.02" + assert nfe.infNFe.total.ICMSTot.vNF == "130.02" + # Decimal com 2 casas sempre (nunca "100.0" ou "100") + assert nfe.infNFe.total.ICMSTot.vProd.count(".") == 1 + assert len(nfe.infNFe.total.ICMSTot.vProd.split(".")[1]) == 2 + + +# --- interestadual -------------------------------------------------------- + + +def test_venda_interestadual_cfop_6102_iddest_2(): + dados = _dados( + uf_destino_tipo="interestadual", + itens=[_item("6102", fiscal_result=_fiscal_result("6102", csosn="102"))], + ind_final="1", + destinatario=_destinatario(indicador_ie="1", address_state="SP"), + ) + + nfe = build_nfe(dados) + + det = nfe.infNFe.det[0] + assert det.prod.CFOP == "6102" + assert nfe.infNFe.ide.idDest == "2" + assert nfe.infNFe.ide.indFinal == "1" + assert nfe.infNFe.dest.indIEDest == "1" + + +# --- pagamento ------------------------------------------------------------- + + +def test_pag_detpag_tpag_do_pagamento(): + dados = _dados(pagamento=_pagamento(tpag="03", valor=Decimal("55.50"))) + + nfe = build_nfe(dados) + + det_pag = nfe.infNFe.pag.detPag[0] + assert det_pag.tPag == "03" + assert det_pag.vPag == "55.50" + + +def test_pag_tpag_17_para_pix(): + dados = _dados(pagamento=_pagamento(tpag="17", valor=Decimal("100.00"))) + + nfe = build_nfe(dados) + + assert nfe.infNFe.pag.detPag[0].tPag == "17" + + +# --- homologação ------------------------------------------------------------- + + +def test_homologacao_forca_xnome_do_destinatario_para_texto_oficial(): + dados = _dados(ambiente="homologacao", destinatario=_destinatario(nome="Cliente Real Ltda")) + + nfe = build_nfe(dados) + + assert nfe.infNFe.dest.xNome == TEXTO_HOMOLOGACAO + assert nfe.infNFe.ide.tpAmb == "2" + + +def test_producao_mantem_xnome_real_do_destinatario(): + dados = _dados(ambiente="producao", destinatario=_destinatario(nome="Cliente Real Ltda")) + + nfe = build_nfe(dados) + + assert nfe.infNFe.dest.xNome == "Cliente Real Ltda" + assert nfe.infNFe.ide.tpAmb == "1" + + +# --- grupo UB (IBS/CBS) ----------------------------------------------------- + + +def test_grupo_ibscbs_ausente_sem_linhas_ibs_cbs_crt1_2026(): + dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102"))]) + + nfe = build_nfe(dados) + + assert nfe.infNFe.det[0].imposto.IBSCBS is None + + +def test_grupo_ibscbs_presente_com_linhas_ibs_cbs(): + fr = _fiscal_result( + "5102", + tributos=[ + _tributo("pis", cst="08"), + _tributo("cofins", cst="08"), + _tributo("ibs", aliquota="0.1000", valor="10.00", base_calc="100.00"), + _tributo("cbs", aliquota="0.9000", valor="90.00", base_calc="100.00"), + ], + ) + dados = _dados(itens=[_item("5102", fiscal_result=fr)]) + + nfe = build_nfe(dados) + + ibscbs = nfe.infNFe.det[0].imposto.IBSCBS + assert ibscbs is not None + assert ibscbs.gIBSCBS.gIBSUF.vIBSUF == "10.00" + assert ibscbs.gIBSCBS.gCBS.vCBS == "90.00" + + +# --- serialização ----------------------------------------------------------- + + +def test_serializacao_gera_xml_bem_formado_com_namespace_correto(): + dados = _dados() + + nfe = build_nfe(dados) + xml = _render(nfe) + + assert xml.startswith(f'') + assert f'Id="NFe{dados.chave_acesso}"' in xml + assert "" in xml + # bem-formado: um parser XML real não estoura + from lxml import etree + + etree.fromstring(xml.encode("utf-8")) + + +def test_unsupported_icms_situation_raises_clear_error(): + from sowai_fiscal.xml_builder import UnsupportedIcmsSituationError + + dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="201"))]) + + with pytest.raises(UnsupportedIcmsSituationError): + build_nfe(dados) + + +def test_origem_none_levanta_valueerror_sem_fallback_silencioso(): + """I3 (review Opus, 2026-07-16): origem None chegando no builder é erro + de PROGRAMAÇÃO (a validação de completude da emissão barra peça sem + `icms_origem` com 409 antes) -- jamais um default '0' silencioso, que + emitiria origem nacional para peça importada (imposto errado).""" + dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", origem=None))]) + + with pytest.raises(ValueError, match="origem"): + build_nfe(dados) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..deaf97f --- /dev/null +++ b/uv.lock @@ -0,0 +1,382 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[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 = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + +[[package]] +name = "nfelib" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "xsdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/77/803771579d7167523dc111972687f11eeb3d7eede661646bd4211819c4d4/nfelib-2.5.2.tar.gz", hash = "sha256:4266adae49f7412900359a215b436aea49c561997298119676f4ee616379d3c3", size = 605114, upload-time = "2026-03-30T21:19:25.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/7d/dfaca94035969d820e9db8cf60bb3dd73c5a548c8725b65432fdbc3616bf/nfelib-2.5.2-py3-none-any.whl", hash = "sha256:88f1823d5e41cb3cb244167f846da9866365a3a7fd8dd6506740de19acdef477", size = 937849, upload-time = "2026-03-30T21:19:24.096Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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 = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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 = "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 = "sowai-fiscal" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "lxml" }, + { name = "nfelib" }, + { name = "pydantic" }, + { name = "xsdata" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "lxml", specifier = ">=5.2" }, + { name = "nfelib", specifier = ">=2.5.2" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "xsdata", specifier = ">=24.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "xsdata" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/71e9e8eac669091fd434ed494d806c8cc37614aecb34ce4c62c283f99abf/xsdata-26.2.tar.gz", hash = "sha256:c631af71aaa75734f8ce92a08fcf8389d905dee2aab0b5032c9032e9071009a6", size = 349690, upload-time = "2026-02-15T16:13:31.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/92/f0edcbc2f895ecea14a68e492b24c157625e251279a94b172a6b263290e7/xsdata-26.2-py3-none-any.whl", hash = "sha256:85a591a4405d903416afbd4a917e8dda8ea44641a3e66d72134bc2a31b3c16b0", size = 235561, upload-time = "2026-02-15T16:13:29.614Z" }, +]