From d7e8f2500995c0eabab7dc3e86b8fa1891842375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 11:59:18 -0300 Subject: [PATCH 1/7] =?UTF-8?q?Especifica=20a=20fonte=20da=20Nova=20Zel?= =?UTF-8?q?=C3=A2ndia=20(spec=20003e)=20e=20o=20ADR=200006?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registra o conjunto "Baby Name popularity over time" do Department of Internal Affairs (CC BY 4.0), a codificação misturada medida byte a byte no CSV real, o descarte de nomes corrompidos na fonte e o bloqueio da Imperva que responde 200 com página HTML. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0006-nomes-corrompidos-na-fonte.md | 25 +++++ docs/specs/003e-nova-zelandia/spec.md | 107 ++++++++++++++++++++ docs/specs/ROADMAP.md | 3 +- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0006-nomes-corrompidos-na-fonte.md create mode 100644 docs/specs/003e-nova-zelandia/spec.md diff --git a/docs/adr/0006-nomes-corrompidos-na-fonte.md b/docs/adr/0006-nomes-corrompidos-na-fonte.md new file mode 100644 index 0000000..9f72c72 --- /dev/null +++ b/docs/adr/0006-nomes-corrompidos-na-fonte.md @@ -0,0 +1,25 @@ +# 0006. 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 nenhuma página de código lê 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: uma página de código o lê como letra, as outras candidatas o leem como pontuação, símbolo ou nada, e os nomes do arquivo real em que ele ocorre confirmam a letra. 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. +- Toda fonte nova precisa ter os bytes não ASCII inspecionados antes do adaptador. diff --git a/docs/specs/003e-nova-zelandia/spec.md b/docs/specs/003e-nova-zelandia/spec.md new file mode 100644 index 0000000..af4e9b1 --- /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 0006](../../adr/0006-nomes-corrompidos-na-fonte.md). Pendências herdadas: issue #10. + +Status: Em implementação. + +## 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`): + +| Byte | Linhas | Exemplos | Leitura | +|---|---|---|---| +| `0x82` | 38 | `Am_lie`, `Andr_`, `Esm_` (2009 a 2023), `Ren_e` | `é` em CP437 e CP850; em Windows-1252 é a aspa `‚`, que não ocorre em nome | +| `0xE9` | 25 | `Zo_`, `Esm_` (2024 e 2025) | `é` em Latin-1 e Windows-1252 | +| `0xF6` | 1 | `Bj_rn` | `ö` em Latin-1 e Windows-1252; em CP437 é `÷` | +| `0x8D` | 4 | `N_kau` | Sem leitura: indefinido em Windows-1252, `ì` em CP437; o nome é Nīkau | +| `0x8F` | 3 | `K_whai` | Sem leitura: indefinido em Windows-1252, `Å` em CP437; o nome é Kāwhai | + +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 (página de código e nomes em que ocorre), conforme o ADR 0006. + +### 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 0006](../../adr/0006-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. + +### 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. 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, `ingest --offline` da fonte produz 74.006 observações e avisa 13 linhas descartadas; tempo medido 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/ROADMAP.md b/docs/specs/ROADMAP.md index 6ef4819..07bebe6 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -12,8 +12,9 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 3b | `003b-fontes-planilhas` | 003 | Leitor de XLS e XLSX e institutos que publicam em planilha (INE e outros) | Planejada | | 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` | 003 | Decisão sobre a Statistics Canada Open Licence e o adaptador da tabela 17-10-0147-01 | Planejada | +| 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). +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), [0006 nomes corrompidos na fonte](../adr/0006-nomes-corrompidos-na-fonte.md). From e9ed47833ddbdf689299f826dd3c485eb75bbf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:00:22 -0300 Subject: [PATCH 2/7] Planeja a spec 003e: plano e tarefas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Três tarefas: recusa de página HTML no cache, adaptador nz-dia-births com a tabela fechada de bytes e o descarte avisado, e o país NZ no passo das fontes oficiais da CI. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/003e-nova-zelandia/plan.md | 70 ++++++++++++++++++++++++++ docs/specs/003e-nova-zelandia/tasks.md | 62 +++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 docs/specs/003e-nova-zelandia/plan.md create mode 100644 docs/specs/003e-nova-zelandia/tasks.md diff --git a/docs/specs/003e-nova-zelandia/plan.md b/docs/specs/003e-nova-zelandia/plan.md new file mode 100644 index 0000000..aff9baa --- /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), [`0006`](../../adr/0006-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 | Nenhuma página de código lê esses bytes como `ī` e `ā`; a tradução seria um palpite apoiado só no nome, e o ADR 0006 só aceita leitura inequívoca | +| 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 0006 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/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md new file mode 100644 index 0000000..1b367d0 --- /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`). + +- [ ] **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. +- [ ] **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`. +- [ ] **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`. + +- [ ] **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. +- [ ] **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. +- [ ] **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. +- [ ] **Passo 4:** `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` + +- [ ] **Passo 1: teste que falha.** `test_passo_das_fontes_oficiais_roda_localmente` espera `5 países` e o total de observações com a amostra nova, e confere `by_country/NZ.csv` escrito à mão. +- [ ] **Passo 2:** acrescentar `NZ` ao laço `for pais in ...` do passo das fontes oficiais. +- [ ] **Passo 3:** `uv run pytest -q` verde; commit. + +### Fechamento + +- [ ] Rodar `namescommons ingest nz-dia-births --offline` com o arquivo real num cache do scratchpad; conferir 74.006 observações e o aviso de 13 linhas e 164 registros; anotar tempo na descrição do pull request. +- [ ] Conferir os critérios de aceite da spec, um a um. +- [ ] Pull request com `Closes #10`. From a842182b67fd4598c4f985ce20d3ce94e66c18e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:00:55 -0300 Subject: [PATCH 3/7] =?UTF-8?q?Recusa=20p=C3=A1gina=20HTML=20no=20lugar=20?= =?UTF-8?q?do=20arquivo=20baixado=20(spec=20003e,=20Task=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servidores como a Imperva do catálogo da Nova Zelândia respondem 200 com uma página de desafio em vez de 403. fetch agora recusa text/html para recurso que não é HTML, sem novas tentativas e sem gravar nada no cache, e diz onde gravar o arquivo baixado pelo navegador. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/003e-nova-zelandia/tasks.md | 6 ++--- src/namescommons/cache.py | 28 +++++++++++++++++----- tests/conftest.py | 3 +++ tests/test_cache.py | 32 +++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/specs/003e-nova-zelandia/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md index 1b367d0..ab62eb7 100644 --- a/docs/specs/003e-nova-zelandia/tasks.md +++ b/docs/specs/003e-nova-zelandia/tasks.md @@ -16,12 +16,12 @@ Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, esc - 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`). -- [ ] **Passo 1: testes que falham** em `tests/test_cache.py` +- [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. -- [ ] **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`. -- [ ] **Passo 3:** `uv run pytest -q` verde; commit. +- [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) diff --git a/src/namescommons/cache.py b/src/namescommons/cache.py index 9dc5446..e14db50 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 and tamanho != int(anunciado): raise FetchError(f"download incompleto de {url}: {tamanho} de {anunciado} bytes") meta = { @@ -95,6 +106,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 if i + 1 < tentativas: diff --git a/tests/conftest.py b/tests/conftest.py index 4328005..d399229 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.requisicoes: list[tuple[str, str]] = [] # (caminho, User-Agent) servidor = self @@ -96,6 +97,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", 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/test_cache.py b/tests/test_cache.py index 80b3a30..fbd4bff 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() @@ -118,3 +118,33 @@ def test_timeout(servidor, tmp_path): with pytest.raises(FetchError, match="após 1 tentativa"): fetch_resource(tmp_path, "exemplo", r, timeout=0.2, tentativas=1) assert not (tmp_path / "exemplo" / "a.csv").exists() + + +# 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 From c4fa1bd8018b7eb0b68d1ef946e8ac3c25f5b1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:02:35 -0300 Subject: [PATCH 4/7] Adiciona a fonte nz-dia-births (spec 003e, Task 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adaptador do CSV "Baby Name popularity over time" do Department of Internal Affairs: lê UTF-8 quando o arquivo inteiro é válido e, senão, traduz só os bytes de leitura inequívoca (0x82 e 0xE9 para é, 0xF6 para ö); nomes com ? ou U+FFFD são descartados e avisados no log com o número de linhas e de registros (ADR 0006). Amostra real de 18 linhas, com acentos em CP437 e Latin-1 e nomes corrompidos na origem. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/003e-nova-zelandia/tasks.md | 8 +- sources.yaml | 10 ++ src/namescommons/adapters/nz_dia_births.py | 89 +++++++++++++++ tests/fixtures/ATRIBUICAO.md | 1 + .../nz-dia-births/baby-names-1900-to-2025.csv | 19 ++++ tests/test_ci.py | 2 +- tests/test_nz_dia_births.py | 103 ++++++++++++++++++ 7 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 src/namescommons/adapters/nz_dia_births.py create mode 100644 tests/fixtures/nz-dia-births/baby-names-1900-to-2025.csv create mode 100644 tests/test_nz_dia_births.py diff --git a/docs/specs/003e-nova-zelandia/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md index ab62eb7..3b842c9 100644 --- a/docs/specs/003e-nova-zelandia/tasks.md +++ b/docs/specs/003e-nova-zelandia/tasks.md @@ -34,8 +34,8 @@ Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, esc - 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`. -- [ ] **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. -- [ ] **Passo 2: testes que falham** em `tests/test_nz_dia_births.py` +- [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`. @@ -43,8 +43,8 @@ Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, esc - `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. -- [ ] **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. -- [ ] **Passo 4:** `uv run pytest -q` verde (inclui `test_amostra_e_atribuicao` e `test_fetch_e_ingest_sobre_a_amostra` para a fonte nova); commit. +- [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) diff --git a/sources.yaml b/sources.yaml index 2db5e14..fcbbc8a 100644 --- a/sources.yaml +++ b/sources.yaml @@ -51,3 +51,13 @@ sources: primary: true suppression: 200 retrieved: 2026-10-12 + - 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..f35f311 --- /dev/null +++ b/src/namescommons/adapters/nz_dia_births.py @@ -0,0 +1,89 @@ +"""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 0006): 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 a leitura é inequívoca (spec 003e, R2.2): +# 0x82 é 'é' em CP437/CP850 (em Windows-1252 é a aspa '‚'); 0xE9 e 0xF6 são 'é' e 'ö' em Latin-1. +_LEGADO = {0x82: "é", 0xE9: "é", 0xF6: "ö"} +_TABELA = {b: chr(b) for b in range(0x80)} | {b: _LEGADO.get(b, "\ufffd") for b in range(0x80, 0x100)} +_CORROMPIDO = ("?", "\ufffd") + +_log = logging.getLogger(__name__) + + +def _decodificar(dados: bytes) -> str: + try: + return dados.decode("utf-8") + except UnicodeDecodeError: + return "".join(_TABELA[b] for b in dados) + + +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/tests/fixtures/ATRIBUICAO.md b/tests/fixtures/ATRIBUICAO.md index a77ffe1..2eb8668 100644 --- a/tests/fixtures/ATRIBUICAO.md +++ b/tests/fixtures/ATRIBUICAO.md @@ -9,3 +9,4 @@ Cada diretório guarda um trecho pequeno do arquivo real da fonte, só para os t | `ie-cso-births` | CC-BY-4.0 | Central Statistics Office (Irlanda), tabelas VSA50 e VSA60, https://data.gov.ie/dataset/vsa50-boys-names-in-ireland-with-3-or-more-occurrences | cabeçalho e 6 linhas de cada CSV | | `no-ssb-fornavn` | CC-BY-4.0 | Statistics Norway (SSB), tabela 10467, https://www.ssb.no/en/statbank/table/10467 | nomes 1NORA, 1Z3SE, 1KIM, 2KIM e 2JAKOB nos anos 1944, 1990 e 2025 | | `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 | +| `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_ci.py b/tests/test_ci.py index 49a5aa1..8a431e1 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -69,5 +69,5 @@ 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 - assert "OK: 39 observações, 4 países" in r.stdout + assert "OK: 54 observações, 5 países" in r.stdout assert (tmp_path / "dist-oficiais" / "by_country" / "NO.csv").read_text(encoding="utf-8") == NO_CSV 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) From ef32d178397ada25deda72a59d54ad3ccfa950e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:03:17 -0300 Subject: [PATCH 5/7] =?UTF-8?q?Confere=20a=20Nova=20Zel=C3=A2ndia=20no=20p?= =?UTF-8?q?asso=20das=20fontes=20oficiais=20da=20CI=20(spec=20003e,=20Task?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O passo passa a exigir by_country/NZ.csv, e o teste compara o arquivo com valores escritos à mão e confere o aviso de nomes descartados. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 2 +- docs/specs/003e-nova-zelandia/tasks.md | 6 +++--- tests/test_ci.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b6c8ce..143003c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,4 @@ jobs: cp -R tests/fixtures/. "$RUNNER_TEMP/cache-oficiais/" uv run namescommons run --offline --cache "$RUNNER_TEMP/cache-oficiais" \ --out "$RUNNER_TEMP/dist-oficiais" --release 2026.10 - for pais in FR IE NO US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done + for pais in FR IE NO NZ US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done diff --git a/docs/specs/003e-nova-zelandia/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md index 3b842c9..46a8107 100644 --- a/docs/specs/003e-nova-zelandia/tasks.md +++ b/docs/specs/003e-nova-zelandia/tasks.md @@ -51,9 +51,9 @@ Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, esc **Arquivos:** - Modificar: `.github/workflows/ci.yml`, `tests/test_ci.py` -- [ ] **Passo 1: teste que falha.** `test_passo_das_fontes_oficiais_roda_localmente` espera `5 países` e o total de observações com a amostra nova, e confere `by_country/NZ.csv` escrito à mão. -- [ ] **Passo 2:** acrescentar `NZ` ao laço `for pais in ...` do passo das fontes oficiais. -- [ ] **Passo 3:** `uv run pytest -q` verde; commit. +- [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 diff --git a/tests/test_ci.py b/tests/test_ci.py index 8a431e1..90bab2a 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -61,6 +61,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.""" @@ -71,3 +89,7 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): assert r.returncode == 0, r.stderr assert "OK: 54 observações, 5 países" in r.stdout assert (tmp_path / "dist-oficiais" / "by_country" / "NO.csv").read_text(encoding="utf-8") == NO_CSV + # Spec 003e, R6: a Nova Zelândia entra no passo, sem os nomes corrompidos na fonte. + assert "for pais in FR IE NO NZ US;" in passo["run"] + 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 From deee59c20e33dac030f4a44c9f3904a03f3a68ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:03:53 -0300 Subject: [PATCH 6/7] =?UTF-8?q?Registra=20a=20medi=C3=A7=C3=A3o=20com=20o?= =?UTF-8?q?=20arquivo=20real=20da=20Nova=20Zel=C3=A2ndia=20(spec=20003e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O adaptador emite 74.006 registros; o ingest produz 73.991 observações porque 15 pares diferem só na caixa e são somados pela chave da spec 002. 13 linhas e 164 registros descartados, 1,2 s e 114 MB. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/003e-nova-zelandia/spec.md | 6 +++--- docs/specs/003e-nova-zelandia/tasks.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/003e-nova-zelandia/spec.md b/docs/specs/003e-nova-zelandia/spec.md index af4e9b1..2d48e8a 100644 --- a/docs/specs/003e-nova-zelandia/spec.md +++ b/docs/specs/003e-nova-zelandia/spec.md @@ -65,7 +65,7 @@ Nenhuma codificação única lê o arquivo: `Esmé` aparece com `0x82` até 2023 - **R3.1** Nome que, depois de R2, contém `?` ou U+FFFD é descartado: nenhum registro é emitido para a linha ([ADR 0006](../../adr/0006-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. +- **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. ### R4. Download bloqueado com status 200 @@ -86,7 +86,7 @@ O passo da CI que roda as fontes oficiais sobre as amostras confere também `by_ ### R7. Desempenho -Com o arquivo real no cache, `namescommons ingest nz-dia-births --offline` termina em poucos segundos. A fonte é 2,4% do volume medido na spec 003 (R6) e não muda a margem de memória. +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 @@ -96,7 +96,7 @@ Com o arquivo real no cache, `namescommons ingest nz-dia-births --offline` termi 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, `ingest --offline` da fonte produz 74.006 observações e avisa 13 linhas descartadas; tempo medido na descrição do 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 diff --git a/docs/specs/003e-nova-zelandia/tasks.md b/docs/specs/003e-nova-zelandia/tasks.md index 46a8107..ab6ddef 100644 --- a/docs/specs/003e-nova-zelandia/tasks.md +++ b/docs/specs/003e-nova-zelandia/tasks.md @@ -57,6 +57,6 @@ Cada tarefa segue TDD: escrever os testes, ver que falham pelo motivo certo, esc ### Fechamento -- [ ] Rodar `namescommons ingest nz-dia-births --offline` com o arquivo real num cache do scratchpad; conferir 74.006 observações e o aviso de 13 linhas e 164 registros; anotar tempo na descrição do pull request. -- [ ] Conferir os critérios de aceite da spec, um a um. +- [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`. From 2b4d235079d80c5c413ffdc3255e1a02e6eed9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Bregu=C3=AAz?= Date: Mon, 12 Oct 2026 12:24:42 -0300 Subject: [PATCH 7/7] =?UTF-8?q?Renumera=20o=20ADR=20para=200007=20e=20deta?= =?UTF-8?q?lha=20a=20evid=C3=AAncia=20dos=20bytes=20(revis=C3=A3o=20da=20s?= =?UTF-8?q?pec=20003e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 0006 vira 0007, porque o master já tem o 0006 da contagem de sobrenomes; referências atualizadas na spec, no plano, nas tarefas, no ROADMAP e no adaptador. - A tabela de R2 lista a leitura de cada byte em CP437, CP850, Windows-1252, Latin-1 e Mac Roman (0xE9 é Ú em CP850 e È em Mac Roman), e o ADR passa a decidir pela forma atestada em todas as ocorrências do byte, não por ser letra numa só página de código. - ADR: edição convertida pela metade descarta os nomes UTF-8 de modo seguro e visível no aviso. - Spec: forma exibida dos pares só de caixa segue a de maior contagem; status Em PR (#28). - Adaptador: decodificação legada com decode("latin-1").translate. Co-Authored-By: Claude Opus 5.5 (1M context) --- ....md => 0007-nomes-corrompidos-na-fonte.md} | 7 ++--- docs/specs/003e-nova-zelandia/plan.md | 6 ++--- docs/specs/003e-nova-zelandia/spec.md | 26 +++++++++---------- src/namescommons/adapters/nz_dia_births.py | 11 ++++---- 4 files changed, 26 insertions(+), 24 deletions(-) rename docs/adr/{0006-nomes-corrompidos-na-fonte.md => 0007-nomes-corrompidos-na-fonte.md} (64%) diff --git a/docs/adr/0006-nomes-corrompidos-na-fonte.md b/docs/adr/0007-nomes-corrompidos-na-fonte.md similarity index 64% rename from docs/adr/0006-nomes-corrompidos-na-fonte.md rename to docs/adr/0007-nomes-corrompidos-na-fonte.md index 9f72c72..98d7101 100644 --- a/docs/adr/0006-nomes-corrompidos-na-fonte.md +++ b/docs/adr/0007-nomes-corrompidos-na-fonte.md @@ -1,4 +1,4 @@ -# 0006. Nomes corrompidos na fonte são descartados, e só se recupera o que é inequívoco +# 0007. Nomes corrompidos na fonte são descartados, e só se recupera o que é inequívoco ## Status @@ -6,14 +6,14 @@ 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 nenhuma página de código lê como `ā` ou `ī`. +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: uma página de código o lê como letra, as outras candidatas o leem como pontuação, símbolo ou nada, e os nomes do arquivo real em que ele ocorre confirmam a letra. 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. +- 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. @@ -22,4 +22,5 @@ Ingerir esses nomes como vieram criaria formas falsas (`M?ia`, `KÅwhai`) que nu - 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 index aff9baa..ccca98b 100644 --- a/docs/specs/003e-nova-zelandia/plan.md +++ b/docs/specs/003e-nova-zelandia/plan.md @@ -6,7 +6,7 @@ **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), [`0006`](../../adr/0006-nomes-corrompidos-na-fonte.md). Pendências herdadas: issue #10. +**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 @@ -22,7 +22,7 @@ | 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 | Nenhuma página de código lê esses bytes como `ī` e `ā`; a tradução seria um palpite apoiado só no nome, e o ADR 0006 só aceita leitura inequívoca | +| `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` | @@ -54,7 +54,7 @@ |---|---| | R1, R1.1 a R1.5 | 2 | | R2.1, R2.2 | 2 | -| R2.3 | Sem código: regra para edições futuras, no ADR 0006 e na docstring | +| 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 | diff --git a/docs/specs/003e-nova-zelandia/spec.md b/docs/specs/003e-nova-zelandia/spec.md index 2d48e8a..c3d28fe 100644 --- a/docs/specs/003e-nova-zelandia/spec.md +++ b/docs/specs/003e-nova-zelandia/spec.md @@ -1,8 +1,8 @@ # 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 0006](../../adr/0006-nomes-corrompidos-na-fonte.md). Pendências herdadas: issue #10. +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 implementação. +Status: Em PR (#28). ## Por quê @@ -45,27 +45,27 @@ Particularidades que o adaptador trata: ### R2. Codificação do arquivo -Bytes acima de 0x7F medidos no arquivo real (71 linhas, todas na coluna `Name`): +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 | Leitura | -|---|---|---|---| -| `0x82` | 38 | `Am_lie`, `Andr_`, `Esm_` (2009 a 2023), `Ren_e` | `é` em CP437 e CP850; em Windows-1252 é a aspa `‚`, que não ocorre em nome | -| `0xE9` | 25 | `Zo_`, `Esm_` (2024 e 2025) | `é` em Latin-1 e Windows-1252 | -| `0xF6` | 1 | `Bj_rn` | `ö` em Latin-1 e Windows-1252; em CP437 é `÷` | -| `0x8D` | 4 | `N_kau` | Sem leitura: indefinido em Windows-1252, `ì` em CP437; o nome é Nīkau | -| `0x8F` | 3 | `K_whai` | Sem leitura: indefinido em Windows-1252, `Å` em CP437; o nome é Kāwhai | +| 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 (página de código e nomes em que ocorre), conforme o ADR 0006. +- **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 0006](../../adr/0006-nomes-corrompidos-na-fonte.md)). +- **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. +- **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 diff --git a/src/namescommons/adapters/nz_dia_births.py b/src/namescommons/adapters/nz_dia_births.py index f35f311..868d9dc 100644 --- a/src/namescommons/adapters/nz_dia_births.py +++ b/src/namescommons/adapters/nz_dia_births.py @@ -3,7 +3,7 @@ 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 0006): se o arquivo inteiro for UTF-8 válido, é lido como UTF-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. @@ -34,10 +34,11 @@ _CABECALHO = ["Year", "Sex", "Name", "Count"] _SEXO = {"F": "F", "M": "M"} -# Byte -> letra, só quando a leitura é inequívoca (spec 003e, R2.2): -# 0x82 é 'é' em CP437/CP850 (em Windows-1252 é a aspa '‚'); 0xE9 e 0xF6 são 'é' e 'ö' em Latin-1. +# 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: chr(b) for b in range(0x80)} | {b: _LEGADO.get(b, "\ufffd") for b in range(0x80, 0x100)} +_TABELA = {b: _LEGADO.get(b, "\ufffd") for b in range(0x80, 0x100)} # para str.translate sobre Latin-1 _CORROMPIDO = ("?", "\ufffd") _log = logging.getLogger(__name__) @@ -47,7 +48,7 @@ def _decodificar(dados: bytes) -> str: try: return dados.decode("utf-8") except UnicodeDecodeError: - return "".join(_TABELA[b] for b in dados) + return dados.decode("latin-1").translate(_TABELA) def parse(files: dict[str, Path]) -> Iterator[RawRecord]: