From 39394bc67e9e847df09b4f4da45c6c6363a57797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:21:12 -0300 Subject: [PATCH 01/14] docs(toolkit): spec 007 and ADR 0013 for adopting tools-dataset Spec 007 replaces the local cache, spreadsheet reader, adapter base, source registry and manifest file helpers with benchcommons-tools-dataset 0.1.0, referencing tools-dataset spec 004 as the owner of the contract. ADR 0013 records the dependency through a uv git source pinned to the tag and the upgrade procedure; ADR 0005 points to it. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0005-leitor-de-planilhas.md | 2 +- docs/adr/0013-dependencia-do-tools-dataset.md | 42 ++++++++++ docs/specs/007-tools-dataset/spec.md | 79 +++++++++++++++++++ docs/specs/ROADMAP.md | 5 +- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0013-dependencia-do-tools-dataset.md create mode 100644 docs/specs/007-tools-dataset/spec.md diff --git a/docs/adr/0005-leitor-de-planilhas.md b/docs/adr/0005-leitor-de-planilhas.md index d89cfb4..989dd05 100644 --- a/docs/adr/0005-leitor-de-planilhas.md +++ b/docs/adr/0005-leitor-de-planilhas.md @@ -2,7 +2,7 @@ ## Status -Aceito (2026-10-12) +Aceito (2026-10-12). Substituído em parte pelo [ADR 0013](0013-dependencia-do-tools-dataset.md) (spec 007): a decisão continua valendo, mas o leitor e a versão fixada do `xlrd` passaram para o pacote `benchcommons-tools-dataset` ([ADR 0005 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/adr/0005-source-toolkit-runtime-dependencies.md)); onde este ADR diz `namescommons.spreadsheet`, leia `datasettool.sources.spreadsheet`. ## Contexto diff --git a/docs/adr/0013-dependencia-do-tools-dataset.md b/docs/adr/0013-dependencia-do-tools-dataset.md new file mode 100644 index 0000000..09eaa7c --- /dev/null +++ b/docs/adr/0013-dependencia-do-tools-dataset.md @@ -0,0 +1,42 @@ +# 0013. Dependência do kit de fontes do tools-dataset por tag git + +## Status + +Aceito (2026-10-12). Spec [007](../specs/007-tools-dataset/spec.md). Substitui em parte o [ADR 0005](0005-leitor-de-planilhas.md): a decisão sobre o leitor de planilhas continua valendo, mas o código e a versão fixada do `xlrd` passam a viver no tools-dataset. + +## Contexto + +O cache de downloads, o leitor de planilhas, a base do adaptador, o registro de fontes e os auxiliares de arquivo do manifesto nasceram neste repositório (specs 002, 003, 003b e 003e). Outros repositórios `dataset-*` da BenchCommons precisam do mesmo código. Em 2026-10-12 o dono decidiu mover a parte genérica para o [tools-dataset](https://github.com/BenchCommons/tools-dataset), como o pacote Python `benchcommons-tools-dataset` (import `datasettool`), definido pela [spec 004 de lá](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/spec.md). O tools-dataset é o dono desse contrato; este repositório só o consome. + +O build precisa continuar reproduzível: o mesmo commit deste repositório deve rodar sempre o mesmo código do kit. O [ADR 0004 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/adr/0004-dataset-repos-depend-on-a-git-tag.md) já compara as opções (PyPI, wheel em release, submódulo ou cópia, branch, tag git) e escolhe a tag git para todos os consumidores. + +## Decisão + +- Este repositório depende do kit pela versão exata e fixa a origem na tag correspondente, com `uv`: + + ```toml + [project] + dependencies = ["benchcommons-tools-dataset==0.1.0", ...] + + [tool.uv.sources] + benchcommons-tools-dataset = { git = "https://github.com/BenchCommons/tools-dataset", tag = "v0.1.0" } + ``` + + O `uv.lock` versionado registra o commit da tag. Uma branch nunca é usada como origem. +- `pyyaml` e `xlrd` saem das dependências diretas: o código daqui não os importa mais, e o kit os traz fixados (`pyyaml==6.0.3`, `xlrd==2.0.2`, [ADR 0005 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/adr/0005-source-toolkit-runtime-dependencies.md)). +- Correções no código genérico são feitas no tools-dataset, numa versão nova, e chegam aqui por atualização da dependência. Este repositório não guarda cópia nem remendo local do kit. + +### Procedimento de atualização + +1. Confirmar que a tag nova existe: `gh api repos/BenchCommons/tools-dataset/tags --jq '.[].name'`. +2. Ler as notas da versão e a spec do tools-dataset na tag nova. Enquanto a versão maior for 0, uma versão menor pode quebrar a API. +3. Numa branch, trocar as duas linhas juntas em `pyproject.toml` (`==X.Y.Z` e `tag = "vX.Y.Z"`) e rodar `uv lock --upgrade-package benchcommons-tools-dataset` e `uv sync`. +4. Rodar `uv run pytest -q` e os dois passos offline da CI, e comparar as saídas com as da versão anterior (`diff -r`). Diferença em saída é mudança de comportamento e precisa de spec ou ADR. +5. Abrir o PR com as duas linhas, o `uv.lock` e a evidência do passo 4. `tests/test_tools_dataset.py` falha se a versão e a tag divergirem. + +## Consequências + +- `uv sync` precisa de acesso ao GitHub quando não há cache, como já precisava ao PyPI. +- As mensagens de erro do kit são em inglês; as deste repositório continuam em português. Uma mensagem com várias linhas do registro de fontes pode misturar as duas línguas. +- Uma falha no código genérico só se corrige com uma versão nova do kit (tag de correção), nunca editando a tag existente. +- Os testes do comportamento genérico vivem no tools-dataset; aqui ficam só os testes do que é de nomes e da integração com o kit. diff --git a/docs/specs/007-tools-dataset/spec.md b/docs/specs/007-tools-dataset/spec.md new file mode 100644 index 0000000..28a144a --- /dev/null +++ b/docs/specs/007-tools-dataset/spec.md @@ -0,0 +1,79 @@ +# 007. Adoção do kit de fontes do tools-dataset + +Parte do [roadmap](../ROADMAP.md). Contrato adotado (dono: tools-dataset): [spec 004 do tools-dataset, o kit de fontes](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/spec.md), na tag `v0.1.0`. Acompanhamento: [BenchCommons/tools-dataset#5](https://github.com/BenchCommons/tools-dataset/issues/5). Dependência: [ADR 0013](../../adr/0013-dependencia-do-tools-dataset.md). Licença dos dados: [ADR 0001](../../adr/0001-licenca-dos-dados.md), ampliado pelo [ADR 0004](../../adr/0004-licenca-statistics-canada.md). + +Status: Em implementação. + +## Por quê + +O cache de downloads, o leitor de planilhas, a base do adaptador, o registro de fontes e os auxiliares de arquivo do manifesto foram escritos aqui (specs 002, 003, 003b e 003e) e endurecidos em várias revisões. Eles não têm nada de nomes: qualquer repositório `dataset-*` precisa deles. O tools-dataset portou essa parte genérica, com os mesmos vetores de teste, para o pacote `benchcommons-tools-dataset` 0.1.0 (import `datasettool`). Enquanto este repositório mantiver as suas cópias, as duas versões vão divergir, e cada correção terá de ser feita duas vezes. + +Esta spec troca as cópias locais pelo pacote e deixa aqui só o que é de nomes. O comportamento genérico (requisitos R2 a R6 da spec 004 do tools-dataset) não é redefinido aqui: vale o texto de lá, na tag fixada. + +## Requisitos + +### R1. Dependência + +- **R1.1** `pyproject.toml` declara `benchcommons-tools-dataset==0.1.0` e fixa a origem em `[tool.uv.sources]` como git `https://github.com/BenchCommons/tools-dataset`, tag `v0.1.0` (ADR 0013, que segue o ADR 0004 do tools-dataset). O `uv.lock` versionado registra o commit da tag. +- **R1.2** `pyyaml` e `xlrd` deixam de ser dependências diretas, porque nenhum módulo de `src/namescommons` os importa mais; chegam fixados pelo kit. `pyarrow` e `pycountry` continuam. Nenhuma outra dependência de runtime entra. + +### R2. O que sai deste repositório + +- **R2.1** `src/namescommons/cache.py` é removido. Os downloads usam `datasettool.sources.cache.fetch_resource` (spec 004 do tools-dataset, R2). +- **R2.2** `src/namescommons/spreadsheet.py` é removido. O adaptador `es-ine-residentes` usa `datasettool.sources.spreadsheet` (R3 de lá), que já traz a correção do PR #39. +- **R2.3** A parte genérica de `src/namescommons/adapters/__init__.py` (erros, `Resource`, `SourceFiles`, `module_name`, conferência de URL e de nome de arquivo, `check_adapter`, `load_adapter`, `parse_count`, `parse_code`) é substituída por `datasettool.sources.adapters` (R4 de lá). +- **R2.4** A parte genérica de `src/namescommons/sources.py` (leitura do YAML, campos genéricos, formato do id, URL, licença, `retrieved`, `adapter`, ids repetidos, coleta de todos os erros) é substituída por `datasettool.sources.registry` (R5 de lá). +- **R2.5** O cálculo de SHA-256 e a contagem de registros CSV de `src/namescommons/manifest.py` são substituídos por `datasettool.files` (R6 de lá). +- **R2.6** Os testes deste repositório que só exercitam o comportamento portado são removidos depois que o kit, na tag fixada, passa nos equivalentes (mapa de portabilidade da seção 3 do [plano da spec 004 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/plan.md)). A lista exata fica no plano desta spec e na descrição do PR. + +### R3. O que fica neste repositório + +- **R3.1 Adaptadores.** `namescommons.adapters` mantém `RawRecord`, `RawLink` e `DEFAULT_PACKAGE`, e reexporta do kit, como os mesmos objetos, `AdapterError`, `FormatError`, `Resource`, `SourceFiles`, `module_name`, `parse_count` e `parse_code`. Assim um `except FormatError` daqui pega também os erros do leitor de planilhas. `load_adapter(source_id, package=DEFAULT_PACKAGE)` e `check_adapter(mod, source_id)` chamam os do kit com `optional=("links",)`: o protocolo de nomes aceita a função opcional `links` (spec 004 daqui, R4.1). Os oito adaptadores de fontes oficiais e o do Wikidata não mudam de interface. +- **R3.2 Registro de fontes.** `namescommons.sources` declara o esquema do registro com a API do kit: + - campos obrigatórios de nomes `country`, `count_type`, `primary` e `suppression`, e opcionais `uniform_case` e `strips_accents` (padrão `false`, spec 005, R6), cada um com a sua conferência; + - licenças aceitas `ATTRIBUTION_ONLY_LICENSES` do kit mais `statcan-open-licence` (ADR 0004 daqui), com a política "ADR 0001" na mensagem de licença recusada; + - a regra entre entradas de exatamente uma `primary` por par (país, `count_type`) com contagem, sem conferir `attested` (spec 004 daqui, R5); + - uma fábrica que constrói o `Source` daqui, com os mesmos campos e padrões de hoje. + `load_sources(path)`, `parse_sources(entradas)`, `is_country`, `COUNT_TYPES` e `Source` mantêm nome e assinatura. `SourceError` passa a ser o `RegistryError` do kit. +- **R3.3 Downloads.** O pipeline passa ao kit o `User-Agent` deste projeto, `namescommons/ (+https://github.com/BenchCommons/dataset-names)`, igual byte a byte ao enviado antes. As opções de tentativas e espera passam com os nomes do kit (`attempts`, `backoff`, `sleep`). +- **R3.4 Manifesto.** `build_manifest` continua dono do formato da release, de `schema_version` e da lista `sources`; a lista `files` vem de `describe_files` do kit, com contador de registros para `.csv` (o do kit) e para `.parquet` (pelos metadados do Parquet). +- **R3.5 O resto.** Esquema e `Observation`, normalização, agregação, variantes, o pipeline de `RawRecord` e `RawLink` para `Observation` e `VariantLink`, a gravação em lotes, `sources.yaml` com os campos de nomes, as saídas e a CLI não mudam. + +### R4. Comportamento preservado + +- **R4.1** A CLI tem os mesmos comandos, opções, padrões e códigos de saída. +- **R4.2** Os dois passos offline da CI produzem saídas idênticas byte a byte às de `master` antes desta spec, e o mesmo vale para `namescommons run --offline` sobre um cache com os arquivos reais completos das fontes. +- **R4.3** Diferenças aceitas, e só elas: + 1. mensagens que vêm do kit ficam em inglês (cache, leitor de planilhas, contrato do adaptador, `parse_count`, `parse_code`, campos genéricos do registro); as mensagens de nomes continuam em português; + 2. a ordem das linhas numa mensagem do registro com vários erros: o kit confere `retrieved` e `adapter` antes dos campos de nomes (plano da spec 004 do tools-dataset, seção 2.4); o conjunto de mensagens é o mesmo; + 3. o cache recusa com `ValueError` id de fonte fora de `[a-z0-9]+(-[a-z0-9]+)*` e nome de arquivo inseguro antes de qualquer acesso; pela CLI isso não é alcançável, porque o registro e `check_adapter` já recusam os dois; + 4. o manifesto recusa arquivo de saída sem contador registrado em vez de contá-lo como CSV; as saídas daqui só têm `.csv` e `.parquet`. + +### R5. Testes + +- **R5.1** Testes que conferem mensagens em português do código movido passam a conferir as mensagens em inglês do kit, sem mudar entradas nem valores esperados. +- **R5.2** Nenhum teste depende da ordem das linhas de uma mensagem do registro com vários erros. +- **R5.3** Testes novos cobrem a integração: os reexports de `namescommons.adapters` são os objetos do kit; `load_adapter` usa o pacote padrão e confere `links`; o `User-Agent` enviado pelo pipeline; os campos de nomes do registro, inclusive `strips_accents`; a versão e a tag fixadas em `pyproject.toml` e no `uv.lock`; e que `namescommons.cache` e `namescommons.spreadsheet` não existem mais. +- **R5.4** Os testes continuam sem rede: downloads só do servidor local de `tests/conftest.py`. + +### R6. Documentação + +`AGENTS.md` (stack e regra de atualização do kit), a seção de desenvolvimento do `README.md`, o `ROADMAP.md` (linha da spec e lista de ADRs) e o status do ADR 0005 apontam o kit e o ADR 0013. + +## Critérios de aceite + +1. `uv sync --locked` instala `benchcommons-tools-dataset` 0.1.0 a partir da tag `v0.1.0`, e o `uv.lock` registra o commit dela. +2. `src/namescommons/cache.py` e `src/namescommons/spreadsheet.py` não existem, e nenhum módulo de `src/namescommons` define `Resource`, `SourceFiles`, `parse_count`, `parse_code`, `check_adapter` (fora do invólucro de R3.1) ou lê YAML. +3. `uv run pytest -q` passa sem rede. +4. Os dois passos offline da CI passam, e `diff -r` entre as saídas deste branch e as de `master` sobre as mesmas fixtures não mostra diferença. +5. `namescommons run --offline` sobre o cache completo das fontes reais produz saídas idênticas às de `master`. +6. `tools/bench_pipeline.py` roda numa medição pequena sem erro. +7. A lista de testes e arquivos removidos está no plano e na descrição do PR. +8. Os itens da issue tools-dataset#5 cumpridos estão marcados, e o que ficar para depois virou issue. + +## Fora de escopo + +- Qualquer mudança no tools-dataset; uma correção no kit vira issue lá e chega aqui por versão nova (ADR 0013). +- Traduzir para o inglês as mensagens que são de nomes (registro, pipeline, adaptadores, CLI). +- O servidor de teste `LocalServer` de `tests/conftest.py`: o kit não o exporta, e ele continua aqui. +- A publicação (spec 006). diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index 911b4ac..f8834b7 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -14,7 +14,8 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 3d | [`003d-statistics-canada`](003d-statistics-canada/spec.md) | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Mesclada | | 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Mesclada | | 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Mesclada | -| 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4), PR #38 | Em PR | +| 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4), PR #38 | Mesclada | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | +| 7 | [`007-tools-dataset`](007-tools-dataset/spec.md) | 005; tools-dataset `v0.1.0` | Troca o cache, o leitor de planilhas, a base do adaptador, o registro de fontes e os auxiliares do manifesto pelo kit `benchcommons-tools-dataset` ([tools-dataset#5](https://github.com/BenchCommons/tools-dataset/issues/5)) | Em PR | -Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md), [0010 regras de agrupamento](../adr/0010-regras-de-agrupamento.md), [0011 correspondência sem acento](../adr/0011-correspondencia-sem-acento.md), [0012 transliteração para ASCII](../adr/0012-transliteracao-ascii.md). +Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md), [0010 regras de agrupamento](../adr/0010-regras-de-agrupamento.md), [0011 correspondência sem acento](../adr/0011-correspondencia-sem-acento.md), [0012 transliteração para ASCII](../adr/0012-transliteracao-ascii.md), [0013 dependência do tools-dataset](../adr/0013-dependencia-do-tools-dataset.md). From adef747076722c0fbb8cac2ff60f2e405bbfaed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:22:45 -0300 Subject: [PATCH 02/14] docs(toolkit): plan and tasks for spec 007 Lists every module and test removed with its replacement in tools-dataset v0.1.0, the baseline outputs to compare against and the task order. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/007-tools-dataset/plan.md | 87 +++++++++++++++++++++++++++ docs/specs/007-tools-dataset/tasks.md | 71 ++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 docs/specs/007-tools-dataset/plan.md create mode 100644 docs/specs/007-tools-dataset/tasks.md diff --git a/docs/specs/007-tools-dataset/plan.md b/docs/specs/007-tools-dataset/plan.md new file mode 100644 index 0000000..84b2ba8 --- /dev/null +++ b/docs/specs/007-tools-dataset/plan.md @@ -0,0 +1,87 @@ +# 007. Adoção do kit de fontes do tools-dataset: plano de implementação + +**Objetivo:** trocar as cópias locais do código genérico de fontes pelo pacote `benchcommons-tools-dataset` 0.1.0 (import `datasettool`), sem mudar CLI nem saídas. + +**Arquitetura:** `datasettool` entra como dependência fixada na tag `v0.1.0`. `namescommons.adapters` vira uma camada fina: guarda `RawRecord`, `RawLink` e `DEFAULT_PACKAGE`, reexporta os objetos do kit e embrulha `load_adapter` e `check_adapter` com `optional=("links",)`. `namescommons.sources` passa a declarar um `RegistrySchema` com os campos de nomes, a lista de licenças e a regra de uma `primary`, e a fábrica do `Source` daqui. O pipeline chama `datasettool.sources.cache.fetch_resource` com o `User-Agent` deste projeto. O adaptador do INE lê planilhas por `datasettool.sources.spreadsheet`. `build_manifest` usa `datasettool.files.describe_files` com contadores para `.csv` e `.parquet`. + +**Stack:** a mesma de antes, com `benchcommons-tools-dataset==0.1.0` no lugar de `pyyaml` e `xlrd` diretos ([ADR 0013](../../adr/0013-dependencia-do-tools-dataset.md)). + +**Spec:** [`spec.md`](spec.md). Contrato do kit (dono: tools-dataset): [spec 004](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/spec.md) e [plano](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/plan.md) na tag `v0.1.0`. Acompanhamento: [tools-dataset#5](https://github.com/BenchCommons/tools-dataset/issues/5). + +## Restrições globais + +- Nenhuma cópia nem remendo local do kit. Se algo do kit não servir, a correção é uma issue no tools-dataset. +- Saídas idênticas byte a byte às de `master` (`0153007`) nos dois passos offline da CI e no cache real completo. Linha de base gravada antes de qualquer mudança de código: fixtures em 832 testes verdes, 8 observações no passo do exemplo, 109 no das fontes oficiais, 4.336.051 no cache real (123 s, 2,96 GB de pico). +- Mensagens de nomes continuam em português; só as do kit mudam para o inglês. +- Um arquivo ou teste só é removido depois que o substituto passa: o kit passa 360 testes na tag `v0.1.0` (conferido em 2026-10-12 com `uv run pytest -q` num clone da tag), e a suíte daqui passa com o kit antes de cada remoção. +- `SCHEMA_VERSION` continua `1.2.0`; nenhum campo de saída muda. + +## Decisões de implementação + +| Decisão | Motivo | Custo se estiver errada | +|---|---|---| +| `namescommons.adapters` reexporta os objetos do kit em vez de os adaptadores importarem de `datasettool` | Os nove adaptadores e os testes continuam com um só ponto de import; `FormatError` é o mesmo objeto, e `except FormatError` pega erros do leitor de planilhas | Uma camada a mais para ler; trocar os imports depois é mecânico | +| `load_adapter` e `check_adapter` embrulhados com `optional=("links",)` e pacote padrão | O kit não tem pacote padrão nem conhece `links`; sem o invólucro, cada chamada teria de repetir os dois | Nenhum: é o uso previsto no plano 2.1 do kit | +| `SourceError = RegistryError` | A CLI e os testes capturam `SourceError`; o nome continua válido e é a mesma classe | Nenhum além de um alias | +| Mensagens dos campos de nomes continuam em português | A spec aceita mudança de língua só no que vem do kit; os testes de nomes ficam iguais | Uma mensagem com vários erros pode misturar português e inglês; traduzir depois é uma issue | +| `parse_count` sem invólucro: o campo padrão vira `count` | Os adaptadores que não passam `field` passam a dizer `count '<5' is neither...`; é mensagem do kit | Nenhum para dados; só o texto do erro | +| `USER_AGENT` em `namescommons/__init__.py`, montado com `make_user_agent` | É o ponto único usado pelo pipeline e por `tools/amostra_wikidata.py`; o texto fica igual ao anterior | Nenhum | +| `pyyaml` e `xlrd` saem das dependências diretas | Nenhum módulo de `src/` os importa; o kit os fixa (ADR 0005 do kit) | Se um módulo daqui voltar a importá-los, precisa declarar de novo | +| `LocalServer` continua em `tests/conftest.py` | O kit não exporta utilidades de teste | Duplicação pequena de código de teste | +| Teste das amostras do INE pelo leitor vai para `tests/test_es_ine_residentes.py` | É o único teste do leitor que o kit não portou (dados CC BY 4.0 daqui, decisão D6 do kit) | Nenhum | +| Testes de registro com campos de nomes ficam; os genéricos saem | O esquema real daqui (pycountry, `strips_accents`) não é o `names_registry.py` de teste do kit | Alguns testes parecidos nos dois repositórios | + +## Foco de revisão + +1. **Saídas idênticas:** `diff -r` das saídas deste branch contra as de `master` nas fixtures e no cache real (Task 7). +2. **`FormatError` do leitor de planilhas** continua capturado pelo pipeline e pelos testes do INE como erro do domínio (Task 2: `test_reexporta_os_objetos_do_kit`; Task 3). +3. **`User-Agent`** igual ao anterior, byte a byte, no pedido real ao servidor local (Task 4: `test_pipeline_envia_o_user_agent_do_projeto`). +4. **`strips_accents`** continua campo de nomes, com padrão `false`, e as três fontes de `sources.yaml` continuam marcadas (Task 5). +5. **Ordem das mensagens do registro:** nenhum teste confere a ordem das linhas (Task 5). + +## Arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `pyproject.toml`, `uv.lock` | Dependência fixada na tag | 1 | +| `tests/test_tools_dataset.py` | Integração com o kit (R5.3) | 1 a 6 | +| `src/namescommons/adapters/__init__.py` | `RawRecord`, `RawLink`, reexports, invólucros | 2 | +| `src/namescommons/adapters/es_ine_residentes.py` | Leitor de planilhas do kit | 3 | +| `src/namescommons/__init__.py` | `USER_AGENT` do projeto | 4 | +| `src/namescommons/pipeline.py` | `fetch_resource` do kit com `User-Agent` | 4 | +| `tools/amostra_wikidata.py` | `USER_AGENT` de `namescommons` | 4 | +| `src/namescommons/sources.py` | Esquema de nomes sobre o registro do kit | 5 | +| `src/namescommons/manifest.py` | `describe_files` com contadores CSV e Parquet | 6 | +| `.github/workflows/ci.yml` | Tira a exclusão de `tests/fixtures/planilhas` | 3 | +| `AGENTS.md`, `README.md`, `docs/specs/ROADMAP.md` | Documentação | 7 | + +### Removidos (R2.6) + +| Arquivo ou teste | Substituto no kit (tag `v0.1.0`) | +|---|---| +| `src/namescommons/cache.py` | `datasettool.sources.cache` | +| `src/namescommons/spreadsheet.py` | `datasettool.sources.spreadsheet` | +| `tests/test_cache.py` (22 testes) | `tests/test_cache.py` (28) | +| `tests/test_spreadsheet.py` (43 de 44 testes; `test_r1_3_amostras_do_ine` vai para `tests/test_es_ine_residentes.py`) | `tests/test_spreadsheet.py` (46) | +| `tests/fixtures/planilhas/tipos.xls` | `tests/fixtures/spreadsheets/types.xls`, cópia idêntica | +| `tests/test_manifest.py` (1 teste) | `tests/test_files.py` | +| Em `tests/test_adapters.py`: `test_nome_do_modulo`, `test_carrega_o_adaptador_de_exemplo`, `test_adaptador_inexistente`, `test_adaptador_valido`, `test_adaptador_invalido`, `test_adaptador_sem_atributo` | `tests/test_adapters.py` | +| Em `tests/test_contrato_adaptador.py`: `test_r2_2_parse_count`, `test_r2_2_parse_count_recusa_o_que_nao_conhece`, `test_r2_2_parse_code_traduz_e_recusa_codigo_desconhecido`, `test_r2_6_arquivo_fora_de_resources_lista_os_disponiveis`, `test_r2_6_adaptador_ausente_mostra_o_caminho_esperado`, `test_r2_6_adaptador_quebrado_nao_se_passa_por_ausente`, `test_r2_6_pacote_de_adaptadores_inexistente` | `tests/test_adapters.py` | +| Em `tests/test_sources.py`: `test_arquivo_sem_fontes`, `test_id_com_formato_invalido`, `test_campo_ausente`, `test_id_duplicado`, `test_licenca_nao_textual_vira_source_error`, `test_id_nao_textual_vira_source_error`, `test_entrada_que_nao_e_mapeamento`, `test_arquivo_inexistente`, `test_yaml_malformado`, `test_raiz_que_nao_e_mapeamento`, `test_sources_que_nao_e_lista`, e os casos `retrieved`, `url` e `extra` de `test_campo_invalido` | `tests/test_registry.py` | +| Em `tests/test_sources_extensoes.py`: `test_retrieved_com_hora_e_recusado`, `test_retrieved_com_hora_no_yaml_e_recusado`, `test_url_sem_esquema_http_e_recusada`, `test_url_http_e_https_aceitas`, `test_url_malformada_nomeia_a_fonte_em_vez_de_levantar_value_error`, e o caso `adapter` de `test_campo_opcional_nao_booleano` | `tests/test_registry.py` | +| Em `tests/test_robustez.py`: `test_manifesto_conta_registros_csv_e_nao_linhas_fisicas` | `tests/test_files.py` | + +## Cobertura requisito -> tarefa + +| Requisito | Tarefa | +|---|---| +| R1.1, R1.2 | 1 (dependência), 3 e 5 (saída de `xlrd` e `pyyaml` quando o último uso some) | +| R2.1, R3.3 | 4 | +| R2.2 | 3 | +| R2.3, R3.1 | 2 | +| R2.4, R3.2 | 5 | +| R2.5, R3.4 | 6 | +| R2.6 | 2 a 6, cada uma remove o que substituiu | +| R3.5, R4 | 7 (verificação), e cada tarefa roda a suíte inteira | +| R5 | 1 a 6 | +| R6 | 7 | diff --git a/docs/specs/007-tools-dataset/tasks.md b/docs/specs/007-tools-dataset/tasks.md new file mode 100644 index 0000000..ab2a19d --- /dev/null +++ b/docs/specs/007-tools-dataset/tasks.md @@ -0,0 +1,71 @@ +# 007. Adoção do kit de fontes do tools-dataset: tarefas + +Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na raiz do repositório, a partir de `master` em `0153007`. Antes da Task 1, `uv sync` e `uv run pytest -q` verdes, e as saídas de linha de base gravadas (plano, restrições globais). + +Cada tarefa segue TDD: escrever ou ajustar os testes, ver falhar com `uv run pytest -q `, mudar o código, ver passar, rodar a suíte inteira e fazer um commit. Uma remoção só acontece na tarefa em que o substituto já passa. + +--- + +### Task 1: dependência do kit fixada na tag + +**Arquivos:** `pyproject.toml`, `uv.lock`; teste novo `tests/test_tools_dataset.py`. + +- [ ] Testes: `test_dependencia_fixada_na_versao_e_na_tag` (versão `==0.1.0` em `dependencies` e `tag = "v0.1.0"` em `[tool.uv.sources]`, com a mesma versão), `test_uv_lock_aponta_a_tag` (fonte git com `tag=v0.1.0` no `uv.lock`), `test_kit_instalado_na_versao_fixada` (`datasettool.__version__ == "0.1.0"`). +- [ ] `uv add` da dependência com a origem git na tag; `uv sync`. +- [ ] Suíte verde; commit `build(deps): depend on benchcommons-tools-dataset 0.1.0 by git tag`. + +### Task 2: base do adaptador do kit + +**Arquivos:** `src/namescommons/adapters/__init__.py`; testes `tests/test_tools_dataset.py`, `tests/test_adapters.py`, `tests/test_contrato_adaptador.py`, `tests/test_links.py` e os de adaptadores que conferem mensagens do contrato. + +- [ ] Testes: `test_reexporta_os_objetos_do_kit` (identidade de `AdapterError`, `FormatError`, `Resource`, `SourceFiles`, `module_name`, `parse_count`, `parse_code`), `test_load_adapter_usa_o_pacote_padrao`, `test_check_adapter_confere_links` (com `links` não chamável, falha; sem `links`, passa). +- [ ] Trocar a parte genérica pelos reexports e invólucros; `RawRecord`, `RawLink` e `DEFAULT_PACKAGE` ficam. +- [ ] Ajustar para o inglês as mensagens conferidas nos testes que continuam (R5.1). +- [ ] Remover de `tests/test_adapters.py` e `tests/test_contrato_adaptador.py` os testes listados no plano. +- [ ] Suíte verde; commit `refactor(adapters): use the datasettool adapter base`. + +### Task 3: leitor de planilhas do kit + +**Arquivos:** `src/namescommons/adapters/es_ine_residentes.py`, `tests/test_es_ine_residentes.py`, `.github/workflows/ci.yml`, `tests/fixtures/ATRIBUICAO.md`, `pyproject.toml`, `uv.lock`. + +- [ ] Teste: `test_leitor_do_kit_le_as_amostras_do_ine` (o antigo `test_r1_3_amostras_do_ine`, com as mesmas entradas e valores esperados) e `test_namescommons_spreadsheet_nao_existe`. +- [ ] O adaptador importa de `datasettool.sources.spreadsheet`. +- [ ] Remover `src/namescommons/spreadsheet.py`, `tests/test_spreadsheet.py` e `tests/fixtures/planilhas/tipos.xls`; tirar `! -name planilhas` da CI e a frase de `ATRIBUICAO.md`; tirar `xlrd` das dependências diretas. +- [ ] Suíte verde; commit `refactor(spreadsheet): read spreadsheets with datasettool`. + +### Task 4: cache de downloads do kit + +**Arquivos:** `src/namescommons/__init__.py`, `src/namescommons/pipeline.py`, `tools/amostra_wikidata.py`; testes `tests/test_tools_dataset.py`, `tests/test_pipeline.py`, `tests/test_cli_pipeline.py`, `tests/test_us_ssa_births.py`. + +- [ ] Testes: `test_user_agent_igual_ao_anterior` (texto exato), `test_pipeline_envia_o_user_agent_do_projeto` (cabeçalho recebido pelo servidor local) e `test_namescommons_cache_nao_existe`. +- [ ] `USER_AGENT` com `make_user_agent`; o pipeline chama `fetch_resource` do kit com ele; opções `backoff`, `attempts`, `sleep`. +- [ ] Ajustar para o inglês as mensagens de download conferidas. +- [ ] Remover `src/namescommons/cache.py` e `tests/test_cache.py`. +- [ ] Suíte verde; commit `refactor(cache): download through datasettool with the project User-Agent`. + +### Task 5: registro de fontes sobre o kit + +**Arquivos:** `src/namescommons/sources.py`, `pyproject.toml`, `uv.lock`; testes `tests/test_sources.py`, `tests/test_sources_extensoes.py`, `tests/test_tools_dataset.py`. + +- [ ] Testes: `test_campo_de_nomes_ausente` (`country` ausente), `test_source_error_e_o_registry_error_do_kit`, `test_licencas_aceitas` (lista do kit mais `statcan-open-licence`), e os de `strips_accents` que já existem. +- [ ] `SCHEMA` com os campos de nomes, `checks` com a regra de uma `primary`, fábrica do `Source`; `load_sources` e `parse_sources` chamam o kit. +- [ ] Remover os testes genéricos listados no plano; nenhum teste confere ordem de linhas. +- [ ] Tirar `pyyaml` das dependências diretas. +- [ ] Suíte verde; commit `refactor(sources): build the names registry on datasettool`. + +### Task 6: auxiliares de arquivo do manifesto + +**Arquivos:** `src/namescommons/manifest.py`; testes `tests/test_robustez.py`, `tests/test_tools_dataset.py`. + +- [ ] Teste: `test_manifesto_usa_describe_files` (CSV e Parquet contados; arquivo de outra extensão é `ValueError`). +- [ ] `build_manifest` com `describe_files(out_dir, counters={".csv": count_csv_records, ".parquet": ...})`. +- [ ] Remover `tests/test_manifest.py` e `test_manifesto_conta_registros_csv_e_nao_linhas_fisicas`. +- [ ] Suíte verde; commit `refactor(manifest): checksums and row counts from datasettool.files`. + +### Task 7: documentação e verificação + +**Arquivos:** `AGENTS.md`, `README.md`, `docs/specs/ROADMAP.md`, esta pasta. + +- [ ] `AGENTS.md`: stack com o kit e a regra de atualização (ADR 0013). `README.md`: seção de desenvolvimento. +- [ ] Verificar: `uv sync --locked`, `uv run pytest -q`, os dois passos offline da CI com `diff -r` contra a linha de base, `namescommons run --offline` sobre o cache real com `diff -r`, e `tools/bench_pipeline.py --observacoes 200000`. +- [ ] Commit `docs(toolkit): document the tools-dataset dependency`. From 2775f62a1bc188c8a62f129ac6901e21429d5164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:23:26 -0300 Subject: [PATCH 03/14] build(deps): depend on benchcommons-tools-dataset 0.1.0 by git tag Declares the toolkit at ==0.1.0 with a uv git source pinned to tag v0.1.0 (ADR 0013); uv.lock records commit f3251d8. Tests check the version, the tag and the installed package. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 4 ++++ tests/test_tools_dataset.py | 34 ++++++++++++++++++++++++++++++++++ uv.lock | 11 +++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/test_tools_dataset.py diff --git a/pyproject.toml b/pyproject.toml index 714826f..6e8016b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Dataset mundial de primeiros nomes e sobrenomes a partir de fonte requires-python = ">=3.12" license = "Apache-2.0" dependencies = [ + "benchcommons-tools-dataset==0.1.0", "pyarrow>=17", "pycountry>=24.6", "pyyaml>=6.0", @@ -17,6 +18,9 @@ namescommons = "namescommons.cli:main" [dependency-groups] dev = ["pytest>=8"] +[tool.uv.sources] +benchcommons-tools-dataset = { git = "https://github.com/BenchCommons/tools-dataset", tag = "v0.1.0" } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py new file mode 100644 index 0000000..b081112 --- /dev/null +++ b/tests/test_tools_dataset.py @@ -0,0 +1,34 @@ +"""Spec 007: integração com o kit de fontes do tools-dataset (R1 e R5.3).""" + +import tomllib +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +VERSAO = "0.1.0" +DISTRIBUICAO = "benchcommons-tools-dataset" + + +def _pyproject() -> dict: + return tomllib.loads((RAIZ / "pyproject.toml").read_text(encoding="utf-8")) + + +# R1: dependência fixada na versão e na tag (ADR 0013) ------------------------------------------- + +def test_dependencia_fixada_na_versao_e_na_tag(): + dados = _pyproject() + assert f"{DISTRIBUICAO}=={VERSAO}" in dados["project"]["dependencies"] + assert dados["tool"]["uv"]["sources"][DISTRIBUICAO] == { + "git": "https://github.com/BenchCommons/tools-dataset", "tag": f"v{VERSAO}", + } + + +def test_uv_lock_aponta_a_tag(): + lock = tomllib.loads((RAIZ / "uv.lock").read_text(encoding="utf-8")) + pacote = next(p for p in lock["package"] if p["name"] == DISTRIBUICAO) + assert pacote["version"] == VERSAO + assert pacote["source"]["git"].startswith(f"https://github.com/BenchCommons/tools-dataset?tag=v{VERSAO}#") + + +def test_kit_instalado_na_versao_fixada(): + import datasettool + assert datasettool.__version__ == VERSAO diff --git a/uv.lock b/uv.lock index 0325f0a..1ddab8e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 5 requires-python = ">=3.12" +[[package]] +name = "benchcommons-tools-dataset" +version = "0.1.0" +source = { git = "https://github.com/BenchCommons/tools-dataset?tag=v0.1.0#f3251d805f1f7839e59c5472d54833ea7bef840d" } +dependencies = [ + { name = "pyyaml" }, + { name = "xlrd" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -25,6 +34,7 @@ name = "names-commons-dataset" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "benchcommons-tools-dataset" }, { name = "pyarrow" }, { name = "pycountry" }, { name = "pyyaml" }, @@ -38,6 +48,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "benchcommons-tools-dataset", git = "https://github.com/BenchCommons/tools-dataset?tag=v0.1.0" }, { name = "pyarrow", specifier = ">=17" }, { name = "pycountry", specifier = ">=24.6" }, { name = "pyyaml", specifier = ">=6.0" }, From ff4f9fef6c21cf61d3cbc2328ad61ef81659fef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:26:15 -0300 Subject: [PATCH 04/14] refactor(adapters): use the datasettool adapter base namescommons.adapters keeps RawRecord, RawLink and DEFAULT_PACKAGE, re-exports AdapterError, FormatError, Resource, SourceFiles, module_name, parse_count and parse_code from datasettool.sources.adapters as the same objects, and wraps load_adapter and check_adapter with optional=("links",). Tests that asserted Portuguese messages of the moved code now assert the toolkit's English messages. Removed tests, ported to tools-dataset tests/test_adapters.py: test_adapters.py: test_nome_do_modulo, test_carrega_o_adaptador_de_exemplo, test_adaptador_inexistente, test_adaptador_valido, test_adaptador_invalido, test_adaptador_sem_atributo; test_contrato_adaptador.py: test_r2_2_parse_count, test_r2_2_parse_count_recusa_o_que_nao_conhece, test_r2_2_parse_code_traduz_e_recusa_codigo_desconhecido, test_r2_6_arquivo_fora_de_resources_lista_os_disponiveis, test_r2_6_adaptador_ausente_mostra_o_caminho_esperado, test_r2_6_adaptador_quebrado_nao_se_passa_por_ausente, test_r2_6_pacote_de_adaptadores_inexistente. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/adapters/__init__.py | 140 ++++---------------------- src/namescommons/sources.py | 2 +- tests/test_adapters.py | 64 +----------- tests/test_ca_statcan_births.py | 2 +- tests/test_contrato_adaptador.py | 66 ++---------- tests/test_es_ine_residentes.py | 4 +- tests/test_fr_insee_births.py | 6 +- tests/test_ie_cso_births.py | 2 +- tests/test_links.py | 2 +- tests/test_no_ssb.py | 2 +- tests/test_nz_dia_births.py | 4 +- tests/test_spreadsheet.py | 2 +- tests/test_tools_dataset.py | 29 ++++++ tests/test_us_ssa_births.py | 4 +- 14 files changed, 75 insertions(+), 254 deletions(-) diff --git a/src/namescommons/adapters/__init__.py b/src/namescommons/adapters/__init__.py index 24d3e1b..b5ca723 100644 --- a/src/namescommons/adapters/__init__.py +++ b/src/namescommons/adapters/__init__.py @@ -1,36 +1,32 @@ -"""Contrato do adaptador de fonte (spec 002, R1, com as regras da spec 003, R2). +"""Adaptadores de fonte do dataset de nomes (spec 002, R1; spec 003, R2; spec 007, R3.1). Cada fonte com adaptador tem um módulo . que expõe SOURCE_ID, RESOURCES (lista de Resource) e parse(files) -> Iterator[RawRecord], e opcionalmente links(files) -> Iterator[RawLink] (spec 004, R4.1). O adaptador converte os valores da fonte: year e count como int, gender como F ou M, country em ISO 3166-1 alfa-2, kind como given ou family. Valores suprimidos são omitidos. + +O contrato genérico (Resource, SourceFiles, erros, parse_count, parse_code, check_adapter, +load_adapter) é o do kit de fontes do tools-dataset (spec 004 de lá, R4), reexportado aqui +como os mesmos objetos. Daqui são só os tipos de registro, o pacote padrão e a função +opcional links. """ -import importlib -import importlib.util -import re -from collections.abc import Collection, Mapping from dataclasses import dataclass -from pathlib import Path from types import ModuleType -from urllib.parse import urlsplit - -DEFAULT_PACKAGE = "namescommons.adapters" - - -class AdapterError(Exception): - """Adaptador ausente ou fora do contrato de R1.""" - -class FormatError(ValueError): - """Arquivo da fonte fora do formato que o adaptador espera (spec 003, R2.2).""" +from datasettool.sources import adapters as _kit +from datasettool.sources.adapters import ( + AdapterError, FormatError, Resource, SourceFiles, module_name, parse_code, parse_count, +) +__all__ = [ + "DEFAULT_PACKAGE", "OPTIONAL", "AdapterError", "FormatError", "RawLink", "RawRecord", "Resource", + "SourceFiles", "check_adapter", "load_adapter", "module_name", "parse_code", "parse_count", +] -@dataclass(frozen=True) -class Resource: - url: str - filename: str +DEFAULT_PACKAGE = "namescommons.adapters" +OPTIONAL = ("links",) # funções opcionais do protocolo de nomes (spec 004, R4.1) @dataclass(frozen=True) @@ -58,107 +54,11 @@ class RawLink: target_kind: str -def module_name(source_id: str) -> str: - return source_id.replace("-", "_") - - -def url_http(url: object) -> bool: - """URL http ou https com host, sem espaços nem caracteres de controle; nunca levanta exceção.""" - if not isinstance(url, str) or any(c.isspace() or not c.isprintable() for c in url): - return False - try: - partes = urlsplit(url) - return partes.scheme.lower() in ("http", "https") and bool(partes.netloc) - except ValueError: - return False - - -def _filename_seguro(nome: object) -> bool: - return ( - isinstance(nome, str) and nome.isprintable() and nome not in ("", ".", "..") and not nome.startswith(".") - and "/" not in nome and "\\" not in nome - and not nome.endswith((".meta.json", ".part")) - ) - - def check_adapter(mod: object, source_id: str) -> None: - """Levanta AdapterError se o módulo não cumpre o contrato de R1.""" - for atributo in ("SOURCE_ID", "RESOURCES", "parse"): - if not hasattr(mod, atributo): - raise AdapterError(f"adaptador de '{source_id}' sem {atributo}") - if mod.SOURCE_ID != source_id: - raise AdapterError(f"SOURCE_ID '{mod.SOURCE_ID}' diverge do id '{source_id}' em sources.yaml") - recursos = mod.RESOURCES - if not isinstance(recursos, (list, tuple)) or not recursos or not all( - isinstance(r, Resource) for r in recursos - ): - raise AdapterError(f"'{source_id}': RESOURCES deve ser uma lista não vazia de Resource") - vistos: set[str] = set() - for r in recursos: - if not url_http(r.url): - raise AdapterError(f"'{source_id}': url {r.url!r} deve usar http ou https") - if not _filename_seguro(r.filename): - raise AdapterError(f"'{source_id}': filename {r.filename!r} inválido") - if r.filename in vistos: - raise AdapterError(f"'{source_id}': filename {r.filename!r} repetido") - vistos.add(r.filename) - if not callable(mod.parse): - raise AdapterError(f"'{source_id}': parse deve ser uma função") - if hasattr(mod, "links") and not callable(mod.links): - raise AdapterError(f"'{source_id}': links deve ser uma função") - - -def _caminho_esperado(package: str, modulo: str) -> str: - try: - spec = importlib.util.find_spec(package) - except ImportError: - spec = None - if spec is None or not spec.submodule_search_locations: - return f"pacote {package!r} não encontrado" - return str(Path(list(spec.submodule_search_locations)[0]) / f"{modulo}.py") + """Levanta AdapterError se o módulo não cumpre o contrato do kit ou tem links que não é função.""" + _kit.check_adapter(mod, source_id, OPTIONAL) def load_adapter(source_id: str, package: str = DEFAULT_PACKAGE) -> ModuleType: - nome = f"{package}.{module_name(source_id)}" - try: - mod = importlib.import_module(nome) - except ModuleNotFoundError as e: - if e.name is not None and (nome == e.name or nome.startswith(e.name + ".")): - esperado = _caminho_esperado(package, module_name(source_id)) - raise AdapterError(f"adaptador {nome} ausente (esperado em {esperado})") from e - raise AdapterError(f"adaptador {nome} quebrado: {type(e).__name__}: {e}") from e - except Exception as e: - raise AdapterError(f"adaptador {nome} quebrado: {type(e).__name__}: {e}") from e - check_adapter(mod, source_id) - return mod - - -class SourceFiles(dict): - """Arquivos baixados de uma fonte (chave = filename); chave fora de RESOURCES gera AdapterError.""" - - def __missing__(self, chave): - raise AdapterError(f"parse pediu o arquivo {chave!r}, fora de RESOURCES; disponíveis: {sorted(self)}") - - -_INTEIRO = re.compile(r"[0-9]+") - - -def parse_count(value: str, missing: Collection[str] = (), field: str = "contagem") -> int | None: - """Inteiro publicado (contagem ou ano); um marcador de `missing` (valor suprimido ou ausente) vira None. - - Qualquer outro texto levanta FormatError, para que um marcador novo da fonte não passe calado. - """ - texto = value.strip() - if texto in missing: - return None - if _INTEIRO.fullmatch(texto): - return int(texto) - raise FormatError(f"{field} {value!r} não é inteiro nem marcador conhecido {sorted(missing)}") - - -def parse_code(value: str, mapping: Mapping[str, str], field: str) -> str: - """Traduz um código da fonte (ex.: sexo '1' e '2') pelo mapeamento; código desconhecido é FormatError.""" - try: - return mapping[value.strip()] - except KeyError: - raise FormatError(f"{field} {value!r} desconhecido; esperado um de {sorted(mapping)}") from None + """Importa e confere o adaptador; ausente e quebrado são distinguidos pelo kit.""" + return _kit.load_adapter(source_id, package, OPTIONAL) diff --git a/src/namescommons/sources.py b/src/namescommons/sources.py index 8c2fc46..4994f4e 100644 --- a/src/namescommons/sources.py +++ b/src/namescommons/sources.py @@ -8,7 +8,7 @@ import pycountry import yaml -from namescommons.adapters import url_http +from datasettool.sources.adapters import is_http_url as url_http LICENCAS_COMPATIVEIS = frozenset({ "public-domain", "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 496d144..e8b8a26 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,34 +1,17 @@ -"""Spec 002, R1 e R7: contrato do adaptador e parse do adaptador de exemplo sobre a fixture.""" +"""Spec 002, R1 e R7: parse do adaptador de exemplo sobre a fixture e pacote padrão. + +O contrato genérico do adaptador é testado no tools-dataset (spec 007, R2.6). +""" from pathlib import Path -from types import SimpleNamespace import pytest -from namescommons.adapters import AdapterError, RawRecord, Resource, check_adapter, load_adapter, module_name +from namescommons.adapters import AdapterError, RawRecord, load_adapter FIXTURES = Path(__file__).parent / "fixtures" -def _parse(files): - return iter(()) - - -def _adaptador(**campos): - base = dict(SOURCE_ID="x", RESOURCES=[Resource("https://example.org/a.csv", "a.csv")], parse=_parse) - return SimpleNamespace(**{**base, **campos}) - - -def test_nome_do_modulo(): - assert module_name("us-ssa-births") == "us_ssa_births" - - -def test_carrega_o_adaptador_de_exemplo(): - mod = load_adapter("exemplo", "adaptadores") - assert mod.SOURCE_ID == "exemplo" - assert [r.filename for r in mod.RESOURCES] == ["primeiros.csv", "sobrenomes.csv"] - - def test_parse_da_fixture_sem_rede(): mod = load_adapter("exemplo", "adaptadores") pasta = FIXTURES / "exemplo" @@ -46,43 +29,6 @@ def test_parse_da_fixture_sem_rede(): ] -def test_adaptador_inexistente(): - with pytest.raises(AdapterError, match="adaptadores.nao_existe"): - load_adapter("nao-existe", "adaptadores") - - def test_pacote_padrao_ainda_nao_tem_adaptadores(): with pytest.raises(AdapterError, match="namescommons.adapters.us_ssa"): load_adapter("us-ssa") - - -def test_adaptador_valido(): - check_adapter(_adaptador(), "x") - - -@pytest.mark.parametrize("campos, trecho", [ - ({"SOURCE_ID": "y"}, "SOURCE_ID 'y' diverge"), - ({"RESOURCES": []}, "RESOURCES"), - ({"RESOURCES": [("https://example.org/a.csv", "a.csv")]}, "RESOURCES"), - ({"RESOURCES": [Resource("ftp://example.org/a.csv", "a.csv")]}, "http ou https"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "../a.csv")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a.csv.meta.json")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a.csv"), - Resource("https://example.org/b.csv", "a.csv")]}, "repetido"), - ({"parse": None}, "parse"), - ({"RESOURCES": [Resource("http://[::1/a.csv", "a.csv")]}, "'x': url 'http://\\[::1/a.csv' deve usar http ou https"), - ({"RESOURCES": [Resource(" https://example.org/a.csv", "a.csv")]}, "http ou https"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a.csv.part")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a\x00.csv")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a\n.csv")]}, "filename"), - ({"RESOURCES": [Resource("https://example.org/a.csv", "a\x7f.csv")]}, "filename"), -]) -def test_adaptador_invalido(campos, trecho): - with pytest.raises(AdapterError, match=trecho): - check_adapter(_adaptador(**campos), "x") - - -def test_adaptador_sem_atributo(): - with pytest.raises(AdapterError, match="SOURCE_ID"): - check_adapter(SimpleNamespace(RESOURCES=[], parse=_parse), "x") diff --git a/tests/test_ca_statcan_births.py b/tests/test_ca_statcan_births.py index 6821e21..7cc853f 100644 --- a/tests/test_ca_statcan_births.py +++ b/tests/test_ca_statcan_births.py @@ -104,7 +104,7 @@ def test_sexo_por_mapeamento(statcan, tmp_path): @pytest.mark.parametrize("sexo", ["Both sexes", "M", "F", "male"]) def test_sexo_desconhecido_falha(statcan, tmp_path, sexo): - with pytest.raises(FormatError, match=f"17100147.csv, linha 2: Sex at birth '{sexo}' desconhecido"): + with pytest.raises(FormatError, match=f"17100147.csv, linha 2: Sex at birth '{sexo}' unknown"): list(statcan.parse(_zip(tmp_path, linha(sexo=sexo)))) diff --git a/tests/test_contrato_adaptador.py b/tests/test_contrato_adaptador.py index 1c37429..0587d16 100644 --- a/tests/test_contrato_adaptador.py +++ b/tests/test_contrato_adaptador.py @@ -1,15 +1,15 @@ -"""Spec 003, R2: regras do contrato do adaptador que a spec 002 deixou implícitas (issue #8).""" +"""Spec 003, R2: regras do contrato do adaptador que a spec 002 deixou implícitas (issue #8). + +parse_count, parse_code, SourceFiles e load_adapter são testados no tools-dataset (spec 007, R2.6). +""" import shutil -import sys from datetime import date from pathlib import Path import pytest -from namescommons.adapters import ( - AdapterError, FormatError, RawRecord, SourceFiles, load_adapter, parse_code, parse_count, -) +from namescommons.adapters import RawRecord from namescommons.pipeline import PipelineError, RecordError, ingest_sources, select_sources, to_observations from namescommons.sources import load_sources, parse_sources @@ -40,26 +40,6 @@ def test_r2_1_valor_nao_convertido_nomeia_a_posicao_no_arquivo(campos, trecho): assert str(e.value).startswith(trecho) -# R2.2: auxiliares de conversão ------------------------------------------------------------------ - -@pytest.mark.parametrize("texto, esperado", [("5", 5), (" 1532 ", 1532), ("0", 0), ("<5", None), ("..", None)]) -def test_r2_2_parse_count(texto, esperado): - assert parse_count(texto, {"<5", ".."}) == esperado - - -@pytest.mark.parametrize("texto", ["", "1.5", "-3", "x", "<3", "١٢"]) -def test_r2_2_parse_count_recusa_o_que_nao_conhece(texto): - with pytest.raises(FormatError, match="contagem"): - parse_count(texto, {"<5"}) - - -def test_r2_2_parse_code_traduz_e_recusa_codigo_desconhecido(): - sexo = {"1": "M", "2": "F"} - assert parse_code(" 2 ", sexo, "sexo") == "F" - with pytest.raises(FormatError, match=r"sexo 'M' desconhecido; esperado um de \['1', '2'\]"): - parse_code("M", sexo, "sexo") - - # R2.3: valores suprimidos são omitidos, nunca emitidos com count vazio ------------------------------ def test_r2_3_count_vazio_em_fonte_com_contagem_e_recusado(): @@ -87,44 +67,10 @@ def test_r2_5_servir_fixtures_baixa_do_servidor_local(servir_fixtures, tmp_path) # R2.6: mensagens de erro -------------------------------------------------------------------------- -def test_r2_6_arquivo_fora_de_resources_lista_os_disponiveis(): - arquivos = SourceFiles({"a.csv": Path("a.csv")}) - with pytest.raises(AdapterError, match=r"parse pediu o arquivo 'b.csv', fora de RESOURCES; disponíveis: \['a.csv'\]"): - arquivos["b.csv"] - - def test_r2_6_ingest_com_chave_errada_nomeia_a_fonte(tmp_path, monkeypatch): import adaptadores.exemplo monkeypatch.setattr(adaptadores.exemplo, "parse", lambda arquivos: iter([arquivos["nomes.csv"]])) shutil.copytree(FIXTURES / "exemplo", tmp_path / "exemplo") fontes = load_sources(FIXTURES / "sources.yaml") - with pytest.raises(PipelineError, match="fonte 'exemplo': parse pediu o arquivo 'nomes.csv'"): + with pytest.raises(PipelineError, match="fonte 'exemplo': parse asked for file 'nomes.csv', outside RESOURCES"): ingest_sources([fontes["exemplo"]], fontes, tmp_path, offline=True, adapters="adaptadores") - - -def test_r2_6_adaptador_ausente_mostra_o_caminho_esperado(): - with pytest.raises(AdapterError) as e: - load_adapter("nao-existe", "adaptadores") - mensagem = str(e.value) - assert "adaptador adaptadores.nao_existe ausente" in mensagem - assert str(Path(__file__).parent / "adaptadores" / "nao_existe.py") in mensagem - - -def test_r2_6_adaptador_quebrado_nao_se_passa_por_ausente(tmp_path, monkeypatch): - pacote = tmp_path / "quebrados" - pacote.mkdir() - (pacote / "__init__.py").write_text("", encoding="utf-8") - (pacote / "fonte_x.py").write_text("import modulo_que_nao_existe\n", encoding="utf-8") - (pacote / "fonte_y.py").write_text("1/0\n", encoding="utf-8") - monkeypatch.syspath_prepend(str(tmp_path)) - with pytest.raises(AdapterError, match="adaptador quebrados.fonte_x quebrado: ModuleNotFoundError: .*modulo_que_nao_existe"): - load_adapter("fonte-x", "quebrados") - with pytest.raises(AdapterError, match="adaptador quebrados.fonte_y quebrado: ZeroDivisionError"): - load_adapter("fonte-y", "quebrados") - for nome in ("quebrados", "quebrados.fonte_x", "quebrados.fonte_y"): - sys.modules.pop(nome, None) - - -def test_r2_6_pacote_de_adaptadores_inexistente(): - with pytest.raises(AdapterError, match="pacote 'nao_ha_pacote' não encontrado"): - load_adapter("x", "nao_ha_pacote") diff --git a/tests/test_es_ine_residentes.py b/tests/test_es_ine_residentes.py index 1ada01a..cb7d82a 100644 --- a/tests/test_es_ine_residentes.py +++ b/tests/test_es_ine_residentes.py @@ -109,7 +109,7 @@ def test_r2_3_sem_a_frase_da_data_falha(mod, tmp_path): def test_r2_4_aba_desconhecida_falha(mod, tmp_path): arquivos = _xlsx_alterado(tmp_path, {'name="Mujeres"': 'name="Total"'}, membro="xl/workbook.xml") - with pytest.raises(FormatError, match=r"aba de nomes 'Total' desconhecido; esperado um de \['Hombres', 'Mujeres'\]"): + with pytest.raises(FormatError, match=r"aba de nomes 'Total' unknown; expected one of \['Hombres', 'Mujeres'\]"): list(mod.parse(arquivos)) @@ -157,7 +157,7 @@ def test_r2_7_marcador_na_coluna_lida_e_omitido(mod, monkeypatch): def test_r2_7_marcador_desconhecido_falha(mod, monkeypatch): _linhas_xls(mod, monkeypatch, lambda aba, n, v: [v[0], v[1], "<20"] + v[3:] if n == 21 else v) - with pytest.raises(FormatError, match=r"aba 'Apellidos >=100', linha 21: Apellido 1º '<20' não é inteiro"): + with pytest.raises(FormatError, match=r"aba 'Apellidos >=100', linha 21: Apellido 1º '<20' is neither an integer"): list(mod.parse(ARQUIVOS)) diff --git a/tests/test_fr_insee_births.py b/tests/test_fr_insee_births.py index 33293b9..9483bc4 100644 --- a/tests/test_fr_insee_births.py +++ b/tests/test_fr_insee_births.py @@ -64,8 +64,8 @@ def test_aceita_bom(insee, tmp_path): @pytest.mark.parametrize("texto, trecho", [ ("sexe;preusuel;annais;nombre\r\n1;ANA;2020;10\r\n", "colunas ausentes \\['periode', 'prenom', 'valeur'\\]"), - ("sexe;prenom;periode;valeur;rang\r\nF;ANA;2020;10;5\r\n", "sexe 'F' desconhecido"), - ("sexe;prenom;periode;valeur;rang\r\n2;ANA;2020;n.d.;5\r\n", "contagem 'n.d.'"), + ("sexe;prenom;periode;valeur;rang\r\nF;ANA;2020;10;5\r\n", "sexe 'F' unknown"), + ("sexe;prenom;periode;valeur;rang\r\n2;ANA;2020;n.d.;5\r\n", "count 'n.d.'"), ("sexe;prenom;periode;valeur;rang\r\n2;ANA;19xx;10;5\r\n", "periode '19xx'"), ]) def test_formato_inesperado_falha(insee, tmp_path, texto, trecho): @@ -86,5 +86,5 @@ def test_nome_com_sublinhado_que_nao_e_o_agregado_conhecido_falha(insee, tmp_pat def test_valeur_vazio_falha(insee, tmp_path): texto = "sexe;prenom;periode;valeur;rang\r\n2;ANA;2020;;5\r\n" - with pytest.raises(FormatError, match="contagem ''"): + with pytest.raises(FormatError, match="count ''"): list(insee.parse(_zip(tmp_path, texto))) diff --git a/tests/test_ie_cso_births.py b/tests/test_ie_cso_births.py index f81dbd9..3d87ea8 100644 --- a/tests/test_ie_cso_births.py +++ b/tests/test_ie_cso_births.py @@ -46,7 +46,7 @@ def test_linha_de_rank_e_ignorada_mesmo_com_valor(cso, tmp_path): @pytest.mark.parametrize("texto, trecho", [ ('"STATISTIC","TLIST(A1)","Nomes","VALUE"\r\n', "colunas ausentes \\['Boys Names'\\]"), - (CABECALHO + '"VSA50C01","x","2020","2020","1","Ola","Number","<3"\r\n', "contagem '<3'"), + (CABECALHO + '"VSA50C01","x","2020","2020","1","Ola","Number","<3"\r\n', "count '<3'"), ]) def test_formato_inesperado_falha(cso, tmp_path, texto, trecho): with pytest.raises(FormatError, match=trecho): diff --git a/tests/test_links.py b/tests/test_links.py index 5c24bca..bd2c24f 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -130,7 +130,7 @@ def _modulo(**extra): def test_check_adapter_recusa_links_que_nao_e_funcao(): - with pytest.raises(AdapterError, match="'wd': links deve ser uma função"): + with pytest.raises(AdapterError, match="'wd': links must be a function"): check_adapter(_modulo(links=[]), "wd") check_adapter(_modulo(links=lambda files: iter(())), "wd") diff --git a/tests/test_no_ssb.py b/tests/test_no_ssb.py index 2736f5b..3fdaccb 100644 --- a/tests/test_no_ssb.py +++ b/tests/test_no_ssb.py @@ -84,7 +84,7 @@ def test_prefixo_de_sexo_desconhecido_falha(tmp_path): categoria["label"] = {("9" + c[1:] if c == "1NORA" else c): v for c, v in categoria["label"].items()} (tmp_path / "10467.json").write_text(json.dumps(dados, ensure_ascii=False), encoding="utf-8") mod = load_adapter("no-ssb-fornavn") - with pytest.raises(FormatError, match="prefixo de sexo '9' desconhecido"): + with pytest.raises(FormatError, match="prefixo de sexo '9' unknown"): list(mod.parse({"10467.json": tmp_path / "10467.json"})) diff --git a/tests/test_nz_dia_births.py b/tests/test_nz_dia_births.py index 373510e..1d3f591 100644 --- a/tests/test_nz_dia_births.py +++ b/tests/test_nz_dia_births.py @@ -84,8 +84,8 @@ def test_byte_fora_da_tabela_descarta_o_nome(dia, tmp_path, byte): @pytest.mark.parametrize("conteudo, trecho", [ (b"Ano,Sexo,Nome,Total\r\n2025,F,Mia,1\r\n", r"cabeçalho \['Ano', 'Sexo', 'Nome', 'Total'\]"), (b"", "arquivo vazio"), - (CABECALHO + b"2025,U,Mia,12\r\n", "linha 2: sexo 'U' desconhecido"), - (CABECALHO + b"2025,F,Mia,<10\r\n", "linha 2: contagem '<10'"), + (CABECALHO + b"2025,U,Mia,12\r\n", "linha 2: sexo 'U' unknown"), + (CABECALHO + b"2025,F,Mia,<10\r\n", "linha 2: count '<10'"), (CABECALHO + b"2025,F,Mia,12\r\n20x5,F,Mia,12\r\n", "linha 3: ano '20x5'"), (CABECALHO + b"2025,F,Mia\r\n", "linha 2: esperava 4 campos, veio 3"), (CABECALHO + b"2025,F,Mia,Rose,12\r\n", "linha 2: esperava 4 campos, veio 5"), diff --git a/tests/test_spreadsheet.py b/tests/test_spreadsheet.py index c9c9399..fd73f90 100644 --- a/tests/test_spreadsheet.py +++ b/tests/test_spreadsheet.py @@ -182,7 +182,7 @@ def test_r1_6_cell_count(valor, esperado): (20.5, "contagem 20.5 não é inteiro"), (-1, "contagem -1 negativa"), (True, "contagem True não é número"), - ("<20", r"contagem '<20' não é inteiro nem marcador conhecido \['..'\]"), + ("<20", r"contagem '<20' is neither an integer nor a known marker \['..'\]"), (None, "contagem vazia"), ]) def test_r1_6_cell_count_recusa(valor, trecho): diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index b081112..0a69c9c 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -32,3 +32,32 @@ def test_uv_lock_aponta_a_tag(): def test_kit_instalado_na_versao_fixada(): import datasettool assert datasettool.__version__ == VERSAO + + +# R3.1: base do adaptador do kit ------------------------------------------------------------------- + +def test_reexporta_os_objetos_do_kit(): + import datasettool.sources.adapters as kit + + import namescommons.adapters as nomes + for nome in ("AdapterError", "FormatError", "Resource", "SourceFiles", "module_name", "parse_count", "parse_code"): + assert getattr(nomes, nome) is getattr(kit, nome), nome + + +def test_load_adapter_usa_o_pacote_padrao(): + from namescommons.adapters import DEFAULT_PACKAGE, load_adapter + mod = load_adapter("us-ssa-births") + assert mod.__name__ == f"{DEFAULT_PACKAGE}.us_ssa_births" + + +def test_check_adapter_confere_links(): + from types import SimpleNamespace + + import pytest + + from namescommons.adapters import AdapterError, Resource, check_adapter + base = dict(SOURCE_ID="x", RESOURCES=[Resource("https://example.org/a.csv", "a.csv")], parse=lambda f: iter(())) + check_adapter(SimpleNamespace(**base), "x") + check_adapter(SimpleNamespace(**base, links=lambda f: iter(())), "x") + with pytest.raises(AdapterError, match="'x': links must be a function"): + check_adapter(SimpleNamespace(**base, links="não"), "x") diff --git a/tests/test_us_ssa_births.py b/tests/test_us_ssa_births.py index 2a4dafe..f6daa64 100644 --- a/tests/test_us_ssa_births.py +++ b/tests/test_us_ssa_births.py @@ -51,8 +51,8 @@ def test_anos_em_ordem_numerica(ssa, tmp_path): @pytest.mark.parametrize("linha, trecho", [ - (b"Ana,X,9\r\n", "sexo 'X' desconhecido"), - (b"Ana,F,<5\r\n", "contagem '<5'"), + (b"Ana,X,9\r\n", "sexo 'X' unknown"), + (b"Ana,F,<5\r\n", "count '<5'"), (b"Ana,F\r\n", "yob2000.txt, linha 1: esperava nome,sexo,contagem"), ]) def test_formato_inesperado_falha(ssa, tmp_path, linha, trecho): From 22cef9c57168a37726415c3299d645469260cb53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:27:50 -0300 Subject: [PATCH 05/14] refactor(spreadsheet): read spreadsheets with datasettool The INE adapter reads XLS and XLSX through datasettool.sources.spreadsheet, which carries the PR #39 hardening. xlrd is no longer a direct dependency; the toolkit pins it at 2.0.2. Removed, ported to tools-dataset at v0.1.0: - src/namescommons/spreadsheet.py (datasettool.sources.spreadsheet) - tests/test_spreadsheet.py (tests/test_spreadsheet.py there); its test_r1_3_amostras_do_ine stays here as test_leitor_do_kit_le_as_amostras_do_ine with the same vectors - tests/fixtures/planilhas/tipos.xls (byte-identical tests/fixtures/spreadsheets/types.xls there) Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 2 +- pyproject.toml | 1 - .../adapters/es_ine_residentes.py | 3 +- src/namescommons/spreadsheet.py | 485 ---------------- tests/fixtures/ATRIBUICAO.md | 2 +- tests/fixtures/planilhas/tipos.xls | Bin 5632 -> 0 bytes tests/test_es_ine_residentes.py | 38 +- tests/test_spreadsheet.py | 530 ------------------ tests/test_tools_dataset.py | 7 + uv.lock | 2 - 10 files changed, 47 insertions(+), 1023 deletions(-) delete mode 100644 src/namescommons/spreadsheet.py delete mode 100644 tests/fixtures/planilhas/tipos.xls delete mode 100644 tests/test_spreadsheet.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7c79e..4150994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: namescommons run --offline sobre as amostras das fontes de sources.yaml run: | mkdir -p "$RUNNER_TEMP/cache-oficiais" - find tests/fixtures -mindepth 1 -maxdepth 1 -type d ! -name exemplo ! -name planilhas -exec cp -R {} "$RUNNER_TEMP/cache-oficiais/" \; + find tests/fixtures -mindepth 1 -maxdepth 1 -type d ! -name exemplo -exec cp -R {} "$RUNNER_TEMP/cache-oficiais/" \; uv run namescommons run --offline --cache "$RUNNER_TEMP/cache-oficiais" \ --out "$RUNNER_TEMP/dist-oficiais" --release "$(date -u +%Y.%m)" for pais in CA ES FR IE NO NZ US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done diff --git a/pyproject.toml b/pyproject.toml index 6e8016b..34251ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "pyarrow>=17", "pycountry>=24.6", "pyyaml>=6.0", - "xlrd==2.0.2", ] [project.scripts] diff --git a/src/namescommons/adapters/es_ine_residentes.py b/src/namescommons/adapters/es_ine_residentes.py index 0ee576e..ba56825 100644 --- a/src/namescommons/adapters/es_ine_residentes.py +++ b/src/namescommons/adapters/es_ine_residentes.py @@ -8,8 +8,9 @@ from collections.abc import Iterator from pathlib import Path +from datasettool.sources.spreadsheet import cell_count, rows, sheet_names + from namescommons.adapters import FormatError, RawRecord, Resource, parse_code -from namescommons.spreadsheet import cell_count, rows, sheet_names SOURCE_ID = "es-ine-residentes" _BASE = "https://www.ine.es/daco/daco42/nombyapel/" diff --git a/src/namescommons/spreadsheet.py b/src/namescommons/spreadsheet.py deleted file mode 100644 index f723d03..0000000 --- a/src/namescommons/spreadsheet.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Leitor de planilhas XLS e XLSX para adaptadores de fontes (spec 003b, R1; ADR 0005). - -O formato é reconhecido pelos primeiros bytes. XLSX é lido em fluxo com a biblioteca padrão; -XLS, com xlrd. Os dois devolvem valores do mesmo tipo: str, int, float, bool ou None. -""" - -import codecs -import io -import posixpath -import re -import zipfile -import xml.etree.ElementTree as ET -import xml.parsers.expat as expat -from collections.abc import Iterable, Iterator -from pathlib import Path - -import xlrd - -from namescommons.adapters import FormatError, parse_count - -_ZIP = b"PK\x03\x04" -_OLE2 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" - -_MAIN = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" -_REL_DOC = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" -_REL_PKG = "{http://schemas.openxmlformats.org/package/2006/relationships}" -_ROW, _C, _V, _IS, _T, _R, _SI, _SHEET, _SHEET_DATA = ( - _MAIN + n for n in ("row", "c", "v", "is", "t", "r", "si", "sheet", "sheetData") -) -_REF = re.compile(r"([A-Z]{1,3})([0-9]+)") -_MAX_COLUNAS = 16384 # XFD, a última coluna do Excel -# Proteção contra ZIP desproporcional: membro acima de 1 GiB, ou acima de 1 MiB com taxa maior que 100:1. -_MAX_MEMBRO = 1 << 30 -_MAX_TAXA = 100 -_MIN_BYTES_TAXA = 1 << 20 -_INTEIRO = re.compile(r"-?[0-9]+") -# Defesa em profundidade (ADR 0005): membro XML com DTD é recusado antes de ir ao parser. -_MAX_PROLOGO = 1 << 16 -_ESPACO = re.compile(r"[\s\ufeff]*") -_DECLARACAO = re.compile(r"<\?xml(?=\s)(.*?)\?>", re.DOTALL) -_ENCODING = re.compile(r"""\sencoding\s*=\s*(?:"([^"]*)"|'([^']*)')""") -_ENCODING_OK = re.compile(r"utf-?(?:8|16)", re.IGNORECASE) -_TAMANHO_BLOCO = 1 << 16 - - -def _formato(caminho: Path) -> str: - with open(caminho, "rb") as fh: - inicio = fh.read(8) - if inicio.startswith(_ZIP): - return "xlsx" - if inicio == _OLE2: - return "xls" - raise FormatError(f"{caminho.name}: não é planilha XLS nem XLSX (começa com {inicio!r})") - - -def sheet_names(path: str | Path) -> list[str]: - """Abas na ordem do arquivo.""" - caminho = Path(path) - if _formato(caminho) == "xls": - return _xls_sheet_names(caminho) - with _abrir_zip(caminho) as z: - return [nome for nome, _ in _xlsx_abas(z, caminho)] - - -def rows(path: str | Path, sheet: str) -> Iterator[tuple[int, list]]: - """(número da linha no Excel, valores por coluna) de cada linha não vazia da aba.""" - caminho = Path(path) - if _formato(caminho) == "xls": - yield from _xls_rows(caminho, sheet) - else: - yield from _xlsx_rows(caminho, sheet) - - -def cell_count(value: object, missing: Iterable[str] = (), field: str = "contagem") -> int | None: - """Contagem de uma célula (spec 003b, R1.6). Marcador de `missing` vira None; o resto é FormatError.""" - marcadores = set(missing) - if value is None: - if "" in marcadores: - return None - raise FormatError(f"{field} vazia; esperava inteiro ou marcador conhecido {sorted(marcadores)}") - if isinstance(value, bool): - raise FormatError(f"{field} {value!r} não é número") - if isinstance(value, float): - if not value.is_integer(): - raise FormatError(f"{field} {value!r} não é inteiro") - value = int(value) - if isinstance(value, int): - if value < 0: - raise FormatError(f"{field} {value!r} negativa") - return value - if isinstance(value, str): - return parse_count(value, marcadores, field) - raise FormatError(f"{field} {value!r} de tipo {type(value).__name__} inesperado") - - -def _sem_none_final(valores: list) -> list: - while valores and valores[-1] is None: - valores.pop() - return valores - - -# XLSX --------------------------------------------------------------------------------------------- - -def _abrir_zip(caminho: Path) -> zipfile.ZipFile: - try: - z = zipfile.ZipFile(caminho) - except zipfile.BadZipFile as e: - raise FormatError(f"{caminho.name}: XLSX corrompido: {e}") from None - if "xl/workbook.xml" not in z.namelist(): - z.close() - raise FormatError(f"{caminho.name}: ZIP sem xl/workbook.xml; não é XLSX") - return z - - -def _membro(z: zipfile.ZipFile, nome: str, caminho: Path): - """Abre um membro do ZIP depois de conferir o tamanho descompactado declarado.""" - try: - info = z.getinfo(nome) - except KeyError: - raise FormatError(f"{caminho.name}: membro {nome} ausente do ZIP") from None - tamanho, compactado = info.file_size, max(info.compress_size, 1) - if tamanho > _MAX_MEMBRO: - raise FormatError(f"{caminho.name}: membro {nome} descompacta {tamanho} bytes, acima do limite de {_MAX_MEMBRO}") - if tamanho > _MIN_BYTES_TAXA and tamanho / compactado > _MAX_TAXA: - raise FormatError(f"{caminho.name}: membro {nome} descompacta {tamanho} bytes, " - f"taxa de compressão {tamanho // compactado}:1 acima de {_MAX_TAXA}:1") - return _SemDtd(z.open(info), nome, caminho) - - -class _SemDtd(io.RawIOBase): - """Fluxo de um membro XML que recusa DTD no prólogo e depois repassa os bytes ao parser. - - Lê no máximo 64 KiB, até a primeira tag de elemento, e devolve esse prefixo ao parser antes - do resto do fluxo; a leitura em fluxo de abas grandes continua igual. - """ - - def __init__(self, fh, nome: str, caminho: Path): - super().__init__() - self._fh = fh - prefixo = b"" - while True: - parte = fh.read(4096) - prefixo += parte - try: - decisao = _prologo(prefixo, f"{caminho.name}: membro {nome}") - except FormatError: - fh.close() - raise - if decisao == "dtd": - fh.close() - raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado") - if decisao == "raiz" or not parte: - break - if len(prefixo) >= _MAX_PROLOGO: - fh.close() - raise FormatError(f"{caminho.name}: membro {nome} tem prólogo acima de {_MAX_PROLOGO} bytes sem elemento raiz") - self._prefixo = memoryview(prefixo) - - def readable(self) -> bool: - return True - - def readinto(self, buf) -> int: - if self._prefixo: - n = min(len(buf), len(self._prefixo)) - buf[:n] = self._prefixo[:n] - self._prefixo = self._prefixo[n:] - return n - return self._fh.readinto(buf) - - def close(self) -> None: - self._fh.close() - super().close() - - -def _texto_do_prefixo(dados: bytes, onde: str) -> str: - """Decodifica o prefixo como UTF-8 ou UTF-16 (por BOM ou NUL); outra coisa é FormatError. - - O expat aceita qualquer codificação declarada (EBCDIC, UTF-32, ISO-8859-x...) e um DOCTYPE - escrito assim passaria despercebido por uma leitura só em UTF-8; por isso o resto é recusado. - """ - if dados[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff") or dados[:4] in (b"<\x00\x00\x00", b"\x00\x00\x00<"): - raise FormatError(f"{onde} está em UTF-32; só UTF-8 e UTF-16 são aceitos") - if dados[:2] in (b"\xff\xfe", b"\xfe\xff"): - codec = "utf-16" - elif dados[:4] == b"<\x00?\x00": - codec = "utf-16-le" - elif dados[:4] == b"\x00<\x00?": - codec = "utf-16-be" - elif b"\x00" in dados[:4]: - raise FormatError(f"{onde} começa com bytes nulos que não são UTF-16; só UTF-8 e UTF-16 são aceitos") - else: - codec = "utf-8-sig" - try: - # final=False: o prefixo pode terminar no meio de um caractere de vários bytes. - return codecs.getincrementaldecoder(codec)("strict").decode(dados, final=False) - except UnicodeDecodeError: - raise FormatError(f"{onde} não é UTF-8 nem UTF-16 válido; só essas codificações são aceitas") from None - - -def _confere_declaracao(texto: str, onde: str) -> None: - """A declaração `` só pode dizer UTF-8 ou UTF-16 (ou não dizer nada).""" - m = _DECLARACAO.match(texto) - if m and (e := _ENCODING.search(m.group(0))): - nome = e.group(1) if e.group(1) is not None else e.group(2) - if not _ENCODING_OK.fullmatch(nome): - raise FormatError(f"{onde} declara a codificação {nome!r}; só UTF-8 e UTF-16 são aceitos") - - -def _prologo(dados: bytes, onde: str = "membro XML") -> str: - """'dtd' se há DOCTYPE/ENTITY antes do elemento raiz, 'raiz' se a raiz já começou, senão 'mais'. - - FormatError se o prefixo não é UTF-8 nem UTF-16 ou se a declaração XML pede outra codificação. - """ - texto = _texto_do_prefixo(dados, onde) - _confere_declaracao(texto.lstrip("\ufeff"), onde) - i = 0 - while True: - i = _ESPACO.match(texto, i).end() - if i >= len(texto): - return "mais" - if texto.startswith("", i), 2 - elif texto.startswith("", i), 3 - elif texto[i:i + 9].upper() == " Iterator[tuple[str, ET.Element]]: - """Eventos ("start"/"end", elemento) do XML de um membro, em fluxo, num expat que recusa DTD. - - Defesa em profundidade, independente da pré-checagem do prólogo (ADR 0005): o próprio expat - levanta FormatError ao ver DOCTYPE ou ENTITY, em qualquer codificação que ele aceite. O - `ET.XMLParser` em C não expõe o expat; por isso o expat é usado direto, com `ET.TreeBuilder`. - Erro de sintaxe vira `ET.ParseError`, como no `iterparse`. - """ - def recusa(*_args): - raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado") - - construtor = ET.TreeBuilder() - pendentes: list[tuple[str, ET.Element]] = [] - quer_start, quer_end = "start" in events, "end" in events - - def inicio(tag, atributos): - el = construtor.start(tag, atributos) - if quer_start: - pendentes.append(("start", el)) - - def fim(tag): - el = construtor.end(tag) - if quer_end: - pendentes.append(("end", el)) - - parser = expat.ParserCreate(namespace_separator="}") - parser.buffer_text = True - parser.StartDoctypeDeclHandler = recusa - parser.EntityDeclHandler = recusa - parser.StartElementHandler = lambda tag, atr: inicio(_tag(tag), {_tag(k): v for k, v in atr.items()}) - parser.EndElementHandler = lambda tag: fim(_tag(tag)) - parser.CharacterDataHandler = construtor.data - try: - while True: - bloco = fh.read(_TAMANHO_BLOCO) - parser.Parse(bloco, not bloco) - yield from pendentes - pendentes.clear() - if not bloco: - break - except expat.ExpatError as e: - raise ET.ParseError(str(e)) from None - - -def _tag(nome: str) -> str: - """'uri}local' do expat vira '{uri}local' (nome qualificado do ElementTree).""" - return "{" + nome if "}" in nome else nome - - -def _raiz(fh, nome: str, caminho: Path) -> ET.Element: - """Elemento raiz do XML de um membro (o `ET.parse(...).getroot()` com DTD recusado).""" - raiz = None - for _, el in _eventos(fh, nome, caminho): - raiz = el - return raiz - - -def _xlsx_abas(z: zipfile.ZipFile, caminho: Path) -> list[tuple[str, str]]: - """(nome, membro do ZIP) de cada aba, na ordem de xl/workbook.xml.""" - try: - with _membro(z, "xl/workbook.xml", caminho) as fh: - livro = _raiz(fh, "xl/workbook.xml", caminho) - with _membro(z, "xl/_rels/workbook.xml.rels", caminho) as fh: - rels = _raiz(fh, "xl/_rels/workbook.xml.rels", caminho) - except ET.ParseError as e: - raise FormatError(f"{caminho.name}: workbook do XLSX ilegível: {e}") from None - alvos = {r.get("Id"): r.get("Target", "") for r in rels.iter(_REL_PKG + "Relationship")} - abas = [] - for folha in livro.iter(_SHEET): - alvo = alvos.get(folha.get(_REL_DOC + "id")) - if alvo is None: - raise FormatError(f"{caminho.name}: aba {folha.get('name')!r} sem relação em workbook.xml.rels") - membro = alvo.lstrip("/") if alvo.startswith("/") else posixpath.normpath(posixpath.join("xl", alvo)) - abas.append((folha.get("name"), membro)) - return abas - - -def _texto(elemento: ET.Element) -> str: - """Texto de ou : diretos e de trechos ; a leitura fonética fica fora.""" - partes = [] - for filho in elemento: - if filho.tag == _T: - partes.append(filho.text or "") - elif filho.tag == _R: - partes.extend(t.text or "" for t in filho.iter(_T)) - return "".join(partes) - - -def _textos_compartilhados(z: zipfile.ZipFile, caminho: Path) -> list[str]: - if "xl/sharedStrings.xml" not in z.namelist(): - return [] - textos = [] - with _membro(z, "xl/sharedStrings.xml", caminho) as fh: - try: - for _, el in _eventos(fh, "xl/sharedStrings.xml", caminho): - if el.tag == _SI: - textos.append(_texto(el)) - el.clear() - except ET.ParseError as e: - raise FormatError(f"{caminho.name}: sharedStrings.xml ilegível: {e}") from None - return textos - - -def _coluna(ref: str, onde: str) -> int: - """Índice da coluna (A = 0) de uma referência como 'C7'.""" - m = _REF.fullmatch(ref) - if not m: - raise FormatError(f"{onde}: referência de célula {ref!r} inválida") - n = 0 - for letra in m.group(1): - n = n * 26 + ord(letra) - ord("A") + 1 - if n > _MAX_COLUNAS: - raise FormatError(f"{onde}, célula {ref}: coluna além de XFD") - return n - 1 - - -def _numero(texto: str, onde: str) -> int | float: - texto = texto.strip() - if _INTEIRO.fullmatch(texto): - return int(texto) - try: - valor = float(texto) - except ValueError: - raise FormatError(f"{onde}: número {texto!r} inválido") from None - return int(valor) if valor.is_integer() else valor - - -def _valor(c: ET.Element, textos: list[str], onde: str): - tipo = c.get("t", "n") - v = c.find(_V) - if tipo == "inlineStr": - bloco = c.find(_IS) - return None if bloco is None else _texto(bloco) - if v is None or v.text is None: - return "" if tipo == "str" and v is not None else None - if tipo == "s": - try: - indice = int(v.text) - except ValueError: - raise FormatError(f"{onde}: índice de texto compartilhado {v.text!r} não é inteiro") from None - if not 0 <= indice < len(textos): - raise FormatError(f"{onde}: texto compartilhado {indice} fora da tabela de {len(textos)}") - return textos[indice] - if tipo == "str": - return v.text - if tipo == "n": - return _numero(v.text, onde) - if tipo == "b": - return v.text.strip() == "1" - if tipo == "e": - raise FormatError(f"{onde}: erro de fórmula {v.text!r}") - raise FormatError(f"{onde}: tipo {tipo!r} desconhecido") - - -def _xlsx_rows(caminho: Path, sheet: str) -> Iterator[tuple[int, list]]: - with _abrir_zip(caminho) as z: - abas = _xlsx_abas(z, caminho) - membro = dict(abas).get(sheet) - if membro is None: - raise FormatError(f"{caminho.name}: aba {sheet!r} não existe; abas: {[n for n, _ in abas]}") - textos = _textos_compartilhados(z, caminho) - prefixo = f"{caminho.name}, aba {sheet!r}" - if membro not in z.namelist(): - raise FormatError(f"{caminho.name}: aba {sheet!r} aponta para {membro}, ausente do ZIP") - with _membro(z, membro, caminho) as fh: - numero, pai = 0, None - try: - for evento, el in _eventos(fh, membro, caminho, ("start", "end")): - if evento == "start": - if el.tag == _SHEET_DATA: - pai = el - continue - if el.tag != _ROW: - continue - r = el.get("r") - if r is None: - numero += 1 - elif r.isdigit(): - numero = int(r) - else: - raise FormatError(f"{prefixo}: linha {r!r} inválida") - valores: list = [] - for c in el.iter(_C): - ref = c.get("r") - posicao = len(valores) if ref is None else _coluna(ref, prefixo) - valores.extend([None] * (posicao - len(valores) + 1)) - valores[posicao] = _valor(c, textos, f"{prefixo}, célula {ref or posicao}") - # Tira a linha lida da árvore: a memória fica plana na aba inteira. - el.clear() - if pai is not None: - pai.remove(el) - if _sem_none_final(valores): - yield numero, valores - except ET.ParseError as e: - raise FormatError(f"{prefixo}: XML ilegível: {e}") from None - - -# XLS ---------------------------------------------------------------------------------------------- - -def _xls_abrir(caminho: Path): - try: - return xlrd.open_workbook(caminho, on_demand=True) - except Exception as e: # xlrd levanta XLRDError, CompDocError, AssertionError e outras - raise FormatError(f"{caminho.name}: XLS ilegível: {type(e).__name__}: {e}") from None - - -def _xls_sheet_names(caminho: Path) -> list[str]: - livro = _xls_abrir(caminho) - try: - return livro.sheet_names() - finally: - livro.release_resources() - - -def _xls_valor(celula, onde: str): - tipo = celula.ctype - if tipo in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK): - return None - if tipo == xlrd.XL_CELL_TEXT: - return celula.value - if tipo in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE): - valor = float(celula.value) - return int(valor) if valor.is_integer() else valor - if tipo == xlrd.XL_CELL_BOOLEAN: - return bool(celula.value) - if tipo == xlrd.XL_CELL_ERROR: - texto = xlrd.error_text_from_code.get(celula.value, f"código {celula.value}") - raise FormatError(f"{onde}: erro de fórmula {texto!r}") - raise FormatError(f"{onde}: tipo de célula {tipo} desconhecido") - - -def _xls_rows(caminho: Path, sheet: str) -> Iterator[tuple[int, list]]: - livro = _xls_abrir(caminho) - try: - if sheet not in livro.sheet_names(): - raise FormatError(f"{caminho.name}: aba {sheet!r} não existe; abas: {livro.sheet_names()}") - try: - aba = livro.sheet_by_name(sheet) - except Exception as e: - raise FormatError(f"{caminho.name}, aba {sheet!r}: XLS ilegível: {type(e).__name__}: {e}") from None - prefixo = f"{caminho.name}, aba {sheet!r}" - for i in range(aba.nrows): - valores = [ - _xls_valor(celula, f"{prefixo}, célula {xlrd.cellname(i, j)}") - for j, celula in enumerate(aba.row(i)) - ] - if _sem_none_final(valores): - yield i + 1, valores - finally: - livro.release_resources() diff --git a/tests/fixtures/ATRIBUICAO.md b/tests/fixtures/ATRIBUICAO.md index 666252a..c4fa896 100644 --- a/tests/fixtures/ATRIBUICAO.md +++ b/tests/fixtures/ATRIBUICAO.md @@ -1,6 +1,6 @@ # Amostras das fontes oficiais -Cada diretório guarda um trecho pequeno do arquivo real da fonte, só para os testes (spec 003, R3; spec 003b, R3). As linhas foram copiadas sem alteração. Os ZIPs foram remontados só com as linhas copiadas. As amostras do SSB são a resposta real da API para uma consulta restrita a poucos nomes e anos. As planilhas do INE mantêm os números de linha do arquivo real. `planilhas/tipos.xls` é sintética, para os testes do leitor de planilhas. +Cada diretório guarda um trecho pequeno do arquivo real da fonte, só para os testes (spec 003, R3; spec 003b, R3). As linhas foram copiadas sem alteração. Os ZIPs foram remontados só com as linhas copiadas. As amostras do SSB são a resposta real da API para uma consulta restrita a poucos nomes e anos. As planilhas do INE mantêm os números de linha do arquivo real. | Fonte | Licença | Origem | Trecho | |---|---|---|---| diff --git a/tests/fixtures/planilhas/tipos.xls b/tests/fixtures/planilhas/tipos.xls deleted file mode 100644 index 184f65e7906aa57b593d4b2f148c5d8e76b6f555..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5632 zcmeHLOGs2<6#o9}%s85_YZ_!AgOrwu5N+D%IOPL-%vc0P1vNUeFl{ETwWvi|<}wIc z6>SVRZLBsuu%LD=0(&fiAkbR23JO|i(|68wVw{Cd1d?&j<(_-~^SI~y=bZcB|K3+K z_Uy#{x;f-QJBsilQ-VMa-;gfJj;lan$jm&H;`srMW# z-19vr20rpUhSP|qQ_0W@!cL7LZX|{_WjV0`%B6x935FbP(! zCz(n*@!?t2u!NKNmI(o0WdZ`vLLPr*^I#AHzKc%*H~+<~OFsX)^vFE+NVWC!12HF&Oi><9pXzs#r9iin95|ay4GhMq6^R~< zMSEk^>wVqZ6I%o9jzyx8P-j!*z%D*mt*x|xy8=`#SpgUZ!zm+9pi*gDpN=X7F@NXF zn|=L#;p55~B}YIWmQ5Oa2A9Nw#uX}&aAiV3vs!Fbd|7m`LFqchHPm5HjeWI?ME98L zTv2Hw{cKlw8*a*>H#ZK(4Jg>sDA>DDoDy3BHZdbZ~gzL}K7oM$o_gg{QxltM`Kw4sS zxkaQnG3EM5i+5?b{HOzJBa(Kj8V@8GbO>66mFOgO$~abUCz+fYNfiZb&3Cq2a;q*C za{Hs%Xx3G~L$Qn8JLE7t(#wFC0WSky2D}V-8Spaj+YHEVRCZLdA9Z(;?mqz8wab5< z1)g&C;Q!3P0i=1HK?q$)@#^3lD_=OQgWqH`BfV4zWd9e6?jd@2Fob@MW2g}GB|7|q g`{iHaSR!0*;C|XIr%|ZvkNxM)zfdR3=>3)dpQAAas{jB1 diff --git a/tests/test_es_ine_residentes.py b/tests/test_es_ine_residentes.py index cb7d82a..38b5610 100644 --- a/tests/test_es_ine_residentes.py +++ b/tests/test_es_ine_residentes.py @@ -162,11 +162,11 @@ def test_r2_7_marcador_desconhecido_falha(mod, monkeypatch): @pytest.mark.parametrize("altera, trecho", [ - (lambda v: [v[0], v[1], 20.5] + v[3:], "linha 21: Apellido 1º 20.5 não é inteiro"), + (lambda v: [v[0], v[1], 20.5] + v[3:], "linha 21: Apellido 1º 20.5 is not an integer"), (lambda v: [v[0], "", v[2]], "linha 21: nome vazio"), (lambda v: [v[0], None, v[2]], "linha 21: nome vazio"), (lambda v: ["Total", v[1], v[2]], "linha 21: ordem 'Total' não é inteiro"), - (lambda v: v[:2], "linha 21: Apellido 1º vazia"), + (lambda v: v[:2], "linha 21: Apellido 1º empty"), ]) def test_formato_inesperado_falha(mod, monkeypatch, altera, trecho): _linhas_xls(mod, monkeypatch, lambda aba, n, v: altera(v) if n == 21 else v) @@ -178,3 +178,37 @@ def test_aba_sem_linhas_de_dados_falha(mod, monkeypatch): _linhas_xls(mod, monkeypatch, lambda aba, n, v: v if n <= 5 or aba != "Apellidos >=20 y <=99" else None) with pytest.raises(FormatError, match=r"aba 'Apellidos >=20 y <=99': nenhuma linha de dados"): list(mod.parse(ARQUIVOS)) + + +def test_leitor_do_kit_le_as_amostras_do_ine(): + """Spec 003b, R1.3, sobre as amostras reais; o resto do leitor é testado no tools-dataset (spec 007, R2.2).""" + from datasettool.sources.spreadsheet import rows, sheet_names + + nomes = AMOSTRA / NOMES + assert sheet_names(nomes) == ["Hombres", "Mujeres"] + assert list(rows(nomes, "Hombres"))[:5] == [ + (1, ["Frecuencias de nombres masculinos"]), + (2, ["Datos procedentes de Censos de población anuales a fecha 01/01/2025"]), + (4, ["Nombres masculinos con frecuencia mayor o igual a 20 a nivel nacional."]), + (5, ["(*) Edad media de las personas que tienen ese nombre a nivel nacional."]), + (7, ["Orden", "Nombre", "Frecuencia", "Edad Media (*)"]), + ] + assert list(rows(nomes, "Mujeres"))[-1] == (32328, [32321, "ZULMA PATRICIA", 20, 43]) + apellidos = AMOSTRA / APELLIDOS + assert sheet_names(apellidos) == ["Apellidos >=100", "Apellidos >=20 y <=99"] + obtido = list(rows(apellidos, "Apellidos >=100")) + assert obtido[2:6] == [ + (3, ["Datos procedentes de Censos de población anuales a fecha 01/01/2025"]), + (4, [None, None, "Apellido 1º", "Apellido 2º", "Ambos apellidos"]), + (5, ["Orden", "Apellido", "Total", "Total", "Total"]), + (6, [1, "GARCIA", 1446937, 1468824, 74765, 74765, 1468824]), + ] + assert obtido[-2] == (16097, [16092, "LYU", 208, "..", "..", 9999999, 9999999]) + + +def test_adaptador_le_planilhas_pelo_kit(): + """Spec 007, R2.2: o adaptador usa o leitor do kit, e o FormatError dele é o mesmo do contrato.""" + import datasettool.sources.spreadsheet as kit + + ine = load_adapter("es-ine-residentes") + assert (ine.rows, ine.sheet_names, ine.cell_count) == (kit.rows, kit.sheet_names, kit.cell_count) diff --git a/tests/test_spreadsheet.py b/tests/test_spreadsheet.py deleted file mode 100644 index fd73f90..0000000 --- a/tests/test_spreadsheet.py +++ /dev/null @@ -1,530 +0,0 @@ -"""Spec 003b, R1: leitor de planilhas XLS e XLSX (namescommons.spreadsheet).""" - -import zipfile -from pathlib import Path - -import pytest - -from namescommons.adapters import FormatError -from namescommons.spreadsheet import cell_count, rows, sheet_names - -FIXTURES = Path(__file__).parent / "fixtures" -NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' -NS_R = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' -REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" - - -def _xlsx(caminho: Path, abas: list[tuple[str, str]], textos: list[str] = (), alvo="worksheets/sheet{n}.xml", - ordem=None) -> Path: - """Grava um XLSX mínimo. abas: (nome, XML do sheetData); textos: XML de cada .""" - ordem = ordem or list(range(1, len(abas) + 1)) - folhas = "".join(f'' - for n, (nome, _) in zip(ordem, abas)) - rels = "".join(f'' - for n in ordem) - rels += f'' - with zipfile.ZipFile(caminho, "w", zipfile.ZIP_DEFLATED) as z: - z.writestr("[Content_Types].xml", "") - z.writestr("xl/workbook.xml", f'{folhas}') - z.writestr("xl/_rels/workbook.xml.rels", - f'{rels}') - z.writestr("xl/sharedStrings.xml", - f'' + "".join(f"{t}" for t in textos) + "") - for n, (_, dados) in zip(ordem, abas): - z.writestr(f"xl/worksheets/sheet{n}.xml", f'{dados}') - return caminho - - -TIPOS_XML = ( - '012' - '359093858.7' - 'IÑIGO1E3' - '' - 'JOANA D\'ARC1' - '0' - '4' -) -TIPOS_TEXTOS = ["Orden", "Nombre", "Frecuencia", "ANTONIO", ".."] -TIPOS_ESPERADO = [ - (1, ["Orden", "Nombre", "Frecuencia"]), - (2, ["ANTONIO", 590938, 58.7]), - (3, ["IÑIGO", 1000]), - (5, ["JOANA D'ARC", True, None, False]), - (7, [None, None, ".."]), -] - - -# R1.1 -------------------------------------------------------------------------------------------- - -def test_r1_1_sheet_names_na_ordem_do_arquivo(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("Hombres", ""), ("Mujeres", ""), ("Notas", "")]) - assert sheet_names(arq) == ["Hombres", "Mujeres", "Notas"] - - -def test_r1_1_aba_ausente_lista_as_abas(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("Hombres", ""), ("Mujeres", "")]) - with pytest.raises(FormatError, match=r"a.xlsx: aba 'Varones' não existe; abas: \['Hombres', 'Mujeres'\]"): - list(rows(arq, "Varones")) - - -def test_r1_1_numero_da_linha_do_excel_e_coluna_pela_referencia(tmp_path): - dados = ('1' - '3' - '27') - arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)]) - assert list(rows(arq, "X")) == [(1, [1]), (3, [None, None, 3]), (10, [None] * 26 + [27])] - - -# R1.2 -------------------------------------------------------------------------------------------- - -@pytest.mark.parametrize("conteudo", [ - b"Error 500", - b"", - b"Orden;Nombre;Frecuencia\n1;ANTONIO;590938\n", -]) -def test_r1_2_conteudo_que_nao_e_planilha(tmp_path, conteudo): - arq = tmp_path / "nombres.xlsx" - arq.write_bytes(conteudo) - with pytest.raises(FormatError, match="nombres.xlsx: não é planilha XLS nem XLSX"): - sheet_names(arq) - with pytest.raises(FormatError, match="não é planilha XLS nem XLSX"): - list(rows(arq, "Hombres")) - - -def test_r1_2_zip_que_nao_e_xlsx(tmp_path): - arq = tmp_path / "a.xlsx" - with zipfile.ZipFile(arq, "w") as z: - z.writestr("nomes.csv", "a,b\n") - with pytest.raises(FormatError, match="a.xlsx: ZIP sem xl/workbook.xml; não é XLSX"): - sheet_names(arq) - - -def test_r1_2_formato_pelo_conteudo_e_nao_pela_extensao(tmp_path): - arq = _xlsx(tmp_path / "planilha.xls", [("X", '1')]) - assert list(rows(arq, "X")) == [(1, [1])] - - -# R1.3 -------------------------------------------------------------------------------------------- - -def test_r1_3_tipos_de_celula_xlsx(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("Dados", TIPOS_XML)], TIPOS_TEXTOS) - obtido = list(rows(arq, "Dados")) - assert obtido == TIPOS_ESPERADO - assert [type(v) for v in obtido[1][1]] == [str, int, float] - assert type(obtido[2][1][1]) is int - - -def test_r1_3_linhas_vazias_puladas_e_none_final_cortado(tmp_path): - dados = ('' - '2' - '3') - arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)]) - assert list(rows(arq, "X")) == [(2, ["", 2]), (3, [3])] - - -def test_r1_3_erro_de_formula_nomeia_aba_e_celula(tmp_path): - dados = '1#N/A' - arq = _xlsx(tmp_path / "a.xlsx", [("Hombres", dados)]) - with pytest.raises(FormatError, match=r"a.xlsx, aba 'Hombres', célula B2: erro de fórmula '#N/A'"): - list(rows(arq, "Hombres")) - - -def test_r1_3_tipo_de_celula_desconhecido(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", '2025-01-01')]) - with pytest.raises(FormatError, match="célula A1: tipo 'd' desconhecido"): - list(rows(arq, "X")) - - -def test_r1_3_numero_invalido(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", 'doze')]) - with pytest.raises(FormatError, match="célula A1: número 'doze' inválido"): - list(rows(arq, "X")) - - -# R1.4 -------------------------------------------------------------------------------------------- - -def test_r1_4_texto_compartilhado_com_formatacao_e_rph(tmp_path): - textos = [ - "MARIA BEGOÑA", - "東京トウキョウ", - ] - dados = '01' - arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)], textos) - assert list(rows(arq, "X")) == [(1, ["MARIA BEGOÑA", "東京"])] - - -def test_r1_4_indice_de_texto_compartilhado_fora_da_tabela(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", '7')], ["a"]) - with pytest.raises(FormatError, match="célula A1: texto compartilhado 7 fora da tabela de 1"): - list(rows(arq, "X")) - - -@pytest.mark.parametrize("alvo", ["worksheets/sheet{n}.xml", "/xl/worksheets/sheet{n}.xml"]) -def test_r1_4_aba_pelo_alvo_das_relacoes(tmp_path, alvo): - abas = [("Primeira", '2'), - ("Segunda", '1')] - arq = _xlsx(tmp_path / "a.xlsx", abas, alvo=alvo, ordem=[2, 1]) - assert sheet_names(arq) == ["Primeira", "Segunda"] - assert list(rows(arq, "Primeira")) == [(1, [2])] - assert list(rows(arq, "Segunda")) == [(1, [1])] - - -# R1.6 -------------------------------------------------------------------------------------------- - -@pytest.mark.parametrize("valor, esperado", [ - (590938, 590938), (0, 0), (" 20 ", 20), ("..", None), (None, None), -]) -def test_r1_6_cell_count(valor, esperado): - assert cell_count(valor, {"..", ""}) == esperado - - -@pytest.mark.parametrize("valor, trecho", [ - (20.5, "contagem 20.5 não é inteiro"), - (-1, "contagem -1 negativa"), - (True, "contagem True não é número"), - ("<20", r"contagem '<20' is neither an integer nor a known marker \['..'\]"), - (None, "contagem vazia"), -]) -def test_r1_6_cell_count_recusa(valor, trecho): - with pytest.raises(FormatError, match=trecho): - cell_count(valor, {".."}) - - -def test_r1_6_cell_count_nomeia_o_campo(): - with pytest.raises(FormatError, match="Frecuencia 1.5 não é inteiro"): - cell_count(1.5, field="Frecuencia") - - -# R1.3 e R1.5: XLS pelo xlrd ---------------------------------------------------------------------- - -TIPOS_XLS = FIXTURES / "planilhas" / "tipos.xls" -INE = FIXTURES / "es-ine-residentes" - - -def test_r1_3_tipos_de_celula_xls(): - assert sheet_names(TIPOS_XLS) == ["Dados", "Erros"] - obtido = list(rows(TIPOS_XLS, "Dados")) - assert obtido == TIPOS_ESPERADO - assert [type(v) for v in obtido[1][1]] == [str, int, float] - assert [type(v) for v in obtido[3][1]] == [str, bool, type(None), bool] - - -def test_r1_3_erro_de_formula_xls(): - with pytest.raises(FormatError, match=r"tipos.xls, aba 'Erros', célula B2: erro de fórmula '#N/A'"): - list(rows(TIPOS_XLS, "Erros")) - - -def test_r1_3_mesmos_valores_nos_dois_formatos(tmp_path): - xlsx = _xlsx(tmp_path / "tipos.xlsx", [("Dados", TIPOS_XML)], TIPOS_TEXTOS) - assert list(rows(xlsx, "Dados")) == list(rows(TIPOS_XLS, "Dados")) - - -def test_r1_1_aba_ausente_xls(): - with pytest.raises(FormatError, match=r"tipos.xls: aba 'Notas' não existe; abas: \['Dados', 'Erros'\]"): - list(rows(TIPOS_XLS, "Notas")) - - -def test_r1_3_amostras_do_ine(): - nomes = INE / "nombres_por_edad_media.xlsx" - assert sheet_names(nomes) == ["Hombres", "Mujeres"] - assert list(rows(nomes, "Hombres"))[:5] == [ - (1, ["Frecuencias de nombres masculinos"]), - (2, ["Datos procedentes de Censos de población anuales a fecha 01/01/2025"]), - (4, ["Nombres masculinos con frecuencia mayor o igual a 20 a nivel nacional."]), - (5, ["(*) Edad media de las personas que tienen ese nombre a nivel nacional."]), - (7, ["Orden", "Nombre", "Frecuencia", "Edad Media (*)"]), - ] - assert list(rows(nomes, "Mujeres"))[-1] == (32328, [32321, "ZULMA PATRICIA", 20, 43]) - apellidos = INE / "apellidos_frecuencia.xls" - assert sheet_names(apellidos) == ["Apellidos >=100", "Apellidos >=20 y <=99"] - obtido = list(rows(apellidos, "Apellidos >=100")) - assert obtido[2:6] == [ - (3, ["Datos procedentes de Censos de población anuales a fecha 01/01/2025"]), - (4, [None, None, "Apellido 1º", "Apellido 2º", "Ambos apellidos"]), - (5, ["Orden", "Apellido", "Total", "Total", "Total"]), - (6, [1, "GARCIA", 1446937, 1468824, 74765, 74765, 1468824]), - ] - assert obtido[-2] == (16097, [16092, "LYU", 208, "..", "..", 9999999, 9999999]) - - -def test_r1_5_xls_corrompido(tmp_path): - arq = tmp_path / "quebrado.xls" - arq.write_bytes(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 600) - with pytest.raises(FormatError, match="quebrado.xls: XLS ilegível"): - sheet_names(arq) - - -def test_r1_5_xlrd_fixado(): - import tomllib - projeto = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")) - assert "xlrd==2.0.2" in projeto["project"]["dependencies"] - - -# Revisão do PR #27: entradas malformadas e ZIP desproporcional viram FormatError ------------------ - -def test_membro_da_aba_ausente_no_zip(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", "")], alvo="worksheets/nao_existe{n}.xml") - with pytest.raises(FormatError, match=r"a.xlsx: aba 'X' aponta para xl/worksheets/nao_existe1.xml, ausente do ZIP"): - list(rows(arq, "X")) - - -def test_indice_de_texto_compartilhado_nao_inteiro(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", 'abc')], ["a"]) - with pytest.raises(FormatError, match="célula A1: índice de texto compartilhado 'abc' não é inteiro"): - list(rows(arq, "X")) - - -@pytest.mark.parametrize("ref, trecho", [ - ("XFE1", "célula XFE1: coluna além de XFD"), - ("1A", "referência de célula '1A' inválida"), - ("a1", "referência de célula 'a1' inválida"), -]) -def test_referencia_de_celula_invalida(tmp_path, ref, trecho): - arq = _xlsx(tmp_path / "a.xlsx", [("X", f'1')]) - with pytest.raises(FormatError, match=trecho): - list(rows(arq, "X")) - - -def test_numero_de_linha_invalido(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", '1')]) - with pytest.raises(FormatError, match="linha 'x' inválida"): - list(rows(arq, "X")) - - -def test_zip_com_taxa_de_compressao_desproporcional(tmp_path): - enchimento = " " * 3_000_000 - arq = _xlsx(tmp_path / "a.xlsx", [("X", f'1{enchimento}')]) - with pytest.raises(FormatError, match=r"a.xlsx: membro xl/worksheets/sheet1.xml descompacta 3\d{6} bytes, .*taxa"): - list(rows(arq, "X")) - - -def test_zip_com_membro_grande_demais(tmp_path, monkeypatch): - import namescommons.spreadsheet as planilha - monkeypatch.setattr(planilha, "_MAX_MEMBRO", 100) - arq = _xlsx(tmp_path / "a.xlsx", [("X", '1')]) - with pytest.raises(FormatError, match=r"a.xlsx: membro xl/workbook.xml descompacta \d+ bytes, acima do limite de 100"): - sheet_names(arq) - - -def test_linhas_lidas_saem_da_arvore(tmp_path): - """Memória plana: a aba de 63 mil linhas do INE não acumula elementos já lidos.""" - import namescommons.spreadsheet as planilha - dados = "".join(f'{i}' for i in range(1, 2001)) - arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)]) - vistos = [] - original = planilha._eventos - - def espia(*args, **kwargs): - for evento, el in original(*args, **kwargs): - yield evento, el - if evento == "end" and el.tag.endswith("sheetData"): - vistos.append(len(el)) - - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr(planilha, "_eventos", espia) - try: - assert sum(1 for _ in rows(arq, "X")) == 2000 - finally: - monkeypatch.undo() - assert vistos == [0] - - -# Defesa em profundidade contra DTD (ADR 0005) ----------------------------------------------------- - -def _regravar(origem: Path, destino: Path, membro: str, conteudo: str) -> Path: - with zipfile.ZipFile(origem) as zin, zipfile.ZipFile(destino, "w", zipfile.ZIP_DEFLATED) as zout: - for info in zin.infolist(): - zout.writestr(info.filename, conteudo if info.filename == membro else zin.read(info.filename)) - return destino - - -BILLION_LAUGHS = ( - '' - '' - ']>' - f'&lol3;' -) - - -@pytest.mark.parametrize("membro, operacao", [ - ("xl/workbook.xml", lambda a: sheet_names(a)), - ("xl/_rels/workbook.xml.rels", lambda a: sheet_names(a)), - ("xl/sharedStrings.xml", lambda a: list(rows(a, "X"))), - ("xl/worksheets/sheet1.xml", lambda a: list(rows(a, "X"))), -]) -@pytest.mark.parametrize("prolog", [ - ']>', - '', - '', -]) -def test_r1_4_dtd_no_prologo_e_recusado(tmp_path, membro, operacao, prolog): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - raiz = {"xl/workbook.xml": f"", "xl/_rels/workbook.xml.rels": "", - "xl/sharedStrings.xml": f"", "xl/worksheets/sheet1.xml": f""}[membro] - arq = _regravar(base, tmp_path / "a.xlsx", membro, f'{prolog}{raiz}') - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): - operacao(arq) - - -def test_r1_4_billion_laughs_falha_pela_checagem_de_dtd(tmp_path): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - arq = _regravar(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", BILLION_LAUGHS) - with pytest.raises(FormatError, match=r"a\.xlsx: membro xl/worksheets/sheet1\.xml tem DTD"): - list(rows(arq, "X")) - - -def test_r1_4_doctype_dentro_de_comentario_nao_e_dtd(tmp_path): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - conteudo = f'7' - arq = _regravar(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", conteudo) - assert list(rows(arq, "X")) == [(1, [7])] - - -def test_r1_4_texto_com_doctype_depois_da_raiz_nao_e_dtd(tmp_path): - arq = _xlsx(tmp_path / "a.xlsx", [("X", '<!DOCTYPE')]) - assert list(rows(arq, "X")) == [(1, ["1')]) - conteudo = '' - arq = _regravar(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", conteudo) - with pytest.raises(FormatError, match=r"a\.xlsx: membro xl/worksheets/sheet1\.xml tem prólogo acima de 65536 bytes"): - list(rows(arq, "X")) - - -def test_r1_4_xlsx_normal_com_bom_continua_lido(tmp_path): - base = _xlsx(tmp_path / "base.xlsx", [("X", "")]) - conteudo = '\n' + f'3' - arq = _regravar(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", conteudo) - assert list(rows(arq, "X")) == [(1, [3])] - - -def test_r1_4_300_mil_linhas_em_fluxo_com_memoria_plana(tmp_path): - import tracemalloc - n = 300_000 - dados = "".join(f'{i}NOME{i}' - for i in range(1, n + 1)) - arq = _xlsx(tmp_path / "a.xlsx", [("X", dados)]) - del dados - tracemalloc.start() - try: - total = ultimo = 0 - for numero, valores in rows(arq, "X"): - total += 1 - ultimo = valores[0] - _, pico = tracemalloc.get_traced_memory() - finally: - tracemalloc.stop() - assert (total, ultimo) == (n, n) - assert pico < 5 * 1024 * 1024, f"pico de {pico} bytes: a aba não está sendo lida em fluxo" - - -@pytest.mark.parametrize("membro, operacao", [ - ("xl/workbook.xml", lambda a: sheet_names(a)), - ("xl/_rels/workbook.xml.rels", lambda a: sheet_names(a)), - ("xl/sharedStrings.xml", lambda a: list(rows(a, "X"))), - ("xl/worksheets/sheet1.xml", lambda a: list(rows(a, "X"))), -]) -@pytest.mark.parametrize("prolog", [ - ']>', - ']>', -]) -def test_r1_4_dtd_depois_de_bom_utf8_e_recusado(tmp_path, membro, operacao, prolog): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - raiz = {"xl/workbook.xml": f"", "xl/_rels/workbook.xml.rels": "", - "xl/sharedStrings.xml": f"", "xl/worksheets/sheet1.xml": f""}[membro] - arq = _regravar(base, tmp_path / "a.xlsx", membro, f'{prolog}{raiz}') - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): - operacao(arq) - - -# DTD em codificações que fogem da pré-checagem em UTF-8 (parser differential) ---------------------- - -_MEMBROS = [ - ("xl/workbook.xml", lambda a: sheet_names(a)), - ("xl/_rels/workbook.xml.rels", lambda a: sheet_names(a)), - ("xl/sharedStrings.xml", lambda a: list(rows(a, "X"))), - ("xl/worksheets/sheet1.xml", lambda a: list(rows(a, "X"))), -] -_RAIZ = {"xl/workbook.xml": f"", "xl/_rels/workbook.xml.rels": "", - "xl/sharedStrings.xml": f"", "xl/worksheets/sheet1.xml": f""} -_DTD = ']>' - - -def _regravar_bytes(origem: Path, destino: Path, membro: str, dados: bytes) -> Path: - with zipfile.ZipFile(origem) as zin, zipfile.ZipFile(destino, "w", zipfile.ZIP_DEFLATED) as zout: - for info in zin.infolist(): - zout.writestr(info.filename, dados if info.filename == membro else zin.read(info.filename)) - return destino - - -@pytest.mark.parametrize("membro, operacao", _MEMBROS) -@pytest.mark.parametrize("declarada, codec", [ - ("cp037", "cp037"), - ("utf-32", "utf-32"), - ("UTF-32LE", "utf-32-le"), - ("UTF-32BE", "utf-32-be"), - ("latin-1", "latin-1"), - ("iso-8859-1", "ascii"), - ("windows-1252", "cp1252"), - ("us-ascii", "ascii"), -]) -def test_r1_4_dtd_em_codificacao_nao_utf_e_recusado(tmp_path, membro, operacao, declarada, codec): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - texto = f'{_DTD}{_RAIZ[membro]}' - arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, texto.encode(codec)) - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} "): - operacao(arq) - - -@pytest.mark.parametrize("membro, operacao", _MEMBROS) -@pytest.mark.parametrize("dados", [ - '{dtd}{raiz}'.encode("cp037"), # sem declaração: EBCDIC não é UTF-8 - '{dtd}{raiz}'.encode("utf-32-le"), - '{dtd}{raiz}'.encode("utf-32-be"), - b"\xff\xfe\x00\x00<\x00\x00\x00", - b"\x00\x00\xfe\xff\x00\x00\x00<", - b'\xe9{dtd}{raiz}', # UTF-8 inválido -], ids=["ebcdic", "utf32le", "utf32be", "bom-utf32le", "bom-utf32be", "utf8-invalido"]) -def test_r1_4_prefixo_que_nao_e_utf8_nem_utf16_e_recusado(tmp_path, membro, operacao, dados): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, dados) - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} "): - operacao(arq) - - -@pytest.mark.parametrize("membro, operacao", _MEMBROS) -@pytest.mark.parametrize("codec, declarada", [ - ("utf-8", "UTF-8"), ("utf-8", "utf8"), ("utf-8", None), ("utf-16", "UTF-16"), ("utf-16", "utf16"), -]) -def test_r1_4_dtd_em_utf8_e_utf16_com_declaracao_valida_continua_recusado(tmp_path, membro, operacao, codec, declarada): - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - enc = f' encoding="{declarada}"' if declarada else "" - texto = f'{_DTD}{_RAIZ[membro]}' - arq = _regravar_bytes(base, tmp_path / "a.xlsx", membro, texto.encode(codec)) - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): - operacao(arq) - - -@pytest.mark.parametrize("codec, declarada", [ - ("utf-8", "UTF-8"), ("utf-8", "utf-8"), ("utf-16", "UTF-16"), ("utf-16-le", None)]) -def test_r1_4_xlsx_normal_em_utf8_ou_utf16_e_lido(tmp_path, codec, declarada): - base = _xlsx(tmp_path / "base.xlsx", [("X", "")]) - enc = f' encoding="{declarada}"' if declarada else "" - texto = (f'' - 'IÑIGO') - arq = _regravar_bytes(base, tmp_path / "a.xlsx", "xl/worksheets/sheet1.xml", texto.encode(codec)) - assert list(rows(arq, "X")) == [(1, ["IÑIGO"])] - - -@pytest.mark.parametrize("membro, operacao", _MEMBROS) -def test_r1_4_o_parser_recusa_dtd_mesmo_sem_a_pre_checagem(tmp_path, monkeypatch, membro, operacao): - """Defesa em profundidade: com a pré-checagem desligada, os handlers do expat ainda recusam DTD.""" - import namescommons.spreadsheet as planilha - monkeypatch.setattr(planilha, "_prologo", lambda dados, onde="": "raiz") - base = _xlsx(tmp_path / "base.xlsx", [("X", '1')]) - for dtd in (_DTD, BILLION_LAUGHS.split("')): - arq = _regravar(base, tmp_path / "a.xlsx", membro, f'{dtd}{_RAIZ[membro]}') - with pytest.raises(FormatError, match=rf"a\.xlsx: membro {membro} tem DTD"): - operacao(arq) diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index 0a69c9c..25ee352 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -61,3 +61,10 @@ def test_check_adapter_confere_links(): check_adapter(SimpleNamespace(**base, links=lambda f: iter(())), "x") with pytest.raises(AdapterError, match="'x': links must be a function"): check_adapter(SimpleNamespace(**base, links="não"), "x") + + +# R2: módulos movidos para o kit não existem mais aqui --------------------------------------------- + +def test_namescommons_spreadsheet_nao_existe(): + import importlib.util + assert importlib.util.find_spec("namescommons.spreadsheet") is None diff --git a/uv.lock b/uv.lock index 1ddab8e..1f7a827 100644 --- a/uv.lock +++ b/uv.lock @@ -38,7 +38,6 @@ dependencies = [ { name = "pyarrow" }, { name = "pycountry" }, { name = "pyyaml" }, - { name = "xlrd" }, ] [package.dev-dependencies] @@ -52,7 +51,6 @@ requires-dist = [ { name = "pyarrow", specifier = ">=17" }, { name = "pycountry", specifier = ">=24.6" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "xlrd", specifier = "==2.0.2" }, ] [package.metadata.requires-dev] From 6bfc3cc38572ba41069a509585d32cba84a44562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:30:05 -0300 Subject: [PATCH 06/14] refactor(cache): download through datasettool with the project User-Agent The pipeline calls datasettool.sources.cache.fetch_resource with namescommons.USER_AGENT, built by make_user_agent and equal byte for byte to the old header. Retry options use the toolkit names (attempts, backoff, sleep). tools/amostra_wikidata.py imports USER_AGENT from namescommons. The toolkit refuses cache entries that resolve outside the cache, which includes symlinks: tools/bench_pipeline.py --wikidata now hard-links (or copies) the files instead of symlinking the directory. Spec 007 R4.3 records the difference; the decision is tracked in BenchCommons/tools-dataset#9. Removed, ported to tools-dataset at v0.1.0: - src/namescommons/cache.py (datasettool.sources.cache) - tests/test_cache.py, 22 tests (tests/test_cache.py there, 28) Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/007-tools-dataset/spec.md | 3 +- src/namescommons/__init__.py | 9 ++ src/namescommons/cache.py | 143 ----------------- src/namescommons/pipeline.py | 17 +- tests/test_cache.py | 225 --------------------------- tests/test_cli_pipeline.py | 4 +- tests/test_pipeline.py | 6 +- tests/test_tools_dataset.py | 26 ++++ tests/test_us_ssa_births.py | 6 +- tools/amostra_wikidata.py | 2 +- tools/bench_pipeline.py | 17 +- 11 files changed, 73 insertions(+), 385 deletions(-) delete mode 100644 src/namescommons/cache.py delete mode 100644 tests/test_cache.py diff --git a/docs/specs/007-tools-dataset/spec.md b/docs/specs/007-tools-dataset/spec.md index 28a144a..8d8f0b4 100644 --- a/docs/specs/007-tools-dataset/spec.md +++ b/docs/specs/007-tools-dataset/spec.md @@ -47,7 +47,8 @@ Esta spec troca as cópias locais pelo pacote e deixa aqui só o que é de nomes 1. mensagens que vêm do kit ficam em inglês (cache, leitor de planilhas, contrato do adaptador, `parse_count`, `parse_code`, campos genéricos do registro); as mensagens de nomes continuam em português; 2. a ordem das linhas numa mensagem do registro com vários erros: o kit confere `retrieved` e `adapter` antes dos campos de nomes (plano da spec 004 do tools-dataset, seção 2.4); o conjunto de mensagens é o mesmo; 3. o cache recusa com `ValueError` id de fonte fora de `[a-z0-9]+(-[a-z0-9]+)*` e nome de arquivo inseguro antes de qualquer acesso; pela CLI isso não é alcançável, porque o registro e `check_adapter` já recusam os dois; - 4. o manifesto recusa arquivo de saída sem contador registrado em vez de contá-lo como CSV; as saídas daqui só têm `.csv` e `.parquet`. + 4. o manifesto recusa arquivo de saída sem contador registrado em vez de contá-lo como CSV; as saídas daqui só têm `.csv` e `.parquet`; + 5. o cache recusa com `ValueError` uma entrada que resolve fora do diretório do cache, o que inclui um link simbólico para um arquivo ou diretório em outro lugar, mesmo com `--offline` e o arquivo presente; antes o arquivo era lido. Encontrado na implementação: `tools/bench_pipeline.py --wikidata` passa a pôr os arquivos no cache por link físico ou cópia, e a decisão sobre aceitar links simbólicos é do tools-dataset ([tools-dataset#9](https://github.com/BenchCommons/tools-dataset/issues/9)). ### R5. Testes diff --git a/src/namescommons/__init__.py b/src/namescommons/__init__.py index e69de29..3b92805 100644 --- a/src/namescommons/__init__.py +++ b/src/namescommons/__init__.py @@ -0,0 +1,9 @@ +"""Names Commons Dataset: primeiros nomes e sobrenomes a partir de fontes oficiais e do Wikidata.""" + +from importlib.metadata import version + +from datasettool.sources.cache import make_user_agent + +REPOSITORIO = "https://github.com/BenchCommons/dataset-names" +# Enviado em todo download (spec 007, R3.3), para os operadores das fontes saberem quem baixa. +USER_AGENT = make_user_agent("namescommons", version("names-commons-dataset"), REPOSITORIO) diff --git a/src/namescommons/cache.py b/src/namescommons/cache.py deleted file mode 100644 index 60b86c8..0000000 --- a/src/namescommons/cache.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Cache de downloads das fontes (spec 002, R4). - -Cada recurso fica em cache//, com .meta.json ao lado. -""" - -import hashlib -import json -import os -import time -import urllib.error -import urllib.request -from collections.abc import Callable -from datetime import datetime, timezone -from http.client import HTTPException -from importlib.metadata import version -from pathlib import Path - -from namescommons.adapters import Resource - -REPOSITORIO = "https://github.com/BenchCommons/dataset-names" -USER_AGENT = f"namescommons/{version('names-commons-dataset')} (+{REPOSITORIO})" -BLOCO = 1 << 16 -ESPERA = 2.0 # segundos antes da segunda tentativa; dobra a cada nova tentativa - - -class FetchError(Exception): - """Falha ao obter um recurso, da rede ou do cache.""" - - -class BlockedError(FetchError): - """O servidor devolveu uma página HTML no lugar do arquivo (spec 003e, R4.1); repetir não adianta.""" - - -_PAGINAS = (".html", ".htm") - - -def cached_path(cache_dir: Path, source_id: str, filename: str) -> Path: - return Path(cache_dir) / source_id / filename - - -def _gravar_atomico(destino: Path, conteudo: bytes) -> None: - temporario = destino.with_name(destino.name + ".part") - temporario.write_bytes(conteudo) - os.replace(temporario, destino) - - -def _baixar(url: str, destino: Path, timeout: float) -> dict: - """Baixa url para destino via arquivo temporário; devolve os metadados.""" - temporario = destino.with_name(destino.name + ".part") - pedido = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - h = hashlib.sha256() - tamanho = 0 - try: - with urllib.request.urlopen(pedido, timeout=timeout) as resposta: - tipo = resposta.headers.get_content_type() # sem parâmetros e em minúsculas - if tipo == "text/html" and not destino.name.lower().endswith(_PAGINAS): - raise BlockedError(f"o servidor devolveu uma página HTML ({tipo}) no lugar de {destino.name}") - with open(temporario, "wb") as fh: - anunciado = resposta.headers.get("Content-Length") - while bloco := resposta.read(BLOCO): - fh.write(bloco) - h.update(bloco) - tamanho += len(bloco) - if anunciado is not None: - try: - esperado = int(anunciado) - except ValueError: - raise FetchError(f"Content-Length {anunciado!r} inválido em {url}") from None - if tamanho != esperado: - raise FetchError(f"download incompleto de {url}: {tamanho} de {anunciado} bytes") - meta = { - "url": url, - "downloaded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "sha256": h.hexdigest(), - "size": tamanho, - } - # O meta antigo sai antes do arquivo novo entrar: uma falha no meio deixa o arquivo sem meta - # (o mesmo estado de um arquivo gravado à mão), nunca um meta descrevendo outro arquivo. - arquivo_meta = destino.with_name(destino.name + ".meta.json") - arquivo_meta.unlink(missing_ok=True) - os.replace(temporario, destino) - _gravar_atomico( - arquivo_meta, - (json.dumps(meta, ensure_ascii=False, indent=2) + "\n").encode("utf-8"), - ) - return meta - finally: - temporario.unlink(missing_ok=True) - - -def _definitivo(e: Exception) -> bool: - """4xx (exceto 408 e 429) é resposta definitiva do servidor: tentar de novo não muda nada.""" - return isinstance(e, urllib.error.HTTPError) and 400 <= e.code < 500 and e.code not in (408, 429) - - -def fetch_resource( - cache_dir: Path, - source_id: str, - resource: Resource, - *, - refresh: bool = False, - offline: bool = False, - timeout: float = 60.0, - tentativas: int = 3, - espera: float | None = None, - dormir: Callable[[float], None] = time.sleep, -) -> Path: - """Caminho do recurso no cache, baixando-o se preciso (R4).""" - if tentativas < 1: - raise ValueError(f"tentativas deve ser pelo menos 1, recebeu {tentativas}") - espera = ESPERA if espera is None else espera - if offline and refresh: - raise ValueError("--offline e --refresh são incompatíveis") - destino = cached_path(cache_dir, source_id, resource.filename) - if destino.is_file() and not refresh: - return destino - if offline: - raise FetchError(f"{destino} ausente do cache e --offline impede o download") - destino.parent.mkdir(parents=True, exist_ok=True) - ultimo: Exception | None = None - for i in range(tentativas): - try: - _baixar(resource.url, destino, timeout) - return destino - except BlockedError as e: - raise BlockedError( - f"falha ao baixar {resource.url}: {e}, provável bloqueio de download automático: " - f"baixe o arquivo pelo navegador, grave-o em {destino} e rode com --offline" - ) from e - except (OSError, HTTPException, FetchError) as e: - ultimo = e - feitas = i + 1 - if _definitivo(e): - break - if feitas < tentativas: - dormir(espera * 2**i) - mensagem = f"falha ao baixar {resource.url} após {feitas} tentativa(s): {ultimo}" - if isinstance(ultimo, urllib.error.HTTPError) and ultimo.code == 403: - mensagem += ( - "; o servidor recusou o download automático: baixe o arquivo pelo navegador, " - f"grave-o em {destino} e rode com --offline" - ) - raise FetchError(mensagem) diff --git a/src/namescommons/pipeline.py b/src/namescommons/pipeline.py index 1bd0845..4196467 100644 --- a/src/namescommons/pipeline.py +++ b/src/namescommons/pipeline.py @@ -4,10 +4,12 @@ from dataclasses import replace from pathlib import Path +from datasettool.sources.cache import FetchError, fetch_resource + +from namescommons import USER_AGENT from namescommons.adapters import ( - DEFAULT_PACKAGE, AdapterError, FormatError, RawLink, RawRecord, SourceFiles, load_adapter, + DEFAULT_PACKAGE, AdapterError, FormatError, RawLink, RawRecord, Resource, SourceFiles, load_adapter, ) -from namescommons.cache import FetchError, fetch_resource from namescommons.links import VariantLink, link_problems, validate_links from namescommons.schema import GENDERS, KINDS, Observation, ValidationError, derive_name_fields, validate from namescommons.sources import Source, is_country @@ -182,6 +184,11 @@ def to_links(records: Iterable[RawLink], source: Source) -> list[VariantLink]: return sorted(unicas, key=VariantLink.ordem) +def _baixar(cache_dir: Path, fonte: Source, recurso: Resource, **opcoes) -> Path: + """fetch_resource do kit com o User-Agent deste projeto (spec 007, R3.3).""" + return fetch_resource(cache_dir, fonte.id, recurso, user_agent=USER_AGENT, **opcoes) + + def select_sources(sources: dict[str, Source], ids: list[str]) -> list[Source]: """Fontes indicadas, ou todas com adaptador se nenhuma for indicada.""" if not ids: @@ -212,7 +219,7 @@ def fetch_sources( try: mod = load_adapter(fonte.id, adapters) for recurso in mod.RESOURCES: - fetch_resource(cache_dir, fonte.id, recurso, refresh=refresh, **opcoes) + _baixar(cache_dir, fonte, recurso, refresh=refresh, **opcoes) except Exception as e: raise _falha(fonte, e) from e @@ -232,7 +239,7 @@ def ingest_sources( try: mod = load_adapter(fonte.id, adapters) arquivos = SourceFiles( - (r.filename, fetch_resource(cache_dir, fonte.id, r, offline=offline, **opcoes)) + (r.filename, _baixar(cache_dir, fonte, r, offline=offline, **opcoes)) for r in mod.RESOURCES ) obs = to_observations(mod.parse(arquivos), fonte) @@ -262,7 +269,7 @@ def ingest_links( if getattr(mod, "links", None) is None: continue arquivos = SourceFiles( - (r.filename, fetch_resource(cache_dir, fonte.id, r, offline=offline, **opcoes)) + (r.filename, _baixar(cache_dir, fonte, r, offline=offline, **opcoes)) for r in mod.RESOURCES ) daqui = to_links(mod.links(arquivos), fonte) diff --git a/tests/test_cache.py b/tests/test_cache.py deleted file mode 100644 index a0c31ff..0000000 --- a/tests/test_cache.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Spec 002, R4: cache de downloads, testado contra um servidor HTTP local.""" - -import hashlib -import json -from datetime import datetime - -import pytest - -from namescommons.adapters import Resource -from namescommons.cache import BlockedError, FetchError, cached_path, fetch_resource - -CORPO = "nome,sexo,ano,quantidade\nMARIA,F,2020,100\n".encode() - - -def _recurso(servidor, caminho="/a.csv", corpo=CORPO): - servidor.arquivos[caminho] = corpo - return Resource(servidor.url(caminho), caminho.lstrip("/")) - - -def test_baixa_e_grava_metadados(servidor, tmp_path): - destino = fetch_resource(tmp_path, "exemplo", _recurso(servidor)) - assert destino == tmp_path / "exemplo" / "a.csv" == cached_path(tmp_path, "exemplo", "a.csv") - assert destino.read_bytes() == CORPO - meta = json.loads((tmp_path / "exemplo" / "a.csv.meta.json").read_text(encoding="utf-8")) - assert set(meta) == {"url", "downloaded_at", "sha256", "size"} - assert meta["url"] == servidor.url("/a.csv") - assert meta["sha256"] == hashlib.sha256(CORPO).hexdigest() - assert meta["size"] == len(CORPO) - assert datetime.fromisoformat(meta["downloaded_at"]).tzinfo is not None - - -def test_user_agent_identifica_projeto_e_repositorio(servidor, tmp_path): - fetch_resource(tmp_path, "exemplo", _recurso(servidor)) - agente = servidor.requisicoes[0][1] - assert agente.startswith("namescommons/") - assert "https://github.com/BenchCommons/dataset-names" in agente - - -def test_arquivo_no_cache_nao_e_baixado_de_novo(servidor, tmp_path): - r = _recurso(servidor) - fetch_resource(tmp_path, "exemplo", r) - fetch_resource(tmp_path, "exemplo", r) - assert servidor.caminhos() == ["/a.csv"] - - -def test_refresh_baixa_de_novo(servidor, tmp_path): - r = _recurso(servidor) - fetch_resource(tmp_path, "exemplo", r) - servidor.arquivos["/a.csv"] = b"novo\n" - destino = fetch_resource(tmp_path, "exemplo", r, refresh=True) - assert servidor.caminhos() == ["/a.csv", "/a.csv"] - assert destino.read_bytes() == b"novo\n" - meta = json.loads((tmp_path / "exemplo" / "a.csv.meta.json").read_text(encoding="utf-8")) - assert meta["size"] == 5 - - -def test_offline_usa_o_cache_sem_rede(servidor, tmp_path): - r = _recurso(servidor) - fetch_resource(tmp_path, "exemplo", r) - assert fetch_resource(tmp_path, "exemplo", r, offline=True).read_bytes() == CORPO - assert servidor.caminhos() == ["/a.csv"] - - -def test_offline_sem_cache_falha_sem_rede(servidor, tmp_path): - with pytest.raises(FetchError, match="a.csv ausente do cache e --offline"): - fetch_resource(tmp_path, "exemplo", _recurso(servidor), offline=True) - assert servidor.requisicoes == [] - - -def test_offline_e_refresh_juntos_sao_recusados(servidor, tmp_path): - with pytest.raises(ValueError, match="incompatíveis"): - fetch_resource(tmp_path, "exemplo", _recurso(servidor), offline=True, refresh=True) - - -def test_download_interrompido_nao_fica_no_cache(servidor, tmp_path): - r = _recurso(servidor) - servidor.truncados.add("/a.csv") - with pytest.raises(FetchError, match="incompleto"): - fetch_resource(tmp_path, "exemplo", r, espera=0) - assert list((tmp_path / "exemplo").iterdir()) == [] - - -def test_refresh_interrompido_preserva_a_versao_anterior(servidor, tmp_path): - r = _recurso(servidor) - fetch_resource(tmp_path, "exemplo", r) - servidor.arquivos["/a.csv"] = b"versao nova que nao chega inteira\n" - servidor.truncados.add("/a.csv") - with pytest.raises(FetchError): - fetch_resource(tmp_path, "exemplo", r, refresh=True, espera=0) - assert (tmp_path / "exemplo" / "a.csv").read_bytes() == CORPO - assert sorted(p.name for p in (tmp_path / "exemplo").iterdir()) == ["a.csv", "a.csv.meta.json"] - - -def test_tres_tentativas_com_espera_crescente(servidor, tmp_path): - r = _recurso(servidor) - servidor.falhas["/a.csv"] = 2 - esperas = [] - destino = fetch_resource(tmp_path, "exemplo", r, espera=1.0, dormir=esperas.append) - assert destino.read_bytes() == CORPO - assert esperas == [1.0, 2.0] - assert servidor.caminhos() == ["/a.csv"] * 3 - - -def test_desiste_depois_de_tres_tentativas(servidor, tmp_path): - r = _recurso(servidor) - servidor.falhas["/a.csv"] = 5 - esperas = [] - with pytest.raises(FetchError, match="após 3 tentativa.*500"): - fetch_resource(tmp_path, "exemplo", r, espera=1.0, dormir=esperas.append) - assert esperas == [1.0, 2.0] - assert len(servidor.requisicoes) == 3 - assert not (tmp_path / "exemplo" / "a.csv").exists() - - -def test_timeout(servidor, tmp_path): - r = _recurso(servidor) - servidor.lentos["/a.csv"] = 1.0 - with pytest.raises(FetchError, match="após 1 tentativa"): - fetch_resource(tmp_path, "exemplo", r, timeout=0.2, tentativas=1) - assert not (tmp_path / "exemplo" / "a.csv").exists() - - -def _falha(servidor, caminho, *codigos): - servidor.respostas[caminho] = list(codigos) - return _recurso(servidor, caminho) - - -@pytest.mark.parametrize("codigo", [400, 401, 403, 404, 410]) -def test_4xx_nao_e_tentado_de_novo(servidor, tmp_path, codigo): - r = _falha(servidor, "/a.csv", codigo, codigo, codigo) - esperas = [] - with pytest.raises(FetchError, match=f"após 1 tentativa.*{codigo}"): - fetch_resource(tmp_path, "exemplo", r, espera=1.0, dormir=esperas.append) - assert len(servidor.requisicoes) == 1 - assert esperas == [] - - -@pytest.mark.parametrize("codigo", [408, 429, 500, 503]) -def test_408_429_e_5xx_sao_tentados_de_novo(servidor, tmp_path, codigo): - r = _falha(servidor, "/a.csv", codigo, codigo) - assert fetch_resource(tmp_path, "exemplo", r, espera=0).read_bytes() == CORPO - assert len(servidor.requisicoes) == 3 - - -def test_erro_diferente_de_403_nao_traz_a_dica_do_cache(servidor, tmp_path): - r = _falha(servidor, "/a.csv", 404) - with pytest.raises(FetchError) as e: - fetch_resource(tmp_path, "exemplo", r, espera=0) - assert "navegador" not in str(e.value) and "--offline" not in str(e.value) - - -def test_403_traz_a_dica_do_cache(servidor, tmp_path): - r = _falha(servidor, "/a.csv", 403) - with pytest.raises(FetchError, match="baixe o arquivo pelo navegador"): - fetch_resource(tmp_path, "exemplo", r, espera=0) - - -def test_content_length_malformado_vira_fetch_error(servidor, tmp_path): - r = _recurso(servidor) - servidor.cabecalho_tamanho["/a.csv"] = "abc" - with pytest.raises(FetchError, match="Content-Length 'abc' inválido"): - fetch_resource(tmp_path, "exemplo", r, espera=0, tentativas=1) - assert list((tmp_path / "exemplo").iterdir()) == [] - - -@pytest.mark.parametrize("n", [0, -1]) -def test_tentativas_menor_que_um_e_recusada(servidor, tmp_path, n): - with pytest.raises(ValueError, match="tentativas"): - fetch_resource(tmp_path, "exemplo", _recurso(servidor), tentativas=n) - assert servidor.requisicoes == [] - - -def test_refresh_com_falha_na_troca_do_arquivo_nao_deixa_meta_que_descreve_outro_arquivo( - servidor, tmp_path, monkeypatch, -): - """A troca do arquivo de dados falha: o meta ao lado nunca pode descrever um arquivo que não está lá.""" - import os - - r = _recurso(servidor) - fetch_resource(tmp_path, "exemplo", r) - antigo = (tmp_path / "exemplo" / "a.csv").read_bytes() - servidor.arquivos["/a.csv"] = b"versao nova\n" - real = os.replace - - def falha_na_troca_dos_dados(origem, destino): - if os.path.basename(destino) == "a.csv": - raise OSError("disco cheio") - return real(origem, destino) - - monkeypatch.setattr("namescommons.cache.os.replace", falha_na_troca_dos_dados) - with pytest.raises(FetchError, match="disco cheio"): - fetch_resource(tmp_path, "exemplo", r, refresh=True, tentativas=1) - assert (tmp_path / "exemplo" / "a.csv").read_bytes() == antigo - meta = tmp_path / "exemplo" / "a.csv.meta.json" - assert not meta.exists() or json.loads(meta.read_text(encoding="utf-8"))["sha256"] == hashlib.sha256(antigo).hexdigest() - - -# Spec 003e, R4.1: servidor que responde 200 com uma página HTML de bloqueio no lugar do arquivo ----- - -DESAFIO = b"\nPardon Our Interruption\n" - - -def test_pagina_html_no_lugar_do_arquivo_nao_entra_no_cache(servidor, tmp_path): - recurso = _recurso(servidor, corpo=DESAFIO) - servidor.tipos["/a.csv"] = "text/html; charset=UTF-8" - with pytest.raises(BlockedError) as e: - fetch_resource(tmp_path, "exemplo", recurso, espera=0) - mensagem = str(e.value) - assert "página HTML (text/html) no lugar de a.csv" in mensagem - assert servidor.url("/a.csv") in mensagem - assert f"grave-o em {tmp_path / 'exemplo' / 'a.csv'} e rode com --offline" in mensagem - assert servidor.caminhos() == ["/a.csv"] - assert sorted(p.name for p in (tmp_path / "exemplo").iterdir()) == [] - - -def test_html_e_aceito_quando_o_recurso_e_html(servidor, tmp_path): - recurso = _recurso(servidor, caminho="/pagina.html", corpo=DESAFIO) - servidor.tipos["/pagina.html"] = "Text/HTML" - assert fetch_resource(tmp_path, "exemplo", recurso).read_bytes() == DESAFIO - - -def test_csv_servido_como_text_csv_continua_aceito(servidor, tmp_path): - recurso = _recurso(servidor) - servidor.tipos["/a.csv"] = "text/csv; charset=utf-8" - assert fetch_resource(tmp_path, "exemplo", recurso).read_bytes() == CORPO diff --git a/tests/test_cli_pipeline.py b/tests/test_cli_pipeline.py index b9eba55..0fcc5af 100644 --- a/tests/test_cli_pipeline.py +++ b/tests/test_cli_pipeline.py @@ -82,7 +82,7 @@ def test_criterio_3_offline_sem_cache_falha_com_mensagem(tmp_path, capsys): args = ["run", "--offline", "--out", str(out), "--release", "2026.10", "--cache", str(tmp_path / "vazio")] assert main([*args, *ADAPTADORES]) == 1 err = capsys.readouterr().err - assert "fonte 'exemplo'" in err and "ausente do cache" in err + assert "fonte 'exemplo'" in err and "missing from the cache" in err assert "Traceback" not in err assert not out.exists() @@ -143,7 +143,7 @@ def test_run_recusa_out_nao_vazio(cache_das_fixtures, tmp_path, capsys): def test_fetch_com_falha_nomeia_a_fonte(exemplo_no_servidor, tmp_path, capsys, monkeypatch): - monkeypatch.setattr("namescommons.cache.ESPERA", 0) + monkeypatch.setattr("datasettool.sources.cache.BACKOFF", 0) del exemplo_no_servidor.arquivos["/exemplo/primeiros.csv"] assert main(["fetch", "--cache", str(tmp_path / "cache"), *ADAPTADORES]) == 1 err = capsys.readouterr().err diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 5820cf6..9a96fe2 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -156,7 +156,7 @@ def test_ingest_offline_sobre_o_cache(fontes_exemplo, tmp_path): def test_ingest_offline_sem_cache_nomeia_a_fonte(fontes_exemplo, tmp_path): - with pytest.raises(PipelineError, match="fonte 'exemplo': .*primeiros.csv ausente do cache"): + with pytest.raises(PipelineError, match="fonte 'exemplo': .*primeiros.csv missing from the cache"): ingest_sources(select_sources(fontes_exemplo, []), fontes_exemplo, tmp_path, offline=True, adapters="adaptadores") @@ -216,8 +216,8 @@ def test_fetch_baixa_todos_os_recursos(fontes_exemplo, exemplo_no_servidor, tmp_ def test_fetch_com_falha_nomeia_a_fonte(fontes_exemplo, exemplo_no_servidor, tmp_path): del exemplo_no_servidor.arquivos["/exemplo/sobrenomes.csv"] - with pytest.raises(PipelineError, match="fonte 'exemplo': falha ao baixar .*sobrenomes.csv.*404"): - fetch_sources(select_sources(fontes_exemplo, []), tmp_path, adapters="adaptadores", espera=0) + with pytest.raises(PipelineError, match="fonte 'exemplo': failed to download .*sobrenomes.csv.*404"): + fetch_sources(select_sources(fontes_exemplo, []), tmp_path, adapters="adaptadores", backoff=0) def test_ingest_sem_offline_baixa_o_que_falta(fontes_exemplo, exemplo_no_servidor, tmp_path): diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index 25ee352..8fa6f30 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -68,3 +68,29 @@ def test_check_adapter_confere_links(): def test_namescommons_spreadsheet_nao_existe(): import importlib.util assert importlib.util.find_spec("namescommons.spreadsheet") is None + + +def test_namescommons_cache_nao_existe(): + import importlib.util + assert importlib.util.find_spec("namescommons.cache") is None + + +# R3.3: downloads pelo kit com o User-Agent deste projeto ------------------------------------------ + +def test_user_agent_igual_ao_anterior(): + from importlib.metadata import version + + from namescommons import USER_AGENT + assert USER_AGENT == f"namescommons/{version('names-commons-dataset')} (+https://github.com/BenchCommons/dataset-names)" + + +def test_pipeline_envia_o_user_agent_do_projeto(servir_fixtures, tmp_path): + import adaptadores.exemplo + + from namescommons import USER_AGENT + from namescommons.pipeline import fetch_sources, select_sources + from namescommons.sources import load_sources + servidor = servir_fixtures(adaptadores.exemplo) + fontes = load_sources(RAIZ / "tests" / "fixtures" / "sources.yaml") + fetch_sources(select_sources(fontes, ["exemplo"]), tmp_path, adapters="adaptadores") + assert [agente for _, agente in servidor.requisicoes] == [USER_AGENT, USER_AGENT] diff --git a/tests/test_us_ssa_births.py b/tests/test_us_ssa_births.py index f6daa64..ad14f73 100644 --- a/tests/test_us_ssa_births.py +++ b/tests/test_us_ssa_births.py @@ -4,9 +4,9 @@ from pathlib import Path import pytest +from datasettool.sources.cache import FetchError, fetch_resource from namescommons.adapters import FormatError, RawRecord, Resource, load_adapter -from namescommons.cache import FetchError, fetch_resource FIXTURES = Path(__file__).parent / "fixtures" / "us-ssa-births" @@ -69,10 +69,10 @@ def test_403_explica_como_colocar_o_arquivo_no_cache(servidor, tmp_path): servidor.recusados.add("/oact/babynames/names.zip") recurso = Resource(servidor.url("/oact/babynames/names.zip"), "names.zip") with pytest.raises(FetchError) as e: - fetch_resource(tmp_path, "us-ssa-births", recurso, espera=0) + fetch_resource(tmp_path, "us-ssa-births", recurso, backoff=0) mensagem = str(e.value) assert "HTTP Error 403" in mensagem - assert f"grave-o em {tmp_path / 'us-ssa-births' / 'names.zip'} e rode com --offline" in mensagem + assert f"save it at {tmp_path / 'us-ssa-births' / 'names.zip'} and rerun with --offline" in mensagem assert len(servidor.requisicoes) == 1 # 4xx não se resolve tentando de novo diff --git a/tools/amostra_wikidata.py b/tools/amostra_wikidata.py index 7fb2df1..2851a8d 100644 --- a/tools/amostra_wikidata.py +++ b/tools/amostra_wikidata.py @@ -15,8 +15,8 @@ from pathlib import Path from urllib.parse import urlencode +from namescommons import USER_AGENT from namescommons.adapters import wikidata -from namescommons.cache import USER_AGENT PRENOMES = ( "Q325872 Q25413386 Q39897333 Q18177306 Q18177321 Q493293 Q637123 Q21104340 Q830350 " diff --git a/tools/bench_pipeline.py b/tools/bench_pipeline.py index 63f9084..1a2c4a0 100644 --- a/tools/bench_pipeline.py +++ b/tools/bench_pipeline.py @@ -14,8 +14,10 @@ import argparse import csv import json +import os import platform import resource +import shutil import sys import tempfile import time @@ -80,11 +82,22 @@ def parse(files): def _incluir_wikidata(base: Path, origem: Path) -> None: - """Fonte, adaptador e cache do Wikidata no diretório da medição (o cache é um link para origem).""" + """Fonte, adaptador e cache do Wikidata no diretório da medição. + + Os arquivos de origem entram no cache por link físico (ou cópia, se o link não for possível): + o cache do tools-dataset recusa caminho que resolve fora do diretório do cache, como um link simbólico. + """ with open(base / "sources.yaml", "a", encoding="utf-8") as fh: fh.write(WIKIDATA_YAML) (base / "bench_adaptadores" / "wikidata.py").write_text(WIKIDATA_ADAPTADOR, encoding="utf-8") - (base / "cache" / "wikidata").symlink_to(origem.resolve(), target_is_directory=True) + destino = base / "cache" / "wikidata" + destino.mkdir(parents=True) + for arquivo in sorted(origem.resolve().iterdir()): + if arquivo.is_file(): + try: + os.link(arquivo, destino / arquivo.name) + except OSError: + shutil.copy2(arquivo, destino / arquivo.name) def total_de_observacoes(pedidas: int) -> int: From 2145d68a89bf968784d7209dc225ca91d31602ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:31:06 -0300 Subject: [PATCH 07/14] refactor(sources): build the names registry on datasettool namescommons.sources declares a RegistrySchema with the names fields (country, count_type, primary, suppression, and the optional uniform_case and strips_accents), the ADR 0001 licence list (the toolkit's ATTRIBUTION_ONLY_LICENSES plus statcan-open-licence) and the one-primary rule as a cross-entry check; the factory builds the same Source dataclass. load_sources and parse_sources keep their signatures, and SourceError is the toolkit's RegistryError. Names field messages stay in Portuguese. pyyaml is no longer a direct dependency. Removed tests, ported to tools-dataset tests/test_registry.py: test_sources.py: test_arquivo_sem_fontes, test_id_com_formato_invalido, test_campo_ausente (replaced here by test_campo_de_nomes_ausente), test_id_duplicado, test_licenca_nao_textual_vira_source_error, test_id_nao_textual_vira_source_error, test_entrada_que_nao_e_mapeamento, test_arquivo_inexistente, test_yaml_malformado, test_raiz_que_nao_e_mapeamento, test_sources_que_nao_e_lista and the retrieved, url and extra cases of test_campo_invalido; test_sources_extensoes.py: test_retrieved_com_hora_e_recusado, test_retrieved_com_hora_no_yaml_e_recusado, test_url_sem_esquema_http_e_recusada, test_url_http_e_https_aceitas, test_url_malformada_nomeia_a_fonte_em_vez_de_levantar_value_error and the adapter case of test_campo_opcional_nao_booleano. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 1 - src/namescommons/sources.py | 157 +++++++++++++------------------- tests/test_sources.py | 76 ++-------------- tests/test_sources_extensoes.py | 53 ++--------- tests/test_tools_dataset.py | 23 +++++ uv.lock | 2 - 6 files changed, 103 insertions(+), 209 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34251ed..9bc5d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "benchcommons-tools-dataset==0.1.0", "pyarrow>=17", "pycountry>=24.6", - "pyyaml>=6.0", ] [project.scripts] diff --git a/src/namescommons/sources.py b/src/namescommons/sources.py index 4994f4e..7fbdaa2 100644 --- a/src/namescommons/sources.py +++ b/src/namescommons/sources.py @@ -1,32 +1,27 @@ -"""Registro de fontes (sources.yaml), regras de licença do ADR 0001 (ampliado pelo 0004) e campos da spec 002 (R2).""" +"""Registro de fontes (sources.yaml) do dataset de nomes (spec 001, R2; spec 002, R2; spec 007, R3.2). -import re +A leitura do YAML, os campos genéricos (id, name, url, license, attribution, retrieved, adapter) +e a coleta de todos os erros são do registro do kit de fontes do tools-dataset (spec 004 de lá, R5). +Daqui são os campos de nomes, a lista de licenças aceitas pelo ADR 0001 (ampliado pelo 0004), +a regra de uma primary por país e tipo de contagem e o tipo Source. +""" + +from collections.abc import Mapping from dataclasses import dataclass -from datetime import date, datetime +from datetime import date from pathlib import Path import pycountry -import yaml - -from datasettool.sources.adapters import is_http_url as url_http +from datasettool.sources.registry import ( + ATTRIBUTION_ONLY_LICENSES, Field, RegistryError, RegistrySchema, load_registry, parse_registry, +) -LICENCAS_COMPATIVEIS = frozenset({ - "public-domain", "CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", - "ODC-By-1.0", "OGL-UK-3.0", "etalab-2.0", +LICENCAS_COMPATIVEIS = ATTRIBUTION_ONLY_LICENSES | { "statcan-open-licence", # ADR 0004 (docs/adr/0004-licenca-statistics-canada.md) -}) -ID_FORMATO = re.compile(r"[a-z0-9]+(-[a-z0-9]+)*") +} COUNT_TYPES = ("births", "population", "attested") -CAMPOS = ( - "id", "name", "url", "license", "attribution", "country", - "count_type", "primary", "suppression", "retrieved", -) -# Campos opcionais da spec 002 (R2) e seus valores padrão. -OPCIONAIS = {"uniform_case": False, "adapter": True, "strips_accents": False} # strips_accents: spec 005, R6 - -class SourceError(ValueError): - """sources.yaml inválido. A mensagem lista todos os problemas encontrados.""" +SourceError = RegistryError # sources.yaml inválido; a mensagem lista todos os problemas encontrados @dataclass(frozen=True) @@ -56,86 +51,64 @@ def is_country(code: object) -> bool: return False -def load_sources(path: Path) -> dict[str, Source]: - try: - dados = yaml.safe_load(Path(path).read_text(encoding="utf-8")) - except OSError as e: - raise SourceError(f"não foi possível ler {path}: {e}") from e - except yaml.YAMLError as e: - raise SourceError(f"{path}: YAML inválido: {e}") from e - if dados is None: - dados = {} - if not isinstance(dados, dict): - raise SourceError(f"{path}: a raiz deve ser um mapeamento com a chave 'sources'") - entradas = dados.get("sources", []) - if entradas is None: - entradas = [] - if not isinstance(entradas, list): - raise SourceError(f"{path}: 'sources' deve ser uma lista") - return parse_sources(entradas) +def _country(valor: object) -> str | None: + if isinstance(valor, bool): + return f"país lido como booleano {valor}; escreva o código entre aspas, ex.: \"NO\"" + if valor is not None and not is_country(valor): + return f"país '{valor}' fora da ISO 3166-1 alfa-2" + return None -def parse_sources(entradas: list[dict]) -> dict[str, Source]: - erros: list[str] = [] - fontes: dict[str, Source] = {} - for i, e in enumerate(entradas): - if not isinstance(e, dict): - erros.append(f"#{i}: entrada deve ser um mapeamento") - continue - rotulo = str(e.get("id") or f"#{i}") - antes = len(erros) - faltando = [c for c in CAMPOS if c not in e] - if faltando: - erros.append(f"{rotulo}: campos ausentes {faltando}") - continue - extras = sorted(set(e) - set(CAMPOS) - set(OPCIONAIS)) - if extras: - erros.append(f"{rotulo}: campos desconhecidos {extras}") - for campo in ("id", "name", "url", "attribution"): - if not isinstance(e[campo], str) or not e[campo].strip(): - erros.append(f"{rotulo}: '{campo}' vazio") - if isinstance(e["id"], str) and e["id"].strip() and not ID_FORMATO.fullmatch(e["id"]): - erros.append(f"{rotulo}: 'id' com formato inválido (use [a-z0-9]+(-[a-z0-9]+)*)") - if isinstance(e["url"], str) and e["url"].strip() and not url_http(e["url"]): - erros.append(f"{rotulo}: 'url' deve usar o esquema http ou https") - if not isinstance(e["license"], str) or e["license"] not in LICENCAS_COMPATIVEIS: - erros.append(f"{rotulo}: licença '{e['license']}' incompatível com o ADR 0001") - if isinstance(e["country"], bool): - erros.append(f"{rotulo}: país lido como booleano {e['country']}; escreva o código entre aspas, ex.: \"NO\"") - elif e["country"] is not None and not is_country(e["country"]): - erros.append(f"{rotulo}: país '{e['country']}' fora da ISO 3166-1 alfa-2") - if e["count_type"] not in COUNT_TYPES: - erros.append(f"{rotulo}: count_type '{e['count_type']}' inválido") - if not isinstance(e["primary"], bool): - erros.append(f"{rotulo}: 'primary' deve ser true ou false") - s = e["suppression"] - if s is not None and (isinstance(s, bool) or not isinstance(s, int) or s < 1): - erros.append(f"{rotulo}: 'suppression' deve ser inteiro positivo ou vazio") - if not isinstance(e["retrieved"], date) or isinstance(e["retrieved"], datetime): - erros.append(f"{rotulo}: 'retrieved' deve ser uma data AAAA-MM-DD, sem hora") - for campo in OPCIONAIS: - if campo in e and not isinstance(e[campo], bool): - erros.append(f"{rotulo}: '{campo}' deve ser true ou false") - if len(erros) > antes: - continue - if e["id"] in fontes: - erros.append(f"{rotulo}: id duplicado") - continue - opcionais = {c: e.get(c, padrao) for c, padrao in OPCIONAIS.items()} - fontes[e["id"]] = Source(**{c: e[c] for c in CAMPOS}, **opcionais) +def _count_type(valor: object) -> str | None: + return None if valor in COUNT_TYPES else f"count_type '{valor}' inválido" - # Exatamente uma primary por (country, count_type) com contagem; em attested, primary não - # tem efeito e não é conferido (spec 004, R5; issue #3). + +def _booleano(campo: str): + def conferir(valor: object) -> str | None: + return None if isinstance(valor, bool) else f"'{campo}' deve ser true ou false" + return conferir + + +def _suppression(valor: object) -> str | None: + if valor is not None and (isinstance(valor, bool) or not isinstance(valor, int) or valor < 1): + return "'suppression' deve ser inteiro positivo ou vazio" + return None + + +def _uma_primary(fontes: Mapping[str, Source]) -> list[str]: + """Exatamente uma primary por (country, count_type) com contagem; em attested, primary não + tem efeito e não é conferido (spec 004, R5; issue #3).""" grupos: dict[tuple, list[Source]] = {} for f in fontes.values(): if f.count_type == "attested": continue grupos.setdefault((f.country, f.count_type), []).append(f) + erros = [] for (pais, tipo), fs in sorted(grupos.items(), key=lambda kv: (str(kv[0][0]), kv[0][1])): if len(fs) > 1 and sum(f.primary is True for f in fs) != 1: - ids = sorted(f.id for f in fs) - erros.append(f"{pais}/{tipo}: fontes {ids}; exatamente uma deve ter primary: true") + erros.append(f"{pais}/{tipo}: fontes {sorted(f.id for f in fs)}; exatamente uma deve ter primary: true") + return erros + + +SCHEMA: RegistrySchema[Source] = RegistrySchema( + accepted_licenses=LICENCAS_COMPATIVEIS, + license_policy="ADR 0001", + fields=( + Field("country", _country), + Field("count_type", _count_type), + Field("primary", _booleano("primary")), + Field("suppression", _suppression), + Field("uniform_case", _booleano("uniform_case"), required=False, default=False), # spec 002, R2 + Field("strips_accents", _booleano("strips_accents"), required=False, default=False), # spec 005, R6 + ), + checks=(_uma_primary,), + factory=lambda campos: Source(**campos), +) - if erros: - raise SourceError("\n".join(erros)) - return fontes + +def load_sources(path: Path) -> dict[str, Source]: + return load_registry(Path(path), SCHEMA) + + +def parse_sources(entradas: list[dict]) -> dict[str, Source]: + return parse_registry(entradas, SCHEMA) diff --git a/tests/test_sources.py b/tests/test_sources.py index 24df245..1c492e2 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,3 +1,8 @@ +"""Registro de fontes: campos de nomes, licenças do ADR 0001 e regra de primary (spec 001, R2; spec 007, R3.2). + +Os campos e as regras genéricas do registro são testados no tools-dataset (spec 007, R2.6). +""" + from datetime import date import pytest @@ -39,12 +44,6 @@ def test_carrega_yaml(tmp_path): assert load_sources(p)["wikidata"].retrieved == date(2026, 10, 1) -def test_arquivo_sem_fontes(tmp_path): - p = tmp_path / "sources.yaml" - p.write_text("sources: []\n", encoding="utf-8") - assert load_sources(p) == {} - - @pytest.mark.parametrize("licenca", ["CC-BY-SA-4.0", "ODbL-1.0", "CC-BY-NC-4.0", "desconhecida"]) def test_licenca_incompativel_e_recusada(licenca): with pytest.raises(SourceError, match="ADR 0001"): @@ -63,34 +62,20 @@ def test_statcan_open_licence_e_aceita_pelo_adr_0004(): ({"count_type": "deaths"}, "count_type"), ({"primary": "sim"}, "primary"), ({"suppression": 0}, "suppression"), - ({"retrieved": "ontem"}, "retrieved"), - ({"url": ""}, "'url' vazio"), - ({"extra": 1}, "desconhecidos"), ]) def test_campo_invalido(campos, trecho): with pytest.raises(SourceError, match=trecho): parse_sources([fonte(**campos)]) -@pytest.mark.parametrize("ident", ["../../etc", "Us-Ssa", "us_ssa", "-us", "us-", "us--ssa", "us ssa", "a/b"]) -def test_id_com_formato_invalido(ident): - with pytest.raises(SourceError, match="formato inválido") as e: - parse_sources([fonte(id=ident)]) - assert ident in str(e.value) - - -def test_campo_ausente(): +@pytest.mark.parametrize("campo", ["country", "count_type", "primary", "suppression"]) +def test_campo_de_nomes_ausente(campo): entrada = fonte() - del entrada["license"] - with pytest.raises(SourceError, match="ausentes"): + del entrada[campo] + with pytest.raises(SourceError, match=f"us-ssa-births: missing fields \\['{campo}'\\]"): parse_sources([entrada]) -def test_id_duplicado(): - with pytest.raises(SourceError, match="duplicado"): - parse_sources([fonte(), fonte()]) - - def test_duas_fontes_no_mesmo_par_exigem_uma_primary(): with pytest.raises(SourceError, match="exatamente uma"): parse_sources([fonte(id="a"), fonte(id="b")]) @@ -105,16 +90,6 @@ def test_todos_os_erros_sao_listados(): assert "ADR 0001" in str(e.value) and "ISO 3166-1" in str(e.value) -def test_licenca_nao_textual_vira_source_error(): - with pytest.raises(SourceError, match="ADR 0001"): - parse_sources([fonte(license=["CC0-1.0"])]) - - -def test_id_nao_textual_vira_source_error(): - with pytest.raises(SourceError, match="'id' vazio"): - parse_sources([fonte(id=["us-ssa-births"])]) - - @pytest.mark.parametrize("campos, trecho", [ ({"country": ["US"]}, "ISO 3166-1"), ({"count_type": ["births"]}, "count_type"), @@ -124,39 +99,6 @@ def test_campo_nao_textual_vira_source_error(campos, trecho): parse_sources([fonte(**campos)]) -def test_entrada_que_nao_e_mapeamento(): - with pytest.raises(SourceError, match="#0: entrada deve ser um mapeamento") as e: - parse_sources(["foo", None]) - assert "#1: entrada deve ser um mapeamento" in str(e.value) - - -def test_arquivo_inexistente(tmp_path): - with pytest.raises(SourceError, match="não foi possível ler"): - load_sources(tmp_path / "nao-existe.yaml") - - -def test_yaml_malformado(tmp_path): - p = tmp_path / "sources.yaml" - p.write_text("sources: [\n", encoding="utf-8") - with pytest.raises(SourceError, match="YAML inválido"): - load_sources(p) - - -def test_raiz_que_nao_e_mapeamento(tmp_path): - p = tmp_path / "sources.yaml" - p.write_text("- foo\n", encoding="utf-8") - with pytest.raises(SourceError, match="a raiz deve ser um mapeamento"): - load_sources(p) - - -@pytest.mark.parametrize("conteudo", ["sources: 5\n", "sources: texto\n"]) -def test_sources_que_nao_e_lista(tmp_path, conteudo): - p = tmp_path / "sources.yaml" - p.write_text(conteudo, encoding="utf-8") - with pytest.raises(SourceError, match="'sources' deve ser uma lista"): - load_sources(p) - - def test_attested_nao_exige_primary(): """Spec 004, R5 (issue #3): em attested, primary não tem efeito e não é conferido.""" sem_pais = [fonte(id=i, country=None, count_type="attested", primary=False) for i in ("a", "b")] diff --git a/tests/test_sources_extensoes.py b/tests/test_sources_extensoes.py index 2284c6f..52aebe5 100644 --- a/tests/test_sources_extensoes.py +++ b/tests/test_sources_extensoes.py @@ -1,6 +1,9 @@ -"""Spec 002, R2 (campos opcionais) e a última recusa de R3 (retrieved e url).""" +"""Spec 002, R2 e spec 005, R6: campos opcionais de nomes. -from datetime import date, datetime +adapter, retrieved e url são campos genéricos, testados no tools-dataset (spec 007, R2.6). +""" + +from datetime import date import pytest @@ -27,57 +30,13 @@ def test_campos_opcionais_declarados(): assert (f.uniform_case, f.adapter) == (True, False) -@pytest.mark.parametrize("campo", ["uniform_case", "adapter", "strips_accents"]) +@pytest.mark.parametrize("campo", ["uniform_case", "strips_accents"]) @pytest.mark.parametrize("valor", ["sim", 1, None]) def test_campo_opcional_nao_booleano(campo, valor): with pytest.raises(SourceError, match=f"'{campo}' deve ser true ou false"): parse_sources([fonte(**{campo: valor})]) -def test_retrieved_com_hora_e_recusado(): - with pytest.raises(SourceError, match="'retrieved' deve ser uma data AAAA-MM-DD"): - parse_sources([fonte(retrieved=datetime(2026, 10, 1, 12, 30))]) - - -def test_retrieved_com_hora_no_yaml_e_recusado(tmp_path): - p = tmp_path / "sources.yaml" - p.write_text( - "sources:\n" - " - id: pt-exemplo\n" - " name: Exemplo\n" - " url: https://example.org/nomes\n" - " license: CC-BY-4.0\n" - " attribution: Exemplo\n" - " country: PT\n" - " count_type: births\n" - " primary: true\n" - " suppression: null\n" - " retrieved: 2026-10-01T10:00:00\n", - encoding="utf-8", - ) - with pytest.raises(SourceError, match="retrieved"): - load_sources(p) - - -@pytest.mark.parametrize("url", [ - "ftp://example.org/nomes", "example.org/nomes", "file:///tmp/nomes.csv", "https://", "mailto:a@b.c", - "http://[::1", " https://example.org/nomes", "https://example.org/nomes\n", "https://exam ple.org/", -]) -def test_url_sem_esquema_http_e_recusada(url): - with pytest.raises(SourceError, match="http ou https"): - parse_sources([fonte(url=url)]) - - -@pytest.mark.parametrize("url", ["http://example.org/a.csv", "HTTPS://example.org/"]) -def test_url_http_e_https_aceitas(url): - assert parse_sources([fonte(url=url)])["pt-exemplo"].url == url - - -def test_url_malformada_nomeia_a_fonte_em_vez_de_levantar_value_error(): - with pytest.raises(SourceError, match="pt-exemplo: 'url' deve usar o esquema http ou https"): - parse_sources([fonte(url="http://[::1")]) - - def test_strips_accents_tem_padrao_false_e_aceita_true(): """Spec 005, R6.""" assert parse_sources([fonte()])["pt-exemplo"].strips_accents is False diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index 8fa6f30..ceddef0 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -94,3 +94,26 @@ def test_pipeline_envia_o_user_agent_do_projeto(servir_fixtures, tmp_path): fontes = load_sources(RAIZ / "tests" / "fixtures" / "sources.yaml") fetch_sources(select_sources(fontes, ["exemplo"]), tmp_path, adapters="adaptadores") assert [agente for _, agente in servidor.requisicoes] == [USER_AGENT, USER_AGENT] + + +# R3.2: registro de fontes sobre o kit ------------------------------------------------------------- + +def test_source_error_e_o_registry_error_do_kit(): + from datasettool.sources.registry import RegistryError + + from namescommons.sources import SourceError + assert SourceError is RegistryError + + +def test_esquema_de_nomes_sobre_o_kit(): + from datasettool.sources.registry import ATTRIBUTION_ONLY_LICENSES, RegistrySchema + + from namescommons.sources import SCHEMA + assert isinstance(SCHEMA, RegistrySchema) + assert SCHEMA.accepted_licenses == ATTRIBUTION_ONLY_LICENSES | {"statcan-open-licence"} + assert SCHEMA.license_policy == "ADR 0001" + campos = {f.name: (f.required, f.default) for f in SCHEMA.fields} + assert campos == { + "country": (True, None), "count_type": (True, None), "primary": (True, None), "suppression": (True, None), + "uniform_case": (False, False), "strips_accents": (False, False), + } diff --git a/uv.lock b/uv.lock index 1f7a827..8de5274 100644 --- a/uv.lock +++ b/uv.lock @@ -37,7 +37,6 @@ dependencies = [ { name = "benchcommons-tools-dataset" }, { name = "pyarrow" }, { name = "pycountry" }, - { name = "pyyaml" }, ] [package.dev-dependencies] @@ -50,7 +49,6 @@ requires-dist = [ { name = "benchcommons-tools-dataset", git = "https://github.com/BenchCommons/tools-dataset?tag=v0.1.0" }, { name = "pyarrow", specifier = ">=17" }, { name = "pycountry", specifier = ">=24.6" }, - { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] From a615604c71c76af1e21e163c122838abbd2408c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:31:45 -0300 Subject: [PATCH 08/14] refactor(manifest): checksums and row counts from datasettool.files build_manifest keeps the release format, schema_version and the sources list, and takes the files map from describe_files with counters for .csv (the toolkit's count_csv_records) and .parquet (Parquet metadata). An output file with another suffix is now a ValueError instead of being counted as CSV; the outputs only have .csv and .parquet. Removed, ported to tools-dataset tests/test_files.py: - tests/test_manifest.py (1 test, 6 cases) - test_robustez.py: test_manifesto_conta_registros_csv_e_nao_linhas_fisicas Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/manifest.py | 30 ++++++++++-------------------- tests/test_manifest.py | 19 ------------------- tests/test_robustez.py | 6 ------ tests/test_tools_dataset.py | 23 +++++++++++++++++++++++ 4 files changed, 33 insertions(+), 45 deletions(-) delete mode 100644 tests/test_manifest.py diff --git a/src/namescommons/manifest.py b/src/namescommons/manifest.py index eb35f0a..2029728 100644 --- a/src/namescommons/manifest.py +++ b/src/namescommons/manifest.py @@ -1,11 +1,14 @@ -"""manifest.json de uma release (spec 001, R5).""" +"""manifest.json de uma release (spec 001, R5). + +O formato da release, schema_version e a lista sources são daqui; o SHA-256 e a contagem de +registros de cada arquivo vêm de datasettool.files (spec 007, R3.4). +""" -import csv -import hashlib import re from pathlib import Path import pyarrow.parquet as pq +from datasettool.files import count_csv_records, describe_files from namescommons.schema import SCHEMA_VERSION from namescommons.sources import Source @@ -13,20 +16,11 @@ RELEASE = re.compile(r"\d{4}\.(0[1-9]|1[0-2])(\.[1-9]\d*)?") -def _sha256(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as fh: - for bloco in iter(lambda: fh.read(1 << 20), b""): - h.update(bloco) - return h.hexdigest() +def _linhas_parquet(path: Path) -> int: + return pq.ParquetFile(path).metadata.num_rows -def _linhas(path: Path) -> int: - if path.suffix == ".parquet": - return pq.ParquetFile(path).metadata.num_rows - with open(path, encoding="utf-8", newline="") as fh: - registros = sum(1 for linha in csv.reader(fh) if linha) # ignora linhas em branco - return max(registros - 1, 0) # desconta o cabeçalho +CONTADORES = {".csv": count_csv_records, ".parquet": _linhas_parquet} def build_manifest(release: str, out_dir: Path, sources: dict[str, Source], used: set[str]) -> dict: @@ -35,10 +29,6 @@ def build_manifest(release: str, out_dir: Path, sources: dict[str, Source], used desconhecidas = sorted(used - set(sources)) if desconhecidas: raise ValueError(f"source_id desconhecido em sources.yaml: {desconhecidas}") - arquivos = {} - for p in sorted(out_dir.rglob("*")): - if p.is_file() and p.name != "manifest.json": - arquivos[p.relative_to(out_dir).as_posix()] = {"sha256": _sha256(p), "rows": _linhas(p)} return { "release": release, "schema_version": SCHEMA_VERSION, @@ -46,5 +36,5 @@ def build_manifest(release: str, out_dir: Path, sources: dict[str, Source], used {"id": s.id, "license": s.license, "retrieved": s.retrieved.isoformat()} for s in sorted((sources[i] for i in used), key=lambda s: s.id) ], - "files": arquivos, + "files": describe_files(out_dir, counters=CONTADORES), } diff --git a/tests/test_manifest.py b/tests/test_manifest.py deleted file mode 100644 index fb04579..0000000 --- a/tests/test_manifest.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Spec 001, R5: contagem de linhas do manifest.""" - -import pytest - -from namescommons.manifest import _linhas - - -@pytest.mark.parametrize("conteudo, esperado", [ - ("", 0), - ("a,b\n", 0), - ("a,b\n1,2\n", 1), - ("a,b\n1,2\n\n", 1), - ("a,b\n1,2\n\n\n3,4\n", 2), - ('a,b\n"x\ny",2\n', 1), -]) -def test_linhas_de_csv_nao_contam_cabecalho_nem_linhas_em_branco(tmp_path, conteudo, esperado): - caminho = tmp_path / "t.csv" - caminho.write_text(conteudo, encoding="utf-8") - assert _linhas(caminho) == esperado diff --git a/tests/test_robustez.py b/tests/test_robustez.py index 6268d5c..6ee94fe 100644 --- a/tests/test_robustez.py +++ b/tests/test_robustez.py @@ -43,12 +43,6 @@ def test_escritas_criam_o_diretorio_pai(observacoes, fontes, tmp_path): assert (tmp_path / "d" / "g.parquet").is_file() -def test_manifesto_conta_registros_csv_e_nao_linhas_fisicas(tmp_path): - (tmp_path / "x.csv").write_text('name,nota\n"Ana","linha 1\nlinha 2"\nBia,ok\n', encoding="utf-8") - m = build_manifest("2026.10", tmp_path, {}, set()) - assert m["files"]["x.csv"]["rows"] == 2 - - def test_manifesto_conta_linhas_dos_indices(observacoes, fontes, tmp_path): paises = by_country(observacoes, fontes) write_index(name_index(observacoes, paises, "given"), tmp_path / "given_names.csv", diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index ceddef0..f2a0cc8 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -117,3 +117,26 @@ def test_esquema_de_nomes_sobre_o_kit(): "country": (True, None), "count_type": (True, None), "primary": (True, None), "suppression": (True, None), "uniform_case": (False, False), "strips_accents": (False, False), } + + +# R3.4: manifesto com os auxiliares de arquivo do kit ---------------------------------------------- + +def test_manifesto_usa_describe_files(tmp_path): + import pyarrow as pa + import pyarrow.parquet as pq + import pytest + from datasettool.files import sha256_file + + from namescommons.manifest import build_manifest + (tmp_path / "x.csv").write_text('name,nota\n"Ana","linha 1\nlinha 2"\n\nBia,ok\n', encoding="utf-8") + (tmp_path / "sub").mkdir() + pq.write_table(pa.table({"n": [1, 2, 3]}), tmp_path / "sub" / "y.parquet") + (tmp_path / "manifest.json").write_text("{}", encoding="utf-8") + arquivos = build_manifest("2026.10", tmp_path, {}, set())["files"] + assert arquivos == { + "sub/y.parquet": {"sha256": sha256_file(tmp_path / "sub" / "y.parquet"), "rows": 3}, + "x.csv": {"sha256": sha256_file(tmp_path / "x.csv"), "rows": 2}, + } + (tmp_path / "z.txt").write_text("a\nb\n", encoding="utf-8") + with pytest.raises(ValueError, match="z.txt"): + build_manifest("2026.10", tmp_path, {}, set()) From 0aa5d804ad199667dba07b9509fbcd29a05472c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:35:11 -0300 Subject: [PATCH 09/14] docs(toolkit): document the tools-dataset dependency AGENTS.md describes the toolkit in the stack and the upgrade steps, README gains a Development section, and spec 007 tasks record the verification: identical outputs on the fixtures and on the full real cache, and the small benchmark run. Refs BenchCommons/tools-dataset#5 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 14 +++++- README.md | 9 ++++ docs/specs/007-tools-dataset/tasks.md | 72 ++++++++++++++++----------- 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 259d8ab..bb38b9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,9 @@ Dataset mundial de nomes: primeiros nomes e sobrenomes com país, frequência, g ## Stack -Python 3.12, `uv`, `pytest`, `pyarrow`, `pyyaml`, `pycountry`, `xlrd` (XLS, [ADR 0005](docs/adr/0005-leitor-de-planilhas.md)). Build com `hatchling`. +Python 3.12, `uv`, `pytest`, `pyarrow`, `pycountry` e o kit de fontes `benchcommons-tools-dataset` (import `datasettool`), fixado na tag `v0.1.0` do [tools-dataset](https://github.com/BenchCommons/tools-dataset) ([ADR 0013](docs/adr/0013-dependencia-do-tools-dataset.md)). O kit traz o cache de downloads, o leitor de planilhas (com `xlrd` fixado, [ADR 0005](docs/adr/0005-leitor-de-planilhas.md)), a base do adaptador, o registro de fontes e os auxiliares de arquivo do manifesto, e fixa `pyyaml` e `xlrd`. Build com `hatchling`. + +O contrato do kit é do tools-dataset ([spec 004 de lá](https://github.com/BenchCommons/tools-dataset/blob/v0.1.0/docs/specs/004-source-toolkit/spec.md)); aqui fica só o que é de nomes ([spec 007](docs/specs/007-tools-dataset/spec.md)). Correção no código genérico é feita lá, numa versão nova, nunca com cópia ou remendo local. ## Comandos @@ -25,6 +27,16 @@ PYTHONPATH=tests uv run namescommons run --offline --sources tests/fixtures/sour --adapters adaptadores --cache --out --release 2026.10 ``` +## Atualizar o kit de fontes + +Procedimento completo no [ADR 0013](docs/adr/0013-dependencia-do-tools-dataset.md): + +1. Troque juntas, em `pyproject.toml`, a versão (`benchcommons-tools-dataset==X.Y.Z`) e a tag (`tag = "vX.Y.Z"` em `[tool.uv.sources]`), e a constante `VERSAO` de `tests/test_tools_dataset.py`. +2. `uv lock --upgrade-package benchcommons-tools-dataset && uv sync`. +3. `uv run pytest -q` e os dois passos offline da CI; compare as saídas com as da versão anterior (`diff -r`). + +O cache do kit recusa entradas que resolvem fora do diretório do cache, inclusive links simbólicos ([tools-dataset#9](https://github.com/BenchCommons/tools-dataset/issues/9)): ponha arquivos baixados à mão no cache por cópia ou link físico. + ## Passo anual do INSEE O INSEE publica cada edição do Fichier des prénoms numa página nova, sem listagem estável da mais recente (conferido em 2026-10: nem a página antiga nem o data.gouv.fr apontam para a nova edição). Quando sair uma edição nova (em geral no começo do ano): diff --git a/README.md b/README.md index 1de88f9..1d78e7f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ Add `--offline` to `ingest` or `run` to use only files already in the cache. Use The SSA server refuses automated downloads (HTTP 403). Download the national data ZIP in a browser and save it where the error message of `fetch` says, then continue with `--offline` ([spec 003, R4](docs/specs/003-fontes-oficiais/spec.md)). +## Development + +```sh +uv sync # installs the dependencies, including the source toolkit +uv run pytest -q # tests never use the network +``` + +The download cache, the spreadsheet reader, the adapter base, the source registry and the manifest file helpers come from the BenchCommons source toolkit, the `benchcommons-tools-dataset` package (import `datasettool`), pinned to a git tag of [tools-dataset](https://github.com/BenchCommons/tools-dataset) ([ADR 0013](docs/adr/0013-dependencia-do-tools-dataset.md)). This repository keeps only what is specific to names: the schema, normalisation, aggregation, variants, the pipeline, the adapters, `sources.yaml` and the CLI ([spec 007](docs/specs/007-tools-dataset/spec.md)). Messages from the toolkit are in English. Files placed in the cache by hand must be copies or hard links, not symbolic links. + ## Licences - Code: Apache-2.0, see [`LICENSE`](LICENSE). diff --git a/docs/specs/007-tools-dataset/tasks.md b/docs/specs/007-tools-dataset/tasks.md index ab2a19d..e260423 100644 --- a/docs/specs/007-tools-dataset/tasks.md +++ b/docs/specs/007-tools-dataset/tasks.md @@ -10,62 +10,76 @@ Cada tarefa segue TDD: escrever ou ajustar os testes, ver falhar com `uv run pyt **Arquivos:** `pyproject.toml`, `uv.lock`; teste novo `tests/test_tools_dataset.py`. -- [ ] Testes: `test_dependencia_fixada_na_versao_e_na_tag` (versão `==0.1.0` em `dependencies` e `tag = "v0.1.0"` em `[tool.uv.sources]`, com a mesma versão), `test_uv_lock_aponta_a_tag` (fonte git com `tag=v0.1.0` no `uv.lock`), `test_kit_instalado_na_versao_fixada` (`datasettool.__version__ == "0.1.0"`). -- [ ] `uv add` da dependência com a origem git na tag; `uv sync`. -- [ ] Suíte verde; commit `build(deps): depend on benchcommons-tools-dataset 0.1.0 by git tag`. +- [x] Testes: `test_dependencia_fixada_na_versao_e_na_tag` (versão `==0.1.0` em `dependencies` e `tag = "v0.1.0"` em `[tool.uv.sources]`, com a mesma versão), `test_uv_lock_aponta_a_tag` (fonte git com `tag=v0.1.0` no `uv.lock`), `test_kit_instalado_na_versao_fixada` (`datasettool.__version__ == "0.1.0"`). +- [x] `uv add` da dependência com a origem git na tag; `uv sync`. +- [x] Suíte verde; commit `build(deps): depend on benchcommons-tools-dataset 0.1.0 by git tag`. ### Task 2: base do adaptador do kit **Arquivos:** `src/namescommons/adapters/__init__.py`; testes `tests/test_tools_dataset.py`, `tests/test_adapters.py`, `tests/test_contrato_adaptador.py`, `tests/test_links.py` e os de adaptadores que conferem mensagens do contrato. -- [ ] Testes: `test_reexporta_os_objetos_do_kit` (identidade de `AdapterError`, `FormatError`, `Resource`, `SourceFiles`, `module_name`, `parse_count`, `parse_code`), `test_load_adapter_usa_o_pacote_padrao`, `test_check_adapter_confere_links` (com `links` não chamável, falha; sem `links`, passa). -- [ ] Trocar a parte genérica pelos reexports e invólucros; `RawRecord`, `RawLink` e `DEFAULT_PACKAGE` ficam. -- [ ] Ajustar para o inglês as mensagens conferidas nos testes que continuam (R5.1). -- [ ] Remover de `tests/test_adapters.py` e `tests/test_contrato_adaptador.py` os testes listados no plano. -- [ ] Suíte verde; commit `refactor(adapters): use the datasettool adapter base`. +- [x] Testes: `test_reexporta_os_objetos_do_kit` (identidade de `AdapterError`, `FormatError`, `Resource`, `SourceFiles`, `module_name`, `parse_count`, `parse_code`), `test_load_adapter_usa_o_pacote_padrao`, `test_check_adapter_confere_links` (com `links` não chamável, falha; sem `links`, passa). +- [x] Trocar a parte genérica pelos reexports e invólucros; `RawRecord`, `RawLink` e `DEFAULT_PACKAGE` ficam. +- [x] Ajustar para o inglês as mensagens conferidas nos testes que continuam (R5.1). +- [x] Remover de `tests/test_adapters.py` e `tests/test_contrato_adaptador.py` os testes listados no plano. +- [x] Suíte verde; commit `refactor(adapters): use the datasettool adapter base`. ### Task 3: leitor de planilhas do kit **Arquivos:** `src/namescommons/adapters/es_ine_residentes.py`, `tests/test_es_ine_residentes.py`, `.github/workflows/ci.yml`, `tests/fixtures/ATRIBUICAO.md`, `pyproject.toml`, `uv.lock`. -- [ ] Teste: `test_leitor_do_kit_le_as_amostras_do_ine` (o antigo `test_r1_3_amostras_do_ine`, com as mesmas entradas e valores esperados) e `test_namescommons_spreadsheet_nao_existe`. -- [ ] O adaptador importa de `datasettool.sources.spreadsheet`. -- [ ] Remover `src/namescommons/spreadsheet.py`, `tests/test_spreadsheet.py` e `tests/fixtures/planilhas/tipos.xls`; tirar `! -name planilhas` da CI e a frase de `ATRIBUICAO.md`; tirar `xlrd` das dependências diretas. -- [ ] Suíte verde; commit `refactor(spreadsheet): read spreadsheets with datasettool`. +- [x] Teste: `test_leitor_do_kit_le_as_amostras_do_ine` (o antigo `test_r1_3_amostras_do_ine`, com as mesmas entradas e valores esperados) e `test_namescommons_spreadsheet_nao_existe`. +- [x] O adaptador importa de `datasettool.sources.spreadsheet`. +- [x] Remover `src/namescommons/spreadsheet.py`, `tests/test_spreadsheet.py` e `tests/fixtures/planilhas/tipos.xls`; tirar `! -name planilhas` da CI e a frase de `ATRIBUICAO.md`; tirar `xlrd` das dependências diretas. +- [x] Suíte verde; commit `refactor(spreadsheet): read spreadsheets with datasettool`. ### Task 4: cache de downloads do kit **Arquivos:** `src/namescommons/__init__.py`, `src/namescommons/pipeline.py`, `tools/amostra_wikidata.py`; testes `tests/test_tools_dataset.py`, `tests/test_pipeline.py`, `tests/test_cli_pipeline.py`, `tests/test_us_ssa_births.py`. -- [ ] Testes: `test_user_agent_igual_ao_anterior` (texto exato), `test_pipeline_envia_o_user_agent_do_projeto` (cabeçalho recebido pelo servidor local) e `test_namescommons_cache_nao_existe`. -- [ ] `USER_AGENT` com `make_user_agent`; o pipeline chama `fetch_resource` do kit com ele; opções `backoff`, `attempts`, `sleep`. -- [ ] Ajustar para o inglês as mensagens de download conferidas. -- [ ] Remover `src/namescommons/cache.py` e `tests/test_cache.py`. -- [ ] Suíte verde; commit `refactor(cache): download through datasettool with the project User-Agent`. +- [x] Testes: `test_user_agent_igual_ao_anterior` (texto exato), `test_pipeline_envia_o_user_agent_do_projeto` (cabeçalho recebido pelo servidor local) e `test_namescommons_cache_nao_existe`. +- [x] `USER_AGENT` com `make_user_agent`; o pipeline chama `fetch_resource` do kit com ele; opções `backoff`, `attempts`, `sleep`. +- [x] Ajustar para o inglês as mensagens de download conferidas. +- [x] Encontrado na implementação: o cache do kit recusa links simbólicos (spec, R4.3, item 5); `tools/bench_pipeline.py --wikidata` passa a usar link físico ou cópia, e a decisão foi para [tools-dataset#9](https://github.com/BenchCommons/tools-dataset/issues/9). +- [x] Remover `src/namescommons/cache.py` e `tests/test_cache.py`. +- [x] Suíte verde; commit `refactor(cache): download through datasettool with the project User-Agent`. ### Task 5: registro de fontes sobre o kit **Arquivos:** `src/namescommons/sources.py`, `pyproject.toml`, `uv.lock`; testes `tests/test_sources.py`, `tests/test_sources_extensoes.py`, `tests/test_tools_dataset.py`. -- [ ] Testes: `test_campo_de_nomes_ausente` (`country` ausente), `test_source_error_e_o_registry_error_do_kit`, `test_licencas_aceitas` (lista do kit mais `statcan-open-licence`), e os de `strips_accents` que já existem. -- [ ] `SCHEMA` com os campos de nomes, `checks` com a regra de uma `primary`, fábrica do `Source`; `load_sources` e `parse_sources` chamam o kit. -- [ ] Remover os testes genéricos listados no plano; nenhum teste confere ordem de linhas. -- [ ] Tirar `pyyaml` das dependências diretas. -- [ ] Suíte verde; commit `refactor(sources): build the names registry on datasettool`. +- [x] Testes: `test_campo_de_nomes_ausente` (`country` ausente), `test_source_error_e_o_registry_error_do_kit`, `test_licencas_aceitas` (lista do kit mais `statcan-open-licence`), e os de `strips_accents` que já existem. +- [x] `SCHEMA` com os campos de nomes, `checks` com a regra de uma `primary`, fábrica do `Source`; `load_sources` e `parse_sources` chamam o kit. +- [x] Remover os testes genéricos listados no plano; nenhum teste confere ordem de linhas. +- [x] Tirar `pyyaml` das dependências diretas. +- [x] Suíte verde; commit `refactor(sources): build the names registry on datasettool`. ### Task 6: auxiliares de arquivo do manifesto **Arquivos:** `src/namescommons/manifest.py`; testes `tests/test_robustez.py`, `tests/test_tools_dataset.py`. -- [ ] Teste: `test_manifesto_usa_describe_files` (CSV e Parquet contados; arquivo de outra extensão é `ValueError`). -- [ ] `build_manifest` com `describe_files(out_dir, counters={".csv": count_csv_records, ".parquet": ...})`. -- [ ] Remover `tests/test_manifest.py` e `test_manifesto_conta_registros_csv_e_nao_linhas_fisicas`. -- [ ] Suíte verde; commit `refactor(manifest): checksums and row counts from datasettool.files`. +- [x] Teste: `test_manifesto_usa_describe_files` (CSV e Parquet contados; arquivo de outra extensão é `ValueError`). +- [x] `build_manifest` com `describe_files(out_dir, counters={".csv": count_csv_records, ".parquet": ...})`. +- [x] Remover `tests/test_manifest.py` e `test_manifesto_conta_registros_csv_e_nao_linhas_fisicas`. +- [x] Suíte verde; commit `refactor(manifest): checksums and row counts from datasettool.files`. ### Task 7: documentação e verificação **Arquivos:** `AGENTS.md`, `README.md`, `docs/specs/ROADMAP.md`, esta pasta. -- [ ] `AGENTS.md`: stack com o kit e a regra de atualização (ADR 0013). `README.md`: seção de desenvolvimento. -- [ ] Verificar: `uv sync --locked`, `uv run pytest -q`, os dois passos offline da CI com `diff -r` contra a linha de base, `namescommons run --offline` sobre o cache real com `diff -r`, e `tools/bench_pipeline.py --observacoes 200000`. -- [ ] Commit `docs(toolkit): document the tools-dataset dependency`. +- [x] `AGENTS.md`: stack com o kit e a regra de atualização (ADR 0013). `README.md`: seção de desenvolvimento. +- [x] Verificar: `uv sync --locked`, `uv run pytest -q`, os dois passos offline da CI com `diff -r` contra a linha de base, `namescommons run --offline` sobre o cache real com `diff -r`, e `tools/bench_pipeline.py --observacoes 200000`. +- [x] Commit `docs(toolkit): document the tools-dataset dependency`. + +## Verificação (2026-10-12) + +| Critério | Evidência | +|---|---| +| 1 | `uv sync --locked` instala `benchcommons-tools-dataset==0.1.0` de `git+https://github.com/BenchCommons/tools-dataset@f3251d8` (tag `v0.1.0`); `test_uv_lock_aponta_a_tag` | +| 2 | `cache.py` e `spreadsheet.py` removidos; `test_namescommons_cache_nao_existe`, `test_namescommons_spreadsheet_nao_existe`; nenhum `import yaml` em `src/` | +| 3 | `uv run pytest -q`: 585 testes verdes (eram 832; os removidos têm equivalentes entre os 360 testes do kit, verdes na tag) | +| 4 | Os dois passos offline da CI passam; `diff -r` contra `master` (`0153007`) sem diferença: 8 observações no exemplo, 109 nas fontes oficiais | +| 5 | `namescommons run --offline` no cache real completo: 4.336.051 observações, `diff -r` sem diferença (89 s e 2,84 GB de pico, contra 123 s e 2,96 GB em `master`) | +| 6 | `tools/bench_pipeline.py --observacoes 200000`: 199.920 observações, ingest 1,5 s, build 1,3 s, 261 MB; com `--wikidata`: 1.016.691 observações e 436.971 ligações, 1.765 MB | +| 7 | Lista no plano (seção Removidos) e na descrição do PR | +| 8 | Checklist da issue tools-dataset#5 marcado; pendências em issues | From f0a6c2d5c293a8fbef959db91086cc21a9610591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:35:57 -0300 Subject: [PATCH 10/14] docs(toolkit): link spec 007 roadmap row to PR #41 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index f8834b7..ac15a73 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -16,6 +16,6 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Mesclada | | 5 | [`005-variantes`](005-variantes/spec.md) | 003, 004 | Escrita, transliteração para ASCII, capitalização, forma de exibição, agrupamento de variantes (`group_id`) e deduplicação (issue #4), PR #38 | Mesclada | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | -| 7 | [`007-tools-dataset`](007-tools-dataset/spec.md) | 005; tools-dataset `v0.1.0` | Troca o cache, o leitor de planilhas, a base do adaptador, o registro de fontes e os auxiliares do manifesto pelo kit `benchcommons-tools-dataset` ([tools-dataset#5](https://github.com/BenchCommons/tools-dataset/issues/5)) | Em PR | +| 7 | [`007-tools-dataset`](007-tools-dataset/spec.md) | 005; tools-dataset `v0.1.0` | Troca o cache, o leitor de planilhas, a base do adaptador, o registro de fontes e os auxiliares do manifesto pelo kit `benchcommons-tools-dataset` ([tools-dataset#5](https://github.com/BenchCommons/tools-dataset/issues/5)), PR #41 | Em PR | Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md), [0010 regras de agrupamento](../adr/0010-regras-de-agrupamento.md), [0011 correspondência sem acento](../adr/0011-correspondencia-sem-acento.md), [0012 transliteração para ASCII](../adr/0012-transliteracao-ascii.md), [0013 dependência do tools-dataset](../adr/0013-dependencia-do-tools-dataset.md). From 55bcadf5d040c881082eed35d0eb540893a4c7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:41:53 -0300 Subject: [PATCH 11/14] ci: install with uv sync --locked Spec 007 acceptance criterion 1 requires the locked install: CI now runs uv sync --locked, so a uv.lock out of sync with pyproject.toml (for example a changed toolkit tag) fails the build. README and AGENTS.md use the same command. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- tests/test_ci.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4150994..606ffca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: with: enable-cache: true - name: Instalar dependências - run: uv sync + run: uv sync --locked - name: Testes run: uv run pytest -q - name: namescommons run --offline sobre o cache montado a partir das fixtures diff --git a/AGENTS.md b/AGENTS.md index bb38b9a..f4b436e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ O contrato do kit é do tools-dataset ([spec 004 de lá](https://github.com/Benc ## Comandos ```sh -uv sync # instala as dependências +uv sync --locked # instala as dependências do uv.lock uv run pytest -q # testes (nunca usam a rede) uv run namescommons --help # CLI do pipeline ``` diff --git a/README.md b/README.md index 1d78e7f..e084e62 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The SSA server refuses automated downloads (HTTP 403). Download the national dat ## Development ```sh -uv sync # installs the dependencies, including the source toolkit +uv sync --locked # installs the locked dependencies, including the source toolkit uv run pytest -q # tests never use the network ``` diff --git a/tests/test_ci.py b/tests/test_ci.py index c66cf46..fe2a232 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -52,7 +52,7 @@ def _indice(comandos, trecho): def test_passos_da_ci(): """Confere os passos pelo conteúdo e pela ordem, não pela posição na lista.""" comandos = _comandos() - assert comandos[_indice(comandos, "uv sync")].strip() == "uv sync" + assert comandos[_indice(comandos, "uv sync")].strip() == "uv sync --locked" # spec 007, critério 1 assert comandos[_indice(comandos, "uv run pytest")].strip() == "uv run pytest -q" assert _indice(comandos, "uv sync") < _indice(comandos, "uv run pytest") < _indice(comandos, "namescommons run --offline") From f31744e9cd35cb018be06d23b085daf83b77c653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:42:00 -0300 Subject: [PATCH 12/14] test(toolkit): pin the toolkit commit sha in uv.lock and the install test_uv_lock_aponta_a_tag now asserts the full commit of tag v0.1.0 (f3251d805f1f7839e59c5472d54833ea7bef840d), and test_kit_instalado_do_commit_da_tag checks the installed distribution's direct_url.json vcs_info.commit_id against it. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_tools_dataset.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_tools_dataset.py b/tests/test_tools_dataset.py index f2a0cc8..e34eca0 100644 --- a/tests/test_tools_dataset.py +++ b/tests/test_tools_dataset.py @@ -5,6 +5,7 @@ RAIZ = Path(__file__).resolve().parent.parent VERSAO = "0.1.0" +COMMIT = "f3251d805f1f7839e59c5472d54833ea7bef840d" # commit da tag v0.1.0 (ADR 0013) DISTRIBUICAO = "benchcommons-tools-dataset" @@ -26,7 +27,15 @@ def test_uv_lock_aponta_a_tag(): lock = tomllib.loads((RAIZ / "uv.lock").read_text(encoding="utf-8")) pacote = next(p for p in lock["package"] if p["name"] == DISTRIBUICAO) assert pacote["version"] == VERSAO - assert pacote["source"]["git"].startswith(f"https://github.com/BenchCommons/tools-dataset?tag=v{VERSAO}#") + assert pacote["source"]["git"] == f"https://github.com/BenchCommons/tools-dataset?tag=v{VERSAO}#{COMMIT}" + + +def test_kit_instalado_do_commit_da_tag(): + import json + from importlib.metadata import distribution + origem = json.loads(distribution(DISTRIBUICAO).read_text("direct_url.json")) + assert origem["url"] == "https://github.com/BenchCommons/tools-dataset" + assert origem["vcs_info"]["commit_id"] == COMMIT def test_kit_instalado_na_versao_fixada(): From b9ea80b38bbb7fbc59d6d0266eb4e3bc218bc659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:42:47 -0300 Subject: [PATCH 13/14] docs(adr): tag integrity check and PyPI name risk in ADR 0013 The upgrade procedure gains an integrity step (git ls-remote of the peeled tag must match the commit in uv.lock and in the COMMIT test constant; git tag -v must show a valid signature) and updates the test constants together with pyproject.toml, aligned with AGENTS.md. Checked for v0.1.0: f3251d8, good signature. The consequences note that non-uv installers would look the name up on PyPI, where it is not registered (BenchCommons/tools-dataset#10). Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 9 ++++++--- docs/adr/0013-dependencia-do-tools-dataset.md | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4b436e..f1abe43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,12 @@ PYTHONPATH=tests uv run namescommons run --offline --sources tests/fixtures/sour Procedimento completo no [ADR 0013](docs/adr/0013-dependencia-do-tools-dataset.md): -1. Troque juntas, em `pyproject.toml`, a versão (`benchcommons-tools-dataset==X.Y.Z`) e a tag (`tag = "vX.Y.Z"` em `[tool.uv.sources]`), e a constante `VERSAO` de `tests/test_tools_dataset.py`. -2. `uv lock --upgrade-package benchcommons-tools-dataset && uv sync`. -3. `uv run pytest -q` e os dois passos offline da CI; compare as saídas com as da versão anterior (`diff -r`). +1. Troque juntas, em `pyproject.toml`, a versão (`benchcommons-tools-dataset==X.Y.Z`) e a tag (`tag = "vX.Y.Z"` em `[tool.uv.sources]`), e as constantes `VERSAO` e `COMMIT` de `tests/test_tools_dataset.py`. +2. `uv lock --upgrade-package benchcommons-tools-dataset && uv sync --locked`. +3. Confira a integridade: `git ls-remote https://github.com/BenchCommons/tools-dataset 'refs/tags/vX.Y.Z^{}'` dá o mesmo commit do `uv.lock` e de `COMMIT`, e `git tag -v vX.Y.Z` num clone do tools-dataset mostra assinatura válida. +4. `uv run pytest -q` e os dois passos offline da CI; compare as saídas com as da versão anterior (`diff -r`). + +Só o `uv` lê a origem git fixada; não instale o projeto com `pip install .` ([tools-dataset#10](https://github.com/BenchCommons/tools-dataset/issues/10)). O cache do kit recusa entradas que resolvem fora do diretório do cache, inclusive links simbólicos ([tools-dataset#9](https://github.com/BenchCommons/tools-dataset/issues/9)): ponha arquivos baixados à mão no cache por cópia ou link físico. diff --git a/docs/adr/0013-dependencia-do-tools-dataset.md b/docs/adr/0013-dependencia-do-tools-dataset.md index 09eaa7c..9cb91f1 100644 --- a/docs/adr/0013-dependencia-do-tools-dataset.md +++ b/docs/adr/0013-dependencia-do-tools-dataset.md @@ -30,13 +30,15 @@ O build precisa continuar reproduzível: o mesmo commit deste repositório deve 1. Confirmar que a tag nova existe: `gh api repos/BenchCommons/tools-dataset/tags --jq '.[].name'`. 2. Ler as notas da versão e a spec do tools-dataset na tag nova. Enquanto a versão maior for 0, uma versão menor pode quebrar a API. -3. Numa branch, trocar as duas linhas juntas em `pyproject.toml` (`==X.Y.Z` e `tag = "vX.Y.Z"`) e rodar `uv lock --upgrade-package benchcommons-tools-dataset` e `uv sync`. -4. Rodar `uv run pytest -q` e os dois passos offline da CI, e comparar as saídas com as da versão anterior (`diff -r`). Diferença em saída é mudança de comportamento e precisa de spec ou ADR. -5. Abrir o PR com as duas linhas, o `uv.lock` e a evidência do passo 4. `tests/test_tools_dataset.py` falha se a versão e a tag divergirem. +3. Numa branch, trocar juntas as duas linhas de `pyproject.toml` (`==X.Y.Z` e `tag = "vX.Y.Z"`) e as constantes `VERSAO` e `COMMIT` de `tests/test_tools_dataset.py`, e rodar `uv lock --upgrade-package benchcommons-tools-dataset` e `uv sync --locked`. +4. Conferir a integridade da tag: o commit de `git ls-remote https://github.com/BenchCommons/tools-dataset 'refs/tags/vX.Y.Z^{}'` precisa ser o mesmo que o `uv.lock` registra depois de `#` na fonte do pacote (e o mesmo de `COMMIT`); num clone do tools-dataset, `git tag -v vX.Y.Z` precisa mostrar uma assinatura válida do mantenedor. +5. Rodar `uv run pytest -q` e os dois passos offline da CI, e comparar as saídas com as da versão anterior (`diff -r`). Diferença em saída é mudança de comportamento e precisa de spec ou ADR. +6. Abrir o PR com as duas linhas, o `uv.lock`, as constantes do teste e a evidência dos passos 4 e 5. `tests/test_tools_dataset.py` falha se a versão, a tag, o commit do `uv.lock` e o do pacote instalado divergirem. ## Consequências - `uv sync` precisa de acesso ao GitHub quando não há cache, como já precisava ao PyPI. +- Só o `uv` lê `[tool.uv.sources]`. Uma instalação por outra ferramenta (`pip install .`, Poetry, pip-tools) procuraria `benchcommons-tools-dataset` no PyPI, onde o nome não está registrado e poderia ser ocupado por terceiros; a reserva do nome ou a regra de instalar só com `uv` é decisão do tools-dataset ([tools-dataset#10](https://github.com/BenchCommons/tools-dataset/issues/10)). - As mensagens de erro do kit são em inglês; as deste repositório continuam em português. Uma mensagem com várias linhas do registro de fontes pode misturar as duas línguas. - Uma falha no código genérico só se corrige com uma versão nova do kit (tag de correção), nunca editando a tag existente. - Os testes do comportamento genérico vivem no tools-dataset; aqui ficam só os testes do que é de nomes e da integração com o kit. From 21dc01c01355e0d5f8abaf10c428fb574b9d70f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 16:42:48 -0300 Subject: [PATCH 14/14] build(deps): add pyyaml 6.0.3 to the dev group Tests import yaml directly (CI workflow and registry checks); pin it in the dev group at the toolkit's version instead of relying on the transitive dependency. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- uv.lock | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9bc5d52..7cfad11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ namescommons = "namescommons.cli:main" [dependency-groups] -dev = ["pytest>=8"] +dev = ["pytest>=8", "pyyaml==6.0.3"] # tests importam yaml; mesma versão fixada pelo kit [tool.uv.sources] benchcommons-tools-dataset = { git = "https://github.com/BenchCommons/tools-dataset", tag = "v0.1.0" } diff --git a/uv.lock b/uv.lock index 8de5274..1a71860 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pyyaml" }, ] [package.metadata] @@ -52,7 +53,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pyyaml", specifier = "==6.0.3" }, +] [[package]] name = "packaging"