diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3992d20..4ea1b19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,11 @@ jobs: uv run namescommons run --offline --sources tests/fixtures/sources.yaml --adapters adaptadores \ --cache "$RUNNER_TEMP/cache" --out "$RUNNER_TEMP/dist" --release "$(date -u +%Y.%m)" test -s "$RUNNER_TEMP/dist/by_country/PT.csv" - - name: namescommons run --offline sobre as amostras das fontes oficiais + - name: namescommons run --offline sobre as amostras das fontes de sources.yaml run: | mkdir -p "$RUNNER_TEMP/cache-oficiais" find tests/fixtures -mindepth 1 -maxdepth 1 -type d ! -name exemplo ! -name planilhas -exec cp -R {} "$RUNNER_TEMP/cache-oficiais/" \; uv run namescommons run --offline --cache "$RUNNER_TEMP/cache-oficiais" \ --out "$RUNNER_TEMP/dist-oficiais" --release "$(date -u +%Y.%m)" for pais in CA ES FR IE NO NZ US; do test -s "$RUNNER_TEMP/dist-oficiais/by_country/$pais.csv"; done + test -s "$RUNNER_TEMP/dist-oficiais/variant_links.parquet" diff --git a/docs/adr/0008-acesso-ao-wikidata.md b/docs/adr/0008-acesso-ao-wikidata.md new file mode 100644 index 0000000..cfb30f3 --- /dev/null +++ b/docs/adr/0008-acesso-ao-wikidata.md @@ -0,0 +1,31 @@ +# 0008. Acesso ao Wikidata: consultas SPARQL no QLever, paginadas e guardadas no cache + +## Status + +Aceito (2026-10-12) + +## Contexto + +A spec 004 lê do Wikidata os itens de prenome (`Q202444` e subclasses) e de sobrenome (`Q101352` e subclasses), com rótulo nativo, classe e ligações entre itens. O contrato da spec 002 exige recursos com URL fixa (`Resource(url, filename)`), baixados uma vez para o cache e lidos depois com `--offline`. + +Há três caminhos para os dados: + +- **Wikidata Query Service (WDQS, `query.wikidata.org`).** Serviço oficial da Wikimedia, com limite de 60 s por consulta. Em 2026-10-12, contar os sobrenomes com rótulo nativo (`?i wdt:P31 wd:Q101352 . ?i wdt:P1705 ?l`) estourou o tempo duas vezes ("upstream request timeout"), e a consulta das subclasses de prenome com contagem também. Uma consulta por letra inicial ou por faixa de identificador continuaria perto do limite, e a lista de páginas mudaria conforme o Wikidata cresce. +- **Dump JSON completo (`dumps.wikimedia.org/wikidatawiki/entities/latest-all.json.bz2`).** Reproduzível, mas tem mais de 100 GB compactado para extrair menos de 1 milhão de itens. Ler o dump inteiro excede o orçamento de tempo e disco da spec 002 (R8) e da CI. +- **QLever (`qlever.dev/api/wikidata`).** Motor SPARQL da Universidade de Freiburg sobre o dump completo do Wikidata, com atualização contínua (a página de estatísticas do índice informa "Complete Wikidata ... updated LIVE"). As mesmas consultas que estouraram no WDQS responderam em 1 a 12 s: 734.234 linhas de sobrenome em 11 s, 134.180 de prenome em 6 s. Os dados são os do Wikidata, sob CC0. + +## Decisão + +- Os recursos do adaptador `wikidata` são resultados de consultas SPARQL ao endpoint `https://qlever.dev/api/wikidata`, no formato SPARQL 1.1 Query Results JSON, pedidos por GET com a consulta inteira na URL e o formato no parâmetro `action=sparql_json`. O texto de cada consulta fica no módulo do adaptador. +- Cada consulta é ordenada (`ORDER BY`) e paginada com `LIMIT 200000 OFFSET k`. O número de páginas de cada consulta é fixo no módulo, com folga sobre o tamanho medido. Uma página de 200.000 linhas de JSON ocupa cerca de 28 MB e cerca de 500 MB depois de lida pelo `json` da biblioteca padrão; o arquivo inteiro de sobrenomes (104 MB) passava de 1,8 GB. +- A última página de uma consulta não pode vir cheia: se vier, o adaptador falha com a instrução de aumentar o número de páginas. Uma página incompleta seguida de página com linhas também é erro (o índice mudou entre os downloads), com a instrução de baixar de novo com `--refresh`. +- As consultas usam só SPARQL 1.1 padrão (sem o serviço de rótulos do WDQS), com `PREFIX` explícitos, para rodar em qualquer endpoint com o Wikidata completo. +- O `User-Agent` é o do pipeline (spec 002, R4), que identifica o projeto e o repositório como pede a política de User-Agent da Wikimedia. Os downloads são sequenciais. + +## Consequências + +- `fetch` baixa 15 arquivos (216 MB, em 54 s na medição de 2026-10-12), e `ingest --offline` lê o cache sem rede, como as outras fontes. +- O projeto depende de um serviço acadêmico sem acordo de nível de serviço. Se o QLever sair do ar, as consultas valem no WDQS só para as classes pequenas; a ingestão do dump completo fica registrada como alternativa na issue #29. +- O resultado reflete o índice do QLever no momento do download, que pode estar alguns dias atrás do Wikidata. O `.meta.json` do cache registra a data e a hora de cada página. +- Se o índice mudar entre o download de duas páginas da mesma consulta, linhas podem se repetir (inofensivo: o pipeline soma observações repetidas de fonte `attested` sem alterar nada) ou faltar na fronteira. A falta não é detectável pela página; o risco é pequeno porque as páginas de uma consulta são baixadas em segundos. +- Quando o Wikidata crescer além da folga, a ingestão falha alto e a correção é mudar um número no módulo. diff --git a/docs/adr/0009-ligacoes-entre-variantes.md b/docs/adr/0009-ligacoes-entre-variantes.md new file mode 100644 index 0000000..a69afc9 --- /dev/null +++ b/docs/adr/0009-ligacoes-entre-variantes.md @@ -0,0 +1,26 @@ +# 0009. Ligações entre variantes numa tabela própria, fora das observações + +## Status + +Aceito (2026-10-12) + +## Contexto + +A spec 005 vai preencher `group_id` (spec 001, R3.2) agrupando variantes de um nome: `Мария` e `Maria`, `Иванов` e `Ivanov`, `Ivanov` e `Ivanova`. O Wikidata publica essas relações entre itens: "said to be the same as" (P460), "family name identical to this given name" (P1533), versões para o outro gênero (P1560, P5278), "different from" (P1889) e transliterações (P2440). + +A tabela de observações (spec 001, R1) descreve um nome atestado numa fonte; ela não tem lugar para uma relação entre dois nomes. Codificar a ligação num campo existente (por exemplo, `ascii`) mudaria o significado do campo, o que exige versão maior. Acrescentar colunas de ligação a `Observation` duplicaria cada nome por ligação e quebraria a chave de unicidade (R1.4). + +## Decisão + +- As ligações formam uma tabela própria, `variant_links`, com uma linha por par de nomes ligados e as colunas `source_id`, `relation`, `qid`, `name`, `name_key`, `kind`, `target_qid`, `target_name`, `target_name_key`, `target_kind`. O formato está na spec 004 (R4). +- O contrato do adaptador (spec 002, R1) ganha uma função opcional, `links(files) -> Iterator[RawLink]`. Só o adaptador do Wikidata a implementa por enquanto. +- `ingest` grava a tabela num arquivo à parte (`--links`), `build` a recebe (`--links`) e `run` a grava na release como `variant_links.parquet`. +- A tabela registra as relações como a fonte as publica. Agrupar, decidir quais relações juntam variantes e quais as separam é trabalho da spec 005. +- Acrescentar um arquivo à release é mudança compatível: `SCHEMA_VERSION` passa de `1.0.0` para `1.1.0` (versão menor, spec 001, R5). + +## Consequências + +- A tabela de observações e as saídas agregadas não mudam; quem lê só `given_names.csv` não percebe a novidade. +- A spec 005 lê as ligações de um único arquivo, com os nomes já normalizados (`name_key`), sem consultar o Wikidata de novo. +- Outras fontes com relações entre nomes (por exemplo, listas oficiais de grafias equivalentes) podem implementar `links` sem mudar o esquema. +- A release ganha um arquivo de cerca de 437 mil linhas (11 MB em Parquet, medido em 2026-10-12), contado no `manifest.json`. diff --git a/docs/specs/001-esquema/spec.md b/docs/specs/001-esquema/spec.md index d70d27b..cc16dca 100644 --- a/docs/specs/001-esquema/spec.md +++ b/docs/specs/001-esquema/spec.md @@ -41,6 +41,8 @@ Regras: Cada fonte tem: `id`, `name`, `url`, `license`, `attribution` (texto exigido pela licença), `country`, `count_type`, `primary` (booleano, ver R3.3), `suppression` (limite abaixo do qual a fonte omite nomes, ex.: `5` na SSA; vazio se não houver) e `retrieved` (data da coleta). Fonte sem licença compatível com o ADR 0001 não pode ser registrada. Quando houver mais de uma fonte para o mesmo par (`country`, `count_type`), exatamente uma delas tem `primary: true`. +Nota (spec 004, R5): a exigência de exatamente uma `primary` vale só para `births` e `population`. Em `attested`, `primary` não tem efeito e não é conferido (issue #3). + ### R3. Saídas agregadas **R3.1 `observations.parquet`.** A tabela de R1 completa. Somente Parquet. diff --git a/docs/specs/004-wikidata/plan.md b/docs/specs/004-wikidata/plan.md new file mode 100644 index 0000000..37e694e --- /dev/null +++ b/docs/specs/004-wikidata/plan.md @@ -0,0 +1,83 @@ +# 004. Wikidata: plano de implementação + +**Objetivo:** ingerir os prenomes e sobrenomes do Wikidata como fonte `attested`, gravar as ligações entre variantes numa tabela própria para a spec 005 e corrigir a regra de `primary` em fontes `attested` (issue #3). + +**Arquitetura:** o adaptador `src/namescommons/adapters/wikidata.py` declara as consultas SPARQL (texto, variáveis, ordem e número de páginas) e deriva delas os 15 `Resource` com URL fixa no QLever. `parse` lê as páginas de rótulos com `json` da biblioteca padrão, uma de cada vez, e emite `RawRecord` sem país, ano nem contagem. A função opcional `links` lê de novo os rótulos, monta o mapa item -> rótulos e cruza com as páginas de ligação e de transliteração, emitindo `RawLink`. O módulo novo `src/namescommons/links.py` define `VariantLink`, `RELATIONS` e `validate_links`; `pipeline.py` ganha `to_links` e `ingest_links`; `io.py` ganha `LINK_SCHEMA`, `write_links` e `read_links`; a CLI ganha `--links` em `ingest` e `build`, e `run` grava `variant_links.parquet`. `sources.py` deixa de conferir `primary` em `attested`. + +**Stack:** a mesma da spec 003 (Python 3.12 via `uv`, `pyarrow`, `pyyaml`, `pycountry`, `pytest`), sem dependência nova. Formato lido: SPARQL 1.1 Query Results JSON. + +**Spec:** [`spec.md`](spec.md). ADRs: [`0001`](../../adr/0001-licenca-dos-dados.md), [`0008`](../../adr/0008-acesso-ao-wikidata.md), [`0009`](../../adr/0009-ligacoes-entre-variantes.md). Pendências herdadas: issue #3. + +## Restrições globais + +- Nenhuma dependência nova de runtime ou de desenvolvimento. +- Os testes nunca acessam a rede. A amostra fica em `tests/fixtures/wikidata/`, com os mesmos 15 nomes de arquivo de `RESOURCES` e menos de 20 KB no total. +- Mensagens: `FormatError` com o arquivo e o esperado; `ligação N: ` para erros de `RawLink`, com N a partir de 1 na ordem de `links`. +- A tabela de observações não muda. `SCHEMA_VERSION` passa a `1.1.0` porque a release ganha `variant_links.parquet`. +- `User-Agent` do pipeline, com o repositório `https://github.com/BenchCommons/dataset-names` como contato. + +## Decisões de implementação + +| Decisão | Motivo | +|---|---| +| QLever em vez de WDQS ou dump (ADR 0008) | O WDQS estourou o tempo nas consultas de sobrenome; o dump tem mais de 100 GB | +| Páginas de 200.000 linhas, número fixo por consulta (1, 5, 3, 1, 1, 1, 2, 1) | Cerca de 500 MB por página lida; o arquivo de sobrenomes inteiro passava de 1,8 GB. A folga sobre o medido é de 36% a 49% nos sobrenomes e prenomes | +| URL com `action=sparql_json` | O cache baixa só com `User-Agent`; o formato fica determinado pela URL e não pelo cabeçalho `Accept` | +| Uma consulta por propriedade de ligação | A consulta única com `VALUES ?property` estourou memória e tempo no QLever | +| Alvo da ligação não é restrito a itens de nome na consulta; o filtro é feito em Python | A restrição no SPARQL estourou o tempo; o mapa de rótulos já diz quais itens são nomes | +| `parse` agrupa por (`name_key`, `kind`) e emite uma linha por gênero informado, ou uma sem gênero | Cumpre R1.5 da spec 002 dentro do adaptador, sem perder atestação (R3.5) | +| Unissex vira `F` e `M` | A classe diz que o nome é usado pelos dois gêneros; uma linha vazia conflitaria com o mesmo nome em itens masculinos ou femininos | +| `links` lê os rótulos de novo, em vez de dividir estado com `parse` | O contrato da spec 002 trata `parse` como função pura sobre os arquivos; ler 130 MB de JSON duas vezes custa segundos | +| Auto-ligação por `name_key` e `kind` descartada no pipeline (`to_links`), recusada em `validate_links` | `Andrea` masculino P1889 `Andrea` feminino é a mesma chave e não informa nada para agrupar chaves | +| `ingest_links` devolve `None` quando nenhuma fonte selecionada tem `links` | `run` só grava `variant_links.parquet` quando há fonte de ligações; a release das fontes de teste da spec 002 não muda | +| Amostra gerada por `tools/amostra_wikidata.py`, que roda as consultas do módulo com `VALUES ?item` | Reproduz a amostra a partir do código, e as consultas da amostra são as mesmas de produção | + +## Foco de revisão + +1. **Nome dos dois gêneros vindo de itens diferentes** (`Andrea` masculino, feminino e unissex; `Letha` sem gênero e feminino) não pode quebrar R1.5 nem perder gênero (Task 3: `test_parse_da_amostra_real`, `test_genero_vazio_cede_ao_informado`). +2. **Wikidata cresce além das páginas** e a última página vem cheia: falha alta, não perda silenciosa (Task 3: `test_ultima_pagina_cheia_falha`). +3. **Páginas de momentos diferentes do índice** (Task 3: `test_pagina_com_linhas_depois_de_incompleta_falha`). +4. **Rótulos que não são nomes** (`Q34147`, `Ritwik (given name)`, `Felbert,von`, valor desconhecido) não entram nem como observação nem como ligação (Task 3: `test_rotulos_que_nao_sao_nomes_sao_pulados`; Task 4: `test_links_da_amostra_real`). +5. **Duas fontes `attested` sem país** passam sem `primary`; duas `births` no mesmo país continuam exigindo uma (Task 1). + +## Arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `src/namescommons/sources.py`, `tests/test_sources.py` | Regra de `primary` só para `births` e `population` (R5) | 1 | +| `src/namescommons/adapters/__init__.py` | `RawLink`; `check_adapter` confere `links` | 2 | +| `src/namescommons/links.py` | `VariantLink`, `RELATIONS`, `validate_links` (R4.2, R4.5) | 2 | +| `src/namescommons/pipeline.py` | `to_links`, `ingest_links` (R4.4) | 2 | +| `src/namescommons/io.py` | `LINK_SCHEMA`, `write_links`, `read_links` | 2 | +| `src/namescommons/schema.py`, `tests/test_io.py` | `SCHEMA_VERSION` 1.1.0 (R4.7) | 2 | +| `tests/test_links.py` | Testes de R4.1, R4.2, R4.4 e R4.5 | 2 | +| `src/namescommons/adapters/wikidata.py` | Consultas, recursos, leitura paginada, `parse` (R1 a R3) e `links` (R4.3) | 3, 4 | +| `tools/amostra_wikidata.py` | Gera a amostra a partir das consultas do módulo | 3 | +| `tests/fixtures/wikidata/`, `tests/fixtures/ATRIBUICAO.md`, `sources.yaml` | Amostra, atribuição e registro da fonte | 3 | +| `tests/test_wikidata.py` | Testes do adaptador | 3, 4 | +| `src/namescommons/cli.py`, `tests/test_cli_pipeline.py` | `--links` em `ingest` e `build`; `variant_links.parquet` em `run` (R4.6) | 5 | +| `.github/workflows/ci.yml`, `tests/test_ci.py` | Passo da CI com o Wikidata (R7) | 5 | + +## Cobertura da spec + +| Requisito | Tarefa | +|---|---| +| R1 | 3 | +| R2.1, R2.2 | 3 (`test_recursos_e_consultas`) | +| R2.3 | 3 | +| R2.4, R2.5 | 3, 4 | +| R3.1 a R3.7 | 3 | +| R4.1, R4.2, R4.4, R4.5, R4.7 | 2 | +| R4.3 | 4 | +| R4.6 | 5 | +| R5 | 1 | +| R6 | 3, 4 (`tests/test_fontes_oficiais.py` percorre a nova fonte) | +| R7 | 5 | +| R8 | Fechamento (medição com os arquivos reais) | +| Critério de aceite 1 | 3 | +| Critério 2 | 3 | +| Critério 3 | 4 | +| Critério 4 | 3 | +| Critério 5 | 5 | +| Critério 6 | 1 | +| Critérios 7, 8, 9 | Fechamento | diff --git a/docs/specs/004-wikidata/spec.md b/docs/specs/004-wikidata/spec.md new file mode 100644 index 0000000..84b77e5 --- /dev/null +++ b/docs/specs/004-wikidata/spec.md @@ -0,0 +1,142 @@ +# 004. Wikidata + +Parte do [roadmap](../ROADMAP.md). Depende da [spec 002](../002-pipeline/spec.md) e segue o contrato do adaptador da [spec 003](../003-fontes-oficiais/spec.md) (R2). Licença dos dados: [ADR 0001](../../adr/0001-licenca-dos-dados.md). Acesso aos dados: [ADR 0008](../../adr/0008-acesso-ao-wikidata.md). Ligações entre variantes: [ADR 0009](../../adr/0009-ligacoes-entre-variantes.md). Pendências herdadas: issue #3. + +Status: Em implementação. + +## Por quê + +As fontes oficiais da spec 003 trazem contagens de cinco países, quase todas em escrita latina. O Wikidata cataloga cerca de 136 mil itens de prenome e 723 mil de sobrenome, de todas as escritas, com rótulo nativo (`Мария`, `李`, `Иванов`), classe de gênero (prenome masculino, feminino ou unissex; sobrenome masculino ou feminino) e ligações entre itens que a spec 005 precisa para agrupar variantes. Os dados estruturados do Wikidata são CC0. + +O Wikidata não tem contagem nem país confiável: ele atesta que o nome existe. A regra de `primary` da spec 001 (R2) foi escrita para fontes com contagem e, como aponta a issue #3, obrigaria a escolher uma `primary` entre duas fontes `attested` multinacionais, onde ela não tem efeito. Esta spec corrige essa regra. + +## Requisitos + +### R1. Fonte e licença + +Uma entrada em `sources.yaml` e um adaptador em `src/namescommons/adapters/wikidata.py`: + +| Campo | Valor | +|---|---| +| `id` | `wikidata` | +| `license` | `CC0-1.0` | +| `country` | vazio (fonte multinacional) | +| `count_type` | `attested` | +| `primary` | `false` (sem efeito em `attested`, ver R5) | +| `suppression` | vazio | +| `uniform_case` | `false` (a caixa publicada é preservada) | + +Licença conferida em https://www.wikidata.org/wiki/Wikidata:Licensing: "All structured data (i.e. the main, Property, Lexeme, and EntitySchema namespaces) is released into the public domain under Creative Commons Zero". CC0 está na lista do ADR 0001. + +### R2. Acesso aos dados + +- **R2.1 Endpoint e formato.** Cada recurso é o resultado de uma consulta SPARQL ao QLever (`https://qlever.dev/api/wikidata`) no formato SPARQL 1.1 Query Results JSON, pedido por GET com a consulta no parâmetro `query` e o formato no parâmetro `action=sparql_json`, de modo que a URL sozinha determina a resposta (ADR 0008). O texto das consultas fica no módulo e usa só SPARQL 1.1 padrão. O `User-Agent` é o do pipeline (spec 002, R4), com a URL do repositório como contato, como pede a política de User-Agent da Wikimedia; hoje `https://github.com/BenchCommons/dataset-names`. +- **R2.2 Consultas.** Cada consulta é ordenada e paginada com `LIMIT 200000 OFFSET k`, num número fixo de páginas: + +| Consulta | Variáveis | Conteúdo | Linhas medidas | Páginas | Arquivos | +|---|---|---|---|---|---| +| Prenomes | `item`, `name`, `gender` | itens `P31/P279*` de `Q202444` com rótulo nativo (P1705) e, quando houver, a classe de gênero alcançada por `P31/P279*` | 134.180 (26 MB) | 1 | `given-names-1.json` | +| Sobrenomes | `item`, `name`, `gender` | o mesmo para `Q101352` | 734.234 (104 MB) | 5 | `family-names-1.json` a `family-names-5.json` | +| P460 | `item`, `target` | "said to be the same as" de itens de nome | 333.225 (50 MB) | 3 | `links-P460-1.json` a `-3` | +| P1533 | `item`, `target` | "family name identical to this given name" | 21.879 (3,3 MB) | 1 | `links-P1533-1.json` | +| P1560 | `item`, `target` | "given name version for other gender" | 2.971 (0,4 MB) | 1 | `links-P1560-1.json` | +| P5278 | `item`, `target` | "surname for other gender" | 22.980 (3,5 MB) | 1 | `links-P5278-1.json` | +| P1889 | `item`, `target` | "different from" | 191.647 (29 MB) | 2 | `links-P1889-1.json` e `-2` | +| P2440 | `item`, `transliteration` | "transliteration or transcription", como afirmação do item ou qualificador do rótulo nativo | 33.820 (4,5 MB) | 1 | `transliterations-1.json` | + +Medições de 2026-10-12, com o índice do QLever atualizado continuamente sobre o dump de 2026-08-10. Total: 15 arquivos, cerca de 220 MB. + +- **R2.3 Falha alta na paginação.** Cada página tem no máximo 200.000 linhas. A última página de uma consulta cheia (200.000 linhas) é `FormatError` que pede mais páginas. Uma página incompleta seguida de página com linhas é `FormatError` que pede `fetch --refresh`. As variáveis do cabeçalho (`head.vars`) diferentes das esperadas são `FormatError`. +- **R2.4 Valores das ligações.** `item` e `target` têm de ser URIs de entidade (`http://www.wikidata.org/entity/Q`); outro valor de `item` é `FormatError`. Um `target` fora desse padrão (valor desconhecido do Wikidata, publicado como URI `/.well-known/genid/`) é pulado. +- **R2.5 Propriedade descartada.** A P5279, citada no roadmap como transliteração, é "hyphenation" (separação silábica) e não entra. + +### R3. Observações + +- **R3.1 Nome.** `name` é o valor do rótulo nativo (P1705), como publicado. Rótulo que não é literal (valor desconhecido, URI `genid`) é pulado. Itens sem P1705 ficam fora (6.569 prenomes e 43.195 sobrenomes, cerca de 5%). +- **R3.2 Filtro de rótulos.** Rótulo com algarismo de qualquer escrita (categoria Unicode `N*`) ou com um dos caracteres `( ) [ ] { } / \ , ; : _ | ? ! @ # " = < > * + & % $ ~ ^` e crase é pulado: são desambiguações (`Ritwik (given name)`), identificadores colados no lugar do nome (`Q34147`), lacunas (`Dau__tsch`), formas invertidas (`Felbert,von`) e duas grafias num rótulo (`Asil/Aseel`). Medido: 90 de 134.180 rótulos de prenome e 84 de 734.234 de sobrenome. O mesmo filtro vale para transliterações (2.130 de 33.820, quase todas com tom numérico, como `Lei⁵`). +- **R3.3 Tipo.** `kind` é `given` para a consulta de prenomes e `family` para a de sobrenomes. Item das duas classes (439 itens) gera linhas nos dois. +- **R3.4 Gênero pela classe.** O gênero vem da classe do item, alcançada por `P31/P279*`: + +| Classe | Rótulo | `gender` | +|---|---|---| +| `Q12308941` | male given name | `M` | +| `Q11879590` | female given name | `F` | +| `Q3409032` | unisex given name | `F` e `M` (duas linhas) | +| `Q18972245`, `Q136439710` | masculine family name, masculine surname | `M` | +| `Q18972207`, `Q136439688` | feminine family name, feminine surname | `F` | +| nenhuma das acima | | vazio | + +Uma classe de gênero fora da tabela é `FormatError`. Item que é ao mesmo tempo masculino e feminino gera as duas linhas. +- **R3.5 Gênero vazio cede ao informado.** Para cumprir a spec 002 (R1.5), se o mesmo `name_key` e `kind` tem linha com gênero e linha sem gênero (de itens diferentes, como `Letha` sem classe de gênero e `Letha` feminino), o adaptador omite a linha sem gênero. Ela só atestava o nome, que continua atestado pelas linhas com gênero. Medido: 1.800 nomes de prenome nessa situação. +- **R3.6 País, ano e contagem vazios.** `country` fica vazio em toda linha. A P495 (país de origem) aparece em 424 dos 135.792 itens de prenome e em 484 dos 723.194 de sobrenome, e a P27 (cidadania) é propriedade de pessoas; nenhuma das duas é confiável para associar um nome a um país. `year` e `count` ficam vazios. +- **R3.7 Escrita.** `script` continua sendo a escrita detectada no nome (spec 001, R1), conferida por `validate`. A P282 (sistema de escrita) não é usada: ela descreve o item, e não cada rótulo, e um item pode declarar várias (142.927 declarações em 135.792 itens de prenome). + +### R4. Ligações entre variantes + +- **R4.1 Contrato.** `namescommons.adapters` ganha `RawLink(relation, qid, name, kind, target_qid, target_name, target_kind)` e a função opcional `links(files) -> Iterator[RawLink]` no módulo do adaptador. `check_adapter` recusa `links` que não seja função. +- **R4.2 Tabela `variant_links`.** O pipeline transforma `RawLink` em linhas da tabela abaixo, com `name` e `target_name` normalizados como `name` da spec 001 (R1) e a caixa conforme `uniform_case` da fonte: + +| Campo | Tipo | Regra | +|---|---|---| +| `source_id` | texto | `id` da fonte | +| `relation` | enum | ver R4.3 | +| `qid` | texto | item de origem, `Q` | +| `name`, `name_key`, `kind` | texto | nome de origem, como em `Observation` | +| `target_qid` | texto | item de destino, `Q`; igual a `qid` em `same_item` e `transliteration` | +| `target_name`, `target_name_key`, `target_kind` | texto | nome de destino | + +- **R4.3 Relações.** + +| `relation` | Origem no Wikidata | Destino | +|---|---|---| +| `same_item` | dois rótulos nativos do mesmo item com `name_key` diferentes | outro rótulo do item, mesmo `kind` | +| `said_to_be_the_same` | P460 | rótulos do item de destino | +| `family_name_identical` | P1533 | rótulos do item de destino | +| `other_gender` | P1560 (prenome) e P5278 (sobrenome) | rótulos do item de destino | +| `different_from` | P1889 | rótulos do item de destino | +| `transliteration` | P2440 | o texto da transliteração, com o `kind` da origem | + +- **R4.4 Regras.** Cada rótulo de origem (com cada `kind` do item) é ligado a cada rótulo de destino. Ligação cujo destino não é item de nome com rótulo aceito por R3 é descartada. Linha cujo nome de origem e de destino têm o mesmo `name_key` e o mesmo `kind` é descartada. A direção é a publicada (A P460 B e B P460 A geram duas linhas). Linhas repetidas são emitidas uma vez, e a tabela é ordenada por todas as colunas. +- **R4.5 Validação.** `relation` fora de R4.3, `qid` ou `target_qid` fora de `Q`, `kind` inválido, nome vazio, `name_key` diferente do derivado de `name` e `source_id` ausente de `sources.yaml` são recusados. +- **R4.6 Comandos.** `ingest --links ARQUIVO` grava a tabela das fontes selecionadas. `build --links ARQUIVO` a valida e a copia para a release. `run` grava `variant_links.parquet` na release quando alguma fonte selecionada tem `links`. A spec 005 é quem usa a tabela para `group_id`; esta spec não agrupa. +- **R4.7 Versão.** A release ganha um arquivo: `SCHEMA_VERSION` passa a `1.1.0` (spec 001, R5: acréscimo compatível). + +### R5. Regra de `primary` (issue #3) + +A exigência de exatamente uma fonte `primary: true` por par (`country`, `count_type`) da spec 001 (R2) passa a valer só para `births` e `population`. Em `attested`, `primary` não tem efeito (nenhuma contagem é escolhida) e não é conferido: duas fontes `attested` sem país, ambas com `primary: false`, são aceitas. A spec 001 recebe uma nota em R2 apontando para este requisito. + +### R6. Amostras e testes + +- `tests/fixtures/wikidata/` tem os 15 arquivos de R2.2, com menos de 20 KB no total. Cada arquivo é a resposta real do QLever à mesma consulta, com a mesma paginação, restrita a poucos itens (`VALUES ?item { ... }`), gerada por `tools/amostra_wikidata.py`; páginas além do fim são a resposta real vazia. Nas ligações ficam só as linhas cujo alvo também está na amostra, mais uma por propriedade com alvo de fora. A atribuição vai em `tests/fixtures/ATRIBUICAO.md`. +- Testes de `parse` e `links` sobre a amostra com registros esperados escritos à mão, de cada regra de R2.3 a R2.5, R3 e R4, e de formato inesperado. +- Os testes nunca acessam a rede (spec 002, R7). + +### R7. CI + +O passo da CI que roda as fontes de `sources.yaml` sobre as amostras passa a incluir o Wikidata e confere que a release tem `variant_links.parquet`. + +### R8. Desempenho com os dados reais + +`namescommons run --offline` só com o Wikidata, com os 15 arquivos reais no cache, fica dentro dos limites de R8 da spec 002 (menos de 10 minutos e menos de 4 GB). A medição vai para o pull request. + +Medido num Apple M4 com 24 GB, em 2026-10-12: `fetch` baixou os 15 arquivos (216 MB) em 54 s. `run --offline` só com o Wikidata gerou 816.771 observações e 436.971 ligações em 60 s, com pico de 2,01 GB. Somado às 3 milhões de observações sintéticas de `tools/bench_pipeline.py` (spec 002, R8), num único `run --offline`, gerou 3.816.771 observações em 99 s, com pico de 3,43 GB: dentro do limite, com margem pequena (issue #31). + +## Critérios de aceite + +1. `sources.yaml` tem a fonte `wikidata` de R1, e `load_sources` a aceita. +2. `parse` sobre a amostra produz os registros esperados escritos à mão, inclusive `Andrea` com `F` e `M` de três itens (masculino, feminino e unissex), `Letha` sem a linha sem gênero, `李` sem gênero e rótulos de R3.2 pulados. +3. `links` sobre a amostra produz as ligações esperadas escritas à mão, inclusive `Иванов` `other_gender` `Иванова`, `Мария` `transliteration` `Mariya` e nenhuma ligação de um nome com ele mesmo. +4. Cada regra de R2.3 tem teste: última página cheia, página com linhas depois de página incompleta e cabeçalho inesperado. +5. `run --offline` sobre um cache com as amostras grava `variant_links.parquet`, e o `manifest.json` traz `schema_version` `1.1.0` e a contagem de linhas do arquivo. +6. Duas fontes `attested` sem país, ambas com `primary: false`, passam em `load_sources`; duas fontes `births` do mesmo país sem `primary` continuam recusadas. +7. A CI roda verde no pull request desta spec, com o passo de R7. +8. A medição de R8 é feita com os arquivos reais e a linha de tempo e memória vai para a descrição do pull request. +9. A issue #3 é fechada pelo pull request. + +## Fora de escopo + +- Agrupamento de variantes, `group_id` e transliteração para ASCII (spec 005). +- Ingestão do dump completo do Wikidata e um endpoint alternativo ao QLever (issue #29). +- Rótulos de itens sem P1705 (rótulos por língua, inclusive `mul`; issue #30) e lexemas do Wikidata. +- País de origem (P495) e língua do nome (P407) como atributos do nome. +- Publicação da tabela de ligações no README e no cartão do dataset (spec 006). diff --git a/docs/specs/004-wikidata/tasks.md b/docs/specs/004-wikidata/tasks.md new file mode 100644 index 0000000..809ce46 --- /dev/null +++ b/docs/specs/004-wikidata/tasks.md @@ -0,0 +1,52 @@ +# 004. Wikidata: tarefas + +Plano: [`plan.md`](plan.md). Spec: [`spec.md`](spec.md). Rodar os comandos na raiz do repositório. Antes da Task 1, rodar `uv sync` e conferir que `uv run pytest -q` passa. Cada tarefa começa pelo teste que falha e termina com `uv run pytest -q` verde e um commit. + +--- + +### Task 1: regra de `primary` só para fontes com contagem (R5, issue #3) + +**Arquivos:** `src/namescommons/sources.py`, `tests/test_sources.py`. + +- [x] Teste que falha: `test_attested_nao_exige_primary` (duas fontes `attested` sem país, ambas `primary: false`, e duas `attested` do mesmo país, ambas `primary: true`, são aceitas) e `test_births_continua_exigindo_uma_primary`. +- [x] Em `parse_sources`, o agrupamento por (`country`, `count_type`) ignora `count_type == "attested"`. +- [x] `uv run pytest -q` e commit. + +### Task 2: tabela `variant_links` no contrato e no pipeline (R4.1, R4.2, R4.4, R4.5, R4.7) + +**Arquivos:** `src/namescommons/adapters/__init__.py`, `src/namescommons/links.py` (novo), `src/namescommons/pipeline.py`, `src/namescommons/io.py`, `src/namescommons/schema.py`, `tests/test_links.py` (novo), `tests/test_io.py`. + +**Produz:** `RawLink(relation, qid, name, kind, target_qid, target_name, target_kind)`; `VariantLink` com os campos de R4.2; `RELATIONS`; `validate_links(links, sources)` que levanta `ValidationError`; `to_links(records, source) -> list[VariantLink]` (normaliza, descarta auto-ligação, remove repetidas, ordena; `RecordError` com `ligação N:` para tipo ou valor inválido); `ingest_links(selected, sources, cache_dir, offline, adapters) -> list[VariantLink] | None`; `write_links(links, path)` e `read_links(path)`. + +- [x] Testes que falham em `tests/test_links.py`: normalização e `name_key`; auto-ligação descartada; repetidas emitidas uma vez e ordem estável; `ligação 2: relation 'x' inválida`; `kind` e QID inválidos; `validate_links` recusando cada caso de R4.5; ida e volta em Parquet; `check_adapter` recusando `links` que não é função; `ingest_links` com um módulo de teste com e sem `links`. +- [x] `test_manifest` passa a esperar `schema_version` `1.1.0`. +- [x] Implementação e commit. + +### Task 3: adaptador do Wikidata, `parse` (R1, R2, R3) + +**Arquivos:** `src/namescommons/adapters/wikidata.py` (novo), `tools/amostra_wikidata.py` (novo), `tests/fixtures/wikidata/` (15 arquivos), `tests/fixtures/ATRIBUICAO.md`, `sources.yaml`, `tests/test_wikidata.py` (novo). + +- [x] Gerar a amostra com `uv run python tools/amostra_wikidata.py tests/fixtures/wikidata` (única etapa com rede; os testes não a rodam). Itens: prenomes `Maria` (F e M), `Мария`, `Andrea` (M, F e unissex), `Kim`, `Иван` (três línguas, mesmo texto), `Ivan`, `John`, `李`, `Letha` (sem gênero e F), `Ritwik (given name)` e um valor desconhecido; sobrenomes `Иванов`, `Иванова`, `Ivanov`, `Ivanova`, `Silva`, `李`, `Kim`, `John`, `Q34147` e `Felbert,von`. +- [x] Testes que falham em `tests/test_wikidata.py`: `test_recursos_e_consultas` (15 recursos, URL do QLever com `action=sparql_json`, `ORDER BY`, `LIMIT 200000 OFFSET k`); `test_parse_da_amostra_real` com os registros escritos à mão; `test_genero_vazio_cede_ao_informado`; `test_rotulos_que_nao_sao_nomes_sao_pulados`; `test_ultima_pagina_cheia_falha`; `test_pagina_com_linhas_depois_de_incompleta_falha`; `test_cabecalho_inesperado_falha`; `test_classe_de_genero_desconhecida_falha`; `test_item_que_nao_e_entidade_falha`. +- [x] Registrar a fonte em `sources.yaml` e a amostra em `ATRIBUICAO.md`. +- [x] Implementação e commit. + +### Task 4: adaptador do Wikidata, `links` (R4.3) + +**Arquivos:** `src/namescommons/adapters/wikidata.py`, `tests/test_wikidata.py`. + +- [x] Testes que falham: `test_links_da_amostra_real` (ligações escritas à mão, por relação); `test_transliteracao_filtrada`; `test_alvo_desconhecido_e_pulado`; `test_ingest_links_da_amostra` (sem auto-ligação, com `name_key`). +- [x] Implementação e commit. + +### Task 5: CLI e CI (R4.6, R7) + +**Arquivos:** `src/namescommons/cli.py`, `tests/test_cli_pipeline.py`, `.github/workflows/ci.yml`, `tests/test_ci.py`. + +- [x] Testes que falham: `ingest --links` grava a tabela; `build --links` a copia para a release e o `manifest.json` conta suas linhas; `run` com o Wikidata grava `variant_links.parquet`; `run` só com fontes sem `links` não grava; o passo da CI das fontes de `sources.yaml` confere `variant_links.parquet`. +- [x] Implementação e commit. + +### Fechamento + +- [x] Medição de R8 com os 15 arquivos reais no cache: `/usr/bin/time -l uv run namescommons run --offline` só com o Wikidata (`sources.yaml` temporário). +- [x] Issues para o que ficou fora: ingestão do dump e endpoint alternativo (#29); rótulos sem P1705 (#30); margem de memória com o Wikidata (#31). +- [x] Pull request com `Closes #3`. diff --git a/docs/specs/ROADMAP.md b/docs/specs/ROADMAP.md index 7a77ae9..467d128 100644 --- a/docs/specs/ROADMAP.md +++ b/docs/specs/ROADMAP.md @@ -13,8 +13,8 @@ Cada spec abaixo passa pelo próprio ciclo spec → plano → implementação | 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 | +| 4 | [`004-wikidata`](004-wikidata/spec.md) | 002 | Adaptador do Wikidata: nomes, gênero, escrita, ligações entre variantes (`variant_links`) e regra de `primary` em `attested` (issue #3) | Em PR | | 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), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md). +Decisões transversais ficam em [`docs/adr/`](../adr/): [0001 licença dos dados](../adr/0001-licenca-dos-dados.md), [0002 valores suprimidos](../adr/0002-valores-suprimidos.md), [0003 nome do projeto e do pacote](../adr/0003-nome-do-projeto-e-do-pacote.md), [0004 Statistics Canada Open Licence](../adr/0004-licenca-statistics-canada.md), [0005 leitor de planilhas](../adr/0005-leitor-de-planilhas.md), [0006 contagem de sobrenomes](../adr/0006-contagem-de-sobrenomes.md), [0007 nomes corrompidos na fonte](../adr/0007-nomes-corrompidos-na-fonte.md), [0008 acesso ao Wikidata](../adr/0008-acesso-ao-wikidata.md), [0009 ligações entre variantes](../adr/0009-ligacoes-entre-variantes.md). diff --git a/sources.yaml b/sources.yaml index 7323c4a..6ac1e68 100644 --- a/sources.yaml +++ b/sources.yaml @@ -1,4 +1,4 @@ -# Fontes do dataset (spec 001, R2; spec 002, R2). Licenças conferidas na spec 003, R1, na spec 003d (ADR 0004) e na spec 003b, R2. +# Fontes do dataset (spec 001, R2; spec 002, R2). Licenças conferidas na spec 003, R1, na spec 003d (ADR 0004), na spec 003b, R2, e na spec 004, R1. sources: - id: us-ssa-births name: Social Security Administration, Popular Baby Names (national data) @@ -83,3 +83,13 @@ sources: primary: true suppression: 10 retrieved: 2026-10-12 + - id: wikidata + name: Wikidata, itens de prenome (Q202444) e de sobrenome (Q101352) com rótulo nativo + url: https://www.wikidata.org/wiki/Wikidata:Licensing + license: CC0-1.0 + attribution: "Wikidata, dados estruturados sob CC0 1.0, consultados no QLever" + country: null + count_type: attested + primary: false + suppression: null + retrieved: 2026-10-12 diff --git a/src/namescommons/adapters/__init__.py b/src/namescommons/adapters/__init__.py index d7fc262..24d3e1b 100644 --- a/src/namescommons/adapters/__init__.py +++ b/src/namescommons/adapters/__init__.py @@ -1,7 +1,8 @@ """Contrato do adaptador de fonte (spec 002, R1, com as regras da spec 003, R2). Cada fonte com adaptador tem um módulo . que expõe -SOURCE_ID, RESOURCES (lista de Resource) e parse(files) -> Iterator[RawRecord]. +SOURCE_ID, RESOURCES (lista de Resource) e parse(files) -> Iterator[RawRecord], +e opcionalmente links(files) -> Iterator[RawLink] (spec 004, R4.1). O adaptador converte os valores da fonte: year e count como int, gender como F ou M, country em ISO 3166-1 alfa-2, kind como given ou family. Valores suprimidos são omitidos. """ @@ -44,6 +45,19 @@ class RawRecord: count: int | None = None +@dataclass(frozen=True) +class RawLink: + """Ligação entre dois nomes como a fonte publica (spec 004, R4.1); o pipeline a transforma em VariantLink.""" + + relation: str + qid: str + name: str + kind: str + target_qid: str + target_name: str + target_kind: str + + def module_name(source_id: str) -> str: return source_id.replace("-", "_") @@ -90,6 +104,8 @@ def check_adapter(mod: object, source_id: str) -> None: vistos.add(r.filename) if not callable(mod.parse): raise AdapterError(f"'{source_id}': parse deve ser uma função") + if hasattr(mod, "links") and not callable(mod.links): + raise AdapterError(f"'{source_id}': links deve ser uma função") def _caminho_esperado(package: str, modulo: str) -> str: diff --git a/src/namescommons/adapters/wikidata.py b/src/namescommons/adapters/wikidata.py new file mode 100644 index 0000000..a5251c7 --- /dev/null +++ b/src/namescommons/adapters/wikidata.py @@ -0,0 +1,234 @@ +"""Wikidata: prenomes (Q202444) e sobrenomes (Q101352) com rótulo nativo e classe de gênero (spec 004). + +Os recursos são páginas de consultas SPARQL ao QLever (ADR 0008), no formato SPARQL 1.1 Query +Results JSON. parse emite os nomes; links emite as ligações entre variantes (ADR 0009). +""" + +import json +import re +import unicodedata +from collections.abc import Iterator +from itertools import combinations +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlencode + +from namescommons.adapters import FormatError, RawLink, RawRecord, Resource +from namescommons.normalize import name_key, normalize_name + +SOURCE_ID = "wikidata" +ENDPOINT = "https://qlever.dev/api/wikidata" +LINHAS_POR_PAGINA = 200_000 +PREFIXOS = ( + "PREFIX wd: " + "PREFIX wdt: " + "PREFIX p: " + "PREFIX pq: " +) +_ENTIDADE = re.compile(r"http://www\.wikidata\.org/entity/(Q[1-9][0-9]*)") + +# R3.4: classe de gênero (alcançada por P31/P279*) -> gêneros emitidos. +GENERO_PRENOME = {"Q12308941": ("M",), "Q11879590": ("F",), "Q3409032": ("F", "M")} +GENERO_SOBRENOME = { + "Q18972245": ("M",), "Q136439710": ("M",), + "Q18972207": ("F",), "Q136439688": ("F",), +} + +# R3.2: caracteres que denunciam um rótulo que não é só o nome. +_PROIBIDOS = frozenset("()[]{}/\\,;:_|?!@#\"=<>*+&%$~^`") + + +@dataclass(frozen=True) +class Consulta: + """Consulta SPARQL paginada: arquivo -.json para k de 1 a paginas.""" + + nome: str + variaveis: tuple[str, ...] + padrao: str # padrão do WHERE, sem chaves + paginas: int + + def sparql(self, pagina: int | None = None, itens: tuple[str, ...] = ()) -> str: + """Texto da consulta; `itens` restringe a poucos itens (só para gerar a amostra).""" + restricao = f"VALUES ?item {{ {' '.join('wd:' + q for q in itens)} }} " if itens else "" + selecao = " ".join("?" + v for v in self.variaveis) + texto = (f"{PREFIXOS} SELECT DISTINCT {selecao} WHERE {{ {restricao}{self.padrao} }} " + f"ORDER BY {selecao}") + if pagina is not None: + texto += f" LIMIT {LINHAS_POR_PAGINA} OFFSET {(pagina - 1) * LINHAS_POR_PAGINA}" + return texto + + def arquivo(self, pagina: int) -> str: + return f"{self.nome}-{pagina}.json" + + +def _rotulos(raiz: str, generos: dict) -> str: + classes = " ".join("wd:" + q for q in generos) + return (f"?item wdt:P31/wdt:P279* wd:{raiz} . ?item wdt:P1705 ?name . " + f"OPTIONAL {{ VALUES ?gender {{ {classes} }} ?item wdt:P31/wdt:P279* ?gender }}") + + +_ITEM_DE_NOME = "VALUES ?root { wd:Q202444 wd:Q101352 } ?item wdt:P31/wdt:P279* ?root . " + +PRENOMES = Consulta("given-names", ("item", "name", "gender"), _rotulos("Q202444", GENERO_PRENOME), 1) +SOBRENOMES = Consulta("family-names", ("item", "name", "gender"), _rotulos("Q101352", GENERO_SOBRENOME), 5) +# R4.3: propriedade -> (relação, páginas). +PROPRIEDADES = { + "P460": ("said_to_be_the_same", 3), + "P1533": ("family_name_identical", 1), + "P1560": ("other_gender", 1), + "P5278": ("other_gender", 1), + "P1889": ("different_from", 2), +} +LIGACOES = { + p: Consulta(f"links-{p}", ("item", "target"), f"{_ITEM_DE_NOME}?item wdt:{p} ?target .", paginas) + for p, (_, paginas) in PROPRIEDADES.items() +} +TRANSLITERACOES = Consulta( + "transliterations", ("item", "transliteration"), + f"{_ITEM_DE_NOME}{{ ?item wdt:P2440 ?transliteration }} UNION {{ ?item p:P1705/pq:P2440 ?transliteration }}", + 1, +) +CONSULTAS = (PRENOMES, SOBRENOMES, *LIGACOES.values(), TRANSLITERACOES) + + +def url(consulta: Consulta, pagina: int) -> str: + return f"{ENDPOINT}?{urlencode({'query': consulta.sparql(pagina), 'action': 'sparql_json'})}" + + +RESOURCES = [ + Resource(url(c, k), c.arquivo(k)) for c in CONSULTAS for k in range(1, c.paginas + 1) +] + + +def linhas(files: dict[str, Path], consulta: Consulta) -> Iterator[dict]: + """Linhas de todas as páginas da consulta, uma página por vez (R2.3).""" + incompleta = None + for k in range(1, consulta.paginas + 1): + nome = consulta.arquivo(k) + try: + dados = json.loads(Path(files[nome]).read_text(encoding="utf-8")) + variaveis, pagina = dados["head"]["vars"], dados["results"]["bindings"] + except (ValueError, KeyError, TypeError) as e: + raise FormatError(f"{nome}: não é um resultado SPARQL JSON ({type(e).__name__}: {e})") from None + if variaveis != list(consulta.variaveis): + raise FormatError(f"{nome}: variáveis {variaveis}, esperava {list(consulta.variaveis)}") + if not isinstance(pagina, list) or len(pagina) > LINHAS_POR_PAGINA: + raise FormatError(f"{nome}: esperava uma lista de no máximo {LINHAS_POR_PAGINA} linhas") + if pagina and incompleta is not None: + raise FormatError( + f"{nome}: página com linhas depois da página incompleta {incompleta}; o índice mudou " + f"entre os downloads, baixe de novo com fetch --refresh {SOURCE_ID}" + ) + if len(pagina) == LINHAS_POR_PAGINA and k == consulta.paginas: + raise FormatError( + f"{nome}: última página cheia ({LINHAS_POR_PAGINA} linhas); aumente as páginas de " + f"'{consulta.nome}' em {__name__}" + ) + if len(pagina) < LINHAS_POR_PAGINA and incompleta is None: + incompleta = nome + del dados + yield from pagina + + +def _entidade(termo: object) -> str | None: + """QID de um termo URI de entidade; None para outro termo (valor desconhecido, genid).""" + if isinstance(termo, dict) and termo.get("type") == "uri": + m = _ENTIDADE.fullmatch(termo.get("value", "")) + if m: + return m.group(1) + return None + + +def _item(linha: dict, consulta: Consulta) -> str: + qid = _entidade(linha.get("item")) + if qid is None: + raise FormatError(f"{consulta.nome}: item {linha.get('item')!r} não é entidade do Wikidata") + return qid + + +def _literal(termo: object, campo: str, consulta: Consulta) -> str | None: + """Texto de um literal; None para valor desconhecido (URI ou nó em branco).""" + if not isinstance(termo, dict): + raise FormatError(f"{consulta.nome}: {campo} ausente") + if termo.get("type") == "literal" and isinstance(termo.get("value"), str): + return termo["value"] + if termo.get("type") in ("uri", "bnode"): + return None + raise FormatError(f"{consulta.nome}: {campo} {termo!r} não é literal") + + +def nome_aceito(texto: str) -> bool: + """R3.2: sem algarismo de nenhuma escrita e sem os caracteres de _PROIBIDOS.""" + return bool(texto.strip()) and not any( + c in _PROIBIDOS or unicodedata.category(c).startswith("N") for c in texto + ) + + +def rotulos(files: dict[str, Path]) -> Iterator[tuple[str, str, str, tuple[str | None, ...]]]: + """(qid, kind, nome, gêneros) de cada rótulo nativo aceito (R3.1 a R3.4).""" + for kind, consulta, generos in (("given", PRENOMES, GENERO_PRENOME), ("family", SOBRENOMES, GENERO_SOBRENOME)): + for linha in linhas(files, consulta): + qid = _item(linha, consulta) + nome = _literal(linha.get("name"), "name", consulta) + if nome is None or not nome_aceito(nome): + continue + classe = linha.get("gender") + if classe is None: + yield qid, kind, nome, (None,) + continue + codigo = _entidade(classe) + if codigo not in generos: + raise FormatError( + f"{consulta.nome}: classe de gênero {classe.get('value') if isinstance(classe, dict) else classe!r} " + f"desconhecida; esperado uma de {sorted(generos)}" + ) + yield qid, kind, nome, generos[codigo] + + +def _chave(nome: str, kind: str) -> tuple[str, str]: + return name_key(normalize_name(nome, False)), kind + + +def parse(files: dict[str, Path]) -> Iterator[RawRecord]: + """Um registro por gênero informado de cada (name_key, kind); sem gênero só se nenhum item informa (R3.5).""" + grupos: dict[tuple[str, str], tuple[str, set]] = {} + for _, kind, nome, generos in rotulos(files): + grupo = grupos.setdefault(_chave(nome, kind), (nome, set())) + grupo[1].update(generos) + for (_, kind), (nome, generos) in grupos.items(): + for genero in sorted(g for g in generos if g) or [None]: + yield RawRecord(name=nome, kind=kind, gender=genero) + + +def _alvo(linha: dict, consulta: Consulta) -> str | None: + """QID do alvo; None para valor desconhecido (genid); termo que não é URI é FormatError (R2.4).""" + termo = linha.get("target") + if not isinstance(termo, dict) or termo.get("type") not in ("uri", "bnode"): + raise FormatError(f"{consulta.nome}: target {termo!r} não é URI") + return _entidade(termo) + + +def links(files: dict[str, Path]) -> Iterator[RawLink]: + """Ligações entre rótulos aceitos (R4.3); o pipeline descarta auto-ligações e repetidas.""" + por_item: dict[str, dict[tuple[str, str], str]] = {} + for qid, kind, nome, _ in rotulos(files): + por_item.setdefault(qid, {}).setdefault(_chave(nome, kind), nome) + for qid, nomes in por_item.items(): + for ((_, ka), a), ((_, kb), b) in combinations(nomes.items(), 2): + if ka == kb: + yield RawLink("same_item", qid, a, ka, qid, b, kb) + for propriedade, (relacao, _) in PROPRIEDADES.items(): + consulta = LIGACOES[propriedade] + for linha in linhas(files, consulta): + origem, destino = _item(linha, consulta), _alvo(linha, consulta) + alvos = por_item.get(destino, {}) if destino else {} + for (_, ka), a in por_item.get(origem, {}).items(): + for (_, kb), b in alvos.items(): + yield RawLink(relacao, origem, a, ka, destino, b, kb) + for linha in linhas(files, TRANSLITERACOES): + origem = _item(linha, TRANSLITERACOES) + texto = _literal(linha.get("transliteration"), "transliteration", TRANSLITERACOES) + if texto is None or not nome_aceito(texto): + continue + for (_, kind), nome in por_item.get(origem, {}).items(): + yield RawLink("transliteration", origem, nome, kind, origem, texto, kind) diff --git a/src/namescommons/cli.py b/src/namescommons/cli.py index acadfd1..702f28f 100644 --- a/src/namescommons/cli.py +++ b/src/namescommons/cli.py @@ -12,9 +12,12 @@ from namescommons.adapters import DEFAULT_PACKAGE from namescommons.aggregate import AggregationError, by_country, name_index -from namescommons.io import read_observations, write_by_country, write_index, write_observations +from namescommons.io import ( + read_links, read_observations, write_by_country, write_index, write_links, write_observations, +) +from namescommons.links import VariantLink, validate_links from namescommons.manifest import RELEASE, build_manifest -from namescommons.pipeline import PipelineError, fetch_sources, ingest_sources, select_sources +from namescommons.pipeline import PipelineError, fetch_sources, ingest_links, ingest_sources, select_sources from namescommons.schema import Observation, ValidationError, validate from namescommons.sources import Source, SourceError, load_sources @@ -58,6 +61,7 @@ def _parser() -> argparse.ArgumentParser: _opcoes_de_fonte(p) p.add_argument("--out", type=Path, required=True, help="arquivo observations.parquet a gravar") p.add_argument("--offline", action="store_true", help="não acessa a rede; arquivo ausente do cache é erro") + p.add_argument("--links", type=Path, help="arquivo variant_links.parquet a gravar com as ligações entre variantes (spec 004)") ajuda = { "validate": "valida um observations.parquet (spec 001)", "build": "gera as saídas da release a partir de um observations.parquet (spec 001)", @@ -68,6 +72,7 @@ def _parser() -> argparse.ArgumentParser: if nome == "build": p.add_argument("--out", type=Path, required=True, help="diretório novo ou vazio para as saídas da release") p.add_argument("--release", required=True, help="AAAA.MM[.N]") + p.add_argument("--links", type=Path, help="variant_links.parquet gravado por ingest --links, copiado para a release") p = sub.add_parser("run", help="fetch, ingest e build em sequência") _opcoes_de_fonte(p, com_ids=False) p.add_argument("--out", type=Path, required=True, help="diretório novo ou vazio para as saídas da release") @@ -94,8 +99,12 @@ def _saida_invalida(release: str, out: Path) -> str | None: return None -def _build(obs: list[Observation], fontes: dict[str, Source], out: Path, release: str) -> int: - """Grava as saídas da spec 001 em out, tudo ou nada; devolve o número de países. +def _build( + obs: list[Observation], fontes: dict[str, Source], out: Path, release: str, + links: list[VariantLink] | None = None, +) -> int: + """Grava as saídas da spec 001 (e variant_links da spec 004, se houver) em out, tudo ou nada; + devolve o número de países. As saídas são montadas num diretório temporário ao lado de out e só então renomeadas: uma falha em qualquer etapa não deixa saída parcial. @@ -111,6 +120,8 @@ def _build(obs: list[Observation], fontes: dict[str, Source], out: Path, release for kind, base in (("given", "given_names"), ("family", "family_names")): write_index(name_index(obs, paises, kind), temporario / f"{base}.csv", temporario / f"{base}.parquet") write_by_country(paises, temporario / "by_country") + if links is not None: + write_links(links, temporario / "variant_links.parquet") manifest = build_manifest(release, temporario, fontes, {o.source_id for o in obs}) (temporario / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" @@ -142,11 +153,18 @@ def main(argv: list[str] | None = None) -> int: print(f"OK: {_n(len(selecionadas), 'fonte', 'fontes')} em {args.cache}.") return 0 obs = ingest_sources(selecionadas, fontes, args.cache, offline=args.offline, adapters=args.adapters) + links = None + if args.cmd == "run" or args.links is not None: + links = ingest_links(selecionadas, fontes, args.cache, offline=args.offline, adapters=args.adapters) if args.cmd == "ingest": if args.out.exists(): print(f"aviso: {args.out} já existe e será substituído", file=sys.stderr) write_observations(obs, args.out) - print(f"OK: {_n(len(obs), 'observação', 'observações')} de {_n(len(selecionadas), 'fonte', 'fontes')} em {args.out}.") + mensagem = f"OK: {_n(len(obs), 'observação', 'observações')} de {_n(len(selecionadas), 'fonte', 'fontes')} em {args.out}" + if args.links is not None: + write_links(links or [], args.links) + mensagem += f"; {_n(len(links or []), 'ligação', 'ligações')} em {args.links}" + print(mensagem + ".") return 0 else: if not args.observations.exists(): @@ -156,10 +174,19 @@ def main(argv: list[str] | None = None) -> int: except (OSError, ArrowException, TypeError, KeyError) as e: return _erro(f"não foi possível ler {args.observations}: {e}") validate(obs, fontes) + links = None + if getattr(args, "links", None) is not None: + if not args.links.exists(): + return _erro(f"arquivo não encontrado: {args.links}") + try: + links = read_links(args.links) + except (OSError, ArrowException, TypeError, KeyError) as e: + return _erro(f"não foi possível ler {args.links}: {e}") + validate_links(links, fontes) if args.cmd == "validate": print(f"OK: {_n(len(obs), 'observação válida', 'observações válidas')}.") return 0 - paises = _build(obs, fontes, args.out, args.release) + paises = _build(obs, fontes, args.out, args.release, links) except ( PipelineError, SourceError, ValidationError, AggregationError, ValueError, OSError, ArrowException, ) as e: diff --git a/src/namescommons/io.py b/src/namescommons/io.py index 78afda7..a6f0766 100644 --- a/src/namescommons/io.py +++ b/src/namescommons/io.py @@ -10,6 +10,7 @@ import pyarrow.parquet as pq from namescommons.aggregate import CountryRow, IndexRow +from namescommons.links import VariantLink from namescommons.schema import Observation OBSERVATION_SCHEMA = pa.schema([ @@ -22,6 +23,9 @@ # read_observations constrói Observation por posição (R8): a ordem precisa coincidir. assert [f.name for f in fields(Observation)] == OBSERVATION_SCHEMA.names, "OBSERVATION_SCHEMA fora de ordem" +# Spec 004, R4.2: tabela variant_links, na ordem dos campos de VariantLink. +LINK_SCHEMA = pa.schema([(f.name, pa.string()) for f in fields(VariantLink)]) + INDEX_SCHEMA = pa.schema([ ("name", pa.string()), ("name_key", pa.string()), ("ascii", pa.string()), ("script", pa.string()), ("country_count", pa.int32()), @@ -109,3 +113,15 @@ def write_by_country(rows_by_country: dict[str, list[CountryRow]], directory: Pa w.writerow(COUNTRY_CSV_COLUMNS) for r in rows: w.writerow([_celula(getattr(r, c)) for c in COUNTRY_CSV_COLUMNS]) + + +def write_links(links: list[VariantLink], path: Path) -> None: + colunas = {campo: [getattr(v, campo) for v in links] for campo in LINK_SCHEMA.names} + path.parent.mkdir(parents=True, exist_ok=True) + pq.write_table(pa.Table.from_pydict(colunas, schema=LINK_SCHEMA), path) + + +def read_links(path: Path) -> list[VariantLink]: + tabela = pq.read_table(path).select(LINK_SCHEMA.names).cast(LINK_SCHEMA) + colunas = [_valores(tabela.column(campo)) for campo in LINK_SCHEMA.names] + return [VariantLink(*valores) for valores in zip(*colunas)] diff --git a/src/namescommons/links.py b/src/namescommons/links.py new file mode 100644 index 0000000..40e11fe --- /dev/null +++ b/src/namescommons/links.py @@ -0,0 +1,75 @@ +"""Tabela variant_links (spec 004, R4; ADR 0009): ligações entre nomes publicadas por uma fonte.""" + +import re +from dataclasses import dataclass + +from namescommons.normalize import name_key +from namescommons.schema import KINDS, ValidationError +from namescommons.sources import Source + +RELATIONS = ( + "same_item", "said_to_be_the_same", "family_name_identical", + "other_gender", "different_from", "transliteration", +) +# Relações entre rótulos do mesmo item: o destino é o próprio item. +MESMO_ITEM = ("same_item", "transliteration") +QID = re.compile(r"Q[1-9][0-9]*") + + +@dataclass(frozen=True, slots=True) +class VariantLink: + source_id: str + relation: str + qid: str + name: str + name_key: str + kind: str + target_qid: str + target_name: str + target_name_key: str + target_kind: str + + def ordem(self) -> tuple: + return (self.source_id, self.qid, self.relation, self.kind, self.name_key, self.name, + self.target_qid, self.target_kind, self.target_name_key, self.target_name) + + +def link_problems(v) -> list[str]: + """Problemas de valor de uma ligação (bruta ou normalizada), sem conferir name_key.""" + problemas = [] + if v.relation not in RELATIONS: + problemas.append(f"relation {v.relation!r} inválida; use uma de {list(RELATIONS)}") + for campo in ("qid", "target_qid"): + valor = getattr(v, campo) + if not isinstance(valor, str) or not QID.fullmatch(valor): + problemas.append(f"{campo} {valor!r} fora do formato Q") + for campo in ("kind", "target_kind"): + if getattr(v, campo) not in KINDS: + problemas.append(f"{campo} {getattr(v, campo)!r} inválido; use 'given' ou 'family'") + if v.relation in MESMO_ITEM and v.target_qid != v.qid: + problemas.append(f"{v.relation} exige target_qid igual a qid") + return problemas + + +def validate_links(links: list[VariantLink], sources: dict[str, Source]) -> None: + """Levanta ValidationError com todos os problemas da tabela (R4.5).""" + erros: list[str] = [] + vistas: set[VariantLink] = set() + for i, v in enumerate(links, start=1): + r = f"ligação {i} ({v.name!r} -> {v.target_name!r})" + erros.extend(f"{r}: {p}" for p in link_problems(v)) + for campo, chave in (("name", "name_key"), ("target_name", "target_name_key")): + nome = getattr(v, campo) + if not nome: + erros.append(f"{r}: '{campo}' vazio") + elif getattr(v, chave) != name_key(nome): + erros.append(f"{r}: '{chave}' {getattr(v, chave)!r} diverge de name_key({campo})") + if (v.name_key, v.kind) == (v.target_name_key, v.target_kind): + erros.append(f"{r}: liga o nome a ele mesmo (mesmo name_key e kind)") + if v.source_id not in sources: + erros.append(f"{r}: source_id {v.source_id!r} ausente de sources.yaml") + if v in vistas: + erros.append(f"{r}: repetida") + vistas.add(v) + if erros: + raise ValidationError(erros) diff --git a/src/namescommons/pipeline.py b/src/namescommons/pipeline.py index 0da0a72..1bd0845 100644 --- a/src/namescommons/pipeline.py +++ b/src/namescommons/pipeline.py @@ -5,9 +5,10 @@ from pathlib import Path from namescommons.adapters import ( - DEFAULT_PACKAGE, AdapterError, FormatError, RawRecord, SourceFiles, load_adapter, + DEFAULT_PACKAGE, AdapterError, FormatError, RawLink, RawRecord, SourceFiles, load_adapter, ) from namescommons.cache import FetchError, fetch_resource +from namescommons.links import VariantLink, link_problems, validate_links from namescommons.schema import GENDERS, KINDS, Observation, ValidationError, derive_name_fields, validate from namescommons.sources import Source, is_country @@ -147,6 +148,40 @@ def to_observations(records: Iterable[RawRecord], source: Source) -> list[Observ return sorted(unicas.values(), key=_ordem) +_CAMPOS_DA_LIGACAO = ("relation", "qid", "name", "kind", "target_qid", "target_name", "target_kind") + + +def to_links(records: Iterable[RawLink], source: Source) -> list[VariantLink]: + """Spec 004, R4.2 e R4.4: normaliza os nomes, descarta auto-ligações e repetidas e ordena.""" + derivados: dict[str, tuple[str, str, str, str]] = {} + unicas: set[VariantLink] = set() + for posicao, r in enumerate(records, start=1): + for campo in _CAMPOS_DA_LIGACAO: + v = getattr(r, campo) + if not isinstance(v, str): + raise RecordError(f"ligação {posicao}: '{campo}' deve ser texto, recebeu {type(v).__name__} {v!r}") + problemas = link_problems(r) + if problemas: + raise RecordError(f"ligação {posicao}: {problemas[0]}") + nomes = [] + for campo in ("name", "target_name"): + bruto = getattr(r, campo) + campos = derivados.get(bruto) + if campos is None: + campos = derivados[bruto] = derive_name_fields(bruto, source.uniform_case) + if not campos[0]: + raise RecordError(f"ligação {posicao}: {campo} vazio") + nomes.append(campos) + (nome, chave, *_), (alvo, chave_alvo, *_) = nomes + if (chave, r.kind) == (chave_alvo, r.target_kind): + continue + unicas.add(VariantLink( + source_id=source.id, relation=r.relation, qid=r.qid, name=nome, name_key=chave, kind=r.kind, + target_qid=r.target_qid, target_name=alvo, target_name_key=chave_alvo, target_kind=r.target_kind, + )) + return sorted(unicas, key=VariantLink.ordem) + + def select_sources(sources: dict[str, Source], ids: list[str]) -> list[Source]: """Fontes indicadas, ou todas com adaptador se nenhuma for indicada.""" if not ids: @@ -208,3 +243,31 @@ def ingest_sources( raise _falha(fonte, e) from e observacoes.extend(obs) return observacoes + + +def ingest_links( + selected: list[Source], + sources: dict[str, Source], + cache_dir: Path, + *, + offline: bool = False, + adapters: str = DEFAULT_PACKAGE, + **opcoes, +) -> list[VariantLink] | None: + """Roda links sobre o cache das fontes que o implementam (spec 004, R4); None se nenhuma implementa.""" + ligacoes: list[VariantLink] | None = None + for fonte in selected: + try: + mod = load_adapter(fonte.id, adapters) + if getattr(mod, "links", None) is None: + continue + arquivos = SourceFiles( + (r.filename, fetch_resource(cache_dir, fonte.id, r, offline=offline, **opcoes)) + for r in mod.RESOURCES + ) + daqui = to_links(mod.links(arquivos), fonte) + validate_links(daqui, sources) + except Exception as e: + raise _falha(fonte, e) from e + ligacoes = (ligacoes or []) + daqui + return ligacoes diff --git a/src/namescommons/schema.py b/src/namescommons/schema.py index b816299..6cee494 100644 --- a/src/namescommons/schema.py +++ b/src/namescommons/schema.py @@ -7,7 +7,7 @@ from namescommons.normalize import ascii_form, detect_script, name_key, normalize_name from namescommons.sources import COUNT_TYPES, Source, is_country -SCHEMA_VERSION = "1.0.0" +SCHEMA_VERSION = "1.1.0" # 1.1.0: variant_links.parquet na release (spec 004, R4.7) KINDS = ("given", "family") GENDERS = ("F", "M") ANO_MINIMO = 1800 diff --git a/src/namescommons/sources.py b/src/namescommons/sources.py index 7c21b9f..7c91704 100644 --- a/src/namescommons/sources.py +++ b/src/namescommons/sources.py @@ -123,8 +123,12 @@ def parse_sources(entradas: list[dict]) -> dict[str, Source]: opcionais = {c: e.get(c, padrao) for c, padrao in OPCIONAIS.items()} fontes[e["id"]] = Source(**{c: e[c] for c in CAMPOS}, **opcionais) + # Exatamente uma primary por (country, count_type) com contagem; em attested, primary não + # tem efeito e não é conferido (spec 004, R5; issue #3). grupos: dict[tuple, list[Source]] = {} for f in fontes.values(): + if f.count_type == "attested": + continue grupos.setdefault((f.country, f.count_type), []).append(f) for (pais, tipo), fs in sorted(grupos.items(), key=lambda kv: (str(kv[0][0]), kv[0][1])): if len(fs) > 1 and sum(f.primary is True for f in fs) != 1: diff --git a/tests/fixtures/ATRIBUICAO.md b/tests/fixtures/ATRIBUICAO.md index afcee1f..666252a 100644 --- a/tests/fixtures/ATRIBUICAO.md +++ b/tests/fixtures/ATRIBUICAO.md @@ -12,3 +12,4 @@ Cada diretório guarda um trecho pequeno do arquivo real da fonte, só para os t | `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 | +| `wikidata` | CC0-1.0 | Wikidata, https://www.wikidata.org/wiki/Wikidata:Licensing, consultado no QLever (https://qlever.dev/api/wikidata) | resposta real das consultas do adaptador restritas a 15 itens de prenome e 10 de sobrenome (`VALUES ?item`), gerada por `tools/amostra_wikidata.py`, sem a chave `meta`; nas ligações, só os alvos da amostra e um alvo de fora por propriedade; páginas além do fim são a resposta vazia real | diff --git a/tests/fixtures/wikidata/family-names-1.json b/tests/fixtures/wikidata/family-names-1.json new file mode 100644 index 0000000..ecb24b8 --- /dev/null +++ b/tests/fixtures/wikidata/family-names-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q110770321"},"name":{"type":"literal","value":"Q34147","xml:lang":"vls"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q111757901"},"name":{"type":"literal","value":"Felbert,von","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q157414"},"name":{"type":"literal","value":"Kim","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"name":{"type":"literal","value":"Иванов","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q136439710"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"name":{"type":"literal","value":"Иванов","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q18972245"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"name":{"type":"literal","value":"Иванова","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q136439688"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"name":{"type":"literal","value":"Иванова","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q18972207"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q37456871"},"name":{"type":"literal","value":"Ivanova","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q40711158"},"name":{"type":"literal","value":"Ivanov","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"},"name":{"type":"literal","value":"John","xml:lang":"de"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"},"name":{"type":"literal","value":"John","xml:lang":"en"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"},"name":{"type":"literal","value":"John","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"},"name":{"type":"literal","value":"John","xml:lang":"pl"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q6327150"},"name":{"type":"literal","value":"Silva","xml:lang":"mul"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"name":{"type":"literal","value":"李","xml:lang":"zh"}}]}} diff --git a/tests/fixtures/wikidata/family-names-2.json b/tests/fixtures/wikidata/family-names-2.json new file mode 100644 index 0000000..5ad4184 --- /dev/null +++ b/tests/fixtures/wikidata/family-names-2.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/family-names-3.json b/tests/fixtures/wikidata/family-names-3.json new file mode 100644 index 0000000..5ad4184 --- /dev/null +++ b/tests/fixtures/wikidata/family-names-3.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/family-names-4.json b/tests/fixtures/wikidata/family-names-4.json new file mode 100644 index 0000000..5ad4184 --- /dev/null +++ b/tests/fixtures/wikidata/family-names-4.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/family-names-5.json b/tests/fixtures/wikidata/family-names-5.json new file mode 100644 index 0000000..5ad4184 --- /dev/null +++ b/tests/fixtures/wikidata/family-names-5.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/given-names-1.json b/tests/fixtures/wikidata/given-names-1.json new file mode 100644 index 0000000..7339c94 --- /dev/null +++ b/tests/fixtures/wikidata/given-names-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","name","gender"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q100275460"},"name":{"type":"literal","value":"Letha","xml:lang":"und"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q102313297"},"name":{"type":"literal","value":"Kadek","xml:lang":"id"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q3409032"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q102313297"},"name":{"type":"uri","value":"http://www.wikidata.org/.well-known/genid/e55bab4cb3c8f1def24f21491bb901d1"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q3409032"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q104829915"},"name":{"type":"literal","value":"Ritwik (given name)","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q112158404"},"name":{"type":"literal","value":"Letha","xml:lang":"de"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q11879590"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"},"name":{"type":"literal","value":"Andrea","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"},"name":{"type":"literal","value":"Andrea","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q11879590"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"},"name":{"type":"literal","value":"Иван","xml:lang":"bg"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"},"name":{"type":"literal","value":"Иван","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"},"name":{"type":"literal","value":"Иван","xml:lang":"ru"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q25413386"},"name":{"type":"literal","value":"Maria","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q325872"},"name":{"type":"literal","value":"Maria","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q11879590"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"name":{"type":"literal","value":"Мария","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q11879590"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"},"name":{"type":"literal","value":"John","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"},"name":{"type":"literal","value":"Andrea","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q3409032"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q637123"},"name":{"type":"literal","value":"Kim","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q3409032"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q830350"},"name":{"type":"literal","value":"Ivan","xml:lang":"mul"},"gender":{"type":"uri","value":"http://www.wikidata.org/entity/Q12308941"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q96209920"},"name":{"type":"literal","value":"李","xml:lang":"zh"}}]}} diff --git a/tests/fixtures/wikidata/links-P1533-1.json b/tests/fixtures/wikidata/links-P1533-1.json new file mode 100644 index 0000000..c62ad7a --- /dev/null +++ b/tests/fixtures/wikidata/links-P1533-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q18178965"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q637123"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q157414"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q96209920"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"}}]}} diff --git a/tests/fixtures/wikidata/links-P1560-1.json b/tests/fixtures/wikidata/links-P1560-1.json new file mode 100644 index 0000000..1426c96 --- /dev/null +++ b/tests/fixtures/wikidata/links-P1560-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q15732892"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q25413386"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q325872"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q25413386"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q325872"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q25413386"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q25413386"}}]}} diff --git a/tests/fixtures/wikidata/links-P1889-1.json b/tests/fixtures/wikidata/links-P1889-1.json new file mode 100644 index 0000000..0f029d2 --- /dev/null +++ b/tests/fixtures/wikidata/links-P1889-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q157414"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q224736"}}]}} diff --git a/tests/fixtures/wikidata/links-P1889-2.json b/tests/fixtures/wikidata/links-P1889-2.json new file mode 100644 index 0000000..090e6b6 --- /dev/null +++ b/tests/fixtures/wikidata/links-P1889-2.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/links-P460-1.json b/tests/fixtures/wikidata/links-P460-1.json new file mode 100644 index 0000000..5e63a3d --- /dev/null +++ b/tests/fixtures/wikidata/links-P460-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q157414"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q13083721"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q40711158"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q830350"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q37456871"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q325872"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q37456871"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q325872"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q40711158"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q830350"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177306"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q18177321"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q830350"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q21104340"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q830350"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"}}]}} diff --git a/tests/fixtures/wikidata/links-P460-2.json b/tests/fixtures/wikidata/links-P460-2.json new file mode 100644 index 0000000..090e6b6 --- /dev/null +++ b/tests/fixtures/wikidata/links-P460-2.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/links-P460-3.json b/tests/fixtures/wikidata/links-P460-3.json new file mode 100644 index 0000000..090e6b6 --- /dev/null +++ b/tests/fixtures/wikidata/links-P460-3.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[]}} diff --git a/tests/fixtures/wikidata/links-P5278-1.json b/tests/fixtures/wikidata/links-P5278-1.json new file mode 100644 index 0000000..0736645 --- /dev/null +++ b/tests/fixtures/wikidata/links-P5278-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","target"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q5281981"},"target":{"type":"uri","value":"http://www.wikidata.org/entity/Q106403072"}}]}} diff --git a/tests/fixtures/wikidata/transliterations-1.json b/tests/fixtures/wikidata/transliterations-1.json new file mode 100644 index 0000000..370453c --- /dev/null +++ b/tests/fixtures/wikidata/transliterations-1.json @@ -0,0 +1 @@ +{"head":{"vars":["item","transliteration"]},"results":{"bindings":[{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"transliteration":{"type":"literal","value":"Ivanov"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"transliteration":{"type":"literal","value":"Iwanow"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q16654835"},"transliteration":{"type":"literal","value":"伊万诺夫"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"transliteration":{"type":"literal","value":"Ivanova"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"transliteration":{"type":"literal","value":"Ivanovová"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q21447935"},"transliteration":{"type":"literal","value":"Iwanowa"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"transliteration":{"type":"literal","value":"Maria"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"transliteration":{"type":"literal","value":"Mariia"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"transliteration":{"type":"literal","value":"Marija"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"transliteration":{"type":"literal","value":"Mariya"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q39897333"},"transliteration":{"type":"literal","value":"Masha"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q4925477"},"transliteration":{"type":"literal","value":"Джон"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"},"transliteration":{"type":"literal","value":"Αντρέα"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q493293"},"transliteration":{"type":"literal","value":"Андреа"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lee"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lei5"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lei⁵"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Léi⁵"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Léih"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Ley"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Li"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Li`"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lí"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lǐ"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Li²"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Li³"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lii"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Lý"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Ли"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"Лі"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"리"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"이"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"り"}},{"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q686223"},"transliteration":{"type":"literal","value":"ㄌㄧˇ"}}]}} diff --git a/tests/test_ci.py b/tests/test_ci.py index f5b2b18..04dcaf9 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", "nz-dia-births", "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", "wikidata") WORKFLOW = RAIZ / ".github" / "workflows" / "ci.yml" @@ -152,7 +152,7 @@ def test_passo_offline_da_ci_roda_localmente(tmp_path): @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.""" + """Spec 003, R5, e spec 004, R7: a CI roda as fontes de sources.yaml sobre um cache montado com as amostras.""" passo = next(p for p in _workflow()["jobs"]["testes"]["steps"] if "cache-oficiais" in p.get("run", "")) ambiente = {**os.environ, **passo.get("env", {}), "RUNNER_TEMP": str(tmp_path)} r = subprocess.run(["bash", "-e", "-c", passo["run"]], cwd=RAIZ, env=ambiente, @@ -169,6 +169,9 @@ def test_passo_das_fontes_oficiais_roda_localmente(tmp_path): # 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 + # Spec 004, R7: o Wikidata entra no passo e grava a tabela de ligações. + assert "variant_links.parquet" in passo["run"] + assert (tmp_path / "dist-oficiais" / "variant_links.parquet").stat().st_size > 0 def test_passo_das_fontes_oficiais_confere_todos_os_paises(): diff --git a/tests/test_cli_links.py b/tests/test_cli_links.py new file mode 100644 index 0000000..e78c24e --- /dev/null +++ b/tests/test_cli_links.py @@ -0,0 +1,82 @@ +"""Spec 004, R4.6 e critério de aceite 5: variant_links pela CLI.""" + +import json +import shutil +from pathlib import Path + +import pytest + +from namescommons.cli import main +from namescommons.io import read_links + +RAIZ = Path(__file__).resolve().parent.parent +FIXTURES = RAIZ / "tests" / "fixtures" +FONTES = str(RAIZ / "sources.yaml") + + +@pytest.fixture +def cache(tmp_path): + shutil.copytree(FIXTURES / "wikidata", tmp_path / "cache" / "wikidata") + return str(tmp_path / "cache") + + +def test_ingest_links_e_build_links(cache, tmp_path, capsys): + obs, ligacoes = tmp_path / "t" / "observations.parquet", tmp_path / "t" / "variant_links.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, + "--out", str(obs), "--links", str(ligacoes)]) == 0 + saida = capsys.readouterr().out + assert "OK: 22 observações de 1 fonte" in saida and "47 ligações em" in saida + links = read_links(ligacoes) + assert len(links) == 47 and {v.source_id for v in links} == {"wikidata"} + dist = tmp_path / "dist" + assert main(["build", str(obs), "--links", str(ligacoes), "--sources", FONTES, + "--out", str(dist), "--release", "2026.10"]) == 0 + assert read_links(dist / "variant_links.parquet") == links + manifest = json.loads((dist / "manifest.json").read_text(encoding="utf-8")) + assert manifest["schema_version"] == "1.1.0" + assert manifest["files"]["variant_links.parquet"]["rows"] == 47 + + +def test_ingest_sem_links_nao_grava_tabela(cache, tmp_path): + obs = tmp_path / "t" / "observations.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, "--out", str(obs)]) == 0 + assert sorted(p.name for p in obs.parent.iterdir()) == ["observations.parquet"] + + +def test_build_recusa_tabela_invalida(cache, tmp_path, capsys): + obs, ligacoes = tmp_path / "observations.parquet", tmp_path / "variant_links.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, + "--out", str(obs), "--links", str(ligacoes)]) == 0 + fontes = tmp_path / "sources.yaml" + texto = Path(FONTES).read_text(encoding="utf-8").replace("id: wikidata", "id: wikidata-x") + fontes.write_text(texto, encoding="utf-8") + capsys.readouterr() + assert main(["build", str(obs), "--links", str(ligacoes), "--sources", str(fontes), + "--out", str(tmp_path / "dist"), "--release", "2026.10"]) == 1 + assert "source_id" in capsys.readouterr().err + + +def test_build_com_links_ausente(tmp_path, cache, capsys): + obs = tmp_path / "observations.parquet" + assert main(["ingest", "wikidata", "--offline", "--cache", cache, "--sources", FONTES, "--out", str(obs)]) == 0 + assert main(["build", str(obs), "--links", str(tmp_path / "nada.parquet"), "--sources", FONTES, + "--out", str(tmp_path / "dist"), "--release", "2026.10"]) == 1 + assert "arquivo não encontrado" in capsys.readouterr().err + + +def test_run_grava_variant_links_so_com_fonte_de_ligacoes(tmp_path): + fontes = tmp_path / "sources.yaml" + entradas = Path(FONTES).read_text(encoding="utf-8").split("\n - id: ") + fontes.write_text(entradas[0] + "\n - id: " + next(e for e in entradas if e.startswith("wikidata")), + encoding="utf-8") + cache = tmp_path / "cache" + shutil.copytree(FIXTURES / "wikidata", cache / "wikidata") + assert main(["run", "--offline", "--cache", str(cache), "--sources", str(fontes), + "--out", str(tmp_path / "dist"), "--release", "2026.10"]) == 0 + assert len(read_links(tmp_path / "dist" / "variant_links.parquet")) == 47 + sem = tmp_path / "sem.yaml" + sem.write_text(entradas[0] + "\n - id: " + entradas[4], encoding="utf-8") + shutil.copytree(FIXTURES / "no-ssb-fornavn", cache / "no-ssb-fornavn") + assert main(["run", "--offline", "--cache", str(cache), "--sources", str(sem), + "--out", str(tmp_path / "dist2"), "--release", "2026.10"]) == 0 + assert not (tmp_path / "dist2" / "variant_links.parquet").exists() diff --git a/tests/test_io.py b/tests/test_io.py index 28426c3..f3152a3 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -51,7 +51,7 @@ def test_manifest(observacoes, fontes, tmp_path): write_by_country(by_country(observacoes, fontes), tmp_path / "by_country") write_observations(observacoes, tmp_path / "observations.parquet") m = build_manifest("2026.10", tmp_path, fontes, {o.source_id for o in observacoes}) - assert m["schema_version"] == "1.0.0" + assert m["schema_version"] == "1.1.0" assert [s["id"] for s in m["sources"]] == ["br-ibge-censo", "us-births-b", "us-ssa", "wikidata"] assert m["files"]["observations.parquet"]["rows"] == len(observacoes) assert m["files"]["by_country/US.csv"]["rows"] == 4 diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..a5e1610 --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,160 @@ +"""Spec 004, R4.1, R4.2, R4.4, R4.5 e R4.7: tabela variant_links no contrato e no pipeline.""" + +from dataclasses import replace +from datetime import date +from types import SimpleNamespace + +import pytest + +from namescommons import pipeline +from namescommons.adapters import AdapterError, RawLink, Resource, check_adapter +from namescommons.io import read_links, write_links +from namescommons.links import RELATIONS, VariantLink, validate_links +from namescommons.pipeline import PipelineError, RecordError, ingest_links, to_links +from namescommons.schema import SCHEMA_VERSION, ValidationError +from namescommons.sources import parse_sources + +FONTES = parse_sources([ + {"id": "wd", "name": "x", "url": "https://x.example", "license": "CC0-1.0", "attribution": "x", + "country": None, "count_type": "attested", "primary": False, "suppression": None, + "retrieved": date(2026, 1, 1)}, + {"id": "pt-x", "name": "x", "url": "https://x.example", "license": "CC0-1.0", "attribution": "x", + "country": "PT", "count_type": "births", "primary": True, "suppression": None, + "retrieved": date(2026, 1, 1)}, +]) + + +def _l(relation="said_to_be_the_same", qid="Q1", name="Maria", kind="given", + target_qid="Q2", target_name="Мария", target_kind="given"): + return RawLink(relation, qid, name, kind, target_qid, target_name, target_kind) + + +def test_relacoes(): + assert RELATIONS == ("same_item", "said_to_be_the_same", "family_name_identical", + "other_gender", "different_from", "transliteration") + + +def test_to_links_normaliza_e_deriva_name_key(): + [v] = to_links([_l(name=" Maria Clara ", target_name="МАРИЯ")], FONTES["wd"]) + assert v == VariantLink( + source_id="wd", relation="said_to_be_the_same", qid="Q1", name="Maria Clara", + name_key="maria clara", kind="given", target_qid="Q2", target_name="МАРИЯ", + target_name_key="мария", target_kind="given", + ) + + +def test_auto_ligacao_e_descartada(): + """R4.4: mesmo name_key e mesmo kind nos dois lados não informa nada para agrupar.""" + assert to_links([_l(relation="different_from", target_name="MARIA")], FONTES["wd"]) == [] + [v] = to_links([_l(relation="family_name_identical", target_name="Maria", target_kind="family")], + FONTES["wd"]) + assert (v.kind, v.target_kind) == ("given", "family") + + +def test_repetidas_uma_vez_e_ordem_estavel(): + a = _l(target_qid="Q3", target_name="Marie") + b = _l(relation="other_gender", target_qid="Q4", target_name="Mario") + c = _l(name="Ana", target_name="Anna") + links = to_links([b, a, c, a, b], FONTES["wd"]) + assert [(v.qid, v.relation, v.target_name) for v in links] == [ + ("Q1", "other_gender", "Mario"), + ("Q1", "said_to_be_the_same", "Anna"), + ("Q1", "said_to_be_the_same", "Marie"), + ] + assert to_links([a, b, c], FONTES["wd"]) == links + + +@pytest.mark.parametrize("campos, trecho", [ + ({"relation": "x"}, "ligação 2: relation 'x' inválida"), + ({"kind": "first"}, "ligação 2: kind 'first' inválido"), + ({"target_kind": "nome"}, "ligação 2: target_kind 'nome' inválido"), + ({"qid": "P31"}, "ligação 2: qid 'P31' fora do formato Q"), + ({"target_qid": "Q01"}, "ligação 2: target_qid 'Q01' fora do formato Q"), + ({"name": " "}, "ligação 2: name vazio"), + ({"target_name": 5}, "ligação 2: 'target_name' deve ser texto"), + ({"relation": "same_item", "target_qid": "Q9"}, "ligação 2: same_item exige target_qid igual a qid"), +]) +def test_ligacao_invalida_nomeia_a_posicao(campos, trecho): + with pytest.raises(RecordError) as e: + to_links([_l(), replace(_l(), **campos)], FONTES["wd"]) + assert str(e.value).startswith(trecho) + + +def _v(**campos): + base = VariantLink("wd", "said_to_be_the_same", "Q1", "Maria", "maria", "given", + "Q2", "Мария", "мария", "given") + return replace(base, **campos) + + +def test_validate_links_aceita_tabela_valida(): + validate_links([_v(), _v(target_qid="Q3")], FONTES) + + +@pytest.mark.parametrize("campos, trecho", [ + ({"relation": "x"}, "relation 'x' inválida"), + ({"qid": "Q"}, "qid 'Q' fora do formato"), + ({"target_qid": "L1"}, "target_qid 'L1' fora do formato"), + ({"kind": "x"}, "kind 'x' inválido"), + ({"target_kind": "x"}, "target_kind 'x' inválido"), + ({"name": ""}, "'name' vazio"), + ({"target_name": ""}, "'target_name' vazio"), + ({"name_key": "Maria"}, "'name_key' 'Maria' diverge"), + ({"target_name_key": "maria"}, "'target_name_key' 'maria' diverge"), + ({"source_id": "nada"}, "source_id 'nada' ausente de sources.yaml"), + ({"target_name": "MARIA", "target_name_key": "maria"}, "liga o nome a ele mesmo"), + ({"relation": "transliteration", "target_qid": "Q9"}, "transliteration exige target_qid igual a qid"), +]) +def test_validate_links_recusa(campos, trecho): + with pytest.raises(ValidationError, match=trecho): + validate_links([_v(**campos)], FONTES) + + +def test_validate_links_recusa_repetida(): + with pytest.raises(ValidationError, match="ligação 2 .*repetida"): + validate_links([_v(), _v()], FONTES) + + +def test_ida_e_volta_em_parquet(tmp_path): + links = [_v(), _v(relation="transliteration", target_qid="Q1", target_name="Mariya", target_name_key="mariya")] + write_links(links, tmp_path / "sub" / "variant_links.parquet") + assert read_links(tmp_path / "sub" / "variant_links.parquet") == links + + +def test_versao_do_esquema(): + assert SCHEMA_VERSION == "1.1.0" + + +def _modulo(**extra): + return SimpleNamespace(SOURCE_ID="wd", RESOURCES=[Resource("https://x.example/a", "a.json")], + parse=lambda files: iter(()), **extra) + + +def test_check_adapter_recusa_links_que_nao_e_funcao(): + with pytest.raises(AdapterError, match="'wd': links deve ser uma função"): + check_adapter(_modulo(links=[]), "wd") + check_adapter(_modulo(links=lambda files: iter(())), "wd") + + +def test_ingest_links(tmp_path, monkeypatch): + (tmp_path / "wd").mkdir() + (tmp_path / "wd" / "a.json").write_text("{}", encoding="utf-8") + vistos = [] + + def links(files): + vistos.append(sorted(files)) + yield _l() + + monkeypatch.setattr(pipeline, "load_adapter", lambda source_id, package: _modulo(links=links)) + assert ingest_links([FONTES["wd"]], FONTES, tmp_path, offline=True) == [_v()] + assert vistos == [["a.json"]] + monkeypatch.setattr(pipeline, "load_adapter", lambda source_id, package: _modulo()) + assert ingest_links([FONTES["wd"]], FONTES, tmp_path, offline=True) is None + + +def test_ingest_links_nomeia_a_fonte(tmp_path, monkeypatch): + (tmp_path / "wd").mkdir() + (tmp_path / "wd" / "a.json").write_text("{}", encoding="utf-8") + modulo = _modulo(links=lambda files: iter([_l(relation="x")])) + monkeypatch.setattr(pipeline, "load_adapter", lambda source_id, package: modulo) + with pytest.raises(PipelineError, match="fonte 'wd': ligação 1: relation 'x' inválida"): + ingest_links([FONTES["wd"]], FONTES, tmp_path, offline=True) diff --git a/tests/test_sources.py b/tests/test_sources.py index 0c60776..24df245 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -155,3 +155,17 @@ def test_sources_que_nao_e_lista(tmp_path, conteudo): p.write_text(conteudo, encoding="utf-8") with pytest.raises(SourceError, match="'sources' deve ser uma lista"): load_sources(p) + + +def test_attested_nao_exige_primary(): + """Spec 004, R5 (issue #3): em attested, primary não tem efeito e não é conferido.""" + sem_pais = [fonte(id=i, country=None, count_type="attested", primary=False) for i in ("a", "b")] + assert set(parse_sources(sem_pais)) == {"a", "b"} + mesmo_pais = [fonte(id=i, count_type="attested", primary=True) for i in ("a", "b")] + assert set(parse_sources(mesmo_pais)) == {"a", "b"} + + +@pytest.mark.parametrize("tipo", ["births", "population"]) +def test_contagem_continua_exigindo_uma_primary(tipo): + with pytest.raises(SourceError, match=f"US/{tipo}: fontes \\['a', 'b'\\]; exatamente uma"): + parse_sources([fonte(id=i, count_type=tipo, primary=False) for i in ("a", "b")]) diff --git a/tests/test_wikidata.py b/tests/test_wikidata.py new file mode 100644 index 0000000..5300079 --- /dev/null +++ b/tests/test_wikidata.py @@ -0,0 +1,267 @@ +"""Spec 004: adaptador do Wikidata sobre a amostra real em tests/fixtures/wikidata/.""" + +import json +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + +from namescommons.adapters import FormatError, RawRecord, load_adapter +from namescommons.sources import load_sources + +RAIZ = Path(__file__).resolve().parent.parent +AMOSTRA = RAIZ / "tests" / "fixtures" / "wikidata" +wd = load_adapter("wikidata") + + +def _arquivos(pasta: Path = AMOSTRA) -> dict[str, Path]: + return {r.filename: pasta / r.filename for r in wd.RESOURCES} + + +def test_fonte_registrada(): + f = load_sources(RAIZ / "sources.yaml")["wikidata"] + assert (f.license, f.country, f.count_type, f.primary, f.suppression, f.uniform_case) == ( + "CC0-1.0", None, "attested", False, None, False) + + +def test_recursos_e_consultas(): + """R2.1 e R2.2: 15 páginas de consultas ordenadas no QLever, com o formato na URL.""" + assert [r.filename for r in wd.RESOURCES] == [ + "given-names-1.json", *(f"family-names-{k}.json" for k in range(1, 6)), + *(f"links-P460-{k}.json" for k in range(1, 4)), "links-P1533-1.json", "links-P1560-1.json", + "links-P5278-1.json", "links-P1889-1.json", "links-P1889-2.json", "transliterations-1.json", + ] + for r in wd.RESOURCES: + partes = urlsplit(r.url) + assert f"{partes.scheme}://{partes.netloc}{partes.path}" == "https://qlever.dev/api/wikidata" + params = parse_qs(partes.query) + assert params["action"] == ["sparql_json"] + [texto] = params["query"] + k = int(r.filename.rsplit("-", 1)[1].removesuffix(".json")) + assert texto.endswith(f" LIMIT 200000 OFFSET {(k - 1) * 200000}") + assert " ORDER BY ?item " in texto and "SELECT DISTINCT ?item " in texto + [prenomes] = [r for r in wd.RESOURCES if r.filename == "given-names-1.json"] + texto = parse_qs(urlsplit(prenomes.url).query)["query"][0] + assert "?item wdt:P31/wdt:P279* wd:Q202444 . ?item wdt:P1705 ?name ." in texto + assert "VALUES ?gender { wd:Q12308941 wd:Q11879590 wd:Q3409032 }" in texto + assert "P5279" not in " ".join(r.url for r in wd.RESOURCES) # R2.5: hyphenation não entra + + +def test_parse_da_amostra_real(): + """R3: um registro por gênero informado, sem país, ano nem contagem; rótulos inválidos pulados.""" + g, f = "given", "family" + assert list(wd.parse(_arquivos())) == [ + RawRecord("Letha", g, None, "F"), + RawRecord("Kadek", g, None, "F"), RawRecord("Kadek", g, None, "M"), + RawRecord("Andrea", g, None, "F"), RawRecord("Andrea", g, None, "M"), + RawRecord("Иван", g, None, "M"), + RawRecord("Maria", g, None, "F"), RawRecord("Maria", g, None, "M"), + RawRecord("Мария", g, None, "F"), + RawRecord("John", g, None, "M"), + RawRecord("Kim", g, None, "F"), RawRecord("Kim", g, None, "M"), + RawRecord("Ivan", g, None, "M"), + RawRecord("李", g), + RawRecord("Kim", f), + RawRecord("Иванов", f, None, "M"), + RawRecord("Иванова", f, None, "F"), + RawRecord("Ivanova", f), RawRecord("Ivanov", f), RawRecord("John", f), RawRecord("Silva", f), + RawRecord("李", f), + ] + + +def _pagina(pasta: Path, consulta, k: int, linhas: list[dict], variaveis=None) -> None: + dados = {"head": {"vars": list(variaveis or consulta.variaveis)}, "results": {"bindings": linhas}} + (pasta / consulta.arquivo(k)).write_text(json.dumps(dados, ensure_ascii=False), encoding="utf-8") + + +def _copia(tmp_path: Path) -> Path: + for r in wd.RESOURCES: + (tmp_path / r.filename).write_bytes((AMOSTRA / r.filename).read_bytes()) + return tmp_path + + +def _rotulo(qid: str, nome: str, genero: str | None = None, tipo: str = "literal") -> dict: + linha = {"item": {"type": "uri", "value": f"http://www.wikidata.org/entity/{qid}"}, + "name": {"type": tipo, "value": nome, "xml:lang": "mul"}} + if genero: + linha["gender"] = {"type": "uri", "value": f"http://www.wikidata.org/entity/{genero}"} + return linha + + +def test_genero_vazio_cede_ao_informado(tmp_path): + """R3.5: sem gênero e com gênero no mesmo name_key e kind: a linha sem gênero é omitida.""" + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [ + _rotulo("Q1", "Jan"), _rotulo("Q2", "JAN", "Q11879590"), _rotulo("Q3", "Jan ", "Q12308941"), + _rotulo("Q4", "Eden"), _rotulo("Q5", "Eden"), + ]) + assert [r for r in wd.parse(_arquivos(pasta)) if r.kind == "given"] == [ + RawRecord("Jan", "given", None, "F"), RawRecord("Jan", "given", None, "M"), + RawRecord("Eden", "given"), + ] + + +@pytest.mark.parametrize("nome", [ + "Q34147", "Ritwik (given name)", "Felbert,von", "Asil/Aseel", "Dau__tsch", "K8", "Lei⁵", "Li`", " ", +]) +def test_rotulos_que_nao_sao_nomes_sao_pulados(nome): + assert not wd.nome_aceito(nome) + + +@pytest.mark.parametrize("nome", ["St. Simon", "D´Amici", "Camil·la", "O'Brien", "Jean-Luc", "সুভাষ", "ཚེ་དབང་"]) +def test_nomes_aceitos(nome): + assert wd.nome_aceito(nome) + + +def test_valor_desconhecido_e_pulado(tmp_path): + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [_rotulo("Q1", "http://www.wikidata.org/.well-known/genid/x", tipo="uri"), + _rotulo("Q2", "Ana")]) + assert [r.name for r in wd.parse(_arquivos(pasta)) if r.kind == "given"] == ["Ana"] + + +def test_ultima_pagina_cheia_falha(tmp_path, monkeypatch): + """R2.3: o Wikidata cresceu além das páginas fixas; falha alta, não perda silenciosa.""" + monkeypatch.setattr(wd, "LINHAS_POR_PAGINA", 2) + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [_rotulo("Q1", "Ana"), _rotulo("Q2", "Bia")]) + with pytest.raises(FormatError, match=r"given-names-1.json: última página cheia \(2 linhas\); aumente as páginas de 'given-names'"): + list(wd.parse(_arquivos(pasta))) + + +def test_pagina_com_linhas_depois_de_incompleta_falha(tmp_path, monkeypatch): + monkeypatch.setattr(wd, "LINHAS_POR_PAGINA", 2) + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [_rotulo("Q1", "Ana")]) + _pagina(pasta, wd.SOBRENOMES, 1, [_rotulo("Q1", "Silva"), _rotulo("Q2", "Souza")]) + _pagina(pasta, wd.SOBRENOMES, 2, [_rotulo("Q3", "Lima")]) + _pagina(pasta, wd.SOBRENOMES, 4, [_rotulo("Q4", "Costa")]) + with pytest.raises(FormatError, match="family-names-4.json: página com linhas depois da página incompleta " + "family-names-2.json; .* fetch --refresh wikidata"): + list(wd.parse(_arquivos(pasta))) + + +def test_pagina_acima_do_limite_falha(tmp_path, monkeypatch): + monkeypatch.setattr(wd, "LINHAS_POR_PAGINA", 1) + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [_rotulo("Q1", "Ana"), _rotulo("Q2", "Bia")]) + with pytest.raises(FormatError, match="no máximo 1 linhas"): + list(wd.parse(_arquivos(pasta))) + + +def test_cabecalho_inesperado_falha(tmp_path): + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [], variaveis=["item", "label", "gender"]) + with pytest.raises(FormatError, match=r"given-names-1.json: variáveis \['item', 'label', 'gender'\], esperava"): + list(wd.parse(_arquivos(pasta))) + (pasta / "given-names-1.json").write_text("erro", encoding="utf-8") + with pytest.raises(FormatError, match="given-names-1.json: não é um resultado SPARQL JSON"): + list(wd.parse(_arquivos(pasta))) + + +def test_classe_de_genero_desconhecida_falha(tmp_path): + pasta = _copia(tmp_path) + _pagina(pasta, wd.SOBRENOMES, 1, [_rotulo("Q1", "Silva", "Q12308941")]) + with pytest.raises(FormatError, match="family-names: classe de gênero .*Q12308941' desconhecida"): + list(wd.parse(_arquivos(pasta))) + + +@pytest.mark.parametrize("item", [ + {"type": "literal", "value": "Q1"}, + {"type": "uri", "value": "http://www.wikidata.org/entity/P31"}, + {"type": "uri", "value": "https://example.org/Q1"}, +]) +def test_item_que_nao_e_entidade_falha(tmp_path, item): + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [{**_rotulo("Q1", "Ana"), "item": item}]) + with pytest.raises(FormatError, match="given-names: item .* não é entidade do Wikidata"): + list(wd.parse(_arquivos(pasta))) + + +def test_nome_que_nao_e_literal_nem_desconhecido_falha(tmp_path): + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [{**_rotulo("Q1", "Ana"), "name": {"type": "typed-literal"}}]) + with pytest.raises(FormatError, match="given-names: name .* não é literal"): + list(wd.parse(_arquivos(pasta))) + + +# R4.3: ligações entre variantes ----------------------------------------------------------------- + +def _ligacoes(pasta: Path = AMOSTRA): + from namescommons.pipeline import to_links + fonte = load_sources(RAIZ / "sources.yaml")["wikidata"] + return to_links(wd.links(_arquivos(pasta)), fonte) + + +def test_links_da_amostra_real(): + """Ligações entre itens com rótulo aceito; alvos de fora da amostra e auto-ligações somem.""" + obtidas = {(v.relation, v.qid, v.name, v.kind, v.target_qid, v.target_name, v.target_kind) + for v in _ligacoes() if v.relation != "transliteration"} + g, f = "given", "family" + s, fn, og = "said_to_be_the_same", "family_name_identical", "other_gender" + assert obtidas == { + (s, "Q16654835", "Иванов", f, "Q40711158", "Ivanov", f), + (s, "Q40711158", "Ivanov", f, "Q16654835", "Иванов", f), + (s, "Q21447935", "Иванова", f, "Q37456871", "Ivanova", f), + (s, "Q37456871", "Ivanova", f, "Q21447935", "Иванова", f), + (s, "Q21104340", "Иван", g, "Q4925477", "John", g), + (s, "Q21104340", "Иван", g, "Q830350", "Ivan", g), + (s, "Q4925477", "John", g, "Q21104340", "Иван", g), + (s, "Q4925477", "John", g, "Q830350", "Ivan", g), + (s, "Q830350", "Ivan", g, "Q21104340", "Иван", g), + (s, "Q830350", "Ivan", g, "Q4925477", "John", g), + (s, "Q325872", "Maria", g, "Q39897333", "Мария", g), + (s, "Q39897333", "Мария", g, "Q325872", "Maria", g), + (fn, "Q4925477", "John", g, "Q5281981", "John", f), + (fn, "Q637123", "Kim", g, "Q157414", "Kim", f), + (fn, "Q96209920", "李", g, "Q686223", "李", f), + (og, "Q25413386", "Maria", g, "Q39897333", "Мария", g), + (og, "Q39897333", "Мария", g, "Q25413386", "Maria", g), + (og, "Q16654835", "Иванов", f, "Q21447935", "Иванова", f), + (og, "Q21447935", "Иванова", f, "Q16654835", "Иванов", f), + } + + +def test_transliteracoes_da_amostra_real(): + """R3.2 vale para transliterações: tom numérico e crase somem; destino é o próprio item.""" + por_item: dict[str, list[str]] = {} + for v in _ligacoes(): + if v.relation == "transliteration": + assert (v.target_qid, v.target_kind) == (v.qid, v.kind) + por_item.setdefault(f"{v.qid} {v.name}", []).append(v.target_name) + assert {k: sorted(v) for k, v in por_item.items()} == { + "Q16654835 Иванов": ["Ivanov", "Iwanow", "伊万诺夫"], + "Q21447935 Иванова": ["Ivanova", "Ivanovová", "Iwanowa"], + "Q39897333 Мария": ["Maria", "Mariia", "Marija", "Mariya", "Masha"], + "Q4925477 John": ["Джон"], + "Q493293 Andrea": ["Αντρέα", "Андреа"], + "Q686223 李": sorted(["Lee", "Léih", "Ley", "Li", "Lí", "Lǐ", "Lii", "Lý", "Ли", "Лі", "리", "이", "り", "ㄌㄧˇ"]), + } + + +def _ligacao(qid: str, alvo: dict) -> dict: + return {"item": {"type": "uri", "value": f"http://www.wikidata.org/entity/{qid}"}, "target": alvo} + + +def test_same_item_e_alvo_desconhecido(tmp_path): + """Rótulos diferentes do mesmo item viram same_item; alvo genid (valor desconhecido) é pulado.""" + pasta = _copia(tmp_path) + _pagina(pasta, wd.PRENOMES, 1, [_rotulo("Q1", "Мария"), _rotulo("Q1", "Maria"), _rotulo("Q1", "MARIA"), + _rotulo("Q2", "Marie")]) + _pagina(pasta, wd.LIGACOES["P460"], 1, [ + _ligacao("Q1", {"type": "uri", "value": "http://www.wikidata.org/.well-known/genid/abc"}), + _ligacao("Q2", {"type": "uri", "value": "http://www.wikidata.org/entity/Q1"}), + ]) + brutas = [l for l in wd.links(_arquivos(pasta)) if l.kind == "given" and l.relation != "transliteration"] + assert [(l.relation, l.qid, l.name, l.target_qid, l.target_name) for l in brutas] == [ + ("same_item", "Q1", "Мария", "Q1", "Maria"), + ("said_to_be_the_same", "Q2", "Marie", "Q1", "Мария"), + ("said_to_be_the_same", "Q2", "Marie", "Q1", "Maria"), + ] + + +def test_alvo_que_nao_e_uri_falha(tmp_path): + pasta = _copia(tmp_path) + _pagina(pasta, wd.LIGACOES["P1889"], 1, [_ligacao("Q1", {"type": "literal", "value": "Q2"})]) + with pytest.raises(FormatError, match="links-P1889: target .* não é URI"): + list(wd.links(_arquivos(pasta))) diff --git a/tools/amostra_wikidata.py b/tools/amostra_wikidata.py new file mode 100644 index 0000000..7fb2df1 --- /dev/null +++ b/tools/amostra_wikidata.py @@ -0,0 +1,66 @@ +"""Gera a amostra de tests/fixtures/wikidata/ com as consultas do adaptador (spec 004, R6). + +Cada arquivo é a resposta real do QLever à mesma consulta do adaptador, restrita aos itens abaixo +(VALUES ?item) e com a mesma paginação; páginas além do fim vêm vazias. A chave "meta" (tempo de +consulta, varia a cada execução) é removida. Nas ligações, só ficam as linhas cujo alvo também está +na amostra, mais a primeira de cada propriedade com alvo fora dela (para testar o descarte de R4.4). +Acessa a rede: não roda nos testes. + +Uso: uv run python tools/amostra_wikidata.py tests/fixtures/wikidata +""" + +import json +import sys +import urllib.request +from pathlib import Path +from urllib.parse import urlencode + +from namescommons.adapters import wikidata +from namescommons.cache import USER_AGENT + +PRENOMES = ( + "Q325872 Q25413386 Q39897333 Q18177306 Q18177321 Q493293 Q637123 Q21104340 Q830350 " + "Q4925477 Q96209920 Q104829915 Q102313297 Q100275460 Q112158404" +).split() +SOBRENOMES = ( + "Q16654835 Q21447935 Q40711158 Q37456871 Q6327150 Q686223 Q157414 Q5281981 Q110770321 Q111757901" +).split() +TRANSLITERADOS = "Q16654835 Q21447935 Q39897333 Q4925477 Q493293 Q686223".split() + + +def itens(consulta: wikidata.Consulta) -> tuple[str, ...]: + if consulta is wikidata.PRENOMES: + return tuple(PRENOMES) + if consulta is wikidata.SOBRENOMES: + return tuple(SOBRENOMES) + if consulta is wikidata.TRANSLITERACOES: + return tuple(TRANSLITERADOS) + return tuple(PRENOMES + SOBRENOMES) + + +def _filtrar_alvos(linhas: list[dict]) -> list[dict]: + amostra = {f"http://www.wikidata.org/entity/{q}" for q in PRENOMES + SOBRENOMES} + fora = [l for l in linhas if l["target"]["value"] not in amostra][:1] + return [l for l in linhas if l["target"]["value"] in amostra or l in fora] + + +def main(destino: Path) -> None: + destino.mkdir(parents=True, exist_ok=True) + for consulta in wikidata.CONSULTAS: + for k in range(1, consulta.paginas + 1): + texto = consulta.sparql(k, itens(consulta)) + endereco = f"{wikidata.ENDPOINT}?{urlencode({'query': texto, 'action': 'sparql_json'})}" + pedido = urllib.request.Request(endereco, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(pedido, timeout=120) as resposta: + dados = json.load(resposta) + dados.pop("meta", None) + if consulta.nome.startswith("links-"): + dados["results"]["bindings"] = _filtrar_alvos(dados["results"]["bindings"]) + (destino / consulta.arquivo(k)).write_text( + json.dumps(dados, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8" + ) + print(consulta.arquivo(k), len(dados["results"]["bindings"])) + + +if __name__ == "__main__": + main(Path(sys.argv[1]))