From 3a78d5b553d7d648ae1ef41d2675c2419bb38436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:19:16 -0300 Subject: [PATCH 01/12] docs(spec): add spec 006 for publication and ADRs 0014 and 0015 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0014-processo-de-release.md | 30 +++++++ docs/adr/0015-retirada-do-legado.md | 29 +++++++ docs/specs/006-publicacao/spec.md | 113 +++++++++++++++++++++++++++ docs/specs/ROADMAP.md | 6 +- 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0014-processo-de-release.md create mode 100644 docs/adr/0015-retirada-do-legado.md create mode 100644 docs/specs/006-publicacao/spec.md diff --git a/docs/adr/0014-processo-de-release.md b/docs/adr/0014-processo-de-release.md new file mode 100644 index 0000000..9a98f53 --- /dev/null +++ b/docs/adr/0014-processo-de-release.md @@ -0,0 +1,30 @@ +# 0014. Release montada por workflow a partir de um rascunho com o cache das fontes + +## Status + +Aceito (2026-10-12). Spec [006](../specs/006-publicacao/spec.md). + +## Contexto + +Uma release do dataset é um conjunto de arquivos gerados a partir do cache das fontes (spec 002) e anexados a uma GitHub Release com tag CalVer (spec 001, R5). Duas fontes bloqueiam o download automático: a SSA responde 403 (spec 003, R4) e o catálogo da Nova Zelândia devolve uma página de desafio (spec 003e, R4). O cache completo, portanto, só pode ser montado por uma pessoa com navegador, e um workflow que baixa as fontes sozinho não consegue montar a release. + +As opções avaliadas: + +1. **Build e envio na máquina do dono** (`namescommons run` e `gh release upload`). Simples, mas o resultado depende do ambiente local, e nada garante que o commit da tag foi o que gerou os arquivos. +2. **Workflow que baixa as fontes.** Falha na SSA e na Nova Zelândia. +3. **Workflow que lê o cache de um rascunho de release.** O dono monta o cache, empacota (`source-cache.tar.gz`) e o anexa a um rascunho com o nome da tag. O push da tag dispara o workflow, que baixa o cache do rascunho, roda o build offline no commit da tag, verifica as somas e envia os assets ao rascunho. Rascunhos não são públicos, e a publicação continua um ato do dono. +4. **Cache como artefato ou cache do Actions.** Não há como o dono enviar um arquivo ao cache do Actions pelo navegador, e artefatos expiram. + +## Decisão + +- Opção 3. O workflow `.github/workflows/release.yml` roda no push de uma tag CalVer, baixa `source-cache.tar.gz` do rascunho da release com o mesmo nome, roda `tools/release.sh build` (que chama `namescommons run --offline` e verifica `SHA256SUMS`) e envia os assets com `tools/release.sh upload`. +- O envio nunca usa `--clobber`, recusa release publicada (releases são imutáveis, spec 001 do tools-dataset, R6.2) e remove `source-cache.tar.gz` do rascunho só depois de enviar os assets, para que os arquivos brutos das fontes nunca sejam publicados junto com o dataset. +- O workflow nunca publica a release nem cria ou move tag. O disparo manual (`workflow_dispatch`) é sempre ensaio, sobre as amostras de `tests/fixtures/`, e a CI roda o mesmo ensaio em todo PR. +- A lógica fica em `tools/release.sh`, e não no YAML, para ser testada com um `gh` falso e rodada localmente. + +## Consequências + +- A release é reproduzível a partir do commit da tag e do cache anexado ao rascunho; o cache não fica público. +- O dono continua tendo de baixar a SSA e a Nova Zelândia pelo navegador antes de cada release, e de revisar o rascunho antes de publicar. +- Se o envio falhar no meio, o rascunho fica com parte dos assets e com o cache; uma nova execução recusa sobrescrever, e o dono apaga os assets do rascunho antes de repetir. +- O caminho real (baixar do rascunho e enviar) só é exercitado com o `gh` falso dos testes até a primeira release; o comportamento do `gh` com rascunhos é o documentado pelo GitHub CLI. diff --git a/docs/adr/0015-retirada-do-legado.md b/docs/adr/0015-retirada-do-legado.md new file mode 100644 index 0000000..0a0cdfe --- /dev/null +++ b/docs/adr/0015-retirada-do-legado.md @@ -0,0 +1,29 @@ +# 0015. Retirada de `legacy/` da árvore do repositório + +## Status + +Proposto (2026-10-12), aguardando aprovação do dono para a remoção dos arquivos. Spec [006](../specs/006-publicacao/spec.md), R10. + +## Contexto + +A spec 001 (R6) moveu as listas planas originais (`first_names.all.txt`, `last_names.all.txt`) e os scripts `generate/` para `legacy/`, sem alteração, e deixou a retirada para a spec 006. São 22 arquivos e 2.091.482 bytes. + +O conteúdo de `legacy/` não tem licença declarada: veio de sites, gists e repositórios sem licença, e inclui nomes dos datasets do IMDb, com termos não comerciais (ADR 0001). O dataset novo não o ingere, e o contrato de entradas do tools-dataset (spec 001 de lá, R1.3) o exclui explicitamente. Mantê-lo na árvore tem custo: + +- o repositório distribui, na versão atual, material sem concessão de licença ao lado de `LICENSE` e `DATA_LICENSE`, e cada leitor precisa achar o `legacy/README.md` para saber que nenhuma das duas vale ali; +- `legacy/generate/` depende de diretórios e URLs que não existem mais, então não reproduz as listas; +- buscas, clones e ferramentas de licença passam pelos 2 MB de listas sem metadados. + +O benefício de manter é a conveniência de quem usava as listas antigas por caminho no repositório. + +## Decisão + +- `legacy/` sai da árvore do repositório num commit próprio. O conteúdo continua acessível no histórico do git e no repositório de origem do fork (Debdut/names.io). +- A remoção só acontece com aprovação explícita do dono sobre a lista exata dos arquivos (caminho e tamanho), registrada na descrição do PR da spec 006 e na issue de acompanhamento. Até lá `legacy/` fica congelado como está. +- No mesmo commit da remoção: `tests/test_legacy.py` passa a conferir que `legacy/` não existe, o README troca a linha de `legacy/` por uma nota que aponta o último commit que o contém, e a spec 001 (R4 e R6) ganha nota remetendo a este ADR. + +## Consequências + +- O repositório passa a ter só código Apache-2.0, dados CC BY 4.0 e documentação; nenhum arquivo sem licença. +- Quem dependia de `legacy/first_names.all.txt` por caminho perde o arquivo em `master`, mas pode fixar o último commit que o contém. +- Os testes de integridade da spec 001 (critério 6) deixam de fazer sentido e são trocados pelo teste de ausência. diff --git a/docs/specs/006-publicacao/spec.md b/docs/specs/006-publicacao/spec.md new file mode 100644 index 0000000..4737460 --- /dev/null +++ b/docs/specs/006-publicacao/spec.md @@ -0,0 +1,113 @@ +# 006. Publicação + +Parte do [roadmap](../ROADMAP.md). Depende da [spec 005](../005-variantes/spec.md) e da [spec 007](../007-tools-dataset/spec.md). Esquema e versionamento: [spec 001](../001-esquema/spec.md) (R4, R5 e R6). Decisões: [ADR 0001](../../adr/0001-licenca-dos-dados.md) (licença dos dados), [ADR 0004](../../adr/0004-licenca-statistics-canada.md) (Statistics Canada), [ADR 0007](../../adr/0007-nomes-corrompidos-na-fonte.md) (nomes corrompidos), [ADR 0014](../../adr/0014-processo-de-release.md) (processo de release) e [ADR 0015](../../adr/0015-retirada-do-legado.md) (retirada do legado). Contrato de entradas de benchmark (dono: tools-dataset): [spec 001 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/main/docs/specs/001-input-contract/spec.md). Issues: #5, #22 e #32. + +Status: Em implementação. + +## Por quê + +O pipeline já gera as saídas da spec 001 num diretório, mas nada disso chega a um usuário. Faltam: + +1. **Atribuições.** O ADR 0001 promete uma página de atribuições gerada de `sources.yaml`, e o ADR 0004 exige que o aviso fixo da Statistics Canada e o aviso de condições acompanhem o dataset (issue #22). Hoje os textos só existem em `sources.yaml` e no README. +2. **Licença do dataset.** `DATA_LICENSE` traz o texto da CC BY 4.0, mas nenhuma linha diz como atribuir o próprio dataset (issue #5). +3. **Descartes.** O adaptador da Nova Zelândia descarta nomes corrompidos na fonte (ADR 0007) e só avisa no log; o número some depois da execução (issue #32). +4. **Cartão do dataset.** Quem baixa uma release não tem onde ler campos, fontes, cobertura, supressão e limitações conhecidas. +5. **Release.** Não há procedimento nem workflow que transforme um cache de fontes numa GitHub Release com as saídas, o manifesto e as somas de verificação. Duas fontes (SSA e Nova Zelândia) bloqueiam o download automático e exigem o navegador, então o build da release não pode baixar tudo sozinho. +6. **Contrato de entradas.** O tools-dataset definiu o contrato de entradas de benchmark (spec 001 de lá), que espera `DATA_LICENSE`, `ATTRIBUTION.txt` e `SHA256SUMS` na release do dataset (R5, R6 e R9 de lá, e a seção 5 do plano de lá). +7. **Pendências da 001.** `.gitignore` com sobras e a decisão sobre `legacy/` (issue #5). + +## Requisitos + +### R1. Canal de descarte do adaptador + +- **R1.1** O protocolo do adaptador de nomes ganha o tipo `RawDrop(name, count)`, em `namescommons.adapters`: um registro que a fonte publicou e que o adaptador descartou por regra documentada (hoje só a do ADR 0007). `parse` pode emitir `RawDrop` no mesmo iterador dos `RawRecord`. `count` é a contagem publicada da linha descartada, ou vazio se a fonte não traz contagem. +- **R1.2** O pipeline separa os `RawDrop` dos `RawRecord` e conta, por fonte, as linhas descartadas (número de `RawDrop`), os registros descartados (soma de `count`) e as formas distintas (`name`). A numeração `registro N` das mensagens continua contando só os `RawRecord`. `RawDrop` com `name` que não é texto ou `count` que não é inteiro não negativo é erro (`descarte N: ...`). +- **R1.3** O pipeline avisa os descartes no logger `namescommons.pipeline`, uma linha por fonte com descarte, com linhas, registros e formas. O adaptador `nz-dia-births` deixa de usar `logging` e emite `RawDrop`. +- **R1.4** `namescommons ingest` grava os descartes de todas as fontes ingeridas (inclusive zero) nos metadados chave-valor do `observations.parquet`, na chave `namescommons.dropped`, como JSON `{source_id: {"lines": n, "records": n}}`. `namescommons build` os lê de lá; `namescommons run` os passa em memória. Um `observations.parquet` sem a chave (gravado antes desta spec) é aceito, e os descartes ficam desconhecidos. + +### R2. Manifesto + +- **R2.1** Cada item de `sources` em `manifest.json` ganha `name` (de `sources.yaml`), `observations` (linhas de `observations.parquet` da fonte) e `dropped` (`{"lines": n, "records": n}`, ou `null` quando desconhecido, R1.4). +- **R2.2** O manifesto ganha `data_license` com o identificador SPDX da licença dos dados, `CC-BY-4.0`. +- **R2.3** `files` continua descrevendo só as tabelas de dados (observações, índices, `by_country/.csv` e `variant_links.parquet`), com o mesmo significado. Os arquivos de publicação de R5 não entram em `files`; entram em `SHA256SUMS`. +- **R2.4** Pela regra da spec 001 (R5), campo novo e arquivo novo na release não mudam o significado de nada: `SCHEMA_VERSION` passa a `1.3.0`. + +### R3. Atribuições (`ATTRIBUTION.txt`) + +- **R3.1** O build gera `ATTRIBUTION.txt`, em inglês, UTF-8 sem BOM, fim de linha LF, a partir de `sources.yaml`. +- **R3.2** Abre com a linha de atribuição do próprio dataset (R4.1) e segue com um bloco por fonte usada na release (fontes das observações e das ligações, como em spec 005, R7.4), em ordem de `id`: nome, `id`, URL, licença (nome e URL da licença) e o texto de `attribution` reproduzido sem nenhuma edição. +- **R3.3** Depois das fontes vem a seção de avisos (`Notices`): para cada licença usada que exige aviso além da atribuição, o texto do aviso sem tradução. Hoje só `statcan-open-licence`, com o aviso de condições do ADR 0004: "Data adapted from Statistics Canada remain subject to the Statistics Canada Open Licence (https://www.statcan.gc.ca/en/reference/licence); users must not use them to identify individuals, imply endorsement, misrepresent them, or use Statistics Canada or Government of Canada symbols." +- **R3.4** O nome e a URL de cada licença e os avisos ficam numa só tabela no código, com uma entrada para cada licença aceita (`LICENCAS_COMPATIVEIS`). Licença sem entrada nessa tabela faz o build falhar. O aviso de condições do README é igual, byte a byte, ao da tabela. +- **R3.5** Nenhum logotipo, brasão ou símbolo da Statistics Canada ou do Governo do Canadá aparece nas páginas do projeto nem nos arquivos da release (issue #22). + +### R4. Licença dos dados + +- **R4.1** A linha de atribuição do dataset é: `Names Commons Dataset, release , BenchCommons (https://github.com/BenchCommons/dataset-names), licensed under CC BY 4.0. Upstream sources and notices: see ATTRIBUTION.txt.` +- **R4.2** `DATA_LICENSE` ganha, antes do texto da CC BY 4.0, um preâmbulo com o nome do dataset, a forma de atribuição de R4.1 (com `` no lugar da versão), a remissão a `ATTRIBUTION.txt` e o aviso de que o código está sob Apache-2.0 e `legacy/` não é coberto. O texto da licença não muda. +- **R4.3** A release leva uma cópia de `DATA_LICENSE` idêntica byte a byte ao arquivo do repositório no commit do build. O caminho vem da opção `--data-license` de `build` e `run` (padrão: `DATA_LICENSE`); arquivo ausente faz o build falhar antes de gravar a saída. + +### R5. Pacote da release + +- **R5.1** Além das saídas da spec 001, o diretório da release passa a ter: `DATA_LICENSE` (R4.3), `ATTRIBUTION.txt` (R3), `DATASET_CARD.md` (R6), `by_country.zip` e `SHA256SUMS`. Tudo é gravado no mesmo diretório temporário e só então renomeado: falha em qualquer etapa não deixa saída parcial (spec 002). +- **R5.2** GitHub Releases não aceitam diretórios. `by_country.zip` traz `by_country/.csv` com os mesmos bytes do diretório, em ordem de nome, com data fixa (1980-01-01 00:00:00), permissões fixas e compressão `deflate`, de modo que o mesmo conteúdo gera o mesmo zip. +- **R5.3** `SHA256SUMS` segue o formato do GNU `sha256sum` (`<64 hex minúsculos> `, um por linha, LF) e cobre todo arquivo regular do nível de cima do diretório da release, exceto ele mesmo, em ordem de nome. `sha256sum -c SHA256SUMS` passa no diretório da release e num diretório com os assets baixados da GitHub Release. +- **R5.4** Os assets publicados são exatamente os arquivos de R5.3 mais `SHA256SUMS`; o diretório `by_country/` não é publicado solto. +- **R5.5** O layout é compatível com a release descrita na seção 5 do plano da spec 001 do tools-dataset: `manifest.json`, as tabelas, `DATA_LICENSE`, `ATTRIBUTION.txt` (um bloco por fonte com nome, licença e texto exigido, e depois os avisos) e `SHA256SUMS` no nível de cima, e a tag da release em CalVer. Este repositório não gera entradas de benchmark (`*.input.json` e amostras); isso é a issue #24, sobre o contrato de lá. +- **R5.6** O build é determinístico: duas execuções sobre o mesmo cache, as mesmas fontes e o mesmo commit produzem `SHA256SUMS` idênticos. + +### R6. Cartão do dataset (`DATASET_CARD.md`) + +O build gera `DATASET_CARD.md`, em inglês (como o README), a partir do manifesto, de `sources.yaml` e das saídas da release, com: + +- **R6.1 Resumo.** Release, `schema_version`, número de observações, de primeiros nomes, de sobrenomes, de países e de ligações entre variantes. +- **R6.2 Arquivos.** Cada arquivo da release com uma linha de descrição e, para as tabelas, o número de linhas do manifesto. +- **R6.3 Campos.** Os campos de `given_names` e `family_names`, de `by_country/.csv`, de `observations.parquet` e de `variant_links.parquet`, com tipo e significado, remetendo às specs 001, 004 e 005. +- **R6.4 Fontes.** Uma linha por fonte usada: nome, país, `count_type`, licença, limite de supressão, `retrieved`, observações e descartes (linhas e registros, ou "unknown"). +- **R6.5 Cobertura.** Uma linha por país de `by_country`: primeiros nomes e sobrenomes (com o `count_type` de cada um) e o intervalo de anos. +- **R6.6 Supressão.** O ADR 0002 em linguagem de usuário: valores suprimidos são omitidos, o limite de cada fonte está na tabela de fontes, e somas de séries subestimam nomes raros. +- **R6.7 Limitações conhecidas.** No mínimo: fontes que publicam sem acento (`strips_accents`), listadas pelo nome; fontes de caixa uniforme recapitalizadas (`uniform_case`); total de linhas e registros descartados por nome corrompido (ADR 0007); `group_id` estável só dentro de uma release (spec 001, R3.2); o Wikidata como retrato do QLever na data de `retrieved` da fonte, não da data da release; e `female_share` como estimativa que combina tipos de contagem (spec 001, R3.2). Itens que dependem de uma fonte só aparecem se a fonte estiver na release. +- **R6.8 Licença e citação.** CC BY 4.0, a linha de R4.1, a remissão a `ATTRIBUTION.txt`, e, se a Statistics Canada estiver na release, o aviso (b) e o aviso de condições, sem tradução. Uma seção "How to cite" com a linha de R4.1 e a URL da release. + +### R7. Workflow de release + +- **R7.1 Comando local.** `tools/release.sh build ` roda `namescommons run --offline` sobre o cache, confere que a saída tem os arquivos de R5.1 e roda a verificação de `SHA256SUMS` (`sha256sum -c` ou `shasum -a 256 -c`). É o mesmo comando que o workflow usa, e qualquer pessoa o roda sem publicar nada. +- **R7.2 Gatilho.** `.github/workflows/release.yml` roda em push de tag CalVer (`AAAA.MM` ou `AAAA.MM.N`) e em `workflow_dispatch`. O disparo manual é sempre ensaio (R7.5). +- **R7.3 Cache.** Na tag, o cache vem do asset `source-cache.tar.gz` de um rascunho (draft) de release com o mesmo nome da tag, preparado pelo dono (ADR 0014). Sem rascunho, ou com a release já publicada, o workflow falha antes do build com mensagem que diz o que fazer. +- **R7.4 Envio.** Depois do build e da verificação, o workflow envia os assets de R5.4 ao rascunho sem sobrescrever asset existente, e só então remove `source-cache.tar.gz` do rascunho, para que os arquivos brutos das fontes nunca sejam publicados. O workflow nunca publica a release, nunca cria nem move tag e nunca envia a uma release publicada: publicar é ato do dono. +- **R7.5 Ensaio.** No `workflow_dispatch`, o cache são as amostras de `tests/fixtures/` com o `sources.yaml` real (como o segundo passo offline da CI), e a saída vai como artefato do workflow. Nenhum comando `gh` de release roda. A CI roda o mesmo ensaio em todo PR. +- **R7.6 Validação da entrada.** O nome da release é conferido contra CalVer antes de qualquer outro passo, e só passa ao shell por variável de ambiente. + +### R8. Documentação + +- **R8.1** README: layout da release (R5), seção "Releases" (como verificar, como citar, onde ficam o cartão e as atribuições) e o comando de ensaio local. +- **R8.2** `AGENTS.md`: o procedimento de release passo a passo (atualizar `retrieved`, conferir se a licença da Statistics Canada mudou desde a versão "Date modified: 2025-07-10" e, se mudou, reavaliar o ADR 0004, montar o cache, ensaiar, preparar o rascunho, criar e enviar a tag assinada, revisar e publicar). +- **R8.3** `ROADMAP.md`, lista de ADRs, e notas na spec 001 (R5 e R6) apontando esta spec. + +### R9. `.gitignore` + +Pendências da issue #5: tirar a linha em branco dupla, tirar `.venv/` da seção do mypy (a seção de virtualenv já ignora `.venv`) e permitir Parquet versionado em `tests/fixtures/` (`!tests/fixtures/**/*.parquet`), mantendo `*.parquet` ignorado no resto. + +### R10. Legado + +Decisão no [ADR 0015](../../adr/0015-retirada-do-legado.md): `legacy/` sai da árvore do repositório (continua no histórico do git). A remoção apaga arquivos versionados e por isso só acontece com aprovação explícita do dono sobre a lista exata dos arquivos; até lá `legacy/` fica como está, congelado, e o README continua dizendo que ele não faz parte do dataset. + +## Critérios de aceite + +1. Um adaptador que emite `RawDrop` tem os descartes contados por fonte (linhas, registros, formas), avisados no logger do pipeline, gravados no `observations.parquet` por `ingest` e lidos por `build`; o `nz-dia-births` não importa `logging`. +2. `manifest.json` tem `data_license`, e cada fonte tem `name`, `observations` e `dropped`; `SCHEMA_VERSION` é `1.3.0`; `files` não lista os arquivos de publicação. +3. `ATTRIBUTION.txt` contém, para cada fonte usada, o `attribution` de `sources.yaml` sem edição, e contém o aviso de condições da Statistics Canada se e somente se uma fonte `statcan-open-licence` estiver na release; licença sem entrada na tabela faz o build falhar; o aviso do README é igual ao da tabela. +4. `DATA_LICENSE` tem a linha de atribuição do dataset, e a cópia na release é idêntica ao arquivo do repositório. +5. `DATASET_CARD.md` tem as seções de R6, com os números do manifesto, as fontes sem acento, os descartes, a nota sobre `group_id`, a data do Wikidata e, com a Statistics Canada, os dois avisos. +6. `sha256sum -c SHA256SUMS` passa no diretório da release; `by_country.zip` tem os mesmos bytes de `by_country/`; dois builds iguais dão `SHA256SUMS` iguais. +7. `tools/release.sh build` roda localmente sobre as amostras das fontes e produz a release verificada; o passo de envio, testado com um `gh` falso, recusa release fora de CalVer, release publicada e ausência de rascunho, envia sem `--clobber` só os assets de R5.4 e remove `source-cache.tar.gz` depois do envio; nenhum teste chama o `gh` real. +8. O workflow de release não contém publicação de release nem push de tag; o disparo manual é ensaio; a CI roda o ensaio em todo PR. +9. `.gitignore` sem as sobras da issue #5, e um Parquet em `tests/fixtures/` não é ignorado. +10. A decisão sobre `legacy/` está no ADR 0015, e a lista exata dos arquivos a remover está na descrição do PR, aguardando aprovação. + +## Fora de escopo + +- Publicar uma release ou criar uma tag: a primeira release real precisa da aprovação explícita do dono e do download manual da SSA e da Nova Zelândia. +- Entradas de benchmark (`*.input.json`, amostras por tamanho): issue #24, sobre o contrato da spec 001 do tools-dataset. +- Versionar `data/by_country/*.csv` no git (spec 001, R4): depende da primeira release real e vira issue própria. +- Remover `legacy/` sem a aprovação do dono (R10). +- `CITATION.cff` e DOI (Zenodo). diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index ac15a73..3d95a4c 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -15,7 +15,7 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 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 | 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)), PR #41 | Em PR | +| 6 | [`006-publicacao`](006-publicacao/spec.md) | 005, 007 | Releases, manifesto, README, cartão do dataset, atribuições, retirada do legado (issues #5, #22 e #32) | 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 | Mesclada | -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). +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), [0014 processo de release](../adr/0014-processo-de-release.md), [0015 retirada do legado](../adr/0015-retirada-do-legado.md). From da1d24382e845e15c7c4cbeb73d18bba070ce997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:20:14 -0300 Subject: [PATCH 02/12] docs(spec): add plan and tasks for spec 006 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/006-publicacao/plan.md | 80 ++++++++++++++++++++++++++++++ docs/specs/006-publicacao/tasks.md | 77 ++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 docs/specs/006-publicacao/plan.md create mode 100644 docs/specs/006-publicacao/tasks.md diff --git a/docs/specs/006-publicacao/plan.md b/docs/specs/006-publicacao/plan.md new file mode 100644 index 0000000..8e39362 --- /dev/null +++ b/docs/specs/006-publicacao/plan.md @@ -0,0 +1,80 @@ +# 006. Publicação: plano de implementação + +**Objetivo:** fazer o build gerar uma release publicável (atribuições, licença, cartão, zip por país e somas de verificação), registrar os descartes por fonte no manifesto e criar o workflow que monta a release a partir do cache sem publicá-la. + +**Arquitetura:** o canal de descarte é um tipo novo do protocolo do adaptador (`RawDrop`), contado pelo pipeline e guardado nos metadados do `observations.parquet`. Um módulo novo, `namescommons.publication`, gera os textos e arquivos de publicação (tabela de licenças e avisos, `ATTRIBUTION.txt`, `DATASET_CARD.md`, `by_country.zip`, `SHA256SUMS`) a partir do manifesto, de `sources.yaml` e das linhas por país. `cli._build` chama esse módulo dentro do mesmo diretório temporário de hoje. A release no GitHub é montada por `tools/release.sh`, chamado por `.github/workflows/release.yml` e pela CI. + +**Stack:** a mesma; nenhuma dependência nova (`zipfile`, `hashlib` e `json` da biblioteca padrão; `datasettool.files.sha256_file` do kit). + +**Spec:** [`spec.md`](spec.md). ADRs: [0014](../../adr/0014-processo-de-release.md), [0015](../../adr/0015-retirada-do-legado.md). + +## Restrições globais + +- As saídas da spec 001 não mudam de conteúdo, exceto `manifest.json` (R2) e os metadados chave-valor do `observations.parquet` (R1.4). +- `SCHEMA_VERSION = "1.3.0"`. +- Textos da release (`ATTRIBUTION.txt`, `DATASET_CARD.md`, preâmbulo de `DATA_LICENSE`) em inglês, como o README; mensagens da CLI e do script em português. +- Nenhum teste chama o `gh` real nem a rede; o script de release é testado com um `gh` falso no `PATH`. +- Nenhuma tag, release ou rascunho é criado no GitHub por esta spec. +- `legacy/` não é tocado (R10). + +## Decisões de implementação + +| Decisão | Motivo | Custo se estiver errada | +|---|---|---| +| Descarte como `RawDrop` no iterador de `parse`, e não uma função extra do módulo | O adaptador decide o descarte linha a linha, no mesmo laço; não precisa de estado global nem de segunda leitura do arquivo | Se um adaptador futuro precisar descartar após ler tudo, emite os `RawDrop` no fim; nada muda no pipeline | +| Descartes do `ingest` para o `build` nos metadados do Parquet (`namescommons.dropped`) | `ingest` e `build` são comandos separados; um arquivo lateral se perderia ao mover o Parquet | Metadados chave-valor não aparecem em leitores que só mostram colunas; o manifesto e o cartão os expõem | +| `dropped: null` quando o Parquet não traz a chave | Zero seria afirmar que não houve descarte | Um consumidor precisa tratar `null` | +| `files` do manifesto só com as tabelas | O significado de `files` fica igual (R2.3) e o cartão, gerado depois do manifesto, não cria dependência circular | Os arquivos de publicação só têm soma em `SHA256SUMS` | +| `by_country.zip` além do diretório `by_country/` | Releases do GitHub não aceitam diretórios; o diretório continua para quem usa a saída local | Os CSV por país aparecem duas vezes no disco | +| Zip com data, permissões e ordem fixas e `deflate` nível 9 | Reprodutibilidade (R5.6) | Outra versão do zlib pode comprimir diferente; o conteúdo descompactado continua idêntico ao do diretório | +| `ATTRIBUTION.txt` em texto puro, e não Markdown | É o nome e o formato que a seção 5 do plano da spec 001 do tools-dataset espera | Sem links clicáveis no GitHub | +| Tabela de licenças com uma entrada por licença aceita, conferida por teste contra `LICENCAS_COMPATIVEIS` | Licença nova no ADR 0001 sem nome e URL quebra o teste antes de quebrar uma release | Uma entrada a mais para manter por licença | +| Cartão e atribuições gerados no build, não versionados no repositório | Não podem divergir de `sources.yaml` nem dos números da release | Quem lê o repositório sem release não vê o cartão; o README remete à release | +| `--data-license` com padrão `DATA_LICENSE` relativo ao diretório atual | Mesmo padrão de `--sources`; a CI e o script rodam da raiz | Rodar de outro diretório exige a opção | +| Lógica de release em `tools/release.sh`, com o YAML só chamando subcomandos | Testável com `gh` falso e reproduzível localmente (ADR 0014) | Um script de shell a mais para manter | + +## Foco de revisão + +1. **Atribuição sem edição:** o `attribution` da Statistics Canada aparece byte a byte em `ATTRIBUTION.txt` e no cartão (Task 3: `test_atribuicao_reproduz_o_texto_sem_edicao`; Task 4: `test_cartao_traz_os_avisos_da_statcan`). +2. **Aviso de condições só com a Statistics Canada:** presente com a fonte, ausente sem ela (Task 3: `test_aviso_de_condicoes_so_com_statcan`). +3. **Descartes desconhecidos versus zero:** Parquet antigo dá `null`, fonte sem descarte dá zero (Task 2: `test_build_sem_metadados_deixa_dropped_nulo`). +4. **Envio nunca sobrescreve nem publica:** o `gh` falso registra os argumentos (Task 6: `test_upload_sem_clobber_e_remove_o_cache_depois`, `test_upload_recusa_release_publicada`). +5. **Determinismo:** dois builds com `SHA256SUMS` iguais (Task 5: `test_dois_builds_iguais`). + +## Arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `src/namescommons/adapters/__init__.py` | `RawDrop` | 1 | +| `src/namescommons/adapters/nz_dia_births.py` | Emite `RawDrop` em vez de logar | 1 | +| `src/namescommons/pipeline.py` | Conta e avisa os descartes (`Drops`) | 1 | +| `src/namescommons/io.py` | Descartes nos metadados do Parquet | 1 | +| `src/namescommons/manifest.py`, `src/namescommons/schema.py` | Campos novos do manifesto, `SCHEMA_VERSION` | 2 | +| `src/namescommons/cli.py` | Descartes de `ingest` para `build`, `--data-license`, arquivos de publicação | 1, 2, 5 | +| `src/namescommons/publication.py` | Licenças, avisos, atribuições, cartão, zip, somas | 3, 4, 5 | +| `DATA_LICENSE` | Preâmbulo com a linha de atribuição | 3 | +| `tools/release.sh`, `.github/workflows/release.yml`, `.github/workflows/ci.yml` | Release e ensaio | 6 | +| `.gitignore` | Limpeza | 7 | +| `README.md`, `AGENTS.md`, `docs/specs/001-esquema/spec.md`, `docs/specs/ROADMAP.md` | Documentação | 8 | +| Testes: `tests/test_descartes.py`, `tests/test_publicacao.py`, `tests/test_release.py` e ajustes em `tests/test_nz_dia_births.py`, `tests/test_cli.py`, `tests/test_io.py`, `tests/test_links.py`, `tests/test_cli_links.py`, `tests/test_cli_pipeline.py`, `tests/test_ci.py` | | 1 a 7 | + +## Cobertura requisito -> tarefa + +| Requisito | Tarefa | +|---|---| +| R1 | 1 | +| R2 | 2 | +| R3, R4 | 3 | +| R6 | 4 | +| R5 | 5 | +| R7 | 6 | +| R9 | 7 | +| R8 | 8 | +| R10 | 9 (aguarda aprovação do dono) | +| Critérios 1 e 2 | 1, 2 | +| Critérios 3 e 4 | 3 | +| Critério 5 | 4 | +| Critério 6 | 5 | +| Critérios 7 e 8 | 6 | +| Critério 9 | 7 | +| Critério 10 | 9 | diff --git a/docs/specs/006-publicacao/tasks.md b/docs/specs/006-publicacao/tasks.md new file mode 100644 index 0000000..a5f9ddd --- /dev/null +++ b/docs/specs/006-publicacao/tasks.md @@ -0,0 +1,77 @@ +# 006. Publicação: tarefas + +Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na raiz do repositório, a partir de `master` em `4cb2c5f`, com `uv sync --locked` e `uv run pytest -q` verdes antes da Task 1. + +Cada tarefa segue TDD: escrever os testes, ver falhar com `uv run pytest -q `, mudar o código, ver passar, rodar a suíte inteira e fazer um commit. + +--- + +### Task 1: canal de descarte + +**Arquivos:** `src/namescommons/adapters/__init__.py`, `src/namescommons/adapters/nz_dia_births.py`, `src/namescommons/pipeline.py`, `src/namescommons/io.py`, `src/namescommons/cli.py`; testes `tests/test_descartes.py`, `tests/test_nz_dia_births.py`. + +- [ ] Testes: `test_nz_emite_raw_drop_para_nomes_corrompidos` (três `RawDrop` da amostra, 49 registros) e `test_nz_nao_usa_logging`; `test_pipeline_conta_descartes_por_fonte` (linhas, registros, formas), `test_pipeline_avisa_descartes_no_logger`, `test_descarte_invalido_e_erro`, `test_numeracao_de_registros_ignora_descartes`; `test_ingest_grava_descartes_no_parquet` (inclusive zero), `test_parquet_sem_metadados_da_descartes_desconhecidos`. +- [ ] `RawDrop`; `Drops` e `ingest_sources(..., drops=)` no pipeline; `write_observations(..., dropped=)` e `read_dropped` em `io`; `ingest` grava. +- [ ] Suíte verde; commit `feat(pipeline): count records dropped by adapters per source`. + +### Task 2: manifesto + +**Arquivos:** `src/namescommons/manifest.py`, `src/namescommons/schema.py`, `src/namescommons/cli.py`; testes `tests/test_io.py`, `tests/test_descartes.py`, `tests/test_links.py`, `tests/test_cli_links.py`, `tests/test_cli_pipeline.py`. + +- [ ] Testes: `test_manifest_traz_nome_observacoes_e_descartes`, `test_manifest_traz_data_license`, `test_build_le_descartes_do_parquet`, `test_build_sem_metadados_deixa_dropped_nulo`, `test_run_grava_descartes_no_manifesto`; versão `1.3.0` nos testes existentes. +- [ ] `build_manifest(..., observations=, dropped=)`; `SCHEMA_VERSION = "1.3.0"`. +- [ ] Suíte verde; commit `feat(manifest): record per-source observations and dropped records`. + +### Task 3: licenças, atribuições e `DATA_LICENSE` + +**Arquivos:** `src/namescommons/publication.py`, `DATA_LICENSE`; teste `tests/test_publicacao.py`. + +- [ ] Testes: `test_tabela_cobre_as_licencas_aceitas`, `test_atribuicao_reproduz_o_texto_sem_edicao`, `test_atribuicao_em_ordem_de_id_so_com_fontes_usadas`, `test_aviso_de_condicoes_so_com_statcan`, `test_licenca_sem_entrada_falha`, `test_aviso_do_readme_igual_ao_da_tabela`, `test_data_license_tem_a_linha_de_atribuicao`, `test_sem_simbolos_da_statcan` (nenhum arquivo de `assets/` nem texto do README cita brasão ou logotipo da Statistics Canada). +- [ ] `LICENCAS`, `AVISOS`, `dataset_attribution(release)`, `attribution_text(release, sources, used)`; preâmbulo em `DATA_LICENSE`. +- [ ] Suíte verde; commit `feat(publication): generate the attribution file from sources.yaml`. + +### Task 4: cartão do dataset + +**Arquivos:** `src/namescommons/publication.py`; teste `tests/test_publicacao.py`. + +- [ ] Testes: `test_cartao_tem_as_secoes`, `test_cartao_usa_os_numeros_do_manifesto`, `test_cartao_lista_fontes_sem_acento_e_descartes`, `test_cartao_traz_os_avisos_da_statcan`, `test_cartao_sem_statcan_nao_traz_avisos`, `test_cartao_data_do_wikidata`. +- [ ] `dataset_card(manifest, sources, countries, index_counts, links)`. +- [ ] Suíte verde; commit `feat(publication): generate the dataset card`. + +### Task 5: pacote da release + +**Arquivos:** `src/namescommons/publication.py`, `src/namescommons/cli.py`; testes `tests/test_publicacao.py`, `tests/test_cli.py`. + +- [ ] Testes: `test_build_gera_todas_as_saidas` (lista nova), `test_sha256sums_verifica` (formato GNU, cobre o nível de cima, sem ele mesmo), `test_zip_tem_os_mesmos_bytes`, `test_dois_builds_iguais`, `test_copia_de_data_license_identica`, `test_data_license_ausente_falha_sem_saida`. +- [ ] `write_by_country_zip`, `write_sha256sums`; `_build` grava `DATA_LICENSE`, `ATTRIBUTION.txt`, `DATASET_CARD.md`, o zip e as somas; `--data-license` em `build` e `run`. +- [ ] Suíte verde; commit `feat(cli): write the publication files with every build`. + +### Task 6: script e workflow de release + +**Arquivos:** `tools/release.sh`, `.github/workflows/release.yml`, `.github/workflows/ci.yml`; teste `tests/test_release.py`. + +- [ ] Testes (com `gh` falso no `PATH` que registra os argumentos): `test_build_local_sobre_as_amostras`, `test_release_fora_de_calver_e_recusada`, `test_fetch_cache_sem_rascunho_falha`, `test_upload_recusa_release_publicada`, `test_upload_sem_clobber_e_remove_o_cache_depois`, `test_upload_envia_so_os_arquivos_do_nivel_de_cima`; `test_workflow_nao_publica_nem_cria_tag`, `test_dispatch_e_ensaio`, `test_ci_roda_o_ensaio`. +- [ ] `tools/release.sh` com `build`, `fetch-cache` e `upload`; `release.yml`; passo de ensaio na CI. +- [ ] Suíte verde; commit `ci(release): build releases from a draft's source cache`. + +### Task 7: `.gitignore` + +**Arquivos:** `.gitignore`; teste `tests/test_ci.py`. + +- [ ] Testes: `test_gitignore_sem_linhas_em_branco_duplas`, `test_parquet_de_fixture_nao_e_ignorado` (`git check-ignore`), `test_parquet_na_raiz_continua_ignorado`. +- [ ] Limpeza de R9. +- [ ] Suíte verde; commit `chore(gitignore): clean up leftovers and allow Parquet fixtures`. + +### Task 8: documentação e verificação + +**Arquivos:** `README.md`, `AGENTS.md`, `docs/specs/001-esquema/spec.md`, `docs/specs/ROADMAP.md`, esta pasta. + +- [ ] README (layout, Releases, ensaio local); `AGENTS.md` (procedimento de release); notas na spec 001. +- [ ] Verificar: `uv sync --locked`, `uv run pytest -q`, os passos offline da CI, `tools/release.sh build` sobre as amostras com `sha256sum -c`, e dois builds com `SHA256SUMS` iguais. +- [ ] Commit `docs(release): document the release procedure`. + +### Task 9: retirada de `legacy/` (aguarda aprovação) + +Bloqueada: apaga arquivos versionados (ADR 0015). Não executar sem a aprovação explícita do dono sobre a lista da descrição do PR. Acompanhamento numa issue própria. + +- [ ] `git rm -r legacy/`; `tests/test_legacy.py` confere a ausência; README e spec 001 atualizados. From 978b422c0187c704aa1e42af4b98de8a742842a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:22:00 -0300 Subject: [PATCH 03/12] feat(pipeline): count records dropped by adapters per source Adapters report dropped records as RawDrop items in the parse stream. The pipeline counts lines, records and forms per source, warns through its own logger and ingest stores the counts in the observations.parquet metadata. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/adapters/__init__.py | 16 +++- src/namescommons/adapters/nz_dia_births.py | 21 ++--- src/namescommons/cli.py | 18 ++-- src/namescommons/io.py | 25 +++++- src/namescommons/pipeline.py | 65 +++++++++++++-- tests/test_ci.py | 2 +- tests/test_descartes.py | 95 ++++++++++++++++++++++ tests/test_nz_dia_births.py | 46 ++++++----- 8 files changed, 234 insertions(+), 54 deletions(-) create mode 100644 tests/test_descartes.py diff --git a/src/namescommons/adapters/__init__.py b/src/namescommons/adapters/__init__.py index b5ca723..f812433 100644 --- a/src/namescommons/adapters/__init__.py +++ b/src/namescommons/adapters/__init__.py @@ -1,10 +1,11 @@ """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], +SOURCE_ID, RESOURCES (lista de Resource) e parse(files) -> Iterator[RawRecord | RawDrop], 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. +Registro publicado e descartado por regra documentada (ADR 0007) sai como RawDrop (spec 006, R1). 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 @@ -21,7 +22,7 @@ ) __all__ = [ - "DEFAULT_PACKAGE", "OPTIONAL", "AdapterError", "FormatError", "RawLink", "RawRecord", "Resource", + "DEFAULT_PACKAGE", "OPTIONAL", "AdapterError", "FormatError", "RawDrop", "RawLink", "RawRecord", "Resource", "SourceFiles", "check_adapter", "load_adapter", "module_name", "parse_code", "parse_count", ] @@ -41,6 +42,17 @@ class RawRecord: count: int | None = None +@dataclass(frozen=True) +class RawDrop: + """Registro publicado pela fonte e descartado pelo adaptador (spec 006, R1.1). + + count é a contagem publicada da linha descartada, ou None se a fonte não traz contagem. + """ + + name: str + count: int | None = None + + @dataclass(frozen=True) class RawLink: """Ligação entre dois nomes como a fonte publica (spec 004, R4.1); o pipeline a transforma em VariantLink.""" diff --git a/src/namescommons/adapters/nz_dia_births.py b/src/namescommons/adapters/nz_dia_births.py index 868d9dc..e9ca148 100644 --- a/src/namescommons/adapters/nz_dia_births.py +++ b/src/namescommons/adapters/nz_dia_births.py @@ -5,8 +5,8 @@ Codificação (spec 003e, R2, e ADR 0007): se o arquivo inteiro for UTF-8 válido, é lido como UTF-8. Senão, os bytes acima de 0x7F passam pela tabela _LEGADO, medida no arquivo real de 2026-10-12; -qualquer outro byte vira U+FFFD. Nome com '?' ou U+FFFD é descartado, e os descartes são avisados -no logger deste módulo ao fim do arquivo. Um byte só entra em _LEGADO com evidência registrada na spec. +qualquer outro byte vira U+FFFD. Nome com '?' ou U+FFFD é descartado e sai como RawDrop, que o +pipeline conta e avisa (spec 006, R1). Um byte só entra em _LEGADO com evidência registrada na spec. Download: o catálogo (Imperva) devolve uma página HTML de desafio a clientes que não são navegador, e o fetch falha (spec 003e, R4). Abra https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time @@ -16,11 +16,10 @@ import csv import io -import logging from collections.abc import Iterator from pathlib import Path -from namescommons.adapters import FormatError, RawRecord, Resource, parse_code, parse_count +from namescommons.adapters import FormatError, RawDrop, RawRecord, Resource, parse_code, parse_count SOURCE_ID = "nz-dia-births" _ARQUIVO = "baby-names-1900-to-2025.csv" @@ -41,8 +40,6 @@ _TABELA = {b: _LEGADO.get(b, "\ufffd") for b in range(0x80, 0x100)} # para str.translate sobre Latin-1 _CORROMPIDO = ("?", "\ufffd") -_log = logging.getLogger(__name__) - def _decodificar(dados: bytes) -> str: try: @@ -51,7 +48,7 @@ def _decodificar(dados: bytes) -> str: return dados.decode("latin-1").translate(_TABELA) -def parse(files: dict[str, Path]) -> Iterator[RawRecord]: +def parse(files: dict[str, Path]) -> Iterator[RawRecord | RawDrop]: caminho = files[_ARQUIVO] dados = caminho.read_bytes() if dados.lstrip().startswith(b"<"): @@ -65,7 +62,6 @@ def parse(files: dict[str, Path]) -> Iterator[RawRecord]: raise FormatError(f"{caminho.name}: arquivo vazio") if cabecalho != _CABECALHO: raise FormatError(f"{caminho.name}: cabeçalho {cabecalho}, esperado {_CABECALHO}") - descartadas, registros, formas = 0, 0, {} for linha, campos in enumerate(leitor, start=2): if len(campos) != len(_CABECALHO): raise FormatError(f"{caminho.name}, linha {linha}: esperava 4 campos, veio {len(campos)}: {campos!r}") @@ -78,13 +74,6 @@ def parse(files: dict[str, Path]) -> Iterator[RawRecord]: except FormatError as e: raise FormatError(f"{caminho.name}, linha {linha}: {e}") from e if any(c in nome for c in _CORROMPIDO): - descartadas += 1 - registros += registro.count - formas[nome] = None + yield RawDrop(nome, registro.count) continue yield registro - if descartadas: - _log.warning( - "%s: %d linha(s) com nome corrompido na fonte descartada(s), %d registro(s): %s", - SOURCE_ID, descartadas, registros, ", ".join(repr(f) for f in sorted(formas)), - ) diff --git a/src/namescommons/cli.py b/src/namescommons/cli.py index 1138f3c..bdb9507 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -13,11 +13,11 @@ from namescommons.adapters import DEFAULT_PACKAGE from namescommons.aggregate import AggregationError, by_country, name_index from namescommons.io import ( - read_links, read_observations, write_by_country, write_index, write_links, write_observations, + read_dropped, read_links, read_observations, write_by_country, write_index, write_links, write_observations, ) from namescommons.links import VariantLink, validate_links from namescommons.manifest import RELEASE, build_manifest -from namescommons.pipeline import PipelineError, fetch_sources, ingest_links, ingest_sources, select_sources +from namescommons.pipeline import Drops, PipelineError, fetch_sources, ingest_links, ingest_sources, select_sources from namescommons.schema import Observation, ValidationError, validate from namescommons.sources import Source, SourceError, load_sources @@ -102,6 +102,7 @@ def _saida_invalida(release: str, out: Path) -> str | None: def _build( obs: list[Observation], fontes: dict[str, Source], out: Path, release: str, links: list[VariantLink] | None = None, + dropped: dict[str, dict[str, int]] | None = None, ) -> int: """Grava as saídas da spec 001 (e variant_links da spec 004, se houver) em out, tudo ou nada; devolve o número de países. @@ -116,7 +117,7 @@ def _build( umask = os.umask(0) os.umask(umask) temporario.chmod(0o777 & ~umask) - write_observations(obs, temporario / "observations.parquet") + write_observations(obs, temporario / "observations.parquet", dropped) for kind, base in (("given", "given_names"), ("family", "family_names")): write_index(name_index(obs, paises, kind, fontes, links), temporario / f"{base}.csv", temporario / f"{base}.parquet") write_by_country(paises, temporario / "by_country") @@ -154,14 +155,18 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "fetch": print(f"OK: {_n(len(selecionadas), 'fonte', 'fontes')} em {args.cache}.") return 0 - obs = ingest_sources(selecionadas, fontes, args.cache, offline=args.offline, adapters=args.adapters) + descartes: dict[str, Drops] = {} + obs = ingest_sources( + selecionadas, fontes, args.cache, offline=args.offline, adapters=args.adapters, drops=descartes, + ) + dropped = {i: d.as_dict() for i, d in descartes.items()} links = None if args.cmd == "run" or args.links is not None: links = ingest_links(selecionadas, fontes, args.cache, offline=args.offline, adapters=args.adapters) if args.cmd == "ingest": if args.out.exists(): print(f"aviso: {args.out} já existe e será substituído", file=sys.stderr) - write_observations(obs, args.out) + write_observations(obs, args.out, dropped) mensagem = f"OK: {_n(len(obs), 'observação', 'observações')} de {_n(len(selecionadas), 'fonte', 'fontes')} em {args.out}" if args.links is not None: write_links(links or [], args.links) @@ -176,6 +181,7 @@ def main(argv: list[str] | None = None) -> int: except (OSError, ArrowException, TypeError, KeyError) as e: return _erro(f"não foi possível ler {args.observations}: {e}") validate(obs, fontes) + dropped = read_dropped(args.observations) links = None if getattr(args, "links", None) is not None: if not args.links.exists(): @@ -188,7 +194,7 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "validate": print(f"OK: {_n(len(obs), 'observação válida', 'observações válidas')}.") return 0 - paises = _build(obs, fontes, args.out, args.release, links) + paises = _build(obs, fontes, args.out, args.release, links, dropped) except ( PipelineError, SourceError, ValidationError, AggregationError, ValueError, OSError, ArrowException, ) as e: diff --git a/src/namescommons/io.py b/src/namescommons/io.py index e440849..82bc1eb 100644 --- a/src/namescommons/io.py +++ b/src/namescommons/io.py @@ -3,6 +3,7 @@ import csv import json import os +from collections.abc import Mapping from dataclasses import asdict, fields from pathlib import Path @@ -48,22 +49,40 @@ LOTE_DE_ESCRITA = 250_000 -def write_observations(observations: list[Observation], path: Path) -> None: +# Spec 006, R1.4: descartes por fonte nos metadados chave-valor do observations.parquet. +CHAVE_DESCARTES = b"namescommons.dropped" + + +def write_observations( + observations: list[Observation], path: Path, dropped: Mapping[str, Mapping[str, int]] | None = None, +) -> None: + """Grava as observações; dropped ({source_id: {"lines", "records"}}) vai para os metadados.""" + esquema = OBSERVATION_SCHEMA + if dropped is not None: + texto = json.dumps({k: dict(v) for k, v in dropped.items()}, sort_keys=True, separators=(",", ":")) + esquema = esquema.with_metadata({CHAVE_DESCARTES: texto.encode("utf-8")}) path.parent.mkdir(parents=True, exist_ok=True) # Grava ao lado e troca por rename: uma falha não deixa arquivo parcial nem apaga o anterior. temporario = path.with_name(path.name + ".part") try: - with pq.ParquetWriter(temporario, OBSERVATION_SCHEMA) as escritor: + with pq.ParquetWriter(temporario, esquema) as escritor: for inicio in range(0, max(len(observations), 1), LOTE_DE_ESCRITA): lote = observations[inicio:inicio + LOTE_DE_ESCRITA] # Colunar: evita um dicionário por observação (R8, spec 002). colunas = {campo: [getattr(o, campo) for o in lote] for campo in OBSERVATION_SCHEMA.names} - escritor.write_table(pa.Table.from_pydict(colunas, schema=OBSERVATION_SCHEMA)) + escritor.write_table(pa.Table.from_pydict(colunas, schema=esquema)) os.replace(temporario, path) finally: temporario.unlink(missing_ok=True) +def read_dropped(path: Path) -> dict[str, dict[str, int]] | None: + """Descartes gravados por write_observations; None se o arquivo não os traz (spec 006, R1.4).""" + metadados = pq.read_schema(path).metadata or {} + texto = metadados.get(CHAVE_DESCARTES) + return None if texto is None else json.loads(texto) + + def _valores(coluna: pa.ChunkedArray) -> list: """Valores Python da coluna; textos repetidos compartilham o mesmo objeto.""" if not pa.types.is_string(coluna.type): diff --git a/src/namescommons/pipeline.py b/src/namescommons/pipeline.py index 4196467..1b0efa3 100644 --- a/src/namescommons/pipeline.py +++ b/src/namescommons/pipeline.py @@ -1,20 +1,39 @@ """Pipeline de ingestão (spec 002, R1 e R5): fontes -> cache -> parse -> observações.""" -from collections.abc import Iterable -from dataclasses import replace +import logging +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, field, 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, Resource, SourceFiles, load_adapter, + DEFAULT_PACKAGE, AdapterError, FormatError, RawDrop, RawLink, RawRecord, Resource, SourceFiles, load_adapter, ) 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 +_log = logging.getLogger(__name__) + +# Formas listadas no aviso de descarte; as demais só entram na contagem. +_FORMAS_NO_AVISO = 20 + + +@dataclass +class Drops: + """Descartes de uma fonte (spec 006, R1.2): linhas, registros somados e formas distintas.""" + + lines: int = 0 + records: int = 0 + forms: set[str] = field(default_factory=set) + + def as_dict(self) -> dict[str, int]: + return {"lines": self.lines, "records": self.records} + + class PipelineError(Exception): """Falha de uma fonte em fetch ou ingest; a mensagem nomeia a fonte e a causa.""" @@ -184,6 +203,34 @@ def to_links(records: Iterable[RawLink], source: Source) -> list[VariantLink]: return sorted(unicas, key=VariantLink.ordem) +def _separar_descartes(itens: Iterable, descartes: Drops) -> Iterator: + """Spec 006, R1.2: conta os RawDrop e repassa o resto; a posição dos registros não inclui descartes.""" + posicao = 0 + for item in itens: + if not isinstance(item, RawDrop): + yield item + continue + posicao += 1 + if not isinstance(item.name, str): + raise RecordError(f"descarte {posicao}: 'name' deve ser texto, recebeu {type(item.name).__name__} {item.name!r}") + if item.count is not None and (type(item.count) is not int or item.count < 0): + raise RecordError(f"descarte {posicao}: 'count' deve ser inteiro não negativo, recebeu {item.count!r}") + descartes.lines += 1 + descartes.records += item.count or 0 + descartes.forms.add(item.name) + + +def _avisar_descartes(fonte: Source, d: Drops) -> None: + if not d.lines: + return + formas = sorted(d.forms) + lista = ", ".join(repr(f) for f in formas[:_FORMAS_NO_AVISO]) + (", ..." if len(formas) > _FORMAS_NO_AVISO else "") + _log.warning( + "%s: %d linha(s) descartada(s) pelo adaptador, %d registro(s), %d forma(s): %s", + fonte.id, d.lines, d.records, len(formas), lista, + ) + + 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) @@ -231,23 +278,31 @@ def ingest_sources( *, offline: bool = False, adapters: str = DEFAULT_PACKAGE, + drops: dict[str, Drops] | None = None, **opcoes, ) -> list[Observation]: - """Roda parse sobre o cache, aplica R1 e valida cada fonte (spec 001 e R3).""" + """Roda parse sobre o cache, aplica R1 e valida cada fonte (spec 001 e R3). + + Se drops for dado, recebe os descartes de cada fonte ingerida, inclusive zero (spec 006, R1). + """ observacoes: list[Observation] = [] for fonte in selected: + descartes = Drops() try: mod = load_adapter(fonte.id, adapters) arquivos = SourceFiles( (r.filename, _baixar(cache_dir, fonte, r, offline=offline, **opcoes)) for r in mod.RESOURCES ) - obs = to_observations(mod.parse(arquivos), fonte) + obs = to_observations(_separar_descartes(mod.parse(arquivos), descartes), fonte) if not obs: raise RecordError("nenhum registro produzido por parse") validate(obs, sources) except Exception as e: raise _falha(fonte, e) from e + _avisar_descartes(fonte, descartes) + if drops is not None: + drops[fonte.id] = descartes observacoes.extend(obs) return observacoes diff --git a/tests/test_ci.py b/tests/test_ci.py index fe2a232..cd6678f 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -169,7 +169,7 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): assert (tmp_path / "dist-oficiais" / "by_country" / "ES.csv").read_text(encoding="utf-8") == ES_CSV # Spec 003e, R6: a Nova Zelândia entra no passo, sem os nomes corrompidos na fonte. assert (tmp_path / "dist-oficiais" / "by_country" / "NZ.csv").read_text(encoding="utf-8") == NZ_CSV - assert "nz-dia-births: 3 linha(s) com nome corrompido" in r.stderr + assert "nz-dia-births: 3 linha(s) descartada(s) pelo adaptador, 49 registro(s)" in r.stderr # spec 006, R1.3 # Spec 004, R7: o Wikidata entra no passo e grava a tabela de ligações. assert "variant_links.parquet" in passo["run"] assert (tmp_path / "dist-oficiais" / "variant_links.parquet").stat().st_size > 0 diff --git a/tests/test_descartes.py b/tests/test_descartes.py new file mode 100644 index 0000000..4bdfcc6 --- /dev/null +++ b/tests/test_descartes.py @@ -0,0 +1,95 @@ +"""Spec 006, R1: canal de descarte do adaptador (RawDrop), contado por fonte e guardado no Parquet.""" + +import logging +import shutil +from pathlib import Path + +import pytest + +import namescommons.adapters.nz_dia_births as nz +from namescommons.adapters import RawDrop, RawRecord +from namescommons.cli import main +from namescommons.io import read_dropped, write_observations +from namescommons.pipeline import Drops, PipelineError, ingest_sources, select_sources +from namescommons.sources import load_sources + +RAIZ = Path(__file__).resolve().parent.parent +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fontes(): + return load_sources(RAIZ / "sources.yaml") + + +def _cache(tmp_path: Path, *ids: str) -> Path: + cache = tmp_path / "cache" + for i in ids: + shutil.copytree(FIXTURES / i, cache / i) + return cache + + +def _ingerir(fontes, cache, ids, drops): + return ingest_sources(select_sources(fontes, list(ids)), fontes, cache, offline=True, drops=drops) + + +def test_pipeline_conta_descartes_por_fonte(fontes, tmp_path): + cache = _cache(tmp_path, "nz-dia-births", "ie-cso-births") + drops: dict[str, Drops] = {} + _ingerir(fontes, cache, ["nz-dia-births", "ie-cso-births"], drops) + assert drops["nz-dia-births"].as_dict() == {"lines": 3, "records": 49} + assert drops["nz-dia-births"].forms == {"K�whai", "M?ia", "N�kau"} + assert drops["ie-cso-births"].as_dict() == {"lines": 0, "records": 0} + + +def test_pipeline_avisa_descartes_no_logger(fontes, tmp_path, caplog): + cache = _cache(tmp_path, "nz-dia-births", "ie-cso-births") + with caplog.at_level(logging.WARNING, logger="namescommons.pipeline"): + _ingerir(fontes, cache, ["nz-dia-births", "ie-cso-births"], {}) + avisos = [r.getMessage() for r in caplog.records if r.name == "namescommons.pipeline"] + assert avisos == [ + "nz-dia-births: 3 linha(s) descartada(s) pelo adaptador, 49 registro(s), 3 forma(s): " + "'K�whai', 'M?ia', 'N�kau'" + ] + + +@pytest.mark.parametrize("descarte, trecho", [ + (RawDrop(name=1, count=2), "descarte 1: 'name' deve ser texto"), + (RawDrop(name="M?ia", count=-1), "descarte 1: 'count' deve ser inteiro não negativo"), + (RawDrop(name="M?ia", count="3"), "descarte 1: 'count' deve ser inteiro não negativo"), + (RawDrop(name="M?ia", count=True), "descarte 1: 'count' deve ser inteiro não negativo"), +]) +def test_descarte_invalido_e_erro(fontes, tmp_path, monkeypatch, descarte, trecho): + monkeypatch.setattr(nz, "parse", lambda files: iter([RawRecord("Mia", "given", "NZ", "F", 2025, 10), descarte])) + with pytest.raises(PipelineError, match=f"fonte 'nz-dia-births': {trecho}"): + _ingerir(fontes, _cache(tmp_path, "nz-dia-births"), ["nz-dia-births"], {}) + + +def test_descarte_sem_contagem_conta_a_linha(fontes, tmp_path, monkeypatch): + monkeypatch.setattr(nz, "parse", lambda files: iter([RawDrop("M?ia", None), RawRecord("Mia", "given", "NZ", "F", 2025, 10)])) + drops: dict[str, Drops] = {} + _ingerir(fontes, _cache(tmp_path, "nz-dia-births"), ["nz-dia-births"], drops) + assert drops["nz-dia-births"].as_dict() == {"lines": 1, "records": 0} + + +def test_numeracao_de_registros_ignora_descartes(fontes, tmp_path, monkeypatch): + monkeypatch.setattr(nz, "parse", lambda files: iter([ + RawDrop("M?ia", 10), RawRecord("Mia", "given", "NZ", "F", 2025, 10), RawRecord("Ana", "outro", "NZ", "F", 2025, 10), + ])) + with pytest.raises(PipelineError, match="registro 2: kind 'outro'"): + _ingerir(fontes, _cache(tmp_path, "nz-dia-births"), ["nz-dia-births"], {}) + + +def test_ingest_grava_descartes_no_parquet(tmp_path): + cache = _cache(tmp_path, "nz-dia-births", "ie-cso-births") + out = tmp_path / "observations.parquet" + assert main(["ingest", "nz-dia-births", "ie-cso-births", "--offline", "--cache", str(cache), "--out", str(out)]) == 0 + assert read_dropped(out) == { + "ie-cso-births": {"lines": 0, "records": 0}, + "nz-dia-births": {"lines": 3, "records": 49}, + } + + +def test_parquet_sem_metadados_da_descartes_desconhecidos(observacoes, tmp_path): + write_observations(observacoes, tmp_path / "o.parquet") + assert read_dropped(tmp_path / "o.parquet") is None diff --git a/tests/test_nz_dia_births.py b/tests/test_nz_dia_births.py index 1d3f591..f8979e7 100644 --- a/tests/test_nz_dia_births.py +++ b/tests/test_nz_dia_births.py @@ -1,16 +1,14 @@ """Spec 003e: adaptador do DIA (Nova Zelândia) sobre a amostra real em tests/fixtures/nz-dia-births/.""" -import logging from pathlib import Path import pytest -from namescommons.adapters import FormatError, RawRecord, load_adapter +from namescommons.adapters import FormatError, RawDrop, RawRecord, load_adapter ARQUIVO = "baby-names-1900-to-2025.csv" AMOSTRA = Path(__file__).parent / "fixtures" / "nz-dia-births" / ARQUIVO CABECALHO = b"Year,Sex,Name,Count\r\n" -LOGGER = "namescommons.adapters.nz_dia_births" @pytest.fixture @@ -27,8 +25,12 @@ def _r(nome, sexo, ano, contagem): return RawRecord(nome, "given", "NZ", sexo, ano, contagem) +def _registros(dia, arquivos): + return [r for r in dia.parse(arquivos) if isinstance(r, RawRecord)] + + def test_parse_da_amostra_real(dia): - assert list(dia.parse({ARQUIVO: AMOSTRA})) == [ + assert _registros(dia, {ARQUIVO: AMOSTRA}) == [ _r("Aadhya", "F", 2022, 13), _r("André", "M", 2003, 20), # 0x82, CP437 _r("Anne-Marie", "F", 1967, 24), @@ -47,38 +49,40 @@ def test_parse_da_amostra_real(dia): ] -def test_nomes_corrompidos_sao_descartados_e_avisados(dia, caplog): - with caplog.at_level(logging.WARNING, logger=LOGGER): - nomes = [r.name for r in dia.parse({ARQUIVO: AMOSTRA})] - assert not any("?" in n or "\ufffd" in n for n in nomes) - avisos = [r for r in caplog.records if r.name == LOGGER] - assert len(avisos) == 1 and avisos[0].levelno == logging.WARNING - assert avisos[0].getMessage() == ( - "nz-dia-births: 3 linha(s) com nome corrompido na fonte descartada(s), 49 registro(s): " - "'K\ufffdwhai', 'M?ia', 'N\ufffdkau'" - ) +def test_nz_emite_raw_drop_para_nomes_corrompidos(dia): + """Spec 006, R1: os descartes do ADR 0007 saem como RawDrop, com a contagem publicada.""" + itens = list(dia.parse({ARQUIVO: AMOSTRA})) + descartes = [i for i in itens if isinstance(i, RawDrop)] + assert sorted(descartes, key=lambda d: d.name) == [ + RawDrop("K\ufffdwhai", 11), RawDrop("M?ia", 25), RawDrop("N\ufffdkau", 13), + ] + assert sum(d.count for d in descartes) == 49 + assert not any("?" in r.name or "\ufffd" in r.name for r in itens if isinstance(r, RawRecord)) + + +def test_nz_nao_usa_logging(): + fonte = Path(__file__).resolve().parent.parent / "src" / "namescommons" / "adapters" / "nz_dia_births.py" + assert "logging" not in fonte.read_text(encoding="utf-8") -def test_sem_descarte_nao_avisa(dia, tmp_path, caplog): - with caplog.at_level(logging.WARNING, logger=LOGGER): - assert list(dia.parse(_arquivos(tmp_path, CABECALHO + b"2025,F,Zo\xe9,10\r\n"))) == [_r("Zoé", "F", 2025, 10)] - assert not [r for r in caplog.records if r.name == LOGGER] +def test_sem_descarte_nao_emite_raw_drop(dia, tmp_path): + assert list(dia.parse(_arquivos(tmp_path, CABECALHO + b"2025,F,Zo\xe9,10\r\n"))) == [_r("Zoé", "F", 2025, 10)] def test_arquivo_utf8_valido_e_lido_como_utf8(dia, tmp_path): conteudo = CABECALHO + "2025,F,Māia,25\r\n2025,F,Zoé,10\r\n".encode("utf-8") - assert [r.name for r in dia.parse(_arquivos(tmp_path, conteudo))] == ["Māia", "Zoé"] + assert [r.name for r in _registros(dia, _arquivos(tmp_path, conteudo))] == ["Māia", "Zoé"] def test_utf8_com_caractere_de_substituicao_tambem_e_descartado(dia, tmp_path): conteudo = CABECALHO + "2025,F,Am\ufffdlie,10\r\n2025,F,Ana,10\r\n".encode("utf-8") - assert [r.name for r in dia.parse(_arquivos(tmp_path, conteudo))] == ["Ana"] + assert [r.name for r in _registros(dia, _arquivos(tmp_path, conteudo))] == ["Ana"] @pytest.mark.parametrize("byte", [b"\xe1", b"\x8d", b"\xff"]) def test_byte_fora_da_tabela_descarta_o_nome(dia, tmp_path, byte): conteudo = CABECALHO + b"2025,F,M" + byte + b"ia,25\r\n2025,F,Mia,125\r\n" - assert [r.name for r in dia.parse(_arquivos(tmp_path, conteudo))] == ["Mia"] + assert [r.name for r in _registros(dia, _arquivos(tmp_path, conteudo))] == ["Mia"] @pytest.mark.parametrize("conteudo, trecho", [ From 5544b80dacc4ec4ca9a8b8dd6f84404045b74bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:22:51 -0300 Subject: [PATCH 04/12] feat(manifest): record per-source observations and dropped records Each manifest source gains name, observations and dropped (null when the observations file predates the drop metadata), and the manifest gains data_license. SCHEMA_VERSION goes to 1.3.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/cli.py | 4 ++- src/namescommons/manifest.py | 20 +++++++++++++-- src/namescommons/schema.py | 4 ++- tests/test_cli_links.py | 2 +- tests/test_cli_pipeline.py | 5 +++- tests/test_descartes.py | 47 +++++++++++++++++++++++++++++++++++- tests/test_io.py | 25 ++++++++++++++++++- tests/test_links.py | 2 +- 8 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/namescommons/cli.py b/src/namescommons/cli.py index bdb9507..1621679 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -2,6 +2,7 @@ import argparse import json +from collections import Counter import os import shutil import sys @@ -125,7 +126,8 @@ def _build( write_links(links, temporario / "variant_links.parquet") # Spec 005, R7.4: fontes das observações e das ligações. usadas = {o.source_id for o in obs} | {v.source_id for v in links or ()} - manifest = build_manifest(release, temporario, fontes, usadas) + por_fonte = Counter(o.source_id for o in obs) + manifest = build_manifest(release, temporario, fontes, usadas, observations=por_fonte, dropped=dropped) (temporario / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) diff --git a/src/namescommons/manifest.py b/src/namescommons/manifest.py index 2029728..d819207 100644 --- a/src/namescommons/manifest.py +++ b/src/namescommons/manifest.py @@ -5,6 +5,7 @@ """ import re +from collections.abc import Mapping from pathlib import Path import pyarrow.parquet as pq @@ -14,6 +15,7 @@ from namescommons.sources import Source RELEASE = re.compile(r"\d{4}\.(0[1-9]|1[0-2])(\.[1-9]\d*)?") +DATA_LICENSE_SPDX = "CC-BY-4.0" # ADR 0001; spec 006, R2.2 def _linhas_parquet(path: Path) -> int: @@ -23,7 +25,16 @@ def _linhas_parquet(path: Path) -> int: CONTADORES = {".csv": count_csv_records, ".parquet": _linhas_parquet} -def build_manifest(release: str, out_dir: Path, sources: dict[str, Source], used: set[str]) -> dict: +def build_manifest( + release: str, + out_dir: Path, + sources: dict[str, Source], + used: set[str], + observations: Mapping[str, int] | None = None, + dropped: Mapping[str, Mapping[str, int]] | None = None, +) -> dict: + """Manifesto da release. observations conta as linhas por fonte; dropped traz os descartes + por fonte (spec 006, R2.1), e fonte ausente de dropped (ou dropped None) fica com null.""" if not RELEASE.fullmatch(release): raise ValueError(f"release '{release}' fora do formato AAAA.MM[.N]") desconhecidas = sorted(used - set(sources)) @@ -32,8 +43,13 @@ def build_manifest(release: str, out_dir: Path, sources: dict[str, Source], used return { "release": release, "schema_version": SCHEMA_VERSION, + "data_license": DATA_LICENSE_SPDX, "sources": [ - {"id": s.id, "license": s.license, "retrieved": s.retrieved.isoformat()} + { + "id": s.id, "name": s.name, "license": s.license, "retrieved": s.retrieved.isoformat(), + "observations": (observations or {}).get(s.id, 0), + "dropped": None if dropped is None or s.id not in dropped else dict(dropped[s.id]), + } for s in sorted((sources[i] for i in used), key=lambda s: s.id) ], "files": describe_files(out_dir, counters=CONTADORES), diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index 1e05009..9012a07 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -12,7 +12,9 @@ # Jpan, Hrkt e Kore; partículas e nova forma de exibição. ingest e build de uma release devem usar # a mesma versão: validate confere script por igualdade com detect_script(name) e ascii só como # ASCII em minúsculas (não recalcula a transliteração). -SCHEMA_VERSION = "1.2.0" +# 1.3.0 (spec 006, R2.4): data_license e, por fonte, name, observations e dropped no manifesto; +# arquivos de publicação (DATA_LICENSE, ATTRIBUTION.txt, DATASET_CARD.md, by_country.zip, SHA256SUMS). +SCHEMA_VERSION = "1.3.0" KINDS = ("given", "family") GENDERS = ("F", "M") ANO_MINIMO = 1800 diff --git a/tests/test_cli_links.py b/tests/test_cli_links.py index 10131de..50b30ed 100644 --- a/tests/test_cli_links.py +++ b/tests/test_cli_links.py @@ -33,7 +33,7 @@ def test_ingest_links_e_build_links(cache, tmp_path, capsys): "--out", str(dist), "--release", "2026.10"]) == 0 assert read_links(dist / "variant_links.parquet") == links manifest = json.loads((dist / "manifest.json").read_text(encoding="utf-8")) - assert manifest["schema_version"] == "1.2.0" + assert manifest["schema_version"] == "1.3.0" assert manifest["files"]["variant_links.parquet"]["rows"] == 47 diff --git a/tests/test_cli_pipeline.py b/tests/test_cli_pipeline.py index 0fcc5af..6e2d7a1 100644 --- a/tests/test_cli_pipeline.py +++ b/tests/test_cli_pipeline.py @@ -65,7 +65,10 @@ def test_criterio_1_fetch_ingest_build(exemplo_no_servidor, tmp_path, capsys): ("Santos", "", "0"), ("Silva", "", "0"), ] manifest = json.loads((dist / "manifest.json").read_text(encoding="utf-8")) - assert manifest["sources"] == [{"id": "exemplo", "license": "CC0-1.0", "retrieved": "2026-10-01"}] + assert manifest["sources"] == [{ + "id": "exemplo", "name": "Fonte de exemplo para testes", "license": "CC0-1.0", "retrieved": "2026-10-01", + "observations": 8, "dropped": {"lines": 0, "records": 0}, + }] def test_criterio_3_fetch_sem_refresh_nao_repete_requisicao(exemplo_no_servidor, tmp_path): diff --git a/tests/test_descartes.py b/tests/test_descartes.py index 4bdfcc6..22ef05a 100644 --- a/tests/test_descartes.py +++ b/tests/test_descartes.py @@ -1,15 +1,17 @@ """Spec 006, R1: canal de descarte do adaptador (RawDrop), contado por fonte e guardado no Parquet.""" +import json import logging import shutil from pathlib import Path import pytest +import yaml import namescommons.adapters.nz_dia_births as nz from namescommons.adapters import RawDrop, RawRecord from namescommons.cli import main -from namescommons.io import read_dropped, write_observations +from namescommons.io import read_dropped, read_observations, write_observations from namescommons.pipeline import Drops, PipelineError, ingest_sources, select_sources from namescommons.sources import load_sources @@ -93,3 +95,46 @@ def test_ingest_grava_descartes_no_parquet(tmp_path): def test_parquet_sem_metadados_da_descartes_desconhecidos(observacoes, tmp_path): write_observations(observacoes, tmp_path / "o.parquet") assert read_dropped(tmp_path / "o.parquet") is None + + +def _build(obs, out): + return main(["build", str(obs), "--out", str(out), "--release", "2026.10"]) + + +def _manifesto(out: Path) -> dict: + return json.loads((out / "manifest.json").read_text(encoding="utf-8")) + + +def test_build_le_descartes_do_parquet(tmp_path): + """Spec 006, R1.4 e R2.1: ingest grava, build lê e o manifesto mostra.""" + cache = _cache(tmp_path, "nz-dia-births") + obs = tmp_path / "observations.parquet" + assert main(["ingest", "nz-dia-births", "--offline", "--cache", str(cache), "--out", str(obs)]) == 0 + assert _build(obs, tmp_path / "dist") == 0 + (nz_,) = _manifesto(tmp_path / "dist")["sources"] + assert nz_["dropped"] == {"lines": 3, "records": 49} + assert nz_["observations"] == 15 + assert read_dropped(tmp_path / "dist" / "observations.parquet") == {"nz-dia-births": {"lines": 3, "records": 49}} + + +def test_build_sem_metadados_deixa_dropped_nulo(tmp_path): + cache = _cache(tmp_path, "nz-dia-births") + obs = tmp_path / "observations.parquet" + assert main(["ingest", "nz-dia-births", "--offline", "--cache", str(cache), "--out", str(obs)]) == 0 + write_observations(read_observations(obs), obs) # regrava sem metadados, como antes da spec 006 + assert _build(obs, tmp_path / "dist") == 0 + assert _manifesto(tmp_path / "dist")["sources"][0]["dropped"] is None + + +def test_run_grava_descartes_no_manifesto(tmp_path): + cache = _cache(tmp_path, "nz-dia-births", "ie-cso-births") + dados = yaml.safe_load((RAIZ / "sources.yaml").read_text(encoding="utf-8")) + dados["sources"] = [f for f in dados["sources"] if f["id"] in ("nz-dia-births", "ie-cso-births")] + sources = tmp_path / "sources.yaml" + sources.write_text(yaml.safe_dump(dados), encoding="utf-8") + out = tmp_path / "dist" + assert main(["run", "--offline", "--cache", str(cache), "--sources", str(sources), "--out", str(out), + "--release", "2026.10"]) == 0 + assert {s["id"]: s["dropped"] for s in _manifesto(out)["sources"]} == { + "ie-cso-births": {"lines": 0, "records": 0}, "nz-dia-births": {"lines": 3, "records": 49}, + } diff --git a/tests/test_io.py b/tests/test_io.py index 91be513..2a00f50 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -51,13 +51,36 @@ def test_manifest(observacoes, fontes, tmp_path): write_by_country(by_country(observacoes, fontes), tmp_path / "by_country") write_observations(observacoes, tmp_path / "observations.parquet") m = build_manifest("2026.10", tmp_path, fontes, {o.source_id for o in observacoes}) - assert m["schema_version"] == "1.2.0" + assert m["schema_version"] == "1.3.0" assert [s["id"] for s in m["sources"]] == ["br-ibge-censo", "us-births-b", "us-ssa", "wikidata"] assert m["files"]["observations.parquet"]["rows"] == len(observacoes) assert m["files"]["by_country/US.csv"]["rows"] == 4 assert len(m["files"]["by_country/BR.csv"]["sha256"]) == 64 +def test_manifest_traz_nome_observacoes_e_descartes(observacoes, fontes, tmp_path): + """Spec 006, R2.1: name, observations e dropped por fonte; dropped desconhecido é null.""" + write_observations(observacoes, tmp_path / "observations.parquet") + usadas = {o.source_id for o in observacoes} + contagem = {i: sum(o.source_id == i for o in observacoes) for i in usadas} + m = build_manifest("2026.10", tmp_path, fontes, usadas, observations=contagem, + dropped={"us-ssa": {"lines": 2, "records": 30}}) + por_id = {s["id"]: s for s in m["sources"]} + assert por_id["us-ssa"] == { + "id": "us-ssa", "name": "SSA", "license": "public-domain", "retrieved": "2026-10-01", + "observations": contagem["us-ssa"], "dropped": {"lines": 2, "records": 30}, + } + assert por_id["wikidata"]["dropped"] is None + assert por_id["wikidata"]["observations"] == 3 + + +def test_manifest_traz_data_license(fontes, tmp_path): + """Spec 006, R2.2 e R2.4.""" + m = build_manifest("2026.10", tmp_path, fontes, set()) + assert m["data_license"] == "CC-BY-4.0" + assert list(m) == ["release", "schema_version", "data_license", "sources", "files"] + + @pytest.mark.parametrize("release", ["2026-10", "2026.13", "26.10", "2026.10.0", "2026.1"]) def test_release_fora_do_formato(release, fontes, tmp_path): with pytest.raises(ValueError, match="AAAA.MM"): diff --git a/tests/test_links.py b/tests/test_links.py index bd2c24f..3a7ecd3 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -121,7 +121,7 @@ def test_ida_e_volta_em_parquet(tmp_path): def test_versao_do_esquema(): - assert SCHEMA_VERSION == "1.2.0" # 1.1.0 na spec 004; 1.2.0 na spec 005, R8 + assert SCHEMA_VERSION == "1.3.0" # 1.1.0 na spec 004; 1.2.0 na spec 005, R8; 1.3.0 na spec 006, R2.4 def _modulo(**extra): From a7d9c11e2fe5252c15c8a3ad7692691cfa82deca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:23:49 -0300 Subject: [PATCH 05/12] feat(publication): generate the attribution file from sources.yaml ATTRIBUTION.txt lists every source used in a release with its licence and its attribution text verbatim, followed by the notices some licences require (the Statistics Canada conditions notice). DATA_LICENSE gains the dataset's own attribution line. Co-Authored-By: Claude Opus 5.5 (1M context) --- DATA_LICENSE | 20 ++++++++ src/namescommons/publication.py | 91 +++++++++++++++++++++++++++++++++ tests/test_publicacao.py | 88 +++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/namescommons/publication.py create mode 100644 tests/test_publicacao.py diff --git a/DATA_LICENSE b/DATA_LICENSE index da6ab6c..69f5df7 100644 --- a/DATA_LICENSE +++ b/DATA_LICENSE @@ -1,3 +1,23 @@ +Names Commons Dataset: data licence + +The data published by this project, that is, the release files built by the +namescommons package from the sources declared in sources.yaml, is licensed +under the Creative Commons Attribution 4.0 International licence (CC BY 4.0), +reproduced below. The code is licensed under Apache-2.0 (see LICENSE). The +contents of legacy/ are not part of the dataset and are not covered by this +licence. + +Attribute the dataset as: + + Names Commons Dataset, release , BenchCommons (https://github.com/BenchCommons/dataset-names), licensed under CC BY 4.0. Upstream sources and notices: see ATTRIBUTION.txt. + +Each release carries ATTRIBUTION.txt with the attribution text and licence of +every upstream source, and the notices some of those licences require (for +example the Statistics Canada Open Licence). Keep them when you redistribute +the data. + +======================================================================= + Attribution 4.0 International ======================================================================= diff --git a/src/namescommons/publication.py b/src/namescommons/publication.py new file mode 100644 index 0000000..fab9fae --- /dev/null +++ b/src/namescommons/publication.py @@ -0,0 +1,91 @@ +"""Arquivos de publicação da release (spec 006): atribuições, cartão do dataset, zip e somas. + +Os textos são em inglês, como o README; ver ADR 0001 (licença dos dados) e ADR 0004 (avisos da +Statistics Canada). +""" + +from collections.abc import Iterable, Mapping + +from namescommons.sources import Source + +REPOSITORIO = "https://github.com/BenchCommons/dataset-names" +NOME_DO_DATASET = "Names Commons Dataset" + +# Spec 006, R3.4: nome e URL de cada licença aceita pelo ADR 0001 (ampliado pelo 0004). +LICENCAS: dict[str, tuple[str, str | None]] = { + "public-domain": ("Public domain", None), + "CC0-1.0": ("CC0 1.0 Universal", "https://creativecommons.org/publicdomain/zero/1.0/"), + "CC-BY-3.0": ("Creative Commons Attribution 3.0", "https://creativecommons.org/licenses/by/3.0/"), + "CC-BY-4.0": ("Creative Commons Attribution 4.0 International", "https://creativecommons.org/licenses/by/4.0/"), + "ODC-By-1.0": ("Open Data Commons Attribution License 1.0", "https://opendatacommons.org/licenses/by/1-0/"), + "OGL-UK-3.0": ( + "Open Government Licence v3.0", "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/", + ), + "etalab-2.0": ("Licence Ouverte / Open Licence 2.0 (Etalab)", "https://www.etalab.gouv.fr/licence-ouverte-open-licence/"), + "statcan-open-licence": ("Statistics Canada Open Licence", "https://www.statcan.gc.ca/en/reference/licence"), +} + +# Spec 006, R3.3: avisos que a licença exige além da atribuição, sem tradução (ADR 0004). +AVISOS: dict[str, str] = { + "statcan-open-licence": ( + "Data adapted from Statistics Canada remain subject to the Statistics Canada Open Licence " + "(https://www.statcan.gc.ca/en/reference/licence); users must not use them to identify individuals, " + "imply endorsement, misrepresent them, or use Statistics Canada or Government of Canada symbols." + ), +} + + +def dataset_attribution(release: str) -> str: + """Linha de atribuição do próprio dataset (spec 006, R4.1).""" + return ( + f"{NOME_DO_DATASET}, release {release}, BenchCommons ({REPOSITORIO}), licensed under CC BY 4.0. " + "Upstream sources and notices: see ATTRIBUTION.txt." + ) + + +def _licenca(fonte: Source) -> tuple[str, str | None]: + try: + return LICENCAS[fonte.license] + except KeyError: + raise ValueError( + f"licença '{fonte.license}' da fonte '{fonte.id}' sem entrada em LICENCAS de namescommons.publication" + ) from None + + +def _usadas(sources: Mapping[str, Source], used: Iterable[str]) -> list[Source]: + return [sources[i] for i in sorted(set(used))] + + +def notices(sources: Mapping[str, Source], used: Iterable[str]) -> list[str]: + """Avisos das licenças usadas, na ordem das fontes, sem repetição.""" + return list(dict.fromkeys(AVISOS[f.license] for f in _usadas(sources, used) if f.license in AVISOS)) + + +def attribution_text(release: str, sources: Mapping[str, Source], used: Iterable[str]) -> str: + """ATTRIBUTION.txt (spec 006, R3): linha do dataset, um bloco por fonte usada e os avisos.""" + fontes = _usadas(sources, used) + linhas = [ + f"Attribution for the {NOME_DO_DATASET}, release {release}", + "", + "Cite the dataset as:", + "", + f" {dataset_attribution(release)}", + "", + "The dataset is licensed under CC BY 4.0 (see DATA_LICENSE). It contains data adapted from the", + "sources below. The attribution each source requires is reproduced verbatim and must be kept", + "when the data is redistributed.", + "", + "Sources", + "=======", + ] + for f in fontes: + nome, url = _licenca(f) + linhas += ["", f.name, f"Id: {f.id}", f"URL: {f.url}", f"Licence: {nome}" + (f" ({url})" if url else ""), + f"Attribution: {f.attribution}"] + linhas += ["", "Notices", "======="] + avisos = notices(sources, [f.id for f in fontes]) + for aviso in avisos: + linhas += ["", aviso] + if not avisos: + linhas += ["", "No additional notices."] + return "\n".join(linhas) + "\n" diff --git a/tests/test_publicacao.py b/tests/test_publicacao.py new file mode 100644 index 0000000..4e43fec --- /dev/null +++ b/tests/test_publicacao.py @@ -0,0 +1,88 @@ +"""Spec 006, R3 a R6: atribuições, licença, cartão do dataset e pacote da release.""" + +from dataclasses import replace +from pathlib import Path + +import pytest + +from namescommons.publication import AVISOS, LICENCAS, attribution_text, dataset_attribution +from namescommons.sources import LICENCAS_COMPATIVEIS, load_sources + +RAIZ = Path(__file__).resolve().parent.parent +STATCAN = "ca-statcan-births" + + +@pytest.fixture +def reais(): + return load_sources(RAIZ / "sources.yaml") + + +# R3 e R4 ----------------------------------------------------------------------------------------- + +def test_tabela_cobre_as_licencas_aceitas(): + assert set(LICENCAS) == set(LICENCAS_COMPATIVEIS) + assert set(AVISOS) <= set(LICENCAS) + for nome, url in LICENCAS.values(): + assert nome and (url is None or url.startswith("https://")) + + +def test_atribuicao_reproduz_o_texto_sem_edicao(reais): + texto = attribution_text("2026.10", reais, set(reais)) + for fonte in reais.values(): + assert f"\nAttribution: {fonte.attribution}\n" in texto + assert f"\nURL: {fonte.url}\n" in texto + assert f"\nLicence: {LICENCAS[fonte.license][0]}" in texto + assert texto.startswith("Attribution for the Names Commons Dataset, release 2026.10\n") + assert dataset_attribution("2026.10") in texto + assert texto.endswith("\n") and "\r" not in texto and not texto.startswith("") + + +def test_atribuicao_em_ordem_de_id_so_com_fontes_usadas(reais): + texto = attribution_text("2026.10", reais, {"wikidata", "fr-insee-births"}) + assert texto.index("Id: fr-insee-births") < texto.index("Id: wikidata") + assert "us-ssa-births" not in texto + assert reais["us-ssa-births"].attribution not in texto + + +def test_aviso_de_condicoes_so_com_statcan(reais): + aviso = AVISOS["statcan-open-licence"] + com = attribution_text("2026.10", reais, {STATCAN, "wikidata"}) + sem = attribution_text("2026.10", reais, {"wikidata"}) + assert com.count(aviso) == 1 and com.index("Notices") < com.index(aviso) + assert aviso not in sem and "Statistics Canada" not in sem + + +def test_licenca_sem_entrada_falha(reais): + fontes = {**reais, "wikidata": replace(reais["wikidata"], license="licenca-nova")} + with pytest.raises(ValueError, match="licença 'licenca-nova' da fonte 'wikidata'"): + attribution_text("2026.10", fontes, {"wikidata"}) + + +def test_aviso_do_readme_igual_ao_da_tabela(): + """ADR 0004: o README traz o aviso de condições sem tradução, igual ao da release.""" + readme = (RAIZ / "README.md").read_text(encoding="utf-8") + assert AVISOS["statcan-open-licence"] in readme + assert AVISOS["statcan-open-licence"].startswith("Data adapted from Statistics Canada remain subject to") + + +def test_data_license_tem_a_linha_de_atribuicao(): + texto = (RAIZ / "DATA_LICENSE").read_text(encoding="utf-8") + assert dataset_attribution("") in texto + assert texto.index(dataset_attribution("")) < texto.index("\nAttribution 4.0 International\n") + assert "ATTRIBUTION.txt" in texto and "Apache-2.0" in texto and "legacy/" in texto + + +def test_linha_de_atribuicao_do_dataset(): + assert dataset_attribution("2026.10") == ( + "Names Commons Dataset, release 2026.10, BenchCommons (https://github.com/BenchCommons/dataset-names), " + "licensed under CC BY 4.0. Upstream sources and notices: see ATTRIBUTION.txt." + ) + + +def test_sem_simbolos_da_statcan(): + """R3.5: nenhum logotipo ou símbolo da Statistics Canada ou do Governo do Canadá no projeto.""" + proibidos = ("statcan", "canada", "maple", "wordmark", "coat-of-arms") + for imagem in (RAIZ / "assets").iterdir(): + assert not any(p in imagem.name.lower() for p in proibidos), imagem.name + readme = (RAIZ / "README.md").read_text(encoding="utf-8").lower() + assert "", 1)[0] for p in proibidos) From fcdb0ee2f6be01409eb9683522efd46b7ea65f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:25:33 -0300 Subject: [PATCH 06/12] feat(publication): generate the dataset card DATASET_CARD.md describes the files, fields, sources, coverage, suppression, known limitations, licence and citation of a release, from the manifest, sources.yaml and the per-country rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/publication.py | 212 ++++++++++++++++++++++++++++++++ tests/test_publicacao.py | 94 +++++++++++++- 2 files changed, 305 insertions(+), 1 deletion(-) diff --git a/src/namescommons/publication.py b/src/namescommons/publication.py index fab9fae..cdc3053 100644 --- a/src/namescommons/publication.py +++ b/src/namescommons/publication.py @@ -89,3 +89,215 @@ def attribution_text(release: str, sources: Mapping[str, Source], used: Iterable if not avisos: linhas += ["", "No additional notices."] return "\n".join(linhas) + "\n" + + +# Cartão do dataset (spec 006, R6) ------------------------------------------------------------------ + +_DESCRICOES = { + "observations.parquet": "Canonical observation table: one row per source, name, kind, country, gender and year.", + "given_names.csv": "One row per given name across all countries.", + "given_names.parquet": "Same as `given_names.csv`, with typed nested columns.", + "family_names.csv": "One row per family name across all countries.", + "family_names.parquet": "Same as `family_names.csv`, with typed nested columns.", + "variant_links.parquet": "Links between name variants published by Wikidata.", + "by_country.zip": "`by_country/.csv`, one file per country (ISO 3166-1 alpha-2).", + "manifest.json": "Release, schema version, sources, drop counts, and row count and SHA-256 of each table.", + "DATA_LICENSE": "CC BY 4.0 licence text and the dataset's attribution line.", + "ATTRIBUTION.txt": "Attribution text and licence of every upstream source, and required notices.", + "DATASET_CARD.md": "This card.", + "SHA256SUMS": "SHA-256 of every release asset, for `sha256sum -c SHA256SUMS`.", +} + +_CAMPOS = """### `given_names` and `family_names` + +| Field | Type | Meaning | +|---|---|---| +| `name` | string | Display form: the most frequent published form, in Unicode NFC | +| `name_key` | string | `casefold()` of `name`; the identity of the name, accents preserved | +| `ascii` | string | Lowercase ASCII transliteration (ICAO Doc 9303 tables); may be empty for scripts without a table | +| `script` | string | Predominant ISO 15924 script of the letters (`Latn`, `Cyrl`, `Hani`, `Jpan` ...) | +| `country_count` | integer | Number of countries that attest the name | +| `countries` | list | One `{country, count, count_type, rank}` per country; JSON in the CSV | +| `female_share` | decimal | F / (F + M) over the counts with gender; empty without a basis | +| `gender_basis` | integer | F + M used for `female_share` | +| `group_id` | string | Variant group: the smallest `name_key` in the group | +| `sources` | list | Sorted ids of the sources that attest the name; JSON in the CSV | + +### `by_country/.csv` + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `given` or `family` | +| `name`, `name_key`, `ascii`, `script` | string | As above | +| `count` | integer | Births summed over all years, or population in the latest edition; empty when only attested | +| `count_type` | string | `births`, `population` or `attested`; counts of different types are never added | +| `rank` | integer | Dense rank by `kind` and `count_type`, highest count first | +| `female_share`, `gender_basis` | decimal, integer | As above, for this country | +| `first_year`, `last_year` | integer | Years covered by the count | + +### `observations.parquet` + +`name`, `name_key`, `ascii`, `script`, `kind`, `country`, `gender` (`F`, `M` or empty, never inferred), `year`, `count`, `count_type` and `source_id`: what one source publishes for one name, kind, country, gender and year. + +### `variant_links.parquet` + +`source_id`, `relation`, `qid`, `name`, `name_key`, `kind`, `target_qid`, `target_name`, `target_name_key` and `target_kind`: one link between two Wikidata name items. + +The schema is defined in [spec 001](https://github.com/BenchCommons/dataset-names/blob/master/docs/specs/001-esquema/spec.md), with the links of spec 004 and the variant rules of spec 005.""" + + +def _n(valor: int) -> str: + return f"{valor:,}" + + +def _arquivos(files: Mapping[str, dict]) -> list[tuple[str, int | None]]: + por_pais = sum(v["rows"] for k, v in files.items() if k.startswith("by_country/")) + linhas = [(k, v["rows"]) for k, v in files.items() if not k.startswith("by_country/")] + linhas.append(("by_country.zip", por_pais)) + linhas += [(nome, None) for nome in ("manifest.json", "DATA_LICENSE", "ATTRIBUTION.txt", "DATASET_CARD.md", "SHA256SUMS")] + return linhas + + +def _descarte(d: Mapping[str, int] | None) -> str: + if d is None: + return "unknown" + if not d["lines"]: + return "0" + return f"{_n(d['lines'])} lines, {_n(d['records'])} records" + + +def _cobertura(linhas: list) -> list[str]: + saida = [] + for kind in ("given", "family"): + daqui = [r for r in linhas if r.kind == kind] + tipos = ", ".join(sorted({r.count_type for r in daqui})) + saida.append(f"{_n(len(daqui))} ({tipos})" if daqui else "") + anos = [a for r in linhas for a in (r.first_year, r.last_year) if a is not None] + saida.append(f"{min(anos)} to {max(anos)}" if anos else "") + return saida + + +def _lista(fontes: list[Source]) -> str: + return ", ".join(f.name for f in fontes) + + +def _limitacoes(manifest: Mapping, usadas: list[Source]) -> list[str]: + itens = [] + sem_acento = [f for f in usadas if f.strips_accents] + if sem_acento: + itens.append( + f"Sources that publish names without accents: {_lista(sem_acento)}. Their names are grouped with " + "accented forms only when the match is unambiguous (ADR 0011), and their own rows keep the " + "unaccented form." + ) + caixa = [f for f in usadas if f.uniform_case] + if caixa: + itens.append( + f"Sources that publish names in a single case, recapitalised by the pipeline: {_lista(caixa)}. " + "The display name prefers forms from sources that publish mixed case." + ) + por_id = {s["id"]: s for s in manifest["sources"]} + com_descarte = [f for f in usadas if (por_id[f.id].get("dropped") or {}).get("lines")] + if com_descarte: + linhas = sum(por_id[f.id]["dropped"]["lines"] for f in com_descarte) + registros = sum(por_id[f.id]["dropped"]["records"] for f in com_descarte) + itens.append( + f"{_n(linhas)} lines ({_n(registros)} records) were dropped because the source published corrupted " + f"names, such as lost diacritics or macrons (ADR 0007): {_lista(com_descarte)}. Country totals " + "exclude them." + ) + desconhecidos = [f for f in usadas if por_id[f.id].get("dropped") is None] + if desconhecidos: + itens.append(f"Drop counts are unknown for: {_lista(desconhecidos)}.") + itens.append( + "`group_id` identifies a variant group only within one release: it can change between releases when a " + "name with a smaller key joins the group or the links change." + ) + for f in usadas: + if f.id == "wikidata": + itens.append( + f"Wikidata names and links are a snapshot of the QLever Wikidata endpoint taken on " + f"{f.retrieved.isoformat()}, not on the release date." + ) + itens.append( + "`female_share` is a weighted estimate that combines countries and count types; it is never a count, " + "and gender is never inferred from the name." + ) + return itens + + +def dataset_card(manifest: Mapping, sources: Mapping[str, Source], countries: Mapping[str, list]) -> str: + """DATASET_CARD.md a partir do manifesto, de sources.yaml e das linhas por país (spec 006, R6).""" + release = manifest["release"] + files = manifest["files"] + usadas = _usadas(sources, (s["id"] for s in manifest["sources"])) + por_id = {s["id"]: s for s in manifest["sources"]} + + def linhas(nome: str) -> int: + return files.get(nome, {}).get("rows", 0) + + out = [ + f"# {NOME_DO_DATASET}, release {release}", + "", + "Given names and family names from around the world, with the country that attests each name, its " + "frequency, its gender (only when the source publishes it) and its writing script. Built only from " + "official statistics and Wikidata.", + "", + "## Summary", + "", + f"- Release: {release}", + f"- Schema version: {manifest['schema_version']}", + f"- Observations: {_n(linhas('observations.parquet'))}", + f"- Given names: {_n(linhas('given_names.csv'))}", + f"- Family names: {_n(linhas('family_names.csv'))}", + f"- Countries: {_n(len(countries))}", + f"- Variant links: {_n(linhas('variant_links.parquet'))}", + f"- Sources: {_n(len(usadas))}", + "", + "## Files", + "", + "| File | Rows | Description |", + "|---|---|---|", + ] + for nome, n in _arquivos(files): + out.append(f"| `{nome}` | {'' if n is None else _n(n)} | {_DESCRICOES.get(nome, '')} |") + out += ["", "## Fields", "", _CAMPOS, "", "## Sources", "", + "| Source | Id | Country | Count type | Licence | Suppression | Retrieved | Observations | Dropped |", + "|---|---|---|---|---|---|---|---|---|"] + for f in usadas: + s = por_id[f.id] + supressao = f"below {_n(f.suppression)}" if f.suppression else "none" + out.append( + f"| {f.name} | `{f.id}` | {f.country or 'all'} | {f.count_type} | {f.license} | {supressao} | " + f"{f.retrieved.isoformat()} | {_n(s.get('observations', 0))} | {_descarte(s.get('dropped'))} |" + ) + out += ["", "## Coverage", "", "| Country | Given names | Family names | Years |", "|---|---|---|---|"] + for pais in sorted(countries): + out.append("| " + " | ".join([pais, *_cobertura(countries[pais])]) + " |") + out += [ + "", "## Suppression", "", + "National statistics offices hide small counts to protect privacy. Suppressed values are omitted, never " + "replaced by zero or an estimate (ADR 0002): a name that a source only publishes as suppressed is not " + "attested by that source. The threshold of each source is in the Suppression column above. Sums over " + "years of births treat suppressed years as zero, so they underestimate rare names.", + "", "## Known limitations", "", + ] + out += [f"- {item}" for item in _limitacoes(manifest, usadas)] + out += [ + "", "## Licence", "", + "The data is licensed under CC BY 4.0 (`DATA_LICENSE`). Attribute it as:", "", + f"> {dataset_attribution(release)}", "", + "`ATTRIBUTION.txt` reproduces the attribution text and licence of every upstream source; keep it when " + "you redistribute the data.", + ] + for f in usadas: + if f.license in AVISOS: + out += ["", f"> {f.attribution}"] + for aviso in notices(sources, [f.id for f in usadas]): + out += ["", f"> {aviso}"] + out += [ + "", "## How to cite", "", + f"> {dataset_attribution(release)}", "", + f"Release page: {REPOSITORIO}/releases/tag/{release}", + ] + return "\n".join(out) + "\n" diff --git a/tests/test_publicacao.py b/tests/test_publicacao.py index 4e43fec..a819f1a 100644 --- a/tests/test_publicacao.py +++ b/tests/test_publicacao.py @@ -5,7 +5,8 @@ import pytest -from namescommons.publication import AVISOS, LICENCAS, attribution_text, dataset_attribution +from namescommons.aggregate import by_country +from namescommons.publication import AVISOS, LICENCAS, attribution_text, dataset_attribution, dataset_card from namescommons.sources import LICENCAS_COMPATIVEIS, load_sources RAIZ = Path(__file__).resolve().parent.parent @@ -86,3 +87,94 @@ def test_sem_simbolos_da_statcan(): assert not any(p in imagem.name.lower() for p in proibidos), imagem.name readme = (RAIZ / "README.md").read_text(encoding="utf-8").lower() assert "", 1)[0] for p in proibidos) + + +# R6 ---------------------------------------------------------------------------------------------- + +SECOES = ["## Summary", "## Files", "## Fields", "## Sources", "## Coverage", "## Suppression", + "## Known limitations", "## Licence", "## How to cite"] + + +def _manifesto(fontes, usadas, dropped=None, links=None): + arquivos = { + "by_country/BR.csv": {"sha256": "0" * 64, "rows": 3}, + "by_country/US.csv": {"sha256": "0" * 64, "rows": 4}, + "family_names.csv": {"sha256": "0" * 64, "rows": 2}, + "family_names.parquet": {"sha256": "0" * 64, "rows": 2}, + "given_names.csv": {"sha256": "0" * 64, "rows": 5}, + "given_names.parquet": {"sha256": "0" * 64, "rows": 5}, + "observations.parquet": {"sha256": "0" * 64, "rows": 16}, + } + if links is not None: + arquivos["variant_links.parquet"] = {"sha256": "0" * 64, "rows": links} + return { + "release": "2026.10", "schema_version": "1.3.0", "data_license": "CC-BY-4.0", + "sources": [ + {"id": i, "name": fontes[i].name, "license": fontes[i].license, + "retrieved": fontes[i].retrieved.isoformat(), "observations": 4, + "dropped": (dropped or {}).get(i)} + for i in sorted(usadas) + ], + "files": arquivos, + } + + +@pytest.fixture +def cartao(observacoes, fontes): + paises = by_country(observacoes, fontes) + usadas = {o.source_id for o in observacoes} + m = _manifesto(fontes, usadas, dropped={"us-ssa": {"lines": 3, "records": 49}, "br-ibge-censo": {"lines": 0, "records": 0}}, links=47) + return dataset_card(m, fontes, paises) + + +def test_cartao_tem_as_secoes(cartao): + assert cartao.startswith("# Names Commons Dataset, release 2026.10\n") + posicoes = [cartao.index(f"\n{s}\n") for s in SECOES] + assert posicoes == sorted(posicoes) + assert "\r" not in cartao and "—" not in cartao + + +def test_cartao_usa_os_numeros_do_manifesto(cartao): + for trecho in ("Schema version: 1.3.0", "Observations: 16", "Given names: 5", "Family names: 2", + "Countries: 2", "Variant links: 47", + "| `given_names.csv` | 5 |", "| `by_country.zip` | 7 |", "| `variant_links.parquet` | 47 |"): + assert trecho in cartao, trecho + # BR usa só a edição mais recente (population); US tem Ana Clara só atestada (fonte não primary). + assert "| BR | 2 (population) | 1 (population) | 2022 to 2022 |" in cartao + assert "| US | 4 (attested, births) | | 2020 to 2021 |" in cartao + + +def test_cartao_lista_fontes_sem_acento_e_descartes(observacoes, fontes): + fontes = {**fontes, "us-ssa": replace(fontes["us-ssa"], strips_accents=True), + "br-ibge-censo": replace(fontes["br-ibge-censo"], uniform_case=True)} + usadas = {o.source_id for o in observacoes} + m = _manifesto(fontes, usadas, dropped={"us-ssa": {"lines": 3, "records": 49}}) + texto = dataset_card(m, fontes, by_country(observacoes, fontes)) + limitacoes = texto.split("## Known limitations", 1)[1].split("\n## ", 1)[0] + assert "Sources that publish names without accents: SSA." in limitacoes + assert "Sources that publish names in a single case, recapitalised by the pipeline: IBGE Censo." in limitacoes + assert "3 lines (49 records) were dropped" in limitacoes + assert "Drop counts are unknown for: IBGE Censo" in limitacoes + assert "`group_id`" in limitacoes and "within one release" in limitacoes + assert "`female_share`" in limitacoes + fontes_tabela = texto.split("## Sources", 1)[1].split("\n## ", 1)[0] + assert "| SSA | `us-ssa` | US | births | public-domain | below 5 | 2026-10-01 | 4 | 3 lines, 49 records |" in fontes_tabela + assert "| IBGE Censo | `br-ibge-censo` | BR | population | CC-BY-4.0 | none | 2026-10-01 | 4 | unknown |" in fontes_tabela + + +def test_cartao_data_do_wikidata(cartao): + assert "QLever" in cartao and "2026-10-01" in cartao.split("QLever", 1)[1].split("\n", 1)[0] + + +def test_cartao_sem_statcan_nao_traz_avisos(cartao): + assert "Statistics Canada" not in cartao + assert dataset_attribution("2026.10") in cartao.split("## How to cite", 1)[1] + assert "https://github.com/BenchCommons/dataset-names/releases/tag/2026.10" in cartao + + +def test_cartao_traz_os_avisos_da_statcan(reais): + m = _manifesto(reais, {STATCAN, "wikidata"}) + texto = dataset_card(m, reais, {}) + licenca = texto.split("## Licence", 1)[1].split("\n## ", 1)[0] + assert reais[STATCAN].attribution in licenca + assert AVISOS["statcan-open-licence"] in licenca From 2cffd30a858ca1072bd23a8e1d0a7d81be3bb706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:26:52 -0300 Subject: [PATCH 07/12] feat(cli): write the publication files with every build build and run now also write DATA_LICENSE (copied from --data-license), ATTRIBUTION.txt, DATASET_CARD.md, a deterministic by_country.zip and a GNU-format SHA256SUMS covering every top-level file, all inside the same atomic temporary directory. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/namescommons/cli.py | 33 +++++++++--- src/namescommons/publication.py | 34 +++++++++++++ tests/test_cli.py | 3 +- tests/test_publicacao.py | 89 +++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 8 deletions(-) diff --git a/src/namescommons/cli.py b/src/namescommons/cli.py index 1621679..fd4cff3 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -2,11 +2,11 @@ import argparse import json -from collections import Counter import os import shutil import sys import tempfile +from collections import Counter from pathlib import Path from pyarrow import ArrowException @@ -19,6 +19,9 @@ from namescommons.links import VariantLink, validate_links from namescommons.manifest import RELEASE, build_manifest from namescommons.pipeline import Drops, PipelineError, fetch_sources, ingest_links, ingest_sources, select_sources +from namescommons.publication import ( + attribution_text, dataset_card, write_by_country_zip, write_sha256sums, write_text, +) from namescommons.schema import Observation, ValidationError, validate from namescommons.sources import Source, SourceError, load_sources @@ -34,6 +37,13 @@ def _opcoes_de_fonte(p: argparse.ArgumentParser, com_ids: bool = True) -> None: p.add_argument("--adapters", default=DEFAULT_PACKAGE, help="pacote Python dos adaptadores; precisa ser importável, ex.: PYTHONPATH=tests para o adaptador de teste (padrão: %(default)s)") +def _opcao_de_licenca(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--data-license", type=Path, default=Path("DATA_LICENSE"), + help="licença dos dados copiada para a release (spec 006, R4.3) (padrão: %(default)s)", + ) + + class _Ajuda(argparse.ArgumentDefaultsHelpFormatter): """Mostra o padrão só quando ele diz algo (nem None nem flags desligadas).""" @@ -74,12 +84,14 @@ def _parser() -> argparse.ArgumentParser: p.add_argument("--out", type=Path, required=True, help="diretório novo ou vazio para as saídas da release") p.add_argument("--release", required=True, help="AAAA.MM[.N]") p.add_argument("--links", type=Path, help="variant_links.parquet gravado por ingest --links, copiado para a release") + _opcao_de_licenca(p) p = sub.add_parser("run", help="fetch, ingest e build em sequência") _opcoes_de_fonte(p, com_ids=False) p.add_argument("--out", type=Path, required=True, help="diretório novo ou vazio para as saídas da release") p.add_argument("--release", required=True, help="AAAA.MM[.N]") p.add_argument("--offline", action="store_true", help="pula o fetch e não acessa a rede") p.add_argument("--refresh", action="store_true", help="baixa de novo mesmo com o arquivo no cache") + _opcao_de_licenca(p) for p in sub.choices.values(): p.add_argument( "--sources", type=Path, default=Path("sources.yaml"), help="arquivo de catálogo de fontes (padrão: %(default)s)" @@ -92,11 +104,13 @@ def _erro(mensagem: str) -> int: return 1 -def _saida_invalida(release: str, out: Path) -> str | None: +def _saida_invalida(release: str, out: Path, data_license: Path) -> str | None: if not RELEASE.fullmatch(release): return f"release '{release}' fora do formato AAAA.MM[.N]" if out.exists() and (not out.is_dir() or any(out.iterdir())): return f"--out {out} já existe e não está vazio; use um diretório novo" + if not data_license.is_file(): + return f"arquivo não encontrado: {data_license} (licença dos dados; use --data-license)" return None @@ -104,6 +118,7 @@ def _build( obs: list[Observation], fontes: dict[str, Source], out: Path, release: str, links: list[VariantLink] | None = None, dropped: dict[str, dict[str, int]] | None = None, + data_license: Path = Path("DATA_LICENSE"), ) -> int: """Grava as saídas da spec 001 (e variant_links da spec 004, se houver) em out, tudo ou nada; devolve o número de países. @@ -128,9 +143,13 @@ def _build( usadas = {o.source_id for o in obs} | {v.source_id for v in links or ()} por_fonte = Counter(o.source_id for o in obs) manifest = build_manifest(release, temporario, fontes, usadas, observations=por_fonte, dropped=dropped) - (temporario / "manifest.json").write_text( - json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) + write_text(temporario / "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + # Spec 006, R5: arquivos de publicação, fora de manifest["files"] e dentro de SHA256SUMS. + shutil.copyfile(data_license, temporario / "DATA_LICENSE") + write_text(temporario / "ATTRIBUTION.txt", attribution_text(release, fontes, usadas)) + write_text(temporario / "DATASET_CARD.md", dataset_card(manifest, fontes, paises)) + write_by_country_zip(temporario) + write_sha256sums(temporario) # rename sobre um diretório vazio existente (já checado por _saida_invalida) o substitui # de forma atômica em POSIX, sem apagar antes o diretório do usuário. os.replace(temporario, out) @@ -143,7 +162,7 @@ def _build( def main(argv: list[str] | None = None) -> int: args = _parser().parse_args(argv) if args.cmd in ("build", "run"): - problema = _saida_invalida(args.release, args.out) + problema = _saida_invalida(args.release, args.out, args.data_license) if problema: return _erro(problema) if args.cmd == "run" and args.offline and args.refresh: @@ -196,7 +215,7 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "validate": print(f"OK: {_n(len(obs), 'observação válida', 'observações válidas')}.") return 0 - paises = _build(obs, fontes, args.out, args.release, links, dropped) + paises = _build(obs, fontes, args.out, args.release, links, dropped, args.data_license) except ( PipelineError, SourceError, ValidationError, AggregationError, ValueError, OSError, ArrowException, ) as e: diff --git a/src/namescommons/publication.py b/src/namescommons/publication.py index cdc3053..4c0c37e 100644 --- a/src/namescommons/publication.py +++ b/src/namescommons/publication.py @@ -4,7 +4,11 @@ Statistics Canada). """ +import zipfile from collections.abc import Iterable, Mapping +from pathlib import Path + +from datasettool.files import sha256_file from namescommons.sources import Source @@ -301,3 +305,33 @@ def linhas(nome: str) -> int: f"Release page: {REPOSITORIO}/releases/tag/{release}", ] return "\n".join(out) + "\n" + + +# Pacote da release (spec 006, R5) ----------------------------------------------------------------- + +SOMAS = "SHA256SUMS" +ZIP_POR_PAIS = "by_country.zip" +_DATA_FIXA = (1980, 1, 1, 0, 0, 0) + + +def write_by_country_zip(out_dir: Path) -> None: + """by_country.zip com os CSV de by_country/, em ordem, com data e permissões fixas (R5.2).""" + with zipfile.ZipFile(out_dir / ZIP_POR_PAIS, "w") as z: + for csv_ in sorted((out_dir / "by_country").glob("*.csv")): + info = zipfile.ZipInfo(f"by_country/{csv_.name}", date_time=_DATA_FIXA) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + info.create_system = 3 # Unix, para external_attr valer igual em qualquer sistema + z.writestr(info, csv_.read_bytes(), compresslevel=9) + + +def write_sha256sums(out_dir: Path) -> None: + """SHA256SUMS no formato do GNU sha256sum, sobre os arquivos do nível de cima (R5.3).""" + nomes = sorted(p.name for p in out_dir.iterdir() if p.is_file() and p.name != SOMAS) + linhas = "".join(f"{sha256_file(out_dir / n)} {n}\n" for n in nomes) + (out_dir / SOMAS).write_bytes(linhas.encode("utf-8")) + + +def write_text(path: Path, texto: str) -> None: + """Texto UTF-8 sem BOM, com LF em qualquer sistema.""" + path.write_bytes(texto.encode("utf-8")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3e3b724..9e8ef99 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,8 @@ def test_build_gera_todas_as_saidas(observacoes, tmp_path): assert main(["build", str(obs), "--sources", str(fontes), "--out", str(out), "--release", "2026.10"]) == 0 arquivos = sorted(p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()) assert arquivos == [ - "by_country/BR.csv", "by_country/US.csv", "family_names.csv", "family_names.parquet", + "ATTRIBUTION.txt", "DATASET_CARD.md", "DATA_LICENSE", "SHA256SUMS", + "by_country.zip", "by_country/BR.csv", "by_country/US.csv", "family_names.csv", "family_names.parquet", "given_names.csv", "given_names.parquet", "manifest.json", "observations.parquet", ] manifest = json.loads((out / "manifest.json").read_text(encoding="utf-8")) diff --git a/tests/test_publicacao.py b/tests/test_publicacao.py index a819f1a..8e365ba 100644 --- a/tests/test_publicacao.py +++ b/tests/test_publicacao.py @@ -1,14 +1,25 @@ """Spec 006, R3 a R6: atribuições, licença, cartão do dataset e pacote da release.""" +import hashlib +import json +import re +import shutil +import subprocess +import zipfile from dataclasses import replace from pathlib import Path import pytest +import yaml from namescommons.aggregate import by_country +from namescommons.cli import main +from namescommons.io import write_observations from namescommons.publication import AVISOS, LICENCAS, attribution_text, dataset_attribution, dataset_card from namescommons.sources import LICENCAS_COMPATIVEIS, load_sources +from conftest import FONTES + RAIZ = Path(__file__).resolve().parent.parent STATCAN = "ca-statcan-births" @@ -178,3 +189,81 @@ def test_cartao_traz_os_avisos_da_statcan(reais): licenca = texto.split("## Licence", 1)[1].split("\n## ", 1)[0] assert reais[STATCAN].attribution in licenca assert AVISOS["statcan-open-licence"] in licenca + + +# R5 ---------------------------------------------------------------------------------------------- + +def _build(tmp_path, observacoes, out_nome="dist", *extra): + fontes = tmp_path / "sources.yaml" + if not fontes.exists(): + fontes.write_text(yaml.safe_dump({"sources": FONTES}, allow_unicode=True), encoding="utf-8") + write_observations(observacoes, tmp_path / "obs.parquet") + out = tmp_path / out_nome + codigo = main(["build", str(tmp_path / "obs.parquet"), "--sources", str(fontes), "--out", str(out), + "--release", "2026.10", *extra]) + return codigo, out + + +def _somas(out): + linhas = (out / "SHA256SUMS").read_text(encoding="utf-8").splitlines() + return dict(reversed(linha.split(" ", 1)) for linha in linhas), linhas + + +def test_sha256sums_verifica(observacoes, tmp_path): + codigo, out = _build(tmp_path, observacoes) + assert codigo == 0 + somas, linhas = _somas(out) + topo = sorted(p.name for p in out.iterdir() if p.is_file() and p.name != "SHA256SUMS") + assert list(somas) == topo and [l.split(" ", 1)[1] for l in linhas] == topo + for nome, soma in somas.items(): + assert re.fullmatch(r"[0-9a-f]{64}", soma) + assert hashlib.sha256((out / nome).read_bytes()).hexdigest() == soma + assert (out / "SHA256SUMS").read_bytes().endswith(b"\n") and b"\r" not in (out / "SHA256SUMS").read_bytes() + verificador = shutil.which("sha256sum") or shutil.which("shasum") + if verificador: + args = [verificador, "-c", "SHA256SUMS"] if verificador.endswith("sha256sum") else [verificador, "-a", "256", "-c", "SHA256SUMS"] + assert subprocess.run(args, cwd=out, capture_output=True).returncode == 0 + + +def test_zip_tem_os_mesmos_bytes(observacoes, tmp_path): + _, out = _build(tmp_path, observacoes) + with zipfile.ZipFile(out / "by_country.zip") as z: + assert z.namelist() == ["by_country/BR.csv", "by_country/US.csv"] + for info in z.infolist(): + assert z.read(info) == (out / info.filename).read_bytes() + assert info.date_time == (1980, 1, 1, 0, 0, 0) and info.compress_type == zipfile.ZIP_DEFLATED + + +def test_dois_builds_iguais(observacoes, tmp_path): + _, a = _build(tmp_path, observacoes, "a") + _, b = _build(tmp_path, observacoes, "b") + assert (a / "SHA256SUMS").read_bytes() == (b / "SHA256SUMS").read_bytes() + + +def test_copia_de_data_license_identica(observacoes, tmp_path): + _, out = _build(tmp_path, observacoes) + assert (out / "DATA_LICENSE").read_bytes() == (RAIZ / "DATA_LICENSE").read_bytes() + outra = tmp_path / "OUTRA" + outra.write_text("outra licença\n", encoding="utf-8") + _, out2 = _build(tmp_path, observacoes, "d2", "--data-license", str(outra)) + assert (out2 / "DATA_LICENSE").read_bytes() == outra.read_bytes() + + +def test_data_license_ausente_falha_sem_saida(observacoes, tmp_path, capsys): + codigo, out = _build(tmp_path, observacoes, "dist", "--data-license", str(tmp_path / "nao_existe")) + assert codigo == 1 + assert "nao_existe" in capsys.readouterr().err + assert not out.exists() + assert not [p for p in tmp_path.iterdir() if p.name.startswith(".dist")] + + +def test_build_grava_atribuicoes_e_cartao(observacoes, tmp_path): + _, out = _build(tmp_path, observacoes) + fontes = {f["id"]: f for f in FONTES} + texto = (out / "ATTRIBUTION.txt").read_text(encoding="utf-8") + for i in ("br-ibge-censo", "us-births-b", "us-ssa", "wikidata"): + assert f"Id: {i}\n" in texto and f"Attribution: {fontes[i]['attribution']}\n" in texto + cartao = (out / "DATASET_CARD.md").read_text(encoding="utf-8") + manifesto = json.loads((out / "manifest.json").read_text(encoding="utf-8")) + assert f"Observations: {manifesto['files']['observations.parquet']['rows']}" in cartao + assert not any(k in manifesto["files"] for k in ("ATTRIBUTION.txt", "DATASET_CARD.md", "DATA_LICENSE", "by_country.zip")) From d769f6e5cd3a8c9c8d8b514d51feb882e18b4569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:29:24 -0300 Subject: [PATCH 08/12] ci(release): build releases from a draft's source cache tools/release.sh checks the CalVer name, builds offline, verifies SHA256SUMS and uploads the assets to a draft release without overwriting, then removes the raw source cache from the draft. The release workflow runs it on CalVer tags; manual dispatch and CI run a dry run on the fixture samples. Nothing publishes a release or creates a tag. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 72 ++++++++++ tests/test_release.py | 248 ++++++++++++++++++++++++++++++++++ tools/release.sh | 124 +++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_release.py create mode 100755 tools/release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 606ffca..e4a50a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,7 @@ jobs: for indice in given_names family_names; do uv run python -c 'import csv, sys; linhas = list(csv.DictReader(open(sys.argv[1], encoding="utf-8"))); sys.exit(not linhas or not all(l["group_id"] for l in linhas))' "$RUNNER_TEMP/dist-oficiais/$indice.csv" done + - name: Ensaio da release (spec 006, R7.5) + run: | + tools/release.sh fixture-cache "$RUNNER_TEMP/cache-release" + tools/release.sh build "$(date -u +%Y.%m)" "$RUNNER_TEMP/cache-release" "$RUNNER_TEMP/release" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a90e786 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +# Spec 006, R7, e ADR 0014. Na tag CalVer, monta a release a partir do cache anexado ao rascunho +# e envia os assets ao rascunho. Nunca publica a release nem cria tag; o disparo manual é ensaio. +on: + push: + tags: + - "[0-9][0-9][0-9][0-9].[0-9][0-9]" + - "[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9]*" + workflow_dispatch: + inputs: + release: + description: "Nome da release do ensaio (AAAA.MM[.N]); o ensaio usa as amostras de tests/fixtures e não envia nada" + required: true + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ensaio: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + env: + RELEASE: ${{ inputs.release }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Instalar dependências + run: uv sync --locked + - name: Conferir o nome da release + run: tools/release.sh check "$RELEASE" + - name: Cache de ensaio com as amostras + run: tools/release.sh fixture-cache "$RUNNER_TEMP/cache" + - name: Build offline e verificação + run: tools/release.sh build "$RELEASE" "$RUNNER_TEMP/cache" "$RUNNER_TEMP/dist/$RELEASE" + - uses: actions/upload-artifact@v4 + with: + name: ensaio-release + path: ${{ runner.temp }}/dist/ + + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - name: Instalar dependências + run: uv sync --locked + - name: Conferir o nome da release + run: tools/release.sh check "$RELEASE" + - name: Cache das fontes anexado ao rascunho + run: tools/release.sh fetch-cache "$RELEASE" "$RUNNER_TEMP/cache" + - name: Build offline e verificação + run: tools/release.sh build "$RELEASE" "$RUNNER_TEMP/cache" "$RUNNER_TEMP/dist/$RELEASE" + - uses: actions/upload-artifact@v4 + with: + name: release + path: ${{ runner.temp }}/dist/ + - name: Enviar os assets ao rascunho + run: tools/release.sh upload "$RELEASE" "$RUNNER_TEMP/dist/$RELEASE" diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..aa7940a --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,248 @@ +"""Spec 006, R7, e ADR 0014: tools/release.sh e o workflow de release. + +O envio é testado com um `gh` falso no PATH, que registra os argumentos e simula o rascunho; +nenhum teste chama o gh real nem a rede. +""" + +import os +import shutil +import subprocess +import tarfile +from pathlib import Path + +import pytest +import yaml + +RAIZ = Path(__file__).resolve().parent.parent +SCRIPT = RAIZ / "tools" / "release.sh" +RELEASE_YML = RAIZ / ".github" / "workflows" / "release.yml" +CI_YML = RAIZ / ".github" / "workflows" / "ci.yml" +ASSETS = sorted([ + "ATTRIBUTION.txt", "DATASET_CARD.md", "DATA_LICENSE", "SHA256SUMS", "by_country.zip", "family_names.csv", + "family_names.parquet", "given_names.csv", "given_names.parquet", "manifest.json", "observations.parquet", + "variant_links.parquet", +]) + +GH_FALSO = r"""#!/usr/bin/env bash +# gh falso: registra a chamada e simula um rascunho conforme FAKE_DRAFT (true, false ou ausente). +printf '%s\n' "$*" >> "$FAKE_GH_LOG" +case "$1 $2" in + "release view") + [ "$FAKE_DRAFT" = ausente ] && { echo "release not found" >&2; exit 1; } + case "$*" in + *isDraft*) echo "$FAKE_DRAFT" ;; + *assets*) printf '%s\n' $FAKE_ASSETS ;; + esac ;; + "release download") + [ -n "$FAKE_TAR" ] || { echo "no assets match" >&2; exit 1; } + while [ $# -gt 0 ]; do [ "$1" = --dir ] && dir=$2; shift; done + cp "$FAKE_TAR" "$dir/source-cache.tar.gz" ;; +esac +exit 0 +""" + +pytestmark = pytest.mark.skipif(shutil.which("uv") is None or shutil.which("bash") is None, reason="uv ou bash fora do PATH") + + +def _rodar(*args, cwd=RAIZ, **env_extra): + return subprocess.run(["bash", str(SCRIPT), *map(str, args)], cwd=cwd, capture_output=True, text=True, + timeout=300, env={**os.environ, **env_extra}) + + +@pytest.fixture +def gh(tmp_path): + binario = tmp_path / "bin" + binario.mkdir() + (binario / "gh").write_text(GH_FALSO, encoding="utf-8") + (binario / "gh").chmod(0o755) + log = tmp_path / "gh.log" + log.touch() + ambiente = {"PATH": f"{binario}{os.pathsep}{os.environ['PATH']}", "FAKE_GH_LOG": str(log), + "FAKE_DRAFT": "true", "FAKE_ASSETS": "source-cache.tar.gz", "FAKE_TAR": ""} + return ambiente, log + + +@pytest.fixture(scope="module") +def release_pronta(tmp_path_factory): + """Uma release montada por tools/release.sh sobre as amostras, reaproveitada pelos testes de envio.""" + base = tmp_path_factory.mktemp("release") + r = _rodar("fixture-cache", base / "cache") + assert r.returncode == 0, r.stderr + r = _rodar("build", "2026.10", base / "cache", base / "dist" / "2026.10") + assert r.returncode == 0, r.stderr + return base / "dist" / "2026.10" + + +def _copia(release_pronta, tmp_path): + destino = tmp_path / "2026.10" + shutil.copytree(release_pronta, destino) + return destino + + +# Script ------------------------------------------------------------------------------------------ + +def test_build_local_sobre_as_amostras(release_pronta): + assert sorted(p.name for p in release_pronta.iterdir() if p.is_file()) == ASSETS + assert (release_pronta / "by_country").is_dir() + + +def test_build_recusa_somas_que_nao_conferem(release_pronta, tmp_path): + saida = _copia(release_pronta, tmp_path) + with open(saida / "given_names.csv", "a", encoding="utf-8") as fh: + fh.write("adulterado\n") + r = _rodar("verify", saida) + assert r.returncode != 0 and "given_names.csv" in r.stdout + r.stderr + + +@pytest.mark.parametrize("release", ["2026-10", "2026.13", "26.10", "2026.10.0", "2026.1", "v2026.10", "2026.10;ls"]) +def test_release_fora_de_calver_e_recusada(release, gh, tmp_path): + ambiente, log = gh + for args in (["check", release], ["fetch-cache", release, tmp_path / "c"], ["upload", release, tmp_path]): + r = _rodar(*args, **ambiente) + assert r.returncode != 0 and "AAAA.MM" in r.stderr + assert log.read_text(encoding="utf-8") == "" + + +def test_calver_aceito(gh): + ambiente, _ = gh + for release in ("2026.10", "2026.01.2", "2027.12.10"): + assert _rodar("check", release, **ambiente).returncode == 0 + + +def test_fetch_cache_sem_rascunho_falha(gh, tmp_path): + ambiente, _ = gh + r = _rodar("fetch-cache", "2026.10", tmp_path / "c", **{**ambiente, "FAKE_DRAFT": "ausente"}) + assert r.returncode != 0 and "rascunho" in r.stderr and "source-cache.tar.gz" in r.stderr + + +def test_fetch_cache_recusa_release_publicada(gh, tmp_path): + ambiente, _ = gh + r = _rodar("fetch-cache", "2026.10", tmp_path / "c", **{**ambiente, "FAKE_DRAFT": "false"}) + assert r.returncode != 0 and "publicada" in r.stderr + + +def test_fetch_cache_extrai_o_cache_do_rascunho(gh, tmp_path): + ambiente, log = gh + pacote = tmp_path / "pacote.tar.gz" + with tarfile.open(pacote, "w:gz") as tar: + tar.add(RAIZ / "tests" / "fixtures" / "nz-dia-births", arcname="nz-dia-births") + r = _rodar("fetch-cache", "2026.10", tmp_path / "c", **{**ambiente, "FAKE_TAR": str(pacote)}) + assert r.returncode == 0, r.stderr + assert (tmp_path / "c" / "nz-dia-births" / "baby-names-1900-to-2025.csv").is_file() + assert "release download 2026.10 --pattern source-cache.tar.gz" in log.read_text(encoding="utf-8") + + +def test_fetch_cache_sem_o_asset_falha(gh, tmp_path): + ambiente, _ = gh + r = _rodar("fetch-cache", "2026.10", tmp_path / "c", **ambiente) + assert r.returncode != 0 and "source-cache.tar.gz" in r.stderr + + +def test_upload_recusa_release_publicada(gh, release_pronta, tmp_path): + ambiente, log = gh + r = _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **{**ambiente, "FAKE_DRAFT": "false"}) + assert r.returncode != 0 and "publicada" in r.stderr + assert "upload" not in log.read_text(encoding="utf-8") and "delete-asset" not in log.read_text(encoding="utf-8") + + +def test_upload_sem_rascunho_falha(gh, release_pronta, tmp_path): + ambiente, log = gh + r = _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **{**ambiente, "FAKE_DRAFT": "ausente"}) + assert r.returncode != 0 and "rascunho" in r.stderr + assert "release upload" not in log.read_text(encoding="utf-8") + + +def test_upload_sem_clobber_e_remove_o_cache_depois(gh, release_pronta, tmp_path): + ambiente, log = gh + saida = _copia(release_pronta, tmp_path) + r = _rodar("upload", "2026.10", saida, **ambiente) + assert r.returncode == 0, r.stderr + chamadas = log.read_text(encoding="utf-8").splitlines() + envio = next(i for i, c in enumerate(chamadas) if c.startswith("release upload 2026.10 ")) + remocao = next(i for i, c in enumerate(chamadas) if c.startswith("release delete-asset 2026.10 source-cache.tar.gz")) + assert envio < remocao + assert all("--clobber" not in c for c in chamadas) + enviados = chamadas[envio].split()[3:] + assert sorted(Path(p).name for p in enviados) == ASSETS + + +def test_upload_envia_so_os_arquivos_do_nivel_de_cima(gh, release_pronta, tmp_path): + ambiente, log = gh + assert _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **ambiente).returncode == 0 + envio = next(c for c in log.read_text(encoding="utf-8").splitlines() if c.startswith("release upload")) + assert "by_country/" not in envio and "/by_country " not in envio + + +def test_upload_sem_cache_no_rascunho_nao_remove_nada(gh, release_pronta, tmp_path): + ambiente, log = gh + assert _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **{**ambiente, "FAKE_ASSETS": "x.csv"}).returncode == 0 + assert "delete-asset" not in log.read_text(encoding="utf-8") + + +def test_upload_recusa_saida_adulterada(gh, release_pronta, tmp_path): + ambiente, log = gh + saida = _copia(release_pronta, tmp_path) + (saida / "extra.txt").write_text("fora das somas\n", encoding="utf-8") + r = _rodar("upload", "2026.10", saida, **ambiente) + assert r.returncode != 0 and "extra.txt" in r.stderr + assert "release upload" not in log.read_text(encoding="utf-8") + + +# Workflows --------------------------------------------------------------------------------------- + +def _yaml(caminho): + dados = yaml.safe_load(caminho.read_text(encoding="utf-8")) + return dados, dados.get("on", dados.get(True)) + + +def _runs(job): + return [p.get("run", "") for p in job["steps"]] + + +def test_workflow_nao_publica_nem_cria_tag(): + texto = RELEASE_YML.read_text(encoding="utf-8") + SCRIPT.read_text(encoding="utf-8") + for proibido in ("gh release create", "gh release edit", "--draft=false", "git push", "git tag", "--clobber", + "softprops/action-gh-release"): + assert proibido not in texto, proibido + + +def test_gatilhos_do_workflow_de_release(): + dados, gatilhos = _yaml(RELEASE_YML) + assert set(gatilhos) == {"push", "workflow_dispatch"} + assert set(gatilhos["push"]) == {"tags"} + assert dados["permissions"] == {"contents": "read"} + + +def test_dispatch_e_ensaio(): + dados, _ = _yaml(RELEASE_YML) + ensaio, release = dados["jobs"]["ensaio"], dados["jobs"]["release"] + assert "workflow_dispatch" in ensaio["if"] and "push" in release["if"] + assert ensaio.get("permissions", dados["permissions"]) == {"contents": "read"} + comandos = "\n".join(_runs(ensaio)) + assert "fixture-cache" in comandos and "tools/release.sh build" in comandos + assert "fetch-cache" not in comandos and "upload" not in comandos and "gh " not in comandos + assert release["permissions"] == {"contents": "write"} + comandos = _runs(release) + ordem = [next(i for i, c in enumerate(comandos) if t in c) + for t in ("tools/release.sh check", "fetch-cache", "tools/release.sh build", "tools/release.sh upload")] + assert ordem == sorted(ordem) + + +def test_release_passa_ao_shell_so_por_variavel(): + """R7.6: o nome da release chega ao shell por env, nunca interpolado no script do passo.""" + dados, _ = _yaml(RELEASE_YML) + for job in dados["jobs"].values(): + assert "RELEASE" in job["env"] + for comando in _runs(job): + assert "${{" not in comando + + +def test_padroes_de_tag_sao_calver(): + _, gatilhos = _yaml(RELEASE_YML) + assert gatilhos["push"]["tags"] == ["[0-9][0-9][0-9][0-9].[0-9][0-9]", "[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9]*"] + + +def test_ci_roda_o_ensaio(): + dados, _ = _yaml(CI_YML) + comandos = "\n".join(_runs(dados["jobs"]["testes"])) + assert "tools/release.sh fixture-cache" in comandos and "tools/release.sh build" in comandos diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..1a5dbe2 --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Release do dataset (spec 006, R7; ADR 0014). Este script nunca publica release nem cria tag: +# publicar o rascunho é ato do dono do repositório. +# +# tools/release.sh check RELEASE confere o nome da release (AAAA.MM[.N]) +# tools/release.sh fixture-cache CACHE monta um cache de ensaio com as amostras de tests/fixtures +# tools/release.sh fetch-cache RELEASE CACHE baixa source-cache.tar.gz do rascunho RELEASE e o extrai em CACHE +# tools/release.sh build RELEASE CACHE SAIDA namescommons run --offline sobre CACHE e verificação da SAIDA +# tools/release.sh verify SAIDA confere os arquivos e o SHA256SUMS da SAIDA +# tools/release.sh upload RELEASE SAIDA envia os assets ao rascunho RELEASE e remove dele o cache +set -euo pipefail + +RAIZ=$(cd "$(dirname "$0")/.." && pwd) +CALVER='^[0-9]{4}\.(0[1-9]|1[0-2])(\.[1-9][0-9]*)?$' +CACHE_ASSET=source-cache.tar.gz +OBRIGATORIOS="manifest.json observations.parquet given_names.csv given_names.parquet family_names.csv +family_names.parquet by_country.zip DATA_LICENSE ATTRIBUTION.txt DATASET_CARD.md SHA256SUMS" + +erro() { + echo "release.sh: $*" >&2 + exit 1 +} + +conferir_release() { + [[ "$1" =~ $CALVER ]] || erro "release '$1' fora do formato AAAA.MM[.N]" +} + +absoluto() { + mkdir -p "$1" + (cd "$1" && pwd) +} + +# Estado da release no GitHub: true (rascunho), false (publicada) ou ausente. +estado() { + gh release view "$1" --json isDraft --jq .isDraft 2>/dev/null || echo ausente +} + +exigir_rascunho() { + case "$(estado "$1")" in + true) ;; + false) erro "a release '$1' já está publicada; releases são imutáveis, use uma release nova" ;; + *) erro "nenhum rascunho de release '$1': crie um rascunho (draft) da release '$1' no GitHub e anexe $CACHE_ASSET (ADR 0014; procedimento no AGENTS.md)" ;; + esac +} + +verificar() { + local saida=$1 nome listados existentes + [ -d "$saida" ] || erro "diretório da release não encontrado: $saida" + for nome in $OBRIGATORIOS; do + [ -f "$saida/$nome" ] || erro "$saida: falta $nome" + done + listados=$(sed 's/^[0-9a-f]\{64\} //' "$saida/SHA256SUMS" | LC_ALL=C sort) + existentes=$(cd "$saida" && for nome in *; do + [ -f "$nome" ] && [ "$nome" != SHA256SUMS ] && echo "$nome" + done | LC_ALL=C sort) + if [ "$listados" != "$existentes" ]; then + erro "SHA256SUMS não cobre exatamente os arquivos de $saida: $(diff <(echo "$listados") <(echo "$existentes") | grep '^[<>]' | tr '\n' ' ')" + fi + if command -v sha256sum >/dev/null 2>&1; then + (cd "$saida" && sha256sum -c SHA256SUMS) || erro "SHA256SUMS não confere em $saida" + else + (cd "$saida" && shasum -a 256 -c SHA256SUMS) || erro "SHA256SUMS não confere em $saida" + fi +} + +comando=${1:-} +[ $# -gt 0 ] && shift +case "$comando" in + check) + [ $# -eq 1 ] || erro "uso: release.sh check RELEASE" + conferir_release "$1" + ;; + fixture-cache) + [ $# -eq 1 ] || erro "uso: release.sh fixture-cache CACHE" + cache=$(absoluto "$1") + find "$RAIZ/tests/fixtures" -mindepth 1 -maxdepth 1 -type d ! -name exemplo -exec cp -R {} "$cache/" \; + ;; + fetch-cache) + [ $# -eq 2 ] || erro "uso: release.sh fetch-cache RELEASE CACHE" + conferir_release "$1" + exigir_rascunho "$1" + temporario=$(mktemp -d) + trap 'rm -rf "$temporario"' EXIT + gh release download "$1" --pattern "$CACHE_ASSET" --dir "$temporario" \ + || erro "o rascunho '$1' não tem o asset $CACHE_ASSET (ADR 0014)" + [ -f "$temporario/$CACHE_ASSET" ] || erro "o rascunho '$1' não tem o asset $CACHE_ASSET (ADR 0014)" + cache=$(absoluto "$2") + tar -xzf "$temporario/$CACHE_ASSET" -C "$cache" + ;; + build) + [ $# -eq 3 ] || erro "uso: release.sh build RELEASE CACHE SAIDA" + conferir_release "$1" + [ -d "$2" ] || erro "cache não encontrado: $2" + cache=$(cd "$2" && pwd) + mkdir -p "$(dirname "$3")" + saida="$(cd "$(dirname "$3")" && pwd)/$(basename "$3")" + (cd "$RAIZ" && uv run namescommons run --offline --cache "$cache" --out "$saida" --release "$1" \ + --sources "$RAIZ/sources.yaml" --data-license "$RAIZ/DATA_LICENSE") + verificar "$saida" + ;; + verify) + [ $# -eq 1 ] || erro "uso: release.sh verify SAIDA" + verificar "$1" + ;; + upload) + [ $# -eq 2 ] || erro "uso: release.sh upload RELEASE SAIDA" + conferir_release "$1" + verificar "$2" + exigir_rascunho "$1" + assets=() + for arquivo in "$2"/*; do + [ -f "$arquivo" ] && assets+=("$arquivo") + done + # Sem sobrescrever: um asset que já existe no rascunho faz o envio falhar. + gh release upload "$1" "${assets[@]}" + if gh release view "$1" --json assets --jq '.assets[].name' | grep -qx "$CACHE_ASSET"; then + gh release delete-asset "$1" "$CACHE_ASSET" --yes + fi + echo "Assets enviados ao rascunho '$1'. Revise e publique pelo GitHub; este script não publica." + ;; + *) + erro "uso: release.sh {check|fixture-cache|fetch-cache|build|verify|upload} ..." + ;; +esac From 8986975f0615f9e7b851577a59dfed6cb12e04e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:29:45 -0300 Subject: [PATCH 09/12] chore(gitignore): clean up leftovers and allow Parquet fixtures Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 6 +++--- tests/test_ci.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f8c4d90..1531248 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,11 @@ __pycache__/ output/ *.tsv *.parquet +# Spec 006, R9: Parquet versionado só em amostras de teste +!tests/fixtures/**/*.parquet *.tar.gz credentials.json - nohup.out *.out @@ -94,7 +95,7 @@ celerybeat-schedule .env # virtualenv -.venv +.venv/ venv/ ENV/ @@ -110,7 +111,6 @@ ENV/ # mypy .mypy_cache/ -.venv/ # Cache de downloads das fontes (spec 002, R4) /cache/ diff --git a/tests/test_ci.py b/tests/test_ci.py index cd6678f..13ed310 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -186,3 +186,34 @@ def test_passo_das_fontes_oficiais_confere_todos_os_paises(): """Spec 003d, R5: o passo confere by_country/ de cada país de sources.yaml, inclusive CA e ES.""" passo = next(p for p in _workflow()["jobs"]["testes"]["steps"] if "cache-oficiais" in p.get("run", "")) assert "for pais in CA ES FR IE NO NZ US;" in passo["run"] + + +# Spec 006, R9: limpeza do .gitignore (issue #5) ---------------------------------------------------- + +def _ignorado(caminho: str) -> bool: + return subprocess.run(["git", "check-ignore", "-q", "--no-index", caminho], cwd=RAIZ).returncode == 0 + + +def test_gitignore_sem_linhas_em_branco_duplas(): + texto = (RAIZ / ".gitignore").read_text(encoding="utf-8") + assert "\n\n\n" not in texto + + +def test_gitignore_sem_venv_na_secao_do_mypy(): + linhas = (RAIZ / ".gitignore").read_text(encoding="utf-8").splitlines() + mypy = linhas.index("# mypy") + assert ".venv/" not in linhas[mypy:mypy + 3] + assert _ignorado(".venv/lib/x.py") + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git fora do PATH") +def test_parquet_de_fixture_nao_e_ignorado(): + assert not _ignorado("tests/fixtures/exemplo/tabela.parquet") + assert not _ignorado("tests/fixtures/x/y/tabela.parquet") + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git fora do PATH") +def test_parquet_na_raiz_continua_ignorado(): + assert _ignorado("observations.parquet") + assert _ignorado("dist/2026.10/given_names.parquet") + assert _ignorado("source-cache.tar.gz") From 880c3de4467d1f069d741ac8df82a298f4beb2a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:31:15 -0300 Subject: [PATCH 10/12] docs(release): document the release procedure README gains the release layout, verification, citation and the local dry run; AGENTS.md gains the step-by-step release procedure; spec 001 gains notes pointing to spec 006 and ADR 0015. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 22 ++++++++++ README.md | 32 +++++++++++--- docs/specs/001-esquema/spec.md | 4 +- docs/specs/006-publicacao/tasks.md | 67 +++++++++++++++++++----------- 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1abe43..508b0bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ O contrato do kit é do tools-dataset ([spec 004 de lá](https://github.com/Benc 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 +tools/release.sh build # release local, sem publicar (spec 006) ``` A CI também roda o pipeline offline sobre as amostras de `tests/fixtures/`: @@ -51,6 +52,27 @@ O INSEE publica cada edição do Fichier des prénoms numa página nova, sem lis 5. Confira o formato (colunas `sexe;prenom;periode;valeur;rang`, códigos de sexo) e ajuste `tests/test_fr_insee_births.py` e o `README.md` se algo mudou. 6. Rode `uv run pytest -q`: `tests/test_insee_edicao.py` falha se algum dos lugares acima ficou para trás. +## Publicar uma release + +Procedimento da [spec 006](docs/specs/006-publicacao/spec.md) e do [ADR 0014](docs/adr/0014-processo-de-release.md). Só o dono publica; nenhum agente cria tag, rascunho ou release sem aprovação explícita. + +1. Escolha o nome em CalVer (`AAAA.MM`, ou `AAAA.MM.N` para mais de uma no mês). Releases são imutáveis: nunca reaproveite um nome. +2. Confira se a [Statistics Canada Open Licence](https://www.statcan.gc.ca/en/reference/licence) mudou desde a versão "Date modified: 2025-07-10". Se mudou, reavalie o [ADR 0004](docs/adr/0004-licenca-statistics-canada.md) antes de seguir. +3. Monte o cache completo: `uv run namescommons fetch --refresh`; baixe pelo navegador o ZIP nacional da SSA e o CSV da Nova Zelândia para os caminhos que as mensagens de erro indicam (por cópia, não link simbólico); atualize `retrieved` em `sources.yaml` e faça o commit em `master` por PR. +4. Ensaie localmente: `tools/release.sh build AAAA.MM cache /tmp/dist/AAAA.MM`. Leia `DATASET_CARD.md` e `ATTRIBUTION.txt` da saída. +5. Empacote o cache e prepare o rascunho: + + ```sh + tar -czf source-cache.tar.gz -C cache . + gh release create AAAA.MM --draft --title AAAA.MM --notes "Names Commons Dataset AAAA.MM" + gh release upload AAAA.MM source-cache.tar.gz + ``` + +6. Crie e envie a tag assinada no commit de `master` que gera a release: `git tag -s AAAA.MM -m AAAA.MM && git push origin AAAA.MM`. O workflow `Release` baixa o cache do rascunho, roda o build offline, confere `SHA256SUMS`, envia os assets ao rascunho e remove dele `source-cache.tar.gz`. +7. Revise o rascunho (assets, cartão, atribuições) e publique pelo GitHub. Se o workflow falhar no meio, apague os assets enviados ao rascunho antes de rodar de novo: o envio nunca sobrescreve. + +O disparo manual do workflow (`workflow_dispatch`) é só ensaio, sobre as amostras de `tests/fixtures/`. + ## Convenções - Commits assinados com GPG, autor Matheus Breguêz ``. Nunca `--no-gpg-sign` nem `--author`. diff --git a/README.md b/README.md index e084e62..a66dce7 100644 --- a/README.md +++ b/README.md @@ -22,19 +22,39 @@ Originally forked from [Debdut/names.io](https://github.com/Debdut/names.io). Attribution text for each source is in `sources.yaml`. -## Output layout +## Releases -A release contains: +Releases are published on the [GitHub Releases page](https://github.com/BenchCommons/dataset-names/releases), tagged in CalVer (`YYYY.MM`, with `.N` for more than one in a month). Each release contains: ``` -by_country/.csv one file per country (ISO 3166-1 alpha-2) given_names.csv one row per given name, all countries family_names.csv one row per family name, all countries +given_names.parquet the same, with typed nested columns +family_names.parquet observations.parquet the full observation table -manifest.json schema version, sources, row counts, SHA-256 of each file +variant_links.parquet links between name variants (Wikidata) +by_country.zip by_country/.csv, one file per country (ISO 3166-1 alpha-2) +manifest.json schema version, sources, dropped records, row counts and SHA-256 of each table +DATASET_CARD.md fields, sources, coverage, suppression, known limitations, licence, how to cite +ATTRIBUTION.txt attribution text and licence of every source, and required notices +DATA_LICENSE CC BY 4.0, with the dataset's attribution line +SHA256SUMS SHA-256 of every asset ``` -`given_names` and `family_names` are also written as Parquet. Counts of different types (`births`, `population`, `attested`) are never added together. The schema is defined in [spec 001](docs/specs/001-esquema/spec.md). +Counts of different types (`births`, `population`, `attested`) are never added together. The schema is defined in [spec 001](docs/specs/001-esquema/spec.md); the release files in [spec 006](docs/specs/006-publicacao/spec.md). + +Verify a download with `sha256sum -c SHA256SUMS` in the directory with the assets. Cite a release as written in its `DATASET_CARD.md`: + +> Names Commons Dataset, release YYYY.MM, BenchCommons (https://github.com/BenchCommons/dataset-names), licensed under CC BY 4.0. Upstream sources and notices: see ATTRIBUTION.txt. + +Keep `ATTRIBUTION.txt` with the data when you redistribute it. The release layout follows the input contract of [tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/main/docs/specs/001-input-contract/spec.md) (`DATA_LICENSE`, `ATTRIBUTION.txt` and `SHA256SUMS` at the top level), so benchmark inputs can be added to the same releases. + +Releases are built by the [release workflow](.github/workflows/release.yml) from a source cache attached to a draft release, and published by the maintainer ([ADR 0014](docs/adr/0014-processo-de-release.md)). To build a release locally without publishing anything: + +```sh +tools/release.sh fixture-cache /tmp/cache # or a full cache from namescommons fetch +tools/release.sh build 2026.10 /tmp/cache /tmp/dist/2026.10 # namescommons run --offline + checksum verification +``` ## Quick start @@ -49,6 +69,8 @@ uv run namescommons build observations.parquet --out dist --release 2026.10 uv run namescommons run --out dist --release 2026.10 # fetch, ingest and build in sequence ``` +`build` and `run` write the whole release directory, including the publication files listed above; `--data-license` points to the licence file copied into it (default `DATA_LICENSE`). + Add `--offline` to `ingest` or `run` to use only files already in the cache. Use `--help` on any command for its options. 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)). diff --git a/docs/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index 2b16485..6484386 100644 --- a/docs/specs/001-esquema/spec.md +++ b/docs/specs/001-esquema/spec.md @@ -101,9 +101,11 @@ docs/specs/, docs/adr/ Nota: a release pode incluir `variant_links.parquet` (spec 004, R4.6), contado no `manifest.json`, cuja lista de fontes é a união das fontes das observações e das ligações (spec 005, R7.4). Versões: `1.1.0` (spec 004, R4.7) e `1.2.0` (spec 005, R8). +Nota (spec 006, R2 e R5): o manifesto ganha `data_license` e, por fonte, `name`, `observations` e `dropped`; a release ganha `DATA_LICENSE`, `ATTRIBUTION.txt`, `DATASET_CARD.md`, `by_country.zip` e `SHA256SUMS`, fora de `files`. Versão `1.3.0` ([spec 006](../006-publicacao/spec.md)). + ### R6. Legado -`first_names.all.txt`, `last_names.all.txt` e `generate/` vão para `legacy/` sem alteração de conteúdo e não são ingeridos no dataset novo (ADR 0001). A retirada é decidida na spec 006. +`first_names.all.txt`, `last_names.all.txt` e `generate/` vão para `legacy/` sem alteração de conteúdo e não são ingeridos no dataset novo (ADR 0001). A retirada é decidida na spec 006. Decisão: [ADR 0015](../../adr/0015-retirada-do-legado.md) (sair da árvore, com aprovação do dono sobre a lista dos arquivos). ## Critérios de aceite diff --git a/docs/specs/006-publicacao/tasks.md b/docs/specs/006-publicacao/tasks.md index a5f9ddd..1d1ce23 100644 --- a/docs/specs/006-publicacao/tasks.md +++ b/docs/specs/006-publicacao/tasks.md @@ -10,68 +10,85 @@ Cada tarefa segue TDD: escrever os testes, ver falhar com `uv run pytest -q Date: Mon, 12 Oct 2026 19:42:54 -0300 Subject: [PATCH 11/12] fix(release): scope the token, pin actions and fail when the cache stays GH_TOKEN and GH_REPO reach only the two steps that call gh, checkout no longer persists credentials, every action is pinned by commit SHA and the release job runs setup-uv without cache. release.sh lists the draft's assets without a grep pipe, fails if the listing fails, confirms the source cache is gone after deleting it, and only treats 'release not found' as a missing draft. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 27 +++++++++----- tests/test_release.py | 66 +++++++++++++++++++++++++++++++++-- tools/release.sh | 34 +++++++++++++++--- 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a90e786..3db28eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,7 @@ name: Release # Spec 006, R7, e ADR 0014. Na tag CalVer, monta a release a partir do cache anexado ao rascunho # e envia os assets ao rascunho. Nunca publica a release nem cria tag; o disparo manual é ensaio. +# Actions fixadas por SHA de commit; o comentário diz a tag correspondente. on: push: tags: @@ -27,8 +28,10 @@ jobs: env: RELEASE: ${{ inputs.release }} steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 with: enable-cache: true - name: Instalar dependências @@ -39,7 +42,7 @@ jobs: run: tools/release.sh fixture-cache "$RUNNER_TEMP/cache" - name: Build offline e verificação run: tools/release.sh build "$RELEASE" "$RUNNER_TEMP/cache" "$RUNNER_TEMP/dist/$RELEASE" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ensaio-release path: ${{ runner.temp }}/dist/ @@ -51,22 +54,30 @@ jobs: contents: write env: RELEASE: ${{ github.ref_name }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + enable-cache: false - name: Instalar dependências run: uv sync --locked - name: Conferir o nome da release run: tools/release.sh check "$RELEASE" - name: Cache das fontes anexado ao rascunho + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: tools/release.sh fetch-cache "$RELEASE" "$RUNNER_TEMP/cache" - name: Build offline e verificação run: tools/release.sh build "$RELEASE" "$RUNNER_TEMP/cache" "$RUNNER_TEMP/dist/$RELEASE" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release path: ${{ runner.temp }}/dist/ - name: Enviar os assets ao rascunho + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: tools/release.sh upload "$RELEASE" "$RUNNER_TEMP/dist/$RELEASE" diff --git a/tests/test_release.py b/tests/test_release.py index aa7940a..4c045c5 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -5,6 +5,7 @@ """ import os +import re import shutil import subprocess import tarfile @@ -24,19 +25,29 @@ ]) GH_FALSO = r"""#!/usr/bin/env bash -# gh falso: registra a chamada e simula um rascunho conforme FAKE_DRAFT (true, false ou ausente). +# gh falso: registra a chamada e simula um rascunho conforme FAKE_DRAFT (true, false, ausente ou erro). +# O estado dos assets fica em FAKE_STATE: delete-asset tira o cache, salvo com FAKE_DELETE_IGNORADO. printf '%s\n' "$*" >> "$FAKE_GH_LOG" +estado="$FAKE_GH_LOG.assets" +[ -f "$estado" ] || printf '%s\n' $FAKE_ASSETS > "$estado" case "$1 $2" in "release view") [ "$FAKE_DRAFT" = ausente ] && { echo "release not found" >&2; exit 1; } + [ "$FAKE_DRAFT" = erro ] && { echo "HTTP 401: Bad credentials (https://api.github.com/graphql)" >&2; exit 1; } case "$*" in *isDraft*) echo "$FAKE_DRAFT" ;; - *assets*) printf '%s\n' $FAKE_ASSETS ;; + *assets*) + if [ -n "$FAKE_VIEW_ASSETS_FALHA" ] && grep -q "release upload" "$FAKE_GH_LOG"; then + echo "HTTP 502" >&2; exit 1 + fi + cat "$estado" ;; esac ;; "release download") [ -n "$FAKE_TAR" ] || { echo "no assets match" >&2; exit 1; } while [ $# -gt 0 ]; do [ "$1" = --dir ] && dir=$2; shift; done cp "$FAKE_TAR" "$dir/source-cache.tar.gz" ;; + "release delete-asset") + [ -n "$FAKE_DELETE_IGNORADO" ] || { grep -vx "$4" "$estado" > "$estado.novo"; mv "$estado.novo" "$estado"; } ;; esac exit 0 """ @@ -58,7 +69,8 @@ def gh(tmp_path): log = tmp_path / "gh.log" log.touch() ambiente = {"PATH": f"{binario}{os.pathsep}{os.environ['PATH']}", "FAKE_GH_LOG": str(log), - "FAKE_DRAFT": "true", "FAKE_ASSETS": "source-cache.tar.gz", "FAKE_TAR": ""} + "FAKE_DRAFT": "true", "FAKE_ASSETS": "source-cache.tar.gz", "FAKE_TAR": "", + "FAKE_VIEW_ASSETS_FALHA": "", "FAKE_DELETE_IGNORADO": ""} return ambiente, log @@ -246,3 +258,51 @@ def test_ci_roda_o_ensaio(): dados, _ = _yaml(CI_YML) comandos = "\n".join(_runs(dados["jobs"]["testes"])) assert "tools/release.sh fixture-cache" in comandos and "tools/release.sh build" in comandos + + +def test_token_so_nos_passos_que_usam_o_gh(): + """Revisão do PR #45: GH_TOKEN não chega ao uv sync nem ao build.""" + dados, _ = _yaml(RELEASE_YML) + for nome, job in dados["jobs"].items(): + assert "GH_TOKEN" not in job.get("env", {}) and "GH_REPO" not in job.get("env", {}), nome + for passo in job["steps"]: + usa_gh = any(c in passo.get("run", "") for c in ("fetch-cache", "tools/release.sh upload")) + assert ("GH_TOKEN" in passo.get("env", {})) == usa_gh, passo.get("name") + if passo.get("uses", "").startswith("actions/checkout"): + assert passo["with"]["persist-credentials"] is False + + +def test_actions_fixadas_por_sha(): + texto = RELEASE_YML.read_text(encoding="utf-8") + usos = re.findall(r"uses: (\S+)(.*)", texto) + assert usos + for acao, resto in usos: + assert re.fullmatch(r"[\w.-]+/[\w.-]+@[0-9a-f]{40}", acao), acao + assert re.fullmatch(r"\s+# v\d+\.\d+\.\d+", resto), acao + + +def test_job_release_sem_cache_do_uv(): + dados, _ = _yaml(RELEASE_YML) + uv = next(p for p in dados["jobs"]["release"]["steps"] if p.get("uses", "").startswith("astral-sh/setup-uv")) + assert uv["with"]["enable-cache"] is False + + +def test_upload_falha_se_nao_consegue_listar_os_assets(gh, release_pronta, tmp_path): + """Revisão do PR #45: view falhando depois do upload não pode pular a remoção do cache em silêncio.""" + ambiente, log = gh + r = _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **{**ambiente, "FAKE_VIEW_ASSETS_FALHA": "1"}) + assert r.returncode != 0 + assert "release upload" in log.read_text(encoding="utf-8") + + +def test_upload_falha_se_o_cache_continua_no_rascunho(gh, release_pronta, tmp_path): + ambiente, _ = gh + r = _rodar("upload", "2026.10", _copia(release_pronta, tmp_path), **{**ambiente, "FAKE_DELETE_IGNORADO": "1"}) + assert r.returncode != 0 and "source-cache.tar.gz" in r.stderr + + +def test_estado_com_erro_que_nao_e_ausencia_falha(gh, tmp_path): + ambiente, _ = gh + r = _rodar("fetch-cache", "2026.10", tmp_path / "c", **{**ambiente, "FAKE_DRAFT": "erro"}) + assert r.returncode != 0 + assert "HTTP 401: Bad credentials" in r.stderr and "rascunho" not in r.stderr diff --git a/tools/release.sh b/tools/release.sh index 1a5dbe2..86dac59 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -30,13 +30,34 @@ absoluto() { (cd "$1" && pwd) } -# Estado da release no GitHub: true (rascunho), false (publicada) ou ausente. +# Estado da release no GitHub: true (rascunho), false (publicada) ou ausente. Qualquer outro erro +# do gh (credencial, rede, limite de requisições) derruba o script com a mensagem real. estado() { - gh release view "$1" --json isDraft --jq .isDraft 2>/dev/null || echo ausente + local saida + if saida=$(gh release view "$1" --json isDraft --jq .isDraft 2>&1); then + echo "$saida" + elif [[ "$saida" == *"release not found"* ]]; then + echo ausente + else + erro "falha ao consultar a release '$1' no GitHub: $saida" + fi +} + +# Nomes dos assets da release, um por linha; falha do gh derruba o script. +assets_da_release() { + gh release view "$1" --json assets --jq '.assets[].name' || erro "falha ao listar os assets da release '$1'" +} + +# Verdadeiro se a lista de nomes (um por linha) tem o asset do cache. Sem pipe nem grep -q: +# com pipefail, um SIGPIPE faria a busca falhar em silêncio. +tem_cache() { + [[ $'\n'"$1"$'\n' == *$'\n'"$CACHE_ASSET"$'\n'* ]] } exigir_rascunho() { - case "$(estado "$1")" in + local e + e=$(estado "$1") || exit 1 + case "$e" in true) ;; false) erro "a release '$1' já está publicada; releases são imutáveis, use uma release nova" ;; *) erro "nenhum rascunho de release '$1': crie um rascunho (draft) da release '$1' no GitHub e anexe $CACHE_ASSET (ADR 0014; procedimento no AGENTS.md)" ;; @@ -113,8 +134,13 @@ case "$comando" in done # Sem sobrescrever: um asset que já existe no rascunho faz o envio falhar. gh release upload "$1" "${assets[@]}" - if gh release view "$1" --json assets --jq '.assets[].name' | grep -qx "$CACHE_ASSET"; then + nomes=$(assets_da_release "$1") || exit 1 + if tem_cache "$nomes"; then gh release delete-asset "$1" "$CACHE_ASSET" --yes + nomes=$(assets_da_release "$1") || exit 1 + if tem_cache "$nomes"; then + erro "$CACHE_ASSET continua no rascunho '$1' depois da remoção; apague-o antes de publicar" + fi fi echo "Assets enviados ao rascunho '$1'. Revise e publique pelo GitHub; este script não publica." ;; From c26d36bc5f06b542be0eb0d240c20c21b7d7c16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 19:43:38 -0300 Subject: [PATCH 12/12] docs(adr): keep legacy/ frozen in the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner decided to keep legacy/ as a frozen historical archive. ADR 0015 is rewritten as accepted with that decision, and spec 006, its plan and tasks, spec 001 and the roadmap follow it. README mentions sha256sum --ignore-missing and AGENTS.md uses . Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 ++-- README.md | 2 +- docs/adr/0015-legado-congelado.md | 27 +++++++++++++++++++++++++++ docs/adr/0015-retirada-do-legado.md | 29 ----------------------------- docs/specs/001-esquema/spec.md | 2 +- docs/specs/006-publicacao/plan.md | 6 +++--- docs/specs/006-publicacao/spec.md | 8 ++++---- docs/specs/006-publicacao/tasks.md | 8 ++++---- docs/specs/ROADMAP.md | 4 ++-- 9 files changed, 44 insertions(+), 46 deletions(-) create mode 100644 docs/adr/0015-legado-congelado.md delete mode 100644 docs/adr/0015-retirada-do-legado.md diff --git a/AGENTS.md b/AGENTS.md index 508b0bf..06c283f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,14 +18,14 @@ O contrato do kit é do tools-dataset ([spec 004 de lá](https://github.com/Benc 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 -tools/release.sh build # release local, sem publicar (spec 006) +tools/release.sh build # release local, sem publicar (spec 006) ``` A CI também roda o pipeline offline sobre as amostras de `tests/fixtures/`: ```sh PYTHONPATH=tests uv run namescommons run --offline --sources tests/fixtures/sources.yaml \ - --adapters adaptadores --cache --out --release 2026.10 + --adapters adaptadores --cache --out --release 2026.10 ``` ## Atualizar o kit de fontes diff --git a/README.md b/README.md index a66dce7..52be204 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ SHA256SUMS SHA-256 of every asset Counts of different types (`births`, `population`, `attested`) are never added together. The schema is defined in [spec 001](docs/specs/001-esquema/spec.md); the release files in [spec 006](docs/specs/006-publicacao/spec.md). -Verify a download with `sha256sum -c SHA256SUMS` in the directory with the assets. Cite a release as written in its `DATASET_CARD.md`: +Verify a download with `sha256sum -c SHA256SUMS` in the directory with the assets, or with `sha256sum -c --ignore-missing SHA256SUMS` if you downloaded only some of them. Cite a release as written in its `DATASET_CARD.md`: > Names Commons Dataset, release YYYY.MM, BenchCommons (https://github.com/BenchCommons/dataset-names), licensed under CC BY 4.0. Upstream sources and notices: see ATTRIBUTION.txt. diff --git a/docs/adr/0015-legado-congelado.md b/docs/adr/0015-legado-congelado.md new file mode 100644 index 0000000..b2993c3 --- /dev/null +++ b/docs/adr/0015-legado-congelado.md @@ -0,0 +1,27 @@ +# 0015. `legacy/` fica no repositório como arquivo histórico congelado + +## Status + +Aceito (2026-10-12), por decisão do dono do repositório. Spec [006](../specs/006-publicacao/spec.md), R10. + +## Contexto + +A spec 001 (R6) moveu as listas planas originais (`first_names.all.txt`, `last_names.all.txt`) e os scripts `generate/` para `legacy/`, sem alteração, e deixou para a spec 006 decidir o destino do diretório. São 22 arquivos e 2.091.482 bytes. + +O conteúdo de `legacy/` não tem licença declarada: veio de sites, gists e repositórios sem licença, e inclui nomes dos datasets do IMDb, com termos não comerciais (ADR 0001). O dataset novo não o ingere, e o contrato de entradas do tools-dataset (spec 001 de lá, R1.3) o exclui. + +As opções eram tirar o diretório da árvore (o conteúdo continuaria no histórico do git) ou mantê-lo congelado. A primeira versão deste ADR propôs a remoção; o dono decidiu manter. + +## Decisão + +- `legacy/` fica no repositório como arquivo histórico congelado: nenhum arquivo é alterado, acrescentado ou removido. +- `legacy/` não é ingerido pelo pipeline, não entra em nenhum arquivo das releases e está fora do contrato de entradas do tools-dataset (R1.3 de lá). +- `legacy/README.md`, o preâmbulo de `DATA_LICENSE` e o README dizem que o diretório não faz parte do dataset e não é coberto por `DATA_LICENSE` nem por `LICENSE`. +- `tests/test_legacy.py` continua conferindo que as listas estão intactas (spec 001, critério 6). + +## Consequências + +- Quem usava as listas antigas por caminho no repositório continua encontrando os arquivos. +- O repositório segue distribuindo, ao lado de `LICENSE` e `DATA_LICENSE`, material sem concessão de licença; o aviso em `legacy/README.md`, no README e em `DATA_LICENSE` é a única barreira contra o uso equivocado. +- Os 2 MB das listas continuam em clones, buscas e ferramentas de verificação de licença, que podem apontar o diretório. +- Rever a decisão exige um ADR novo que substitua este. diff --git a/docs/adr/0015-retirada-do-legado.md b/docs/adr/0015-retirada-do-legado.md deleted file mode 100644 index 0a0cdfe..0000000 --- a/docs/adr/0015-retirada-do-legado.md +++ /dev/null @@ -1,29 +0,0 @@ -# 0015. Retirada de `legacy/` da árvore do repositório - -## Status - -Proposto (2026-10-12), aguardando aprovação do dono para a remoção dos arquivos. Spec [006](../specs/006-publicacao/spec.md), R10. - -## Contexto - -A spec 001 (R6) moveu as listas planas originais (`first_names.all.txt`, `last_names.all.txt`) e os scripts `generate/` para `legacy/`, sem alteração, e deixou a retirada para a spec 006. São 22 arquivos e 2.091.482 bytes. - -O conteúdo de `legacy/` não tem licença declarada: veio de sites, gists e repositórios sem licença, e inclui nomes dos datasets do IMDb, com termos não comerciais (ADR 0001). O dataset novo não o ingere, e o contrato de entradas do tools-dataset (spec 001 de lá, R1.3) o exclui explicitamente. Mantê-lo na árvore tem custo: - -- o repositório distribui, na versão atual, material sem concessão de licença ao lado de `LICENSE` e `DATA_LICENSE`, e cada leitor precisa achar o `legacy/README.md` para saber que nenhuma das duas vale ali; -- `legacy/generate/` depende de diretórios e URLs que não existem mais, então não reproduz as listas; -- buscas, clones e ferramentas de licença passam pelos 2 MB de listas sem metadados. - -O benefício de manter é a conveniência de quem usava as listas antigas por caminho no repositório. - -## Decisão - -- `legacy/` sai da árvore do repositório num commit próprio. O conteúdo continua acessível no histórico do git e no repositório de origem do fork (Debdut/names.io). -- A remoção só acontece com aprovação explícita do dono sobre a lista exata dos arquivos (caminho e tamanho), registrada na descrição do PR da spec 006 e na issue de acompanhamento. Até lá `legacy/` fica congelado como está. -- No mesmo commit da remoção: `tests/test_legacy.py` passa a conferir que `legacy/` não existe, o README troca a linha de `legacy/` por uma nota que aponta o último commit que o contém, e a spec 001 (R4 e R6) ganha nota remetendo a este ADR. - -## Consequências - -- O repositório passa a ter só código Apache-2.0, dados CC BY 4.0 e documentação; nenhum arquivo sem licença. -- Quem dependia de `legacy/first_names.all.txt` por caminho perde o arquivo em `master`, mas pode fixar o último commit que o contém. -- Os testes de integridade da spec 001 (critério 6) deixam de fazer sentido e são trocados pelo teste de ausência. diff --git a/docs/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index 6484386..2cb8254 100644 --- a/docs/specs/001-esquema/spec.md +++ b/docs/specs/001-esquema/spec.md @@ -105,7 +105,7 @@ Nota (spec 006, R2 e R5): o manifesto ganha `data_license` e, por fonte, `name`, ### R6. Legado -`first_names.all.txt`, `last_names.all.txt` e `generate/` vão para `legacy/` sem alteração de conteúdo e não são ingeridos no dataset novo (ADR 0001). A retirada é decidida na spec 006. Decisão: [ADR 0015](../../adr/0015-retirada-do-legado.md) (sair da árvore, com aprovação do dono sobre a lista dos arquivos). +`first_names.all.txt`, `last_names.all.txt` e `generate/` vão para `legacy/` sem alteração de conteúdo e não são ingeridos no dataset novo (ADR 0001). A retirada é decidida na spec 006. Decisão: [ADR 0015](../../adr/0015-legado-congelado.md) (fica congelado no repositório). ## Critérios de aceite diff --git a/docs/specs/006-publicacao/plan.md b/docs/specs/006-publicacao/plan.md index 8e39362..2ac7b3c 100644 --- a/docs/specs/006-publicacao/plan.md +++ b/docs/specs/006-publicacao/plan.md @@ -6,7 +6,7 @@ **Stack:** a mesma; nenhuma dependência nova (`zipfile`, `hashlib` e `json` da biblioteca padrão; `datasettool.files.sha256_file` do kit). -**Spec:** [`spec.md`](spec.md). ADRs: [0014](../../adr/0014-processo-de-release.md), [0015](../../adr/0015-retirada-do-legado.md). +**Spec:** [`spec.md`](spec.md). ADRs: [0014](../../adr/0014-processo-de-release.md), [0015](../../adr/0015-legado-congelado.md). ## Restrições globais @@ -15,7 +15,7 @@ - Textos da release (`ATTRIBUTION.txt`, `DATASET_CARD.md`, preâmbulo de `DATA_LICENSE`) em inglês, como o README; mensagens da CLI e do script em português. - Nenhum teste chama o `gh` real nem a rede; o script de release é testado com um `gh` falso no `PATH`. - Nenhuma tag, release ou rascunho é criado no GitHub por esta spec. -- `legacy/` não é tocado (R10). +- `legacy/` não é tocado: fica congelado (R10, ADR 0015). ## Decisões de implementação @@ -70,7 +70,7 @@ | R7 | 6 | | R9 | 7 | | R8 | 8 | -| R10 | 9 (aguarda aprovação do dono) | +| R10 | 9 | | Critérios 1 e 2 | 1, 2 | | Critérios 3 e 4 | 3 | | Critério 5 | 4 | diff --git a/docs/specs/006-publicacao/spec.md b/docs/specs/006-publicacao/spec.md index 4737460..9da793e 100644 --- a/docs/specs/006-publicacao/spec.md +++ b/docs/specs/006-publicacao/spec.md @@ -1,6 +1,6 @@ # 006. Publicação -Parte do [roadmap](../ROADMAP.md). Depende da [spec 005](../005-variantes/spec.md) e da [spec 007](../007-tools-dataset/spec.md). Esquema e versionamento: [spec 001](../001-esquema/spec.md) (R4, R5 e R6). Decisões: [ADR 0001](../../adr/0001-licenca-dos-dados.md) (licença dos dados), [ADR 0004](../../adr/0004-licenca-statistics-canada.md) (Statistics Canada), [ADR 0007](../../adr/0007-nomes-corrompidos-na-fonte.md) (nomes corrompidos), [ADR 0014](../../adr/0014-processo-de-release.md) (processo de release) e [ADR 0015](../../adr/0015-retirada-do-legado.md) (retirada do legado). Contrato de entradas de benchmark (dono: tools-dataset): [spec 001 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/main/docs/specs/001-input-contract/spec.md). Issues: #5, #22 e #32. +Parte do [roadmap](../ROADMAP.md). Depende da [spec 005](../005-variantes/spec.md) e da [spec 007](../007-tools-dataset/spec.md). Esquema e versionamento: [spec 001](../001-esquema/spec.md) (R4, R5 e R6). Decisões: [ADR 0001](../../adr/0001-licenca-dos-dados.md) (licença dos dados), [ADR 0004](../../adr/0004-licenca-statistics-canada.md) (Statistics Canada), [ADR 0007](../../adr/0007-nomes-corrompidos-na-fonte.md) (nomes corrompidos), [ADR 0014](../../adr/0014-processo-de-release.md) (processo de release) e [ADR 0015](../../adr/0015-legado-congelado.md) (legado congelado). Contrato de entradas de benchmark (dono: tools-dataset): [spec 001 do tools-dataset](https://github.com/BenchCommons/tools-dataset/blob/main/docs/specs/001-input-contract/spec.md). Issues: #5, #22 e #32. Status: Em implementação. @@ -89,7 +89,7 @@ Pendências da issue #5: tirar a linha em branco dupla, tirar `.venv/` da seçã ### R10. Legado -Decisão no [ADR 0015](../../adr/0015-retirada-do-legado.md): `legacy/` sai da árvore do repositório (continua no histórico do git). A remoção apaga arquivos versionados e por isso só acontece com aprovação explícita do dono sobre a lista exata dos arquivos; até lá `legacy/` fica como está, congelado, e o README continua dizendo que ele não faz parte do dataset. +Decisão do dono no [ADR 0015](../../adr/0015-legado-congelado.md): `legacy/` fica no repositório como arquivo histórico congelado, sem nenhuma alteração. Não é ingerido, não entra em nenhum arquivo das releases e está fora do contrato de entradas do tools-dataset (R1.3 de lá). O README, `legacy/README.md` e o preâmbulo de `DATA_LICENSE` dizem que ele não faz parte do dataset. ## Critérios de aceite @@ -102,12 +102,12 @@ Decisão no [ADR 0015](../../adr/0015-retirada-do-legado.md): `legacy/` sai da 7. `tools/release.sh build` roda localmente sobre as amostras das fontes e produz a release verificada; o passo de envio, testado com um `gh` falso, recusa release fora de CalVer, release publicada e ausência de rascunho, envia sem `--clobber` só os assets de R5.4 e remove `source-cache.tar.gz` depois do envio; nenhum teste chama o `gh` real. 8. O workflow de release não contém publicação de release nem push de tag; o disparo manual é ensaio; a CI roda o ensaio em todo PR. 9. `.gitignore` sem as sobras da issue #5, e um Parquet em `tests/fixtures/` não é ignorado. -10. A decisão sobre `legacy/` está no ADR 0015, e a lista exata dos arquivos a remover está na descrição do PR, aguardando aprovação. +10. A decisão sobre `legacy/` (manter congelado) está no ADR 0015; nenhum arquivo de `legacy/` muda, `tests/test_legacy.py` continua verde e nenhum arquivo da release vem de `legacy/`. ## Fora de escopo - Publicar uma release ou criar uma tag: a primeira release real precisa da aprovação explícita do dono e do download manual da SSA e da Nova Zelândia. - Entradas de benchmark (`*.input.json`, amostras por tamanho): issue #24, sobre o contrato da spec 001 do tools-dataset. - Versionar `data/by_country/*.csv` no git (spec 001, R4): depende da primeira release real e vira issue própria. -- Remover `legacy/` sem a aprovação do dono (R10). +- Remover ou alterar `legacy/` (R10). - `CITATION.cff` e DOI (Zenodo). diff --git a/docs/specs/006-publicacao/tasks.md b/docs/specs/006-publicacao/tasks.md index 1d1ce23..0cee36f 100644 --- a/docs/specs/006-publicacao/tasks.md +++ b/docs/specs/006-publicacao/tasks.md @@ -70,11 +70,11 @@ Cada tarefa segue TDD: escrever os testes, ver falhar com `uv run pytest -q