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..3db28eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +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: + - "[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@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 + 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + 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 }} + steps: + - 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@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/.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/AGENTS.md b/AGENTS.md index f1abe43..06c283f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +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) ``` 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 @@ -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/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/README.md b/README.md index e084e62..52be204 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, 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. + +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/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-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/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index 2b16485..2cb8254 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-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 new file mode 100644 index 0000000..2ac7b3c --- /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-legado-congelado.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: fica congelado (R10, ADR 0015). + +## 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 | +| 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/spec.md b/docs/specs/006-publicacao/spec.md new file mode 100644 index 0000000..9da793e --- /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-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. + +## 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 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 + +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/` (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 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 new file mode 100644 index 0000000..0cee36f --- /dev/null +++ b/docs/specs/006-publicacao/tasks.md @@ -0,0 +1,94 @@ +# 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`. + +- [x] 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`. +- [x] `RawDrop`; `Drops` e `ingest_sources(..., drops=)` no pipeline; `write_observations(..., dropped=)` e `read_dropped` em `io`; `ingest` grava. +- [x] 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`. + +- [x] 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. +- [x] `build_manifest(..., observations=, dropped=)`; `SCHEMA_VERSION = "1.3.0"`. +- [x] 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`. + +- [x] 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). +- [x] `LICENCAS`, `AVISOS`, `dataset_attribution(release)`, `attribution_text(release, sources, used)`; preâmbulo em `DATA_LICENSE`. +- [x] 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`. + +- [x] 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`. +- [x] `dataset_card(manifest, sources, countries, index_counts, links)`. +- [x] 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`. + +- [x] 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`. +- [x] `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`. +- [x] 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`. + +- [x] 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`. +- [x] `tools/release.sh` com `build`, `fetch-cache` e `upload`; `release.yml`; passo de ensaio na CI. +- [x] Suíte verde; commit `ci(release): build releases from a draft's source cache`. + +### Task 7: `.gitignore` + +**Arquivos:** `.gitignore`; teste `tests/test_ci.py`. + +- [x] Testes: `test_gitignore_sem_linhas_em_branco_duplas`, `test_parquet_de_fixture_nao_e_ignorado` (`git check-ignore`), `test_parquet_na_raiz_continua_ignorado`. +- [x] Limpeza de R9. +- [x] 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. + +- [x] README (layout, Releases, ensaio local); `AGENTS.md` (procedimento de release); notas na spec 001. +- [x] 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. +- [x] Commit `docs(release): document the release procedure`. + +### Task 9: decisão sobre `legacy/` + +**Arquivos:** `docs/adr/0015-legado-congelado.md`. + +- [x] ADR 0015 com a decisão do dono: manter congelado; nenhum arquivo de `legacy/` muda (issue #42). + +## Verificação (2026-10-12) + +| Critério | Evidência | +|---|---| +| 1 | `tests/test_descartes.py`: 3 linhas e 49 registros da amostra da Nova Zelândia contados, avisados no logger `namescommons.pipeline`, gravados por `ingest` e lidos por `build`; `test_nz_nao_usa_logging` | +| 2 | `test_manifest_traz_nome_observacoes_e_descartes`, `test_manifest_traz_data_license`, `test_build_sem_metadados_deixa_dropped_nulo`, `test_build_grava_atribuicoes_e_cartao` (`files` sem os arquivos de publicação); `SCHEMA_VERSION` `1.3.0` | +| 3 | `test_atribuicao_reproduz_o_texto_sem_edicao`, `test_aviso_de_condicoes_so_com_statcan`, `test_licenca_sem_entrada_falha`, `test_aviso_do_readme_igual_ao_da_tabela` | +| 4 | `test_data_license_tem_a_linha_de_atribuicao`, `test_copia_de_data_license_identica` | +| 5 | `test_cartao_*` em `tests/test_publicacao.py`; cartão do ensaio sobre as amostras lido à mão | +| 6 | `test_sha256sums_verifica`, `test_zip_tem_os_mesmos_bytes`, `test_dois_builds_iguais`; `tools/release.sh build` duas vezes sobre as amostras com `diff -r` vazio e `sha256sum -c` OK | +| 7 | `tests/test_release.py` com `gh` falso: CalVer, rascunho ausente, release publicada, envio sem sobrescrever e remoção do cache depois do envio, saída adulterada recusada | +| 8 | `test_workflow_nao_publica_nem_cria_tag`, `test_dispatch_e_ensaio`, `test_release_passa_ao_shell_so_por_variavel`, `test_ci_roda_o_ensaio`; `shellcheck tools/release.sh` sem avisos | +| 9 | `test_gitignore_*` e `test_parquet_*` em `tests/test_ci.py` | +| 10 | ADR 0015 (Aceito, manter congelado); `git diff origin/master -- legacy/` vazio; `tests/test_legacy.py` verde | + +`uv sync --locked` e `uv run pytest -q`: 653 testes verdes, sem rede. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index ac15a73..f156a8f 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, legado congelado (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 legado congelado](../adr/0015-legado-congelado.md). 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..fd4cff3 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -6,6 +6,7 @@ import shutil import sys import tempfile +from collections import Counter from pathlib import Path from pyarrow import ArrowException @@ -13,11 +14,14 @@ 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.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 @@ -33,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).""" @@ -73,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)" @@ -91,17 +104,21 @@ 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 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. @@ -116,7 +133,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") @@ -124,10 +141,15 @@ 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) - (temporario / "manifest.json").write_text( - json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) + por_fonte = Counter(o.source_id for o in obs) + manifest = build_manifest(release, temporario, fontes, usadas, observations=por_fonte, dropped=dropped) + 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) @@ -140,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: @@ -154,14 +176,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 +202,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 +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) + 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/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/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/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/src/namescommons/publication.py b/src/namescommons/publication.py new file mode 100644 index 0000000..4c0c37e --- /dev/null +++ b/src/namescommons/publication.py @@ -0,0 +1,337 @@ +"""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). +""" + +import zipfile +from collections.abc import Iterable, Mapping +from pathlib import Path + +from datasettool.files import sha256_file + +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" + + +# 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" + + +# 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/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_ci.py b/tests/test_ci.py index fe2a232..13ed310 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 @@ -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") 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_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 new file mode 100644 index 0000000..22ef05a --- /dev/null +++ b/tests/test_descartes.py @@ -0,0 +1,140 @@ +"""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, read_observations, 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 + + +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): 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", [ diff --git a/tests/test_publicacao.py b/tests/test_publicacao.py new file mode 100644 index 0000000..8e365ba --- /dev/null +++ b/tests/test_publicacao.py @@ -0,0 +1,269 @@ +"""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" + + +@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) + + +# 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 + + +# 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")) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..4c045c5 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,308 @@ +"""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 re +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, 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*) + 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 +""" + +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": "", + "FAKE_VIEW_ASSETS_FALHA": "", "FAKE_DELETE_IGNORADO": ""} + 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 + + +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 new file mode 100755 index 0000000..86dac59 --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,150 @@ +#!/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. Qualquer outro erro +# do gh (credencial, rede, limite de requisições) derruba o script com a mensagem real. +estado() { + 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() { + 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)" ;; + 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[@]}" + 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." + ;; + *) + erro "uso: release.sh {check|fixture-cache|fetch-cache|build|verify|upload} ..." + ;; +esac