diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e092de6..3992d20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,4 +35,4 @@ jobs: find tests/fixtures -mindepth 1 -maxdepth 1 -type d ! -name exemplo ! -name planilhas -exec cp -R {} "$RUNNER_TEMP/cache-oficiais/" \; uv run namescommons run --offline --cache "$RUNNER_TEMP/cache-oficiais" \ --out "$RUNNER_TEMP/dist-oficiais" --release "$(date -u +%Y.%m)" - for pais in CA ES FR IE NO US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done + for pais in CA ES FR IE NO NZ US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done diff --git a/docs/adr/0007-nomes-corrompidos-na-fonte.md b/docs/adr/0007-nomes-corrompidos-na-fonte.md new file mode 100644 index 0000000..98d7101 --- /dev/null +++ b/docs/adr/0007-nomes-corrompidos-na-fonte.md @@ -0,0 +1,26 @@ +# 0007. Nomes corrompidos na fonte são descartados, e só se recupera o que é inequívoco + +## Status + +Aceito (2026-10-12) + +## Contexto + +Algumas fontes publicam nomes com caracteres perdidos antes de o arquivo chegar ao dataset. O CSV do Department of Internal Affairs da Nova Zelândia (spec 003e) é o primeiro caso: acentos vêm em bytes de páginas de código diferentes no mesmo arquivo (`0x82` de CP437 e `0xE9` de Latin-1 para o mesmo `é`), e as vogais com macron do te reo Māori viram `?` ou bytes que as páginas de código leem como outras letras (`ì`, `Å`), nunca como `ī` ou `ā`. + +Ingerir esses nomes como vieram criaria formas falsas (`M?ia`, `KÅwhai`) que nunca foram registradas, com contagem e `female_share` próprios, e que a etapa de variantes (spec 005) agruparia com nomes reais por acaso. Adivinhar a letra perdida (`?` vira `ā`) também inventaria dado: o mesmo `?` substitui `ā` em `M?ia` e `ī` em `N?kau`, no mesmo arquivo. Decodificar com uma única página de código escolhida a dedo troca uma corrupção por outra (`Zoé` lido em CP437 vira `ZoΘ`). + +## Decisão + +- Um nome que, depois da decodificação, contém `?` ou U+FFFD (caractere de substituição) não entra no dataset: o adaptador não emite registro para a linha. +- Um byte só é traduzido para um caractere quando a leitura é inequívoca pelos nomes atestados: entre as leituras das páginas de código candidatas, exatamente uma forma um nome atestado em todas as ocorrências do byte no arquivo real (por exemplo, `0xE9` é `é` em Latin-1, `Ú` em CP850 e `È` em Mac Roman, e só `é` forma Zoé e Esmé). Ser letra em alguma página não basta: quase todo byte é letra em alguma delas. Byte cujas leituras não formam o nome atestado (`0x8D` é `ì` em CP437 onde o nome é Nīkau) não é traduzido. A tradução fica numa tabela fechada no adaptador, com a evidência registrada na spec da fonte. Byte fora da tabela vira U+FFFD e cai na regra anterior. +- Se o arquivo inteiro for UTF-8 válido, ele é lido como UTF-8, e a tabela não se aplica. +- O adaptador conta as linhas descartadas, a soma das contagens delas e as formas distintas, e avisa no log ao fim do arquivo. A spec da fonte registra os números medidos no arquivo real. + +## Consequências + +- Nenhuma forma de nome que a fonte não registrou entra no dataset; a contagem do país perde as linhas descartadas (na Nova Zelândia, 13 de 74.019 linhas e 164 de 4.516.506 registros). +- Nomes com macron ficam de fora da fonte até que ela corrija o arquivo. Como um arquivo UTF-8 válido é lido sem tabela, a correção na origem passa a valer sem mudança de código. +- Uma edição nova com um byte desconhecido não quebra a ingestão: o nome é descartado e aparece no aviso, e quem atualiza a fonte decide se o byte merece entrar na tabela. +- Uma edição futura convertida pela metade (parte em UTF-8, parte legada) não é UTF-8 válida e cai na tabela: os nomes acentuados em UTF-8 viram U+FFFD e são descartados. A falha é segura (nenhuma forma falsa entra) e visível, porque as formas aparecem no aviso. +- Toda fonte nova precisa ter os bytes não ASCII inspecionados antes do adaptador. diff --git a/docs/specs/003e-nova-zelandia/plan.md b/docs/specs/003e-nova-zelandia/plan.md new file mode 100644 index 0000000..ccca98b --- /dev/null +++ b/docs/specs/003e-nova-zelandia/plan.md @@ -0,0 +1,70 @@ +# 003e. Nova Zelândia: plano de implementação + +**Objetivo:** ingerir o CSV de nomes de bebês do Department of Internal Affairs (DIA) da Nova Zelândia sem deixar entrar nome corrompido na fonte, e fazer o download bloqueado pela Imperva falhar com a orientação de gravar o arquivo do navegador no cache. + +**Arquitetura:** `cache._baixar` passa a recusar resposta `text/html` para recurso que não é HTML, com uma exceção `BlockedError(FetchError)` que `fetch_resource` não repete. O adaptador `nz_dia_births.py` lê o CSV em bytes, decodifica como UTF-8 se o arquivo inteiro for válido e, senão, pela tabela fechada de R2.2; descarta nomes com `?` ou U+FFFD, conta os descartes e avisa pelo `logging` ao fim do arquivo. Só biblioteca padrão. + +**Stack:** a mesma da spec 003, sem dependência nova. + +**Spec:** [`spec.md`](spec.md). ADRs: [`0001`](../../adr/0001-licenca-dos-dados.md), [`0002`](../../adr/0002-valores-suprimidos.md), [`0007`](../../adr/0007-nomes-corrompidos-na-fonte.md). Pendências herdadas: issue #10. + +## Restrições globais + +- Nenhuma dependência nova de runtime ou de desenvolvimento; nenhum navegador automatizado. +- Testes sem rede: o servidor de `tests/conftest.py` serve a amostra e a página HTML de bloqueio. +- Amostra com bytes copiados do arquivo real, gerada por script (os bytes `0x82`, `0x8D`, `0x8F`, `0xE9` e `0xF6` não podem depender do editor), menos de 20 KB. +- Nenhum nome com `?` ou U+FFFD sai do adaptador. +- `sources.yaml` e `tests/fixtures/ATRIBUICAO.md` ganham a fonte ao fim do arquivo (outras specs acrescentam fontes em paralelo). +- `SCHEMA_VERSION` continua `1.0.0`. + +## Decisões de implementação + +| Decisão | Motivo | +|---|---| +| Tabela fechada `{0x82: é, 0xE9: é, 0xF6: ö}` em vez de uma página de código | O arquivo mistura CP437/CP850 e Latin-1; qualquer página única corrompe parte dos nomes (`Zoé` em CP437 vira `ZoΘ`, `Amélie` em Windows-1252 vira `Am‚lie`) | +| `0x8D` e `0x8F` sem tradução, mesmo sabendo que os nomes são Nīkau e Kāwhai | As páginas de código leem esses bytes como outras letras (`ì`, `Å`, `ç`, `è`), nunca `ī` e `ā`; a tradução seria um palpite apoiado só no nome, e o ADR 0007 só aceita a leitura que forma o nome atestado | +| UTF-8 primeiro, para o arquivo inteiro | Uma edição corrigida pela fonte é lida sem mudança de código; um arquivo legado com bytes acentuados quase nunca forma UTF-8 válido por acaso | +| Decodificação por linha só depois de decidir a codificação do arquivo | A decisão por linha deixaria um arquivo meio UTF-8 e meio legado passar sem aviso | +| Aviso pelo `logging` (logger do módulo), e não um canal novo no contrato | O contrato de `parse` só produz registros; o `logging` aparece no `stderr` da CLI sem configuração (`lastResort`) e é testável com `caplog` | +| `BlockedError` sem novas tentativas | A página de desafio não some com nova tentativa; repetir só atrasa a mensagem | +| Regra do HTML em `cache.py`, para toda fonte | Uma página de erro HTML gravada com o nome do CSV corromperia o cache de qualquer fonte; o `filename` `.html` ou `.htm` fica de fora para não barrar uma fonte que publique HTML | +| Também `FormatError` no adaptador para arquivo que começa com `<` | Quem salva a página de desafio pelo navegador em vez do CSV recebe a mesma orientação | +| Ano e contagem por `parse_count` sem marcadores | A fonte omite a linha suprimida; um marcador novo tem de falhar, como na spec 003 | + +## Foco de revisão + +1. **Esmé em duas codificações** (`0x82` até 2023, `0xE9` em 2024 e 2025) vira um único nome (Task 2: `test_parse_da_amostra_real`). +2. **Nome com `?` ou byte sem leitura** nunca sai do adaptador, e o aviso conta linhas e registros (Task 2: `test_nomes_corrompidos_sao_descartados_e_avisados`). +3. **Página de desafio com status 200** não entra no cache (Task 1: `test_pagina_html_no_lugar_do_arquivo_nao_entra_no_cache`). +4. **Arquivo corrigido pela fonte em UTF-8** é lido sem tabela (Task 2: `test_arquivo_utf8_valido_e_lido_como_utf8`). +5. **Campo a mais ou a menos, sexo desconhecido ou contagem com marcador** falham com `FormatError` e número da linha (Task 2: `test_formato_inesperado_falha`). + +## Arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `src/namescommons/cache.py`, `tests/conftest.py`, `tests/test_cache.py` | Recusa de `text/html` (R4.1); servidor local com `Content-Type` programável | 1 | +| `src/namescommons/adapters/nz_dia_births.py`, `tests/test_nz_dia_births.py`, `tests/fixtures/nz-dia-births/` | Adaptador (R1 a R3, R4.2, R4.4) e amostra (R5) | 2 | +| `sources.yaml`, `tests/fixtures/ATRIBUICAO.md` | Registro da fonte e atribuição da amostra | 2 | +| `.github/workflows/ci.yml`, `tests/test_ci.py` | `NZ` no passo das fontes oficiais (R6) | 3 | + +## Cobertura da spec + +| Requisito | Tarefa | +|---|---| +| R1, R1.1 a R1.5 | 2 | +| R2.1, R2.2 | 2 | +| R2.3 | Sem código: regra para edições futuras, no ADR 0007 e na docstring | +| R3.1, R3.2 | 2 | +| R3.3 | Fechamento (medição com o arquivo real) | +| R4.1 | 1 | +| R4.2, R4.4 | 2 | +| R4.3 | Todas (nenhum contorno, nenhuma dependência) | +| R5 | 2 (`tests/test_fontes_oficiais.py` passa a cobrir a fonte) | +| R6 | 3 | +| R7 | Fechamento | +| Critérios 1 a 4 | 2 | +| Critério 5 | 1 e 2 | +| Critério 6 | 3 e fechamento (CI verde) | +| Critério 7 | Fechamento | +| Critério 8 | Fechamento (`Closes #10`) | diff --git a/docs/specs/003e-nova-zelandia/spec.md b/docs/specs/003e-nova-zelandia/spec.md new file mode 100644 index 0000000..c3d28fe --- /dev/null +++ b/docs/specs/003e-nova-zelandia/spec.md @@ -0,0 +1,107 @@ +# 003e. Nova Zelândia: nomes de bebês do Department of Internal Affairs + +Parte do [roadmap](../ROADMAP.md). Depende da [spec 003](../003-fontes-oficiais/spec.md). Licença dos dados: [ADR 0001](../../adr/0001-licenca-dos-dados.md). Valores suprimidos: [ADR 0002](../../adr/0002-valores-suprimidos.md). Nomes corrompidos na fonte: [ADR 0007](../../adr/0007-nomes-corrompidos-na-fonte.md). Pendências herdadas: issue #10. + +Status: Em PR (#28). + +## Por quê + +A spec 003 (R7) deixou a Nova Zelândia "não avaliada", porque o catálogo `catalogue.data.govt.nz` bloqueia o acesso automático. A issue #10 avaliou a fonte pelo navegador: a licença é CC BY 4.0 e o arquivo é um CSV nacional de 1900 a 2025, com sexo e contagem por ano. A fonte tem duas ressalvas que nenhum adaptador da 003 enfrentou: + +1. **Codificação misturada.** O CSV não é UTF-8 nem uma única codificação legada. Alguns acentos vêm num byte de uma página de código, outros noutra, e as vogais com macron do te reo Māori (ā, ī) vêm como `?` ou como bytes sem correspondência em nenhuma página de código conhecida. +2. **Bloqueio sem 403.** O host (Imperva) responde ao cliente HTTP comum com status 200 e uma página HTML de desafio ("Pardon Our Interruption"), e não com 403. A mensagem da spec 003 (R4) não dispara, e o cache gravaria a página HTML com o nome do CSV. + +Esta spec registra a fonte, decide byte a byte o que pode ser recuperado e o que é descartado, e faz o download bloqueado falhar com a mesma orientação da SSA. + +## Requisitos + +### R1. Fonte + +Uma entrada nova em `sources.yaml` com `primary: true` (nenhuma outra fonte tem o par `NZ`, `births`) e um adaptador em `src/namescommons/adapters/nz_dia_births.py`. Números medidos no arquivo real baixado pelo navegador em 2026-10-12. + +| Campo | Valor | +|---|---| +| `id` | `nz-dia-births` | +| Instituto | Department of Internal Affairs (DIA), conjunto "Baby Name popularity over time" | +| País | NZ | +| `kind` | given | +| `count_type` | births (registros de nascimento por ano) | +| Anos | 1900 a 2025, ano do registro do nascimento e não da data de nascimento | +| Supressão (`suppression`) | nomes com menos de 10 registros no ano são omitidos pela fonte (10) | +| Recurso | `baby-names-1900-to-2025.csv`, https://catalogue.data.govt.nz/dataset/01ee87cd-ecf8-44a1-ad33-b376a689e597/resource/0b0b326c-d720-480f-8f86-bf2d221c7d3f/download/baby-names-1900-to-2025.csv | +| Formato | CSV `Year,Sex,Name,Count`, fim de linha CRLF, sem BOM, sem aspas, quatro campos em toda linha | +| Tamanho medido | 1.326.413 bytes (igual ao `size` do catálogo), SHA-256 `dccaa0ac0b46725cf355174b42ffce5b8e92f9ed0e26c6d5841412f95999e102` | +| Contagens | 74.019 linhas (40.204 F, 33.815 M), 4.516.506 registros somados; contagens de 10 a 1.628 | + +Licença, conferida na página e na API do catálogo (`package_show`): `license_id` `CC-BY-4.0`, "Creative Commons Attribution 4.0 International", organização Department of Internal Affairs. Página: https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time + +Particularidades que o adaptador trata: + +- **R1.1** O cabeçalho é exatamente `Year,Sex,Name,Count`; outro cabeçalho é `FormatError` com o cabeçalho recebido. +- **R1.2** Sexo `F` é feminino e `M` é masculino, por `parse_code`; outro código é `FormatError`. A fonte exclui registros com sexo indeterminado. +- **R1.3** Ano e contagem por `parse_count`, sem marcador de supressão: a fonte não publica a linha suprimida, então qualquer texto não inteiro é `FormatError`. +- **R1.4** Linha com número de campos diferente de quatro é `FormatError` com o número da linha. +- **R1.5** Nomes com espaço (`Te Ariki`, `Te Ao`) e hífen (`Anne-Marie`) são emitidos como publicados. A fonte separa o primeiro nome dos nomes do meio por um processo automático; o adaptador não tenta corrigir essa separação. + +### R2. Codificação do arquivo + +Bytes acima de 0x7F medidos no arquivo real (71 linhas, todas na coluna `Name`), com a leitura de cada página de código candidata (CP437, CP850, Windows-1252, Latin-1 e Mac Roman). Toda página lê algum desses bytes como letra; o que decide a tradução é a posição do byte nos nomes do arquivo real: a letra só é aceita quando toda ocorrência do byte forma um nome atestado. + +| Byte | Linhas | Exemplos | Leituras candidatas | Decisão | +|---|---|---|---|---| +| `0x82` | 38 | `Am_lie`, `Andr_`, `Esm_` (2009 a 2023), `Ren_e` | `é` em CP437 e CP850; `‚` (aspa) em Windows-1252; `Ç` em Mac Roman; controle em Latin-1 | `é`: só essa leitura forma Amélie, André, Danté, Esmé, Renée e Reneé em todas as ocorrências | +| `0xE9` | 25 | `Zo_`, `Esm_` (2024 e 2025) | `é` em Latin-1 e Windows-1252; `Ú` em CP850; `Θ` em CP437; `È` em Mac Roman | `é`: só essa leitura forma Zoé e Esmé; `ZoÚ`, `ZoΘ` e `ZoÈ` não são nomes atestados | +| `0xF6` | 1 | `Bj_rn` | `ö` em Latin-1 e Windows-1252; `÷` em CP437 e CP850; `ˆ` em Mac Roman | `ö`: forma Björn; as outras leituras não são letras | +| `0x8D` | 4 | `N_kau` | `ì` em CP437 e CP850; `ç` em Mac Roman; indefinido em Windows-1252; controle em Latin-1 | Sem tradução: o nome é Nīkau, e nenhuma leitura dá `ī` (`Nìkau` e `Nçkau` não são atestados) | +| `0x8F` | 3 | `K_whai` | `Å` em CP437 e CP850; `è` em Mac Roman; indefinido em Windows-1252; controle em Latin-1 | Sem tradução: o nome é Kāwhai, e nenhuma leitura dá `ā` (`KÅwhai` e `Kèwhai` não são atestados) | + +Nenhuma codificação única lê o arquivo: `Esmé` aparece com `0x82` até 2023 e com `0xE9` em 2024 e 2025, e o arquivo não é UTF-8 válido (byte `0x82` na posição 54.334). + +- **R2.1** Se o arquivo inteiro for UTF-8 válido, o adaptador o lê como UTF-8 (uma edição corrigida da fonte passa a valer sem mudança de código). +- **R2.2** Senão, o adaptador lê os bytes até 0x7F como ASCII e traduz os bytes acima de 0x7F por uma tabela fechada: `0x82` e `0xE9` viram `é`, `0xF6` vira `ö`. Qualquer outro byte acima de 0x7F vira U+FFFD, o que torna o nome corrompido (R3). +- **R2.3** A tabela só ganha um byte novo com evidência do arquivo real registrada nesta spec (leituras candidatas e nomes atestados em todas as ocorrências), conforme o ADR 0007. + +### R3. Nomes corrompidos na fonte + +- **R3.1** Nome que, depois de R2, contém `?` ou U+FFFD é descartado: nenhum registro é emitido para a linha ([ADR 0007](../../adr/0007-nomes-corrompidos-na-fonte.md)). +- **R3.2** Ao terminar o arquivo, se houve descarte, o adaptador registra um aviso no logger `namescommons.adapters.nz_dia_births` com o número de linhas descartadas, a soma das contagens delas e as formas distintas, por exemplo `nz-dia-births: 13 linha(s) com nome corrompido na fonte descartada(s), 164 registro(s): '?io', ...`. O aviso aparece no `stderr` da CLI sem configuração de logging. +- **R3.3** Medido no arquivo real: 64 linhas recuperadas por R2.2 (`Amélie`, `André`, `Björn`, `Danté`, `Esmé`, `Reneé`, `Renée`, `Zoé`) e 13 linhas descartadas, com 164 registros: `?io` (2), `?ria`, `M?hina`, `M?ia`, `N?kau`, `K�whai` (3) e `N�kau` (4), em que `�` é U+FFFD. Restam 74.006 linhas e 2.856 nomes distintos. O `ingest` produz 73.991 observações e 2.854 nomes: 15 pares de linhas do mesmo ano e sexo diferem só na caixa (`Jo-Anne` e `Jo-anne`, `MacKenzie` e `Mackenzie`, `McKenzie` e `Mckenzie`) e são somados pela chave da spec 002 (R1.4), com 4.516.342 registros no total; a forma exibida de cada par é a de maior contagem (spec 002, R1.4, como ajustada no PR #21). + +### R4. Download bloqueado com status 200 + +- **R4.1** Quando a resposta de um download tem `Content-Type` `text/html` e o `filename` do recurso não termina em `.html` ou `.htm`, `fetch` não grava nada no cache e falha sem novas tentativas, com uma mensagem que diz que o servidor devolveu uma página HTML no lugar do arquivo (provável bloqueio de download automático), traz a URL e o caminho exato no cache onde gravar o arquivo baixado pelo navegador, para seguir com `--offline`. A regra vale para todas as fontes, como o 403 da spec 003 (R4). +- **R4.2** Se o arquivo no cache começar com `<` (por exemplo, a página de desafio gravada à mão pelo navegador), o adaptador levanta `FormatError` dizendo que o arquivo parece uma página HTML e indicando o caminho a substituir pelo CSV. +- **R4.3** Nenhum contorno de bloqueio é feito: sem troca de `User-Agent`, sem navegador automatizado, sem dependência nova. +- **R4.4** O procedimento manual (abrir a página do conjunto no navegador, baixar o CSV e gravá-lo em `/nz-dia-births/baby-names-1900-to-2025.csv`) fica na docstring do adaptador. + +### R5. Amostra e testes + +- `tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv` tem o cabeçalho e linhas copiadas byte a byte do arquivo real, com CRLF, menos de 20 KB, incluindo: nomes com `0x82`, `0xE9` e `0xF6`; `Esmé` nas duas codificações; um nome com `?` e dois com bytes sem leitura; nomes Māori sem macron (`Aroha`, `Nikau`, `Te Ariki`); um nome nos dois sexos (`Te Ao`); a primeira e a última linha do arquivo. +- `tests/fixtures/ATRIBUICAO.md` ganha a linha da fonte, ao fim da tabela. +- Testes de `parse` sobre a amostra com os registros esperados escritos à mão, de cada regra de R1.1 a R1.4, R2.1, R2.2, R3.1, R3.2, R4.1 e R4.2, todos sem rede. + +### R6. CI + +O passo da CI que roda as fontes oficiais sobre as amostras confere também `by_country/NZ.csv`. + +### R7. Desempenho + +Com o arquivo real no cache, `namescommons ingest nz-dia-births --offline` termina em poucos segundos (medido num Apple M4: 1,2 s e 114 MB de pico). A fonte é 2,4% do volume medido na spec 003 (R6) e não muda a margem de memória. + +## Critérios de aceite + +1. `sources.yaml` tem `nz-dia-births` com `license: CC-BY-4.0`, `country: NZ`, `count_type: births`, `suppression: 10`, e `load_sources` o aceita. +2. `parse` sobre a amostra real produz exatamente os registros escritos à mão no teste, com `Esmé` das duas codificações, `Björn` e `Zoé` corretos, e sem nenhum nome com `?` ou U+FFFD. +3. O descarte de nomes corrompidos gera um aviso com o número de linhas, a soma das contagens e as formas descartadas (teste com `caplog`). +4. Um arquivo UTF-8 válido com `Māia` é lido como UTF-8 e emite `Māia`. +5. Um download respondido com 200 e `text/html` falha sem tentar de novo, não deixa arquivo no cache e diz onde gravar o arquivo baixado pelo navegador; um arquivo HTML já no cache gera `FormatError` com o caminho. +6. `namescommons run --offline` sobre o cache montado com `tests/fixtures/` gera `by_country/NZ.csv`, e a CI roda verde no pull request. +7. Com o arquivo real, o adaptador emite 74.006 registros, `ingest --offline` da fonte produz 73.991 observações (R3.3) e avisa 13 linhas descartadas com 164 registros; tempo e memória medidos na descrição do pull request. +8. A issue #10 é fechada pelo pull request. + +## Fora de escopo + +- Recuperar as vogais com macron (`ā`, `ī`) a partir de `?`, `0x8D` ou `0x8F`: não há página de código que as leia, e `?` perde a informação. Fica a cargo da fonte corrigir o arquivo; R2.1 o aceita sem mudança de código. +- A planilha `top-names-by-year-1900-2025.xlsx` do mesmo conjunto: traz os mesmos nomes ordenados por ano e exige leitor de planilha (spec 003b). +- Corrigir a separação automática do primeiro nome feita pela fonte (R1.5). +- Download automatizado por navegador ou qualquer contorno do bloqueio da Imperva. diff --git a/docs/specs/003e-nova-zelandia/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md new file mode 100644 index 0000000..ab6ddef --- /dev/null +++ b/docs/specs/003e-nova-zelandia/tasks.md @@ -0,0 +1,62 @@ +# 003e. Nova Zelândia: tarefas + +Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na raiz do repositório, a partir de `master` com a spec 003. Antes da Task 1, rodar `uv sync` e conferir `uv run pytest -q` com `318 passed`. + +Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, escrever o código, ver que passam, commit. + +--- + +### Task 1: página HTML no lugar do arquivo (R4.1) + +**Arquivos:** +- Modificar: `src/namescommons/cache.py` +- Modificar (teste): `tests/conftest.py`, `tests/test_cache.py` + +**Interfaces:** +- Consome: `fetch_resource`, `_baixar`, `FetchError` (spec 002); servidor local (spec 002, R7). +- Produz: `namescommons.cache.BlockedError(FetchError)`; `ServidorLocal.tipos: dict[str, str]` (caminho -> `Content-Type`). + +- [x] **Passo 1: testes que falham** em `tests/test_cache.py` + - `test_pagina_html_no_lugar_do_arquivo_nao_entra_no_cache`: o servidor responde 200 com `text/html; charset=UTF-8` e a página "Pardon Our Interruption" para `/a.csv`; `fetch_resource` levanta `BlockedError` cuja mensagem tem "página HTML", a URL, o caminho `tmp_path/exemplo/a.csv` e `--offline`; o servidor recebeu uma única requisição; nem `a.csv`, nem `a.csv.part`, nem `a.csv.meta.json` existem. + - `test_html_e_aceito_quando_o_recurso_e_html`: recurso `pagina.html` servido como `text/html` é gravado. + - `test_csv_servido_como_text_csv_continua_aceito`: `text/csv` não muda nada. +- [x] **Passo 2: código.** `ServidorLocal` envia `Content-Type` quando o caminho está em `tipos`. Em `_baixar`, depois de abrir a resposta e antes de gravar, se o tipo de mídia (antes do `;`, sem caixa) for `text/html` e o `destino` não terminar em `.html` ou `.htm`, levantar `BlockedError`. Em `fetch_resource`, `BlockedError` sai do laço sem nova tentativa e a mensagem final é `falha ao baixar : o servidor devolveu uma página HTML (text/html) no lugar de , provável bloqueio de download automático: baixe o arquivo pelo navegador, grave-o em e rode com --offline`. +- [x] **Passo 3:** `uv run pytest -q` verde; commit. + +### Task 2: adaptador `nz-dia-births` (R1 a R3, R4.2, R5) + +**Arquivos:** +- Criar: `src/namescommons/adapters/nz_dia_births.py`, `tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv` +- Modificar: `sources.yaml`, `tests/fixtures/ATRIBUICAO.md` (linha nova ao fim) +- Teste: `tests/test_nz_dia_births.py` + +**Interfaces:** +- Consome: `FormatError`, `RawRecord`, `Resource`, `parse_code`, `parse_count` (spec 003); `servir_fixtures` e `tests/test_fontes_oficiais.py` (spec 003). +- Produz: fonte `nz-dia-births` (`country: NZ`, `count_type: births`, `suppression: 10`, `license: CC-BY-4.0`); `parse(files)` que avisa os descartes no logger `namescommons.adapters.nz_dia_births`. + +- [x] **Passo 1: gerar a amostra** com um script que copia do arquivo real, sem alteração e na ordem original, o cabeçalho e as linhas: `2022,F,Aadhya,13` (primeira), `2003,M,Andr\x82,20`, `1967,F,Anne-Marie,24`, `2025,F,Aroha,22`, `2025,M,Bj\xf6rn,10`, `2022,F,Esm\x82,25`, `2025,F,Esm\xe9,20`, `1900,M,John,636`, `2023,F,K\x8fwhai,11`, `2025,F,M?ia,25`, `1900,F,Mary,410`, `2023,M,N\x8dkau,13`, `2025,M,Nikau,36`, `2024,F,Te Ao,15`, `2024,M,Te Ao,12`, `2025,M,Te Ariki,34`, `2007,F,Zo\xe9,26` e `2015,F,Zoya,11` (última), cada uma terminada em CRLF. +- [x] **Passo 2: testes que falham** em `tests/test_nz_dia_births.py` + - `test_parse_da_amostra_real`: os 15 registros esperados, escritos à mão, com `André`, `Björn`, `Esmé` (2022 e 2025) e `Zoé`. + - `test_nomes_corrompidos_sao_descartados_e_avisados`: nenhum nome com `?` ou U+FFFD; um aviso com `3 linha(s)`, `49 registro(s)` e as formas `K�whai`, `M?ia`, `N�kau`. + - `test_sem_descarte_nao_avisa`. + - `test_arquivo_utf8_valido_e_lido_como_utf8`: `Māia` e `Zoé` em UTF-8 saem como estão. + - `test_byte_fora_da_tabela_descarta_o_nome` (ex.: `0xE1`). + - `test_formato_inesperado_falha`: cabeçalho diferente, sexo `U`, contagem `<10`, ano `20x5`, linha com três ou cinco campos, arquivo vazio, cada um com `FormatError` e número da linha quando houver. + - `test_pagina_html_no_cache_explica_o_que_fazer`: arquivo que começa com `` gera `FormatError` com "página HTML" e o caminho do arquivo. +- [x] **Passo 3: código.** Adaptador com `RESOURCES` apontando para a URL de download do catálogo, decodificação de R2, `csv.reader` sobre o texto decodificado, `parse_code` para o sexo e `parse_count` para ano e contagem. Acrescentar ao fim de `sources.yaml` a entrada `nz-dia-births` (`url` da página do conjunto, `attribution` "Department of Internal Affairs (Nova Zelândia), Baby Name popularity over time, CC BY 4.0", `retrieved: 2026-10-12`) e ao fim de `ATRIBUICAO.md` a linha da amostra. +- [x] **Passo 4:** atualizar em `tests/test_ci.py` o total esperado do passo das fontes oficiais (`54 observações, 5 países`), consequência direta da fonte nova; `uv run pytest -q` verde (inclui `test_amostra_e_atribuicao` e `test_fetch_e_ingest_sobre_a_amostra` para a fonte nova); commit. + +### Task 3: CI (R6) + +**Arquivos:** +- Modificar: `.github/workflows/ci.yml`, `tests/test_ci.py` + +- [x] **Passo 1: teste que falha.** `test_passo_das_fontes_oficiais_roda_localmente` confere que o laço do passo inclui `NZ`, que o `stderr` traz o aviso de descarte e `by_country/NZ.csv` escrito à mão. +- [x] **Passo 2:** acrescentar `NZ` ao laço `for pais in ...` do passo das fontes oficiais. +- [x] **Passo 3:** `uv run pytest -q` verde; commit. + +### Fechamento + +- [x] Rodar `namescommons ingest nz-dia-births --offline` com o arquivo real num cache do scratchpad; conferir 74.006 registros do adaptador, 73.991 observações depois da soma por caixa (spec, R3.3) e o aviso de 13 linhas e 164 registros; anotar tempo na descrição do pull request. +- [x] Conferir os critérios de aceite da spec, um a um. +- [ ] Pull request com `Closes #10`. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index 721b83d..7a77ae9 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -9,11 +9,12 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 1 | [`001-esquema`](001-esquema/spec.md) | nenhuma | Tabela canônica de observações, saídas agregadas, layout do repositório, versionamento | Mesclada | | 2 | [`002-pipeline`](002-pipeline/spec.md) | 001 | Estrutura de adaptadores, cache de downloads, CLI, CI, testes | Mesclada | | 3 | [`003-fontes-oficiais`](003-fontes-oficiais/spec.md) | 002 | Primeiro lote de institutos nacionais em texto (SSA, INSEE, CSO, Statistics Norway) e regras do contrato do adaptador da issue #8 | Mesclada | -| 3b | [`003b-fontes-planilhas`](003b-fontes-planilhas/spec.md) | 003 | Leitor de XLS e XLSX e institutos que publicam em planilha (INE e outros) | Em PR | +| 3b | [`003b-fontes-planilhas`](003b-fontes-planilhas/spec.md) | 003 | Leitor de XLS e XLSX e institutos que publicam em planilha (INE e outros) | Mesclada | | 3c | `003c-fontes-subnacionais` | 003b | Regra para fontes que cobrem parte de um país e seus adaptadores (ONS, Retraite Québec e outros) | Planejada | | 3d | [`003d-statistics-canada`](003d-statistics-canada/spec.md) | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Mesclada | +| 3e | [`003e-nova-zelandia`](003e-nova-zelandia/spec.md) | 003 | Nomes de bebês do Department of Internal Affairs da Nova Zelândia e regra para nomes corrompidos na fonte | Em PR | | 4 | `004-wikidata` | 002 | Adaptador do Wikidata: nomes, gênero, escrita e ligações entre variantes | Planejada | | 5 | `005-variantes` | 003, 004 | Transliteração para ASCII, agrupamento de variantes (`group_id`), deduplicação | Planejada | | 6 | `006-publicacao` | 005 | Releases, manifesto, README, cartão do dataset, retirada do legado | Planejada | -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). +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). diff --git a/sources.yaml b/sources.yaml index 831ab84..7323c4a 100644 --- a/sources.yaml +++ b/sources.yaml @@ -73,3 +73,13 @@ sources: suppression: 20 retrieved: 2026-10-12 uniform_case: true + - id: nz-dia-births + name: Department of Internal Affairs, Baby Name popularity over time (Nova Zelândia) + url: https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time + license: CC-BY-4.0 + attribution: "Department of Internal Affairs (Nova Zelândia), Baby Name popularity over time, CC BY 4.0" + country: NZ + count_type: births + primary: true + suppression: 10 + retrieved: 2026-10-12 diff --git a/src/namescommons/adapters/nz_dia_births.py b/src/namescommons/adapters/nz_dia_births.py new file mode 100644 index 0000000..868d9dc --- /dev/null +++ b/src/namescommons/adapters/nz_dia_births.py @@ -0,0 +1,90 @@ +"""DIA, nomes de bebês da Nova Zelândia, "Baby Name popularity over time" (spec 003e). + +O CSV traz Year,Sex,Name,Count, com o ano do registro do nascimento. A fonte omite nomes com +menos de 10 registros no ano; não há marcador de supressão no arquivo. + +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. + +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 +no navegador, baixe o CSV e grave-o em /nz-dia-births/baby-names-1900-to-2025.csv; depois rode +com --offline. +""" + +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 + +SOURCE_ID = "nz-dia-births" +_ARQUIVO = "baby-names-1900-to-2025.csv" +RESOURCES = [ + Resource( + "https://catalogue.data.govt.nz/dataset/01ee87cd-ecf8-44a1-ad33-b376a689e597" + f"/resource/0b0b326c-d720-480f-8f86-bf2d221c7d3f/download/{_ARQUIVO}", + _ARQUIVO, + ) +] + +_CABECALHO = ["Year", "Sex", "Name", "Count"] +_SEXO = {"F": "F", "M": "M"} +# Byte -> letra, só quando todas as ocorrências do byte no arquivo real formam um nome atestado +# (spec 003e, R2.2, e ADR 0007): 0x82 é 'é' em CP437/CP850 (Amélie, André, Esmé); 0xE9 e 0xF6 são +# 'é' e 'ö' em Latin-1 (Zoé, Esmé, Björn). Os demais bytes de 0x80 a 0xFF viram U+FFFD. +_LEGADO = {0x82: "é", 0xE9: "é", 0xF6: "ö"} +_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: + return dados.decode("utf-8") + except UnicodeDecodeError: + return dados.decode("latin-1").translate(_TABELA) + + +def parse(files: dict[str, Path]) -> Iterator[RawRecord]: + caminho = files[_ARQUIVO] + dados = caminho.read_bytes() + if dados.lstrip().startswith(b"<"): + raise FormatError( + f"{caminho} parece uma página HTML (bloqueio do catálogo), e não o CSV: baixe o CSV pelo navegador " + "em https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time e grave-o nesse caminho" + ) + leitor = csv.reader(io.StringIO(_decodificar(dados).removeprefix("\ufeff"), newline="")) + cabecalho = next(leitor, None) + if cabecalho is None: + 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}") + ano, sexo, nome, contagem = campos + try: + registro = RawRecord( + name=nome, kind="given", country="NZ", gender=parse_code(sexo, _SEXO, "sexo"), + year=parse_count(ano, field="ano"), count=parse_count(contagem), + ) + 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 + 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/cache.py b/src/namescommons/cache.py index 01d1bed..60b86c8 100644 --- a/src/namescommons/cache.py +++ b/src/namescommons/cache.py @@ -27,6 +27,13 @@ class FetchError(Exception): """Falha ao obter um recurso, da rede ou do cache.""" +class BlockedError(FetchError): + """O servidor devolveu uma página HTML no lugar do arquivo (spec 003e, R4.1); repetir não adianta.""" + + +_PAGINAS = (".html", ".htm") + + def cached_path(cache_dir: Path, source_id: str, filename: str) -> Path: return Path(cache_dir) / source_id / filename @@ -44,12 +51,16 @@ def _baixar(url: str, destino: Path, timeout: float) -> dict: h = hashlib.sha256() tamanho = 0 try: - with urllib.request.urlopen(pedido, timeout=timeout) as resposta, open(temporario, "wb") as fh: - anunciado = resposta.headers.get("Content-Length") - while bloco := resposta.read(BLOCO): - fh.write(bloco) - h.update(bloco) - tamanho += len(bloco) + with urllib.request.urlopen(pedido, timeout=timeout) as resposta: + tipo = resposta.headers.get_content_type() # sem parâmetros e em minúsculas + if tipo == "text/html" and not destino.name.lower().endswith(_PAGINAS): + raise BlockedError(f"o servidor devolveu uma página HTML ({tipo}) no lugar de {destino.name}") + with open(temporario, "wb") as fh: + anunciado = resposta.headers.get("Content-Length") + while bloco := resposta.read(BLOCO): + fh.write(bloco) + h.update(bloco) + tamanho += len(bloco) if anunciado is not None: try: esperado = int(anunciado) @@ -111,6 +122,11 @@ def fetch_resource( try: _baixar(resource.url, destino, timeout) return destino + except BlockedError as e: + raise BlockedError( + f"falha ao baixar {resource.url}: {e}, provável bloqueio de download automático: " + f"baixe o arquivo pelo navegador, grave-o em {destino} e rode com --offline" + ) from e except (OSError, HTTPException, FetchError) as e: ultimo = e feitas = i + 1 diff --git a/tests/conftest.py b/tests/conftest.py index 51a7604..9e077f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,7 @@ def __init__(self): self.truncados: set[str] = set() # caminhos que anunciam o dobro do tamanho e fecham self.lentos: dict[str, float] = {} # caminho -> segundos de espera antes de responder self.recusados: set[str] = set() # caminhos que respondem 403, como um servidor que bloqueia robôs + self.tipos: dict[str, str] = {} # caminho -> Content-Type enviado com a resposta self.respostas: dict[str, list[int]] = {} # caminho -> códigos HTTP servidos em sequência antes de servir self.cabecalho_tamanho: dict[str, str] = {} # caminho -> Content-Length literal (para valores malformados) self.requisicoes: list[tuple[str, str]] = [] # (caminho, User-Agent) @@ -101,6 +102,8 @@ def do_GET(self): anunciado = len(corpo) * 2 if self.path in servidor.truncados else len(corpo) self.send_response(200) self.send_header("Content-Length", servidor.cabecalho_tamanho.get(self.path, str(anunciado))) + if self.path in servidor.tipos: + self.send_header("Content-Type", servidor.tipos[self.path]) self.end_headers() self.wfile.write(corpo) diff --git a/tests/fixtures/ATRIBUICAO.md b/tests/fixtures/ATRIBUICAO.md index 5be8a5a..afcee1f 100644 --- a/tests/fixtures/ATRIBUICAO.md +++ b/tests/fixtures/ATRIBUICAO.md @@ -11,3 +11,4 @@ Cada diretório guarda um trecho pequeno do arquivo real da fonte, só para os t | `no-ssb-etternavn` | CC-BY-4.0 | Statistics Norway (SSB), tabela 12891, https://www.ssb.no/en/statbank/table/12891 | sobrenomes HANSEN, DA-SILVA e ABDO nos anos 2020 e 2025 | | `ca-statcan-births` | statcan-open-licence | Adapted from Statistics Canada, First names at birth by sex at birth, selected indicators (table 17-10-0147-01), 1991 to 2025. This does not constitute an endorsement by Statistics Canada of this product. 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. https://doi.org/10.25318/1710014701-eng | cabeçalho e 20 linhas de `17100147.csv` (anos 1991 e 2025), no membro de mesmo nome | | `es-ine-residentes` | CC-BY-4.0 | Elaboración propia con datos extraídos del sitio web del INE: www.ine.es, Apellidos y nombres más frecuentes, datos a 01/01/2025, https://ine.es/dyngs/INEbase/operacion.htm?c=Estadistica_C&cid=1254736177009&menu=resultados&idp=1254735572981 | cabeçalho e 6 linhas de cada aba de `nombres_por_edad_media.xlsx` (XML original, textos compartilhados reindexados); cabeçalho e 9 linhas de `apellidos_frecuencia.xls`, regravadas em BIFF8 nas mesmas linhas e colunas | +| `nz-dia-births` | CC-BY-4.0 | Department of Internal Affairs (Nova Zelândia), Baby Name popularity over time, https://catalogue.data.govt.nz/dataset/baby-name-popularity-over-time | cabeçalho e 18 linhas de `baby-names-1900-to-2025.csv`, bytes copiados sem alteração (inclusive os acentos em CP437 e Latin-1 e os nomes corrompidos na origem), baixado pelo navegador porque o catálogo recusa o download automático | diff --git a/tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv b/tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv new file mode 100644 index 0000000..b69be07 --- /dev/null +++ b/tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv @@ -0,0 +1,19 @@ +Year,Sex,Name,Count +2022,F,Aadhya,13 +2003,M,Andr‚,20 +1967,F,Anne-Marie,24 +2025,F,Aroha,22 +2025,M,Björn,10 +2022,F,Esm‚,25 +2025,F,Esmé,20 +1900,M,John,636 +2023,F,K�whai,11 +2025,F,M?ia,25 +1900,F,Mary,410 +2023,M,N�kau,13 +2025,M,Nikau,36 +2024,F,Te Ao,15 +2024,M,Te Ao,12 +2025,M,Te Ariki,34 +2007,F,Zoé,26 +2015,F,Zoya,11 diff --git a/tests/test_cache.py b/tests/test_cache.py index 9886e9f..a0c31ff 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,7 +7,7 @@ import pytest from namescommons.adapters import Resource -from namescommons.cache import FetchError, cached_path, fetch_resource +from namescommons.cache import BlockedError, FetchError, cached_path, fetch_resource CORPO = "nome,sexo,ano,quantidade\nMARIA,F,2020,100\n".encode() @@ -193,3 +193,33 @@ def falha_na_troca_dos_dados(origem, destino): assert (tmp_path / "exemplo" / "a.csv").read_bytes() == antigo meta = tmp_path / "exemplo" / "a.csv.meta.json" assert not meta.exists() or json.loads(meta.read_text(encoding="utf-8"))["sha256"] == hashlib.sha256(antigo).hexdigest() + + +# Spec 003e, R4.1: servidor que responde 200 com uma página HTML de bloqueio no lugar do arquivo ----- + +DESAFIO = b"\nPardon Our Interruption\n" + + +def test_pagina_html_no_lugar_do_arquivo_nao_entra_no_cache(servidor, tmp_path): + recurso = _recurso(servidor, corpo=DESAFIO) + servidor.tipos["/a.csv"] = "text/html; charset=UTF-8" + with pytest.raises(BlockedError) as e: + fetch_resource(tmp_path, "exemplo", recurso, espera=0) + mensagem = str(e.value) + assert "página HTML (text/html) no lugar de a.csv" in mensagem + assert servidor.url("/a.csv") in mensagem + assert f"grave-o em {tmp_path / 'exemplo' / 'a.csv'} e rode com --offline" in mensagem + assert servidor.caminhos() == ["/a.csv"] + assert sorted(p.name for p in (tmp_path / "exemplo").iterdir()) == [] + + +def test_html_e_aceito_quando_o_recurso_e_html(servidor, tmp_path): + recurso = _recurso(servidor, caminho="/pagina.html", corpo=DESAFIO) + servidor.tipos["/pagina.html"] = "Text/HTML" + assert fetch_resource(tmp_path, "exemplo", recurso).read_bytes() == DESAFIO + + +def test_csv_servido_como_text_csv_continua_aceito(servidor, tmp_path): + recurso = _recurso(servidor) + servidor.tipos["/a.csv"] = "text/csv; charset=utf-8" + assert fetch_resource(tmp_path, "exemplo", recurso).read_bytes() == CORPO diff --git a/tests/test_ci.py b/tests/test_ci.py index 09d9b48..f5b2b18 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -11,7 +11,7 @@ import yaml RAIZ = Path(__file__).resolve().parent.parent -FONTES_OFICIAIS = ("fr-insee-births", "ca-statcan-births", "es-ine-residentes", "ie-cso-births", "no-ssb-etternavn", "no-ssb-fornavn", "us-ssa-births") +FONTES_OFICIAIS = ("fr-insee-births", "ca-statcan-births", "es-ine-residentes", "ie-cso-births", "no-ssb-etternavn", "no-ssb-fornavn", "nz-dia-births", "us-ssa-births") WORKFLOW = RAIZ / ".github" / "workflows" / "ci.yml" @@ -132,6 +132,24 @@ def test_passo_offline_da_ci_roda_localmente(tmp_path): """ +NZ_CSV = """\ +kind,name,name_key,ascii,script,count,count_type,rank,female_share,gender_basis,first_year,last_year +given,John,john,john,Latn,636,births,1,0.000000,636,1900,1900 +given,Mary,mary,mary,Latn,410,births,2,1.000000,410,1900,1900 +given,Esmé,esmé,esme,Latn,45,births,3,1.000000,45,2022,2025 +given,Nikau,nikau,nikau,Latn,36,births,4,0.000000,36,2025,2025 +given,Te Ariki,te ariki,te ariki,Latn,34,births,5,0.000000,34,2025,2025 +given,Te Ao,te ao,te ao,Latn,27,births,6,0.555556,27,2024,2024 +given,Zoé,zoé,zoe,Latn,26,births,7,1.000000,26,2007,2007 +given,Anne-Marie,anne-marie,anne-marie,Latn,24,births,8,1.000000,24,1967,1967 +given,Aroha,aroha,aroha,Latn,22,births,9,1.000000,22,2025,2025 +given,André,andré,andre,Latn,20,births,10,0.000000,20,2003,2003 +given,Aadhya,aadhya,aadhya,Latn,13,births,11,1.000000,13,2022,2022 +given,Zoya,zoya,zoya,Latn,11,births,12,1.000000,11,2015,2015 +given,Björn,björn,bjorn,Latn,10,births,13,0.000000,10,2025,2025 +""" + + @pytest.mark.skipif(shutil.which("uv") is None, reason="uv fora do PATH") def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): """Spec 003, R5: a CI roda as fontes de sources.yaml sobre um cache montado com as amostras.""" @@ -140,7 +158,7 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): r = subprocess.run(["bash", "-e", "-c", passo["run"]], cwd=RAIZ, env=ambiente, capture_output=True, text=True, timeout=300) assert r.returncode == 0, r.stderr - resumo = re.search(r"OK: (\d+) observações, 6 países", r.stdout) + resumo = re.search(r"OK: (\d+) observações, 7 países", r.stdout) assert resumo, r.stdout assert int(resumo.group(1)) == pq.ParquetFile(tmp_path / "dist-oficiais" / "observations.parquet").metadata.num_rows > 0 assert sorted(p.name for p in (tmp_path / "cache-oficiais").iterdir()) == sorted(FONTES_OFICIAIS) @@ -148,9 +166,12 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): assert (tmp_path / "dist-oficiais" / "by_country" / "CA.csv").read_text(encoding="utf-8") == CA_CSV # Spec 003b, R4: Espanha com nomes e sobrenomes population de 2025; ANDREA dos dois sexos 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 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 US;" in passo["run"] + assert "for pais in CA ES FR IE NO NZ US;" in passo["run"] diff --git a/tests/test_nz_dia_births.py b/tests/test_nz_dia_births.py new file mode 100644 index 0000000..373510e --- /dev/null +++ b/tests/test_nz_dia_births.py @@ -0,0 +1,103 @@ +"""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 + +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 +def dia(): + return load_adapter("nz-dia-births") + + +def _arquivos(tmp_path, conteudo: bytes) -> dict[str, Path]: + (tmp_path / ARQUIVO).write_bytes(conteudo) + return {ARQUIVO: tmp_path / ARQUIVO} + + +def _r(nome, sexo, ano, contagem): + return RawRecord(nome, "given", "NZ", sexo, ano, contagem) + + +def test_parse_da_amostra_real(dia): + assert list(dia.parse({ARQUIVO: AMOSTRA})) == [ + _r("Aadhya", "F", 2022, 13), + _r("André", "M", 2003, 20), # 0x82, CP437 + _r("Anne-Marie", "F", 1967, 24), + _r("Aroha", "F", 2025, 22), + _r("Björn", "M", 2025, 10), # 0xF6, Latin-1 + _r("Esmé", "F", 2022, 25), # 0x82 + _r("Esmé", "F", 2025, 20), # 0xE9, mesmo nome noutra codificação + _r("John", "M", 1900, 636), + _r("Mary", "F", 1900, 410), + _r("Nikau", "M", 2025, 36), + _r("Te Ao", "F", 2024, 15), + _r("Te Ao", "M", 2024, 12), + _r("Te Ariki", "M", 2025, 34), + _r("Zoé", "F", 2007, 26), # 0xE9 + _r("Zoya", "F", 2015, 11), + ] + + +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_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_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é"] + + +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"] + + +@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"] + + +@pytest.mark.parametrize("conteudo, trecho", [ + (b"Ano,Sexo,Nome,Total\r\n2025,F,Mia,1\r\n", r"cabeçalho \['Ano', 'Sexo', 'Nome', 'Total'\]"), + (b"", "arquivo vazio"), + (CABECALHO + b"2025,U,Mia,12\r\n", "linha 2: sexo 'U' desconhecido"), + (CABECALHO + b"2025,F,Mia,<10\r\n", "linha 2: contagem '<10'"), + (CABECALHO + b"2025,F,Mia,12\r\n20x5,F,Mia,12\r\n", "linha 3: ano '20x5'"), + (CABECALHO + b"2025,F,Mia\r\n", "linha 2: esperava 4 campos, veio 3"), + (CABECALHO + b"2025,F,Mia,Rose,12\r\n", "linha 2: esperava 4 campos, veio 5"), +]) +def test_formato_inesperado_falha(dia, tmp_path, conteudo, trecho): + with pytest.raises(FormatError, match=trecho): + list(dia.parse(_arquivos(tmp_path, conteudo))) + + +def test_pagina_html_no_cache_explica_o_que_fazer(dia, tmp_path): + arquivos = _arquivos(tmp_path, b"\nPardon Our Interruption") + with pytest.raises(FormatError) as e: + list(dia.parse(arquivos)) + assert "página HTML" in str(e.value) + assert str(tmp_path / ARQUIVO) in str(e.value)