diff --git a/.claude/agents/coder.md b/.claude/agents/coder.md index 4d6def2..8ac3386 100644 --- a/.claude/agents/coder.md +++ b/.claude/agents/coder.md @@ -34,10 +34,23 @@ todo verificable: otro agente (`verifier`) va a auditar tu diff. - Lee `docs/API.md` (contrato), `docs/ARCHITECTURE.md` y el `docs/ROADMAP/` del hito antes de escribir lógica nueva. -## El gate (corrélo, reportá la salida real) -``` -uv run ruff check . && uv run ruff format --check . && uv run mypy src && uv run pytest -``` +## Testing: iterá con el subconjunto, dejá el suite completo para el cierre +El suite completo (`uv run pytest`) tarda **~4-8 min en esta máquina** — NO lo uses como bucle de +feedback. (Lección del epic #167: correr el suite entero para triagear un fallo de UN archivo fue +**~la mitad** del tiempo perdido de los coders.) + +- **En el loop:** corré SOLO los tests pertinentes a lo que tocás — + `uv run pytest tests/unit/test_X.py::TestY --tb=short` (7-60 s). Si un test falla, re-corré **ese + node id**, no el suite entero con flags distintos. +- **Antes de chequear formato/lint:** auto-formateá primero — + `uv run ruff format . && uv run ruff check --fix .` — y recién después `ruff check`/`format --check`. + Evita el gate/CI rojo **solo por formato**. +- **Cierre (una sola vez):** `uv run ruff check . && uv run ruff format --check . && uv run mypy src` + + `uv run pytest` acotado a los archivos/áreas que tocaste. El **suite completo lo corren el + `verifier` y el CI** — no necesitás repetirlo en loop; reportá qué subconjunto corriste. +- **Timeout:** poné `timeout` ≥ 600000 ms en cualquier corrida larga de pytest (el default de 2 min + mata el suite completo). + Markers: `unit` (default, sin red), `integration` (DuckDB/red, en el gate), `network` (API real de OpenAlex, **fuera** del gate — `-m "not network"`). TDD selectivo: test antes del código en el núcleo; no testees wrappers finos ni plumbing de Click. diff --git a/.claude/agents/verifier.md b/.claude/agents/verifier.md index c262fe8..39844b9 100644 --- a/.claude/agents/verifier.md +++ b/.claude/agents/verifier.md @@ -13,12 +13,15 @@ Sos el **verificador** de bib2graph. Revisás el trabajo del `coder` con ojo adv tenés Write/Edit a propósito:** tu única salida es un veredicto honesto y hallazgos accionables. Si algo está mal, lo marcás — no lo arreglás (arreglar destruiría tu independencia). -## El gate (corrélo, pegá la salida real) +## El gate (corrélo COMPLETO, pegá la salida real) ``` uv run ruff check . && uv run ruff format --check . && uv run mypy src && uv run pytest ``` -Markers: `unit` (sin red), `integration` (DuckDB/red, en el gate), `network` (API real, **fuera** -del gate — no lo exijas). `git diff`/`git status` para ver el cambio. +**Vos sos el dueño del gate completo.** El `coder` itera con **subconjuntos** de tests (el suite +entero tarda ~6 min), así que tu corrida del **suite completo** es la red de seguridad real — corrélo +entero siempre, con `timeout` ≥ 600000 ms. Si tarda más que el límite, corré por lotes pero **cubrí +todo** y reportá honesto qué corriste. Markers: `unit` (sin red), `integration` (DuckDB/red, en el +gate), `network` (API real, **fuera** del gate — no lo exijas). `git diff`/`git status` para ver el cambio. ## Qué revisás (default a la sospecha) 1. **Correctitud:** ¿hace lo que la tarea pedía? ¿bugs, casos borde, regresiones? Buscá por qué diff --git a/.claude/commands/retro-ciclo.md b/.claude/commands/retro-ciclo.md new file mode 100644 index 0000000..f38e84d --- /dev/null +++ b/.claude/commands/retro-ciclo.md @@ -0,0 +1,71 @@ +--- +description: >- + Retrospectiva metacognitiva de fin de ciclo — mide dónde se fue el tiempo (examina + los transcripts de los subagentes), reflexiona sobre la estrategia y APLICA mejoras al + proceso (.claude/agents/, AGENTS.md, hooks). Usala al cerrar un epic, un feature-cycle + grande, un release o una sesión con mucho fan-out. Cierra el lazo de aprendizaje. +argument-hint: "[qué ciclo cerrás: epic #N / release / sesión]" +--- + +# /retro-ciclo — retrospectiva del proceso de los agentes + +Sos el **orquestador/tech-lead**. Acaba de cerrarse un ciclo de trabajo +($ARGUMENTS). Corré la retrospectiva **del proceso de los agentes** (no del producto: +eso es `/cosechar-sesion`) para responder con DATOS: *¿qué nos tardó más? ¿la forma de +trabajar fue la correcta? ¿en qué falla hoy y cómo lo mejoramos?* — y dejar el proceso +mejor que como lo encontraste. + +## Procedimiento + +1. **Medí — datos duros, no impresiones.** Los transcripts de los subagentes de esta + sesión están como JSONL en el task dir (`…/tasks/.output`). **NO los leas + enteros** (overflow). Despachá un subagente `general-purpose` que los procese con + Bash/python línea por línea y devuelva **métricas, no dumps**: + - Wall-clock por agente (`duration_ms` de las notificaciones) y por rol + (coder/verifier/architect). + - Para los coders más lentos: #Bash/#Edit/#Read; **cuántas veces corrió el suite + completo de pytest vs subconjuntos**; ruff/mypy/uv sync; y **qué fracción del tiempo + fue esperando tests** (gaps tras `pytest`). + - Reintentos, loops editar→test→fallar, errores de formato/CRLF/encoding, timeouts, + conflictos de merge, y **trabajo perdido/rehecho** (p.ej. agentes que escribieron en + el worktree equivocado). + - **Avisá si faltan transcripts** (vacíos / no capturados): punto ciego de auditoría. + +2. **Reflexioná — honesto, incluí tus propios errores de orquestación.** + - ¿Cuál fue el cuello de botella dominante? Cuantificalo. + - ¿La estrategia fue la correcta? ¿hubo cambio (p.ej. secuencial→paralelo) y valió la + pena? ¿qué fricción introdujo? + - Separá **desperdicio** de **inversión valiosa** (el rework que nace de un verifier que + cazó un bug real NO es desperdicio). + - Clasificá las lecciones: (a) generales, (b) específicas del repo (suite lento, + archivos calientes como `build.py`/`cli/__init__.py`), (c) errores puntuales de la sesión. + +3. **Traé el veredicto al PO y esperá su OK.** Top 3 cuellos de botella con evidencia + + 3-5 mejoras accionables. No cambies el proceso sin confirmar el rumbo. + +4. **Aplicá las mejoras DONDE VIVEN** (una retro que no cambia nada es un informe, no una + mejora): + - **Comportamiento de un rol** (cómo testea el coder, qué corre el verifier) → + `.claude/agents/.md`. + - **Convención de orquestación** (paralelizar con prudencia, worktrees, batchear + decisiones) → `AGENTS.md` §"Ejecución concurrente y testing". + - **Mapa/fases del flujo** → la skill `flujo`. + - **Guardarraíl** que conviene volver imposible de violar → `.claude/hooks/`. + - Va por **PR a `dev`** (`chore(proceso): …`), porque `.claude/agents/`, + `.claude/commands/` y `AGENTS.md` están versionados. + +5. **Cerrá con seguimientos.** Lo que no se baka ahora → issue (no nota suelta). Si una + lección amerita decisión de fondo → ADR vía `/graduar-adr`. + +## No-negociables +- **Datos antes de opinar.** Cuantificá el cuello de botella. +- **Honestidad sobre los errores de orquestación** — la fuente más rica de mejora. +- **La retro debe cambiar algo** (bakear ≥1 lección o abrir seguimiento), o no terminó. +- No leas transcripts enteros en tu contexto; delegá la medición. + +## Lecciones ya bakeadas (extender, no re-litigar) +- **Epic 0.10.0 / #167:** el suite completo de pytest (~6 min) usado como bucle de + feedback fue ~50% del tiempo de los coders → testing por capas (coder con subconjunto; + verifier+CI con el suite) en `.claude/agents/coder.md` y `verifier.md`. Paralelizar solo + archivos disjuntos; los agentes escriben en el worktree de la sesión, no en la ruta del + prompt → `AGENTS.md` §"Ejecución concurrente y testing". diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7803e..9320838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,27 +51,3 @@ jobs: run: uv sync --all-extras --dev - name: Pytest run: uv run pytest -q - - frontend: - name: frontend - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - name: Setup Node 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Enable corepack (pnpm pinneado en package.json) - run: corepack enable - - name: Install dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - name: Lint (tsc --noEmit) - working-directory: frontend - run: pnpm lint - - name: Test (vitest) - working-directory: frontend - run: pnpm test:run - - name: Build - working-directory: frontend - run: pnpm build diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 48f6cf1..4661657 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -13,10 +13,9 @@ name: publish-pypi # - Workflow name: publish-pypi.yml # - Environment name: pypi # -# Mismo empaquetado que publish-testpypi.yml: se buildea el frontend ANTES del wheel y se usa -# `uv build --wheel` (no `uv build` a secas, que arma el wheel desde el sdist y pierde el frontend -# gitignored vía force-include). La única diferencia con TestPyPI es el índice destino (productivo, -# default de la action) y el environment (`pypi`). +# El paquete es Python puro (no hay frontend que buildear). `uv build` arma sdist + wheel. +# La única diferencia con TestPyPI es el índice destino (productivo, default de la action) +# y el environment (`pypi`). on: # Reusable: lo invoca release-please.yml (job `publish`) cuando corta un Release. @@ -40,22 +39,19 @@ jobs: uses: astral-sh/setup-uv@v6 with: python-version: "3.12" - - name: Setup Node 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Enable corepack (pnpm pinneado en package.json) - run: corepack enable - - name: Build frontend (llena src/bib2graph/gui/static/ antes del wheel) - working-directory: frontend - run: | - pnpm install --frozen-lockfile - pnpm build - - name: Build wheel (directo del working tree, con el frontend ya buildeado) - # NO `uv build` a secas: eso arma sdist y el wheel DESDE el sdist, que - # excluye el frontend gitignored (gui/static) → force-include falla. - # `--wheel` construye el wheel directo del árbol (con el pnpm build de arriba). - run: uv build --wheel + - name: Build (paquete Python puro; sin frontend) + run: uv build - name: Publish to PyPI (trusted publishing) # Sin repository-url → la action publica a PyPI productivo (https://upload.pypi.org/legacy/). uses: pypa/gh-action-pypi-publish@release/v1 + with: + # attestations OFF: cuando este workflow se invoca como REUSABLE desde + # release-please.yml, el certificado de la attestation (PEP 740) registra el + # workflow top-level (release-please.yml), no este archivo. El Trusted Publisher + # de PyPI espera publish-pypi.yml → PyPI rechaza el upload con 400 ("Build Config + # URI does not match expected Trusted Publisher"). PyPA no soporta reusable + # workflows con Trusted Publishing. El OIDC del token sí matchea (job_workflow_ref + # = publish-pypi.yml), así que deshabilitar la attestation desbloquea el publish + # sin perder el trusted publishing. El disparo MANUAL (workflow_dispatch) no sufre + # esto (corre directo, no reusable), pero mantenemos el flag por consistencia. + attestations: false diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 8754f2d..496ea38 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -31,22 +31,8 @@ jobs: uses: astral-sh/setup-uv@v6 with: python-version: "3.12" - - name: Setup Node 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Enable corepack (pnpm pinneado en package.json) - run: corepack enable - - name: Build frontend (llena src/bib2graph/gui/static/ antes del wheel) - working-directory: frontend - run: | - pnpm install --frozen-lockfile - pnpm build - - name: Build wheel (directo del working tree, con el frontend ya buildeado) - # NO `uv build` a secas: eso arma sdist y el wheel DESDE el sdist, que - # excluye el frontend gitignored (gui/static) → force-include falla. - # `--wheel` construye el wheel directo del árbol (con el pnpm build de arriba). - run: uv build --wheel + - name: Build (paquete Python puro; sin frontend) + run: uv build - name: Publish to TestPyPI (trusted publishing) uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.gitignore b/.gitignore index fa206c9..b2b938a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,6 @@ snapshots/*.parquet *.duckdb.*.bak prueba/ prueba_e2e/ -app/ redes/ valoraciones_* @@ -61,19 +60,6 @@ valoraciones_* # siquiera dentro de un workspace de ejemplo (ADR 0030 §Convención lo prohíbe). examples/**/*.duckdb -# Frontend (app/) — prototipo local de la GUI (MVP), ver app/ARCHITECTURE.md -app/node_modules/ -app/dist/ -app/.vite/ - -# Frontend (frontend/) — SPA G4, build output no se commitea (va al wheel en G5) -frontend/node_modules/ -frontend/dist/ -# Build output del frontend: se ignora el contenido pero se conserva un .gitkeep -# para que el directorio EXISTA siempre — el force-include de hatchling (pyproject) -# rompe el editable install (uv sync en lint/test) si el dir no existe. -src/bib2graph/gui/static/* -!src/bib2graph/gui/static/.gitkeep # Logs logs diff --git a/AGENTS.md b/AGENTS.md index 685eb8e..8b88792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,15 +33,48 @@ explícito), `filters/` (PRISMA), `networks/` (proyectores, analyzer, spec, facade), `sources/equation.py` (capa declarativa de la ecuación, 9a), `exporters/` (GraphML, CSV) y `cli/`. - El **CLI `b2g` es real** —paquete `cli/` con 19 subcomandos en `cli/commands/`, no un - placeholder (el 16° `b2g networks` es la capa declarativa YAML del Hito 9; el 17° `b2g restore` - rehidrata un corpus curado sin red, Ciclo 9a; el 18° `b2g thesaurus` aplica el thesaurus curado, - único paso explícito del preproc, #88, abajo; el 19° `b2g gui` levanta la API local FastAPI, Hito G3 - del MVP GUI, ADR 0028)—. + El **CLI `b2g` es real** —paquete `cli/`, no un placeholder—. **Superficie 0.10.0 (ADR 0037/0038/0039/0040, + AS-BUILT):** **10 verbos del ciclo** (`init`, `seed`, `chain`, `curate`, `build`, `read`, `export`, + `snapshot`, `status`, `validate`) **+ 3 grupos noun-verb** (`read {list,stats,show,top}`, + `curate {dump,apply,accept,reject,filter}`, `snapshot {create,restore}`) **+ 1 comando meta** + fuera de los 10 (**`skill add`**, ADR 0039 — instala la skill de Claude Code + end-user que materializa el mensaje *"la mejor forma de usar bib2graph es pedirle a Claude que lo + use"* [`pip install bib2graph` → `b2g skill add`]; vendoreada en el wheel con version-lock + skill==cli) **+ 9 aliases deprecados** + (`accept`/`reject`/`filter`/`inspect`/`monitor`/`networks`/`enrich`/`restore`/`resolve`, retiro + 0.11.0). **`thesaurus` se retiró como verbo** (#164): su capacidad es **`b2g build --thesaurus`**. + **El verbo `gui` se retiró con la GUI local** (ADR 0040, #190 — ver abajo). + Conteo verificable contra `b2g --help` (10 del ciclo + `skill`); detalle en + `docs/API.md` §Convenciones CLI. + **Grupo noun-verb `read {list,stats,show,top}` (#156/#157, ADR 0037 §b):** primer grupo del CLI (lectura pura + del corpus, no transiciona); `read list` filtra por `--query`/`--status`/`--seeds|--candidates`/`--year`, + `read stats --group-by {status,year,is_seed}`, `read show --id` (resuelve id/doi/source_id, ADR 0036), + `read top --top N --kind {…}` (la salida de investigación: nodos centrales + co-citación con título; + default `bibliographic_coupling`, robusto en one-shot; co-citación vacía → honest-empty exit 0 + + reason/fix_command). **Artefactos one-shot honestos (#160, ADR 0037 §f):** el `--json` de `build`, + `snapshot create` y `read top` suma un bloque aditivo **`maturity`** (`{curated, scope, saturated, + empty_networks}`, `schema="1"` intacto) que autodeclara que el resultado es un borrador sin pulir; + forma estable en `docs/API.md` §Apéndice `maturity`. `read` sin subcomando → ayuda + exit 0 (`invoke_without_command=True`, workaround Click 8.4); el + `command` del envelope usa la ruta completa (`"read list"`). `inspect` queda **en deprecación** (#165, + lo absorben `read show` + `status`) pero **sigue vivo**. Ver `docs/API.md` §Convenciones CLI. + **Grupo noun-verb `curate {dump,apply,accept,reject,filter}` (#155, ADR 0037 §b):** SEGUNDO grupo del + CLI. **BREAKING:** la forma-flag `curate --dump`/`--from-csv` y `--all` fueron **eliminadas sin alias** + (`dump --scope all` reemplaza a `--all`; `apply ` reemplaza a `--from-csv`). A diferencia de `read` + (transversal entero), **la transición la define el VERBO** (precedente D1 de #159): solo + **`curate filter`→`FILTERED`**; `dump`/`apply`/`accept`/`reject` son transversales. Lógica fuente única + en `service/curate.py` (`run_curate_dump`/`run_curate_from_csv`/`filter_corpus` con `decided_at` + inyectado). Los verbos sueltos `accept`/`reject`/`filter` siguen vivos como **alias deprecados** + (retiro 0.11.0, ADR 0038 P1 + enmienda #155). Ver `docs/API.md` §`curate`. + **Grupo noun-verb `snapshot {create, restore}` (#163, ADR 0038):** TERCER grupo del CLI. **BREAKING:** + el `snapshot` plano → **`snapshot create`** (sin alias); `snapshot restore` = ex verbo plano `restore` + (el suelto `restore` sigue vivo como **alias deprecado**, `command="restore"`, retiro #165). La + transición la define el VERBO: `snapshot create` **NO** transiciona y lleva el bloque `maturity`; + `snapshot restore`→`FILTERED`. Lógica fuente única en `service/snapshot.py` + (`run_snapshot`/`run_restore`, `decided_at` inyectado). Ver `docs/API.md` §`snapshot`. **645 tests verdes** (mypy/ruff limpios; el núcleo importa sin `duckdb`). Entre las redes, la **composición de comunidades es exportable**: `networks/cluster_table` (función pura) resume cada comunidad de una red de paper en una fila y `b2g build` la escribe como `clusters.csv` - (#31, AS-BUILT 2026-06-17; ver `docs/API.md` §7.2). **`b2g snapshot`/`b2g export` resuelven por + (#31, AS-BUILT 2026-06-17; ver `docs/API.md` §7.2). **`b2g snapshot create`/`b2g export` resuelven por workspace** (`--out-dir` override opcional → `/snapshots|exports/`) y **`b2g status` avisa de staleness** de la cache de redes (`networks_cache_stale`; avisa, no regenera) — remanentes del modelo workspace cerrados (#32, AS-BUILT 2026-06-17; ver el bullet de workspace abajo). @@ -54,46 +87,30 @@ `_write_artifacts` (mismos GraphML + metrics.json + clusters.csv que `build`); **NO** transiciona el `CycleState` ni sella `.corpus_hash` (transversal al lazo, como `enrich`/`curate`). `pyyaml` pasó a dependencia del núcleo (import perezoso). **516 tests verdes**. Ver `docs/API.md` §10. -- **MVP GUI — Hitos G1–G5 COMPLETOS · build entero del MVP (AS-BUILT 2026-06-18, ADR - [0028](docs/decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** los 5 hitos de construcción - están **AS-BUILT** en `feat/gui-g1-capa-servicios` — G1 (capa de servicios neutral + contrato subido), - G2 (6 lecturas read-only en `service/reads.py`), G3 (API local FastAPI + extra `[gui]` + 19º - subcomando `b2g gui`), G4 (SPA `frontend/`), G5 (empaquetado). Lo único pendiente es el **gate #34** - (un tercero usa la GUI sin ayuda para reproducir/curar `examples/valoraciones/`), que **NO es - construcción**: es el criterio de aceptación/descarte de producto de la epic, al final (ADR 0027 - §Gate). Detalle por hito en `docs/ROADMAP/05-gui.md`. - - **G4 — SPA `frontend/`** (paquete JS del monorepo, **`pnpm` —nunca npm**): React 18 + Vite + TS - estricto + Cytoscape/fcose + Zustand + Tailwind + TanStack Query, dirección visual **D-2 - "Observatorio"** (oscuro, grafo-céntrico, design tokens propios en `tailwind.config.js`). Consume los - **7 endpoints reales** de la API G3 (`src/{client,types,store,components,lib,styles}`): 3 columnas - (rondas/grafo/candidato) + curar (refetch, sin Louvain client-side) + diff de rondas; cliente tipado - que des-envuelve `schema="1"` (`error.code` **string**, header `Bearer`) y tipos que espejan los DTO - reales. **Wiring del token (B-G4-3):** `b2g gui` **inyecta el token en el `index.html` servido** - (`cli/commands/gui.py::_make_index_response` reemplaza el placeholder `__B2G_TOKEN__`; ruta `GET /` - sirve el HTML con token sin exigir Bearer, `StaticFiles` —`html=False`— sirve los assets); el frontend - lo lee de `window.__B2G_TOKEN__`. El **build** sale a `src/bib2graph/gui/static/` (`outDir`, - `base: "./"`) y **NO se commitea** (gitignoreado). **Tests vitest (14).** - - **G5 — empaquetado:** el wheel **vendorea el build del frontend** vía - `[tool.hatch.build.targets.wheel.force-include]` de hatchling (`src/bib2graph/gui/static` → - `bib2graph/gui/static`) → `b2g gui` funciona **sin Node** desde el wheel; clone fresco sin `pnpm - build` previo → `uv build` **falla ruidosamente** (no wheel mudo). `ci.yml` suma el job **`frontend`** - (setup-node 20 + pnpm `install`/`lint`/`test:run`/`build`, corre siempre); `publish-testpypi.yml` - hace `pnpm build` **antes** del `uv build` (Trusted Publishing intacto, `release-please.yml` no se - tocó). `tests/unit/test_packaging_config.py` (**2 tests**) guarda la config `force-include`. Ver - §`frontend/` abajo, `docs/API.md` §0.2 y `docs/ROADMAP/05-gui.md` §G5. + **Absorbido por `b2g build --spec` (#159, ADR 0037 (a) / 0038):** `build` ahora carga el mismo YAML + (helper compartido `_build_from_spec_file`) y **sí** transiciona a `BUILT` + sella `.corpus_hash` + (decisión D1); `build` suma `--scope all|accepted|seeds` (default `all`) y `--min-weight` (solo modo + quick). `networks` y `--corpus-scope` quedan como **alias en deprecación** (cierran 0.11.0). +- **GUI local — ⛔ FUERA de la librería** (ADR [0040](docs/decisiones/0040-retiro-gui-local.md), + [#190](https://github.com/complexluise/bib2graph/issues/190)): el core es **CLI/agente-native**. No + existen `b2g gui`, la API local FastAPI (`api/`), la SPA `frontend/`, el extra `[gui]` ni el vendoreo + del frontend en el wheel; el repo es 100% Python con uv. **La capa de servicios neutral `service/` + (incl. `reads.py`) se conserva** (la usa el CLI: `read`/`curate`/`snapshot`/…). La experiencia visual + library-centric vive en un **producto separado**. El historial de la SPA vive en `git log`. - **#88 — preprocesamiento automático en la ingesta (AS-BUILT 2026-06-18, ADR [0031](docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md)):** `normalize` + dedup fuzzy corren **automáticamente** en `seed`/`seed_from_bib`/`chain`/`restore` (helper `cli/_ingest.py::normalize_and_dedup` sobre el corpus **completo mergeado** ⇒ dedup **cross-biblioteca**); el corpus queda siempre normalizado y deduplicado. **`rapidfuzz` pasa al núcleo** (`[project.dependencies]`; **el extra `[dedup]` se elimina**, import ya no perezoso). - Nuevo **18° subcomando `b2g thesaurus --from `** (único paso explícito del preproc, - transversal al FSM). La ingesta y `thesaurus` persisten con **`persist_replace`** / + El thesaurus era entonces el 18° subcomando `b2g thesaurus --from ` —**RETIRADO como verbo + en 0.10.0 (#164, ADR 0038)**: su capacidad vive como flag **`b2g build --thesaurus`**—. La ingesta y + la pasada `build --thesaurus` persisten con **`persist_replace`** / `overwrite_corpus` (DELETE+INSERT, preservan tablas hermanas; evita que el upsert-concat D3 reintroduzca variantes). `build`/`networks` siguen puros. Deuda conocida: dedup O(n²) por ingesta (optimización futura) y skip #93 (`test_run_seed_from_bib_reseed_incrementa_ronda`, crash `BibDataString`/`pyparsing` en reseed mismo-proceso; no afecta el CLI real). La **revisión asistida - de clusters ambiguos** se difiere a la epic GUI #34. Ver `docs/API.md` §6/§11/§4.1. + de clusters ambiguos** queda diferida (requiere superficie interactiva). Ver `docs/API.md` §6/§11/§4.1. - **Ciclo B — `examples/valoraciones/` rehecho 100% por CLI (AS-BUILT 2026-06-17, ADR [0030](docs/decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md) §Ciclo B):** materializa el principio **CLI-puro** del PO. `build_corpus.py` **eliminado**; el ejemplo se arma y reproduce @@ -133,8 +150,7 @@ 7 tests) corre `restore --from-corpus` → `build` → `networks` **sin red** sobre el corpus real y asserta `corpus_hash` estable + composición de comunidades Louvain estable entre corridas (cierra el agujero R2 de la [Nota 09](docs/Notas/09-sesion-qa-prueba-ecologia-valoraciones.md)). Con esto **#33 - queda cerrado** (caso real reproducible = gate de la epic GUI #34); `seed --from-bib` y - `examples/bibtex/` siguen diferidos (issue #50). Ver `docs/API.md` §2.1. + queda cerrado** (caso real reproducible sin red). Ver `docs/API.md` §2.1. - **Hito 8 COMPLETO** (Ciclos 8a + 8b, ADR [0025](docs/decisiones/0025-enricher-cocitacion-openalex.md)): el `OpenAlexEnricher` (opt-in, núcleo) hace 2 pasadas — **refs→DOI** (8a) **+ co-citación end-to-end** (8b): pobla `cited_by_id` @@ -164,7 +180,7 @@ `docs/API.md` §5/§4 y ADR [0020](docs/decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) §AS-BUILT #54. - **Forward chaining del `Forager` batcheado** (#21, 2026-06-16): el forward del `Forager` - (`b2g chain`/`b2g monitor`) **ya no es N+1** — reusa `OpenAlexSource.fetch_citing_batch` (batcheo OR + (`b2g chain`, incl. `chain --since` —ex `monitor`, #158) **ya no es N+1** — reusa `OpenAlexSource.fetch_citing_batch` (batcheo OR + cap por semilla `max_citing_per_paper`/`--max-citing`, default 50) con preview sin red. **Opera sobre `is_seed=True`** (todas las semillas, **sin** filtrar `curation_status`): el chaining precede a la curación; la restricción a `accepted` es del **Enricher** (Hito 8b), no del Forager. Ver @@ -191,9 +207,8 @@ [0026](docs/decisiones/0026-dedup-fuzzy-determinista.md) — **automático en la ingesta y `rapidfuzz` al núcleo desde #88, ADR [0031](docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md)**), el **Hito 9 ✅** (`NetworkSpec` - YAML) y el **Ciclo #33 ✅** (ecuación declarativa + `restore` + corpus de ejemplo, 9a+9b). Con #33 - cerrado, **todo el terreno pre-GUI está completo**; lo que sigue es la epic GUI #34. El entorno se - levanta con `uv sync`. + YAML) y el **Ciclo #33 ✅** (ecuación declarativa + `restore` + corpus de ejemplo, 9a+9b). El entorno + se levanta con `uv sync`. - **Fundación workspace COMPLETA** (ADR [0029](docs/decisiones/0029-workspace-por-investigacion.md), AS-BUILT 2026-06-16; issues [#32](https://github.com/complexluise/bib2graph/issues/32)/ @@ -253,6 +268,22 @@ - **El CLI es la API para LLM/agentes** (Hito 6). Subprocess + JSON stdout, exit codes claros, sin estado entre invocaciones (el estado vive en DuckDB). +## Documentación viva (docs vivos) + +- **Los docs vivos describen el PRESENTE, no el camino.** `docs/ARCHITECTURE.md`, `docs/API.md` y + `docs/PRD.md` describen lo que el sistema **ES**. Cuando una decisión cuaja, el doc vivo se + **reescribe** para reflejar el presente; **el debate y el "porqué" viven en el ADR** + (`docs/decisiones/`, historia inmutable), no en el doc vivo. **El changelog lo gestiona + release-please** — no se narra la evolución en los docs vivos. Sacá la dualidad + descripción/realidad: nada de marcadores `AS-BUILT`/`TARGET`/`SUPERADO`/`HISTÓRICO` ni banners de + "antes era X, ahora Y" en el cuerpo. +- **Regla de prosa.** Preferí **la idea en una línea y al punto**. Sin banners redundantes, sin + repetir lo que ya dice el código o un ADR (linkealo). Gastá palabras en el drift real, no en + re-narrar lo construido. Si está bien, decilo corto. +- **Sincronía tras un cambio de código:** actualizá `docs/API.md`/`ARCHITECTURE.md`/`PRD.md` al nuevo + presente y, si se tomó una decisión, redactá el ADR. El índice (README/AGENTS) tiene que seguir + siendo verdad. + ## Flujo de trabajo (ramas dev/main) — LEER ANTES DE TOCAR GIT Modelo **GitFlow-lite** con dos ramas protegidas (PR + CI verde obligatorios; nunca @@ -288,12 +319,23 @@ abre su PR de release → mergearlo crea el tag + GitHub Release. una idea; el commit/PR sigue Conventional Commits (abajo); no bumpear versión ni editar `CHANGELOG.md` a mano (lo hace release-please). +**Milestones de GitHub = la versión que un issue va a liberar.** Un milestone de GitHub representa +**la versión que un issue va a cortar** (p. ej. `0.10.0`, `0.11.0`). Al **encuadrar** un issue se lo +**asigna a su milestone destino** (la versión donde debe entrar). Un **release = cerrar su milestone**: +el milestone es el **espejo de lo que falta** para liberar esa versión. Si un issue no tiene milestone, +todavía no está encuadrado; si un milestone tiene issues abiertos, esa versión no está lista para +cortarse. + ## Tooling de agentes Claude Code (`.claude/`) El repo versiona su propia config de Claude Code para que **el equipo herede los roles y los guardarraíles** al clonar (project-level **gana** sobre la config de usuario). Se versiona -`.claude/settings.json` + `.claude/agents/` + `.claude/hooks/`; queda ignorado el estado local -(`settings.local.json`, `worktrees/`, `System_prompt.md`). +`.claude/settings.json` + `.claude/agents/` + `.claude/hooks/` + `.claude/commands/`; queda ignorado +el estado local (`settings.local.json`, `worktrees/`, `System_prompt.md`). + +**Comandos de proyecto** (`.claude/commands/*.md`, slash commands del equipo): `/retro-ciclo` — +retrospectiva metacognitiva de fin de ciclo que mide dónde se fue el tiempo y **baka las lecciones** +en el proceso (ver §"Ejecución concurrente y testing"). **Subagentes** (`.claude/agents/*.md`), afinados a bib2graph y con **una frontera dura por rol** ("cada uno es responsable de sus artefactos"): @@ -321,6 +363,28 @@ su frontmatter no toma efecto hasta reiniciar). Los **hooks de `settings.json` s caliente**. Si un guardarraíl bloquea algo legítimo, se afloja editando el script en `.claude/hooks/`. +### Ejecución concurrente y testing — lecciones del epic 0.10.0 (#167) + +Destiladas del giro de superficie 0.10.0 (medición forense: **~50% del tiempo de cada `coder` se +fue esperando el suite completo de tests**). Las captura y actualiza el comando **`/retro-ciclo`** +(`.claude/commands/`) al cerrar cada ciclo. + +- **Testing por capas.** El `coder` itera con **tests pertinentes** (`pytest test_X.py::test_Y`, + 7-60 s) y auto-formatea (`ruff format` + `ruff check --fix`) antes de gatear; el **gate completo + (`pytest` entero, ~6 min) lo corren el `verifier` y el CI**, no el coder en loop. Elimina una de + las 3 corridas redundantes del suite por sub-issue. +- **Paralelizar con prudencia (archivos disjuntos).** Fan-out de varios sub-issues a la vez **solo + si tocan archivos disjuntos**. Ramas que comparten un archivo caliente (`build.py`, + `cli/__init__.py`) → **serializar** (mergear una, rebasar la siguiente) para no pagar el baile de + conflictos. Batchear los encuadres y resolver las decisiones del PO en **una sola ronda** es + ganancia neta sin riesgo. +- **Confiabilidad de worktrees.** Los `Edit`/`Write` de un subagente se aíslan al worktree de la + **sesión**, no a la ruta que se le pase en el prompt. Para trabajo sobre una rama: tenerla + **checked out en el worktree de la sesión** (o recuperar el trabajo vía `git diff`/patch). No + asumir que el agente escribe en la ruta del prompt. +- **Windows:** evitar rutas con acentos en Git Bash (rompe el quoting); preferir PowerShell para + operaciones de filesystem. Reservar Bash para comandos POSIX simples. + ## Comandos de build / lint / test El proyecto se gestiona con **uv** (entorno + lockfile + versión de Python). **No** uses @@ -329,11 +393,11 @@ El proyecto se gestiona con **uv** (entorno + lockfile + versión de Python). ** - **Setup dev completo:** `uv sync` (crea `.venv`, instala núcleo + dev-deps desde `uv.lock`) y `uv run pre-commit install`. -- **Con una capacidad opcional:** `uv sync --extra bibtex` (siembra BibTeX) o `uv sync --extra gui` - (`fastapi` + `uvicorn` para `b2g gui` / la API local, AS-BUILT G3, ADR 0028) — los dos extras poblados - hoy. Sin dev-deps: `uv sync --no-dev`. *(No hay extra `[llm]`: **se eliminó** en la remediación - R4 — el producto no usa IA generativa, ADR 0022. Tampoco hay extra `[dedup]`: `rapidfuzz` pasó al - núcleo en #88 porque el dedup es automático en la ingesta, ADR 0031.)* +- **Con una capacidad opcional:** `uv sync --extra bibtex` (siembra BibTeX) — el **único extra poblado + hoy**. Sin dev-deps: `uv sync --no-dev`. *(No hay extra `[gui]`: **se eliminó** al retirar la GUI + local —`fastapi`/`uvicorn`/`b2g gui`/API— ADR 0040, #190. Tampoco hay extra `[llm]`: **se eliminó** en + la remediación R4 — el producto no usa IA generativa, ADR 0022. Tampoco hay extra `[dedup]`: + `rapidfuzz` pasó al núcleo en #88 porque el dedup es automático en la ingesta, ADR 0031.)* - **Agregar dependencias:** `uv add ` (núcleo) · `uv add --dev ` (desarrollo) · `uv add --optional ` (capacidad opcional). - **Tests (toda la suite):** `uv run pytest` @@ -456,33 +520,28 @@ src/bib2graph/ # compartido por CLI/API, agnóstico de transporte (sin print/sys.exit/Click/ # FastAPI). envelope.py = build_envelope + ENVELOPE_SCHEMA_VERSION; errors.py = # jerarquía B2GError (+ Usage/Data/Dependency/Network/StoreError) + code_for - # (mapeo puro error→exit code 0–5). reads.py (G2 ✅) = 6 lecturas read-only de la SPA - # sobre un Workspace resuelto: get_workspace/list_rounds/get_paper/get_scent/ - # get_network/compare_rounds (ronda=snapshot; sin red/mutación/transición; API.md §0.1). - # curate.py (G3 ✅) = orquestación de curación SUBIDA desde cli/: accept_papers/ - # reject_papers/curate_paper (toma store_path; decided_at inyectado en la frontera); - # run_accept/run_reject del CLI son shims que delegan (firma intacta). API.md §0.2. - # cli/ re-exporta el contrato (subido desde cli/_envelope.py·_errors.py) y conserva - # solo el I/O del adaptador. La migración del resto de la orquestación run_ sigue TARGET. - api/ # API LOCAL FastAPI (ADR 0028, AS-BUILT G3 del MVP GUI): adaptador DELGADO sobre - # service/ (NO importa de cli/; el núcleo NO importa fastapi —import perezoso, extra - # [gui]). app.py = create_app(ws, *, token, cors_origins); routers/reads.py (6 GET) - # + routers/curate.py (POST); security.py = token Bearer efímero; deps.py = workspace - # singleton + require_token (401) + WriteLock global; envelopes.py = mapeo código→HTTP - # (0→200,1→400,2→422,3→501,4→502,5→409; inesperado→500 INTERNAL_ERROR), reusa - # service.build_envelope/code_for. La SPA (frontend/, G4) y el empaquetado del wheel - # (G5: force-include) están AS-BUILT; solo queda el gate #34 (validación, no build). API.md §0.2. + # (mapeo puro error→exit code 0–5). reads.py (G2 ✅) = lecturas read-only del corpus. SE CONSERVA tras + # retirar la GUI (ADR 0040): el grupo CLI read la usa (list_papers/corpus_stats/get_paper/ + # get_top). Las ex-API-only (get_workspace/list_rounds/get_scent/get_network/compare_rounds) + # quedan inertes, poda opcional → #191. Sin red/mutación/transición; API.md §0.1. + # curate.py = orquestación de curación (fuente única CLI): accept_papers/ + # reject_papers/curate_paper/filter_corpus (toma store_path; decided_at inyectado en la + # frontera); los verbos del CLI delegan. cli/ re-exporta el contrato (envelope·errores). + # api/ ⛔ RETIRADO (ADR 0040, #190): la API local FastAPI, la SPA frontend/ y el extra [gui] se + # eliminaron de la librería (GUI fuera del foco; el core es CLI/agente-native). La capa + # service/ que la alimentaba se conserva (la usa el CLI). Limpieza profunda: #191. stores/ # DuckDBStore (núcleo, por defecto: biblioteca viva); # ParquetStore (export); ZoteroStore ([zotero], V1.1); # Neo4jStore ([neo4j], post-V1) cli/ # paquete de 3 capas (Click → run_() núcleo → envelope/errores); # _ingest.py = helper normalize_and_dedup (auto-preproc en la ingesta, ADR 0031); - # cli/commands/ = 19 subcomandos (incl. monitor FSM→MONITORED, enrich refs→DOI + co-citación, - # init scaffold de workspace —ADR 0029, curate dump/import CSV —#22+#26, - # networks capa declarativa YAML —Hito 9, restore rehidrata corpus curado sin - # red →FILTERED —ADR 0030/9a, thesaurus aplica thesaurus curado transversal —#88/ADR 0031, - # gui levanta la API local FastAPI —Hito G3 del MVP GUI/ADR 0028, extra [gui]; - # G4: _make_index_response inyecta el token en el index.html servido vía ruta GET /). + # cli/commands/ = superficie 0.10.0 (ADR 0037/0038/0039/0040): 10 verbos del ciclo + 3 grupos + # noun-verb (read/curate/snapshot) + 1 comando meta (skill add —ADR 0039) + 9 aliases deprecados + # (accept/reject/filter/inspect/monitor/networks/enrich/restore/resolve, retiro 0.11.0). + # chain --since absorbe monitor →MONITORED (#158); enrich absorbido en chain (refs→DOI) + # + build (co-citación) (#162); thesaurus retirado → build --thesaurus (#164); + # _deprecation.py emite avisos a stderr + warnings[] (#165). init scaffold —ADR 0029; + # build --spec absorbe networks —#159. El verbo gui SE RETIRÓ con la GUI local (ADR 0040, #190). # CLI = API # para LLM y agentes (Hito 6, ARCHITECTURE.md §6.3). No es un cli.py plano. workspace.py # Workspace (init/open/resolve; snapshots_dir/exports_dir/networks_dir; @@ -498,31 +557,14 @@ La estructura es orientativa (ADR 0006): un módulo plano (`corpus.py`) o un paq (`sources/`) es decisión del implementador según crezca. Lo fijo son los **nombres del dominio** y los **contratos de `docs/API.md`**. -### `frontend/` — la SPA (paquete JS, NO Python; AS-BUILT G4, ADR 0028) - -El **único subárbol JS** del repo. El resto del monorepo es Python con **uv**; `frontend/` es -JavaScript con **`pnpm` (SIEMPRE pnpm, nunca npm** — preferencia firme del PO). Es la SPA "tool for -thought" del MVP GUI (React 18 + Vite + TS estricto + Cytoscape/fcose + Zustand + Tailwind + TanStack -Query), dirección visual **D-2 "Observatorio"** (oscuro, grafo-céntrico; design tokens propios en -`tailwind.config.js`). - -``` -frontend/ # NO va al wheel; su build (gui/static/) sí (vendoreado vía force-include, G5) - package.json # packageManager: pnpm@…; scripts dev/build/test:run/lint - pnpm-lock.yaml # lockfile reproducible - vite.config.ts # outDir → ../src/bib2graph/gui/static ; base "./" ; alias @ → frontend/src - index.html # placeholder del token ( + window.__B2G_TOKEN__) - src/{client,types,store,components,lib,styles}/ # cliente tipado, DTO espejo, estado, UI - src/__tests__/ # vitest (14) -``` +### `frontend/` — ⛔ RETIRADO (la SPA se eliminó con la GUI local, ADR 0040, #190) -Comandos (desde `frontend/`): **`pnpm lint`** (`tsc --noEmit`) · **`pnpm test:run`** (vitest, 14) · -**`pnpm build`** (`tsc --noEmit && vite build` → escribe `src/bib2graph/gui/static/`). El **alias `@`** -resuelve a `frontend/src/`. El **build output** (`src/bib2graph/gui/static/`) está **gitignoreado** -(no se commitea; lo vendorea el wheel vía `force-include`, G5 AS-BUILT). El cliente consume los 7 endpoints reales de la API G3 -(`docs/API.md` §0.2): des-envuelve el envelope `schema="1"`, ramea por `error.code` (**string**) y -manda `Authorization: Bearer ` (token leído de `window.__B2G_TOKEN__`, inyectado por `b2g gui` -en el `index.html` servido). +El subárbol JS `frontend/` (SPA "Observatorio") y el prototipo `app/` **se retiraron de la librería** +junto con la GUI local (ADR [0040](docs/decisiones/0040-retiro-gui-local.md), +[#190](https://github.com/complexluise/bib2graph/issues/190); supersede 0027/0028). El repo vuelve a +ser **100% Python con uv**, sin Node/`pnpm`. El wheel es **Python puro** (sin `force-include` del +frontend, sin job `frontend` en CI ni build de Node en los workflows de publish). El historial de la +SPA vive en `git log` y en `docs/ROADMAP/05-gui.md` (deprecado). ### Manejo de errores @@ -581,8 +623,12 @@ en el `index.html` servido). ### CLI como API para LLM y agentes -- Cada subcomando expone `--json` con salida estructurada (un objeto por corrida, - estable y versionado). +- Cada subcomando expone `--json` (por-comando, post-verbo) con salida estructurada + (un objeto por corrida, estable y versionado). **Alternativa por entorno:** `export + B2G_JSON=1` (truthy: `1`/`true`/`yes`) activa el modo JSON en **todos** los comandos + sin repetir el flag; precedencia `--json` > `B2G_JSON`, sin `--no-json` (#151). +- **stdout puro** en modo JSON: stdout = una línea-envelope `schema="1"` (incl. el + camino de error); el texto humano va a stderr. - Exit codes claros (ver §Manejo de errores). - Sin estado entre invocaciones: cada llamada es independiente. El agente orquesta orquestando subprocess. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11f35bc..6566544 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -177,6 +177,9 @@ cambio): si es la única forma de hacer el feature, pero decirlo explícito. - **Checklist:** `ruff check` + `mypy` + `pytest` corriendo limpio. CI lo valida igual; el checklist es cortesía para el revisor. +- **¿Si el PR cambia un ADR firmado, reescribiste los docs vivos para reflejarlo?** + (`docs/ARCHITECTURE.md`/`API.md`/`PRD.md` describen el **presente**: si la decisión cambió, el doc + vivo se reescribe al nuevo presente y el debate queda en el ADR — ver AGENTS.md §Documentación viva). ## Antes de mergear diff --git a/README.md b/README.md index beb307b..8b3e9a4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,21 @@ b2g export --format graphml # → rede Cada comando acepta `--json` para orquestarlo desde scripts o agentes. Lista completa de comandos: `b2g --help`. +### Con Claude Code: pedile a Claude que lo use + +La forma más simple de usar bib2graph es **pedirle a Claude que lo use por vos**. bib2graph trae una +**skill de Claude Code** que entrevista tu pregunta de investigación y corre el ciclo completo +(`init → seed → chain → build → read`) sin que escribas comandos: + +```bash +pip install bib2graph +b2g skill add # instala la skill en ~/.claude/skills/bib2graph/ +``` + +Después, en Claude Code: *"usá bib2graph para armar la red de citación de estos papers…"*. La skill +viaja **dentro del mismo paquete** que el CLI, así que **siempre está al día con tu versión** de +bib2graph. Usá `--project` para instalarla solo en el proyecto actual. + ### Desde Python ```python diff --git a/docs/API.md b/docs/API.md index e810fc3..b2f0c5b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,254 +1,24 @@ # API — superficie pública de bib2graph > Contratos de las costuras y del núcleo: el "producto" que ve quien la integra o la extiende. -> Son **bocetos de interfaz** (firmas + docstrings), no la implementación. El código es la fuente -> de verdad última; este doc describe el contrato que ese código debe cumplir. Fecha: 2026-06-15. +> Son **bocetos de interfaz** (firmas + docstrings), no la implementación: el código es la fuente de +> verdad última y este doc describe el contrato que ese código cumple. Diseño de fondo en +> [`ARCHITECTURE.md`](ARCHITECTURE.md); método en [`Notas/metodología.md`](Notas/metodología.md). > -> **Reconciliado con el giro** (`Notas/04`–`07` archivadas) y los ADR -> [0007](decisiones/0007-openalex-backbone.md) (OpenAlex backbone), -> [0009](decisiones/0009-biblioteca-viva-duckdb.md) (biblioteca viva en DuckDB), -> [0010](decisiones/0010-agente-native-columna.md) (agente-native), -> [0011](decisiones/0011-thesaurus-multilingue.md) (thesaurus). Diseño de fondo en -> [`ARCHITECTURE.md`](ARCHITECTURE.md); método en [`metodología.md`](Notas/metodología.md). El `Corpus` -> sigue siendo una **tabla Arrow validada con Pydantic v2** (ADR 0006); `Paper`/`Author`/ -> `Keyword`/`Institution` son **vistas derivadas**, no tipos del modelo. +> El `Corpus` es una **tabla Arrow validada con Pydantic v2** (ADR +> [0006](decisiones/0006-tabla-canonica-y-networkspec.md)) respaldada por un **`TabularBackend`** +> (`InMemoryBackend` puro / `DuckDBBackend` por defecto; ADR +> [0015](decisiones/0015-corpus-tabular-backend.md)); `Paper`/`Author`/`Keyword`/`Institution` son +> **vistas derivadas**, no tipos. El estado del lazo (`CycleState`) vive en el backend persistente (ADR +> [0016](decisiones/0016-maquina-estados-lazo.md)). El contrato `Source` separa **mínimo universal vs +> enriquecimiento opcional** (ADR [0018](decisiones/0018-source-agnostico-calidad.md)). El **producto +> no usa IA generativa** (ADR [0022](decisiones/0022-producto-sin-ia-generativa.md)): la asistencia del +> forrajeo es estructura bibliométrica determinista (*information scent*). > -> **Reconciliado con el 2º giro (2026-06-15):** el `Corpus` se respalda en un **`TabularBackend` -> (Protocol)** —`InMemoryBackend` puro / `DuckDBBackend` por defecto— y **delega las mutaciones** -> al backend (ADR [0015](decisiones/0015-corpus-tabular-backend.md)), en vez de la semántica de -> valor por copia en memoria del Hito 1. `corpus.to_arrow()` sigue siendo el **puente a los -> proyectores puros**. El estado del lazo (`LoopState`) vive en el backend persistente (ADR -> [0016](decisiones/0016-maquina-estados-lazo.md)). El contrato `Source` separa **mínimo universal -> vs enriquecimiento opcional** (ADR [0018](decisiones/0018-source-agnostico-calidad.md)). -> -> **Sincronizado con el Hito 3 (2026-06-15):** `DuckDBBackend` y `DuckDBStore` están **construidos** -> (§4/§4.1): mutación por SQL puro, `LoopState` (log append-only), fachada `DuckDBStore` con -> `.backend`, single-writer (`StoreLockedError`) y **carga perezosa** (PEP 562) para no acoplar el -> núcleo a duckdb. -> -> **Sincronizado con el Hito 4 (2026-06-15):** `OpenAlexSource` y `BibtexSource` están **construidos** -> (§2): traducción **passthrough** + reporte (traductor WoS diferido a v0.2), flag `native`, -> `cited_by_id` diferido al chaining/Enricher, `bibtexparser` como extra **`[bibtex]`**, credenciales -> inyectadas (ADR 0012) y `Manifest.openalex_version` poblada (ADR 0017). El método -> `Corpus.with_manifest()` (§1.2) es la API canónica que usan para sellar metadata. **Con el Hito 4, -> v0.1 queda feature-complete** (ver [`ROADMAP.md`](ROADMAP/README.md)). -> -> **Sincronizado con el Hito 5 (2026-06-15):** `Forager`, `GrowthPreview`, `RankedCandidates`, -> `Preprocessor`, `FilterCriterion`/`apply_filters` están **construidos** (§5/§6). El *information -> scent* es **frecuencia de enlace** (no acoplamiento/centralidad); `preview` opera **sin red** -> (backward exacto local; forward no estimable → `chain`); los filtros PRISMA **marcan `rejected` -> (no borran)**; `apply_thesaurus` **sobrescribe `keywords_id` desde `keywords_raw`** (ADR -> [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md); thesaurus ADR 0011). `depth>1` -> lanza `NotImplementedError`; `explain_candidate` (B4) es un stub gateado en `[llm]`. -> -> **Sincronizado con el Hito 6 (2026-06-15):** el **CLI agente-native `b2g`** está **construido** -> (paquete `bib2graph.cli`, §convenciones): **12 subcomandos** (`seed`, `chain`, `filter`, `build`, -> `export`, `snapshot`, `status`, `inspect`, `validate`, `accept`, `reject`, **`monitor`**), **envelope -> JSON común versionado** (`schema="1"`), exit codes 0–5 mapeados **por tipo de error**, opción global -> `--store` (obligatoria) y `CycleState` que transiciona automáticamente por comando (ADR -> [0021](decisiones/0021-cli-agente-native-contrato.md)). **Con el Hito 6, las capacidades de v0.2 -> (Hitos 5–6) quedan completas** (ver [`ROADMAP.md`](ROADMAP/README.md)). *(El 12° subcomando `monitor` -> —AS-BUILT del cleanup pre-v0.3— cierra el paso 8 del ciclo: `MONITORED` ahora es alcanzable.)* -> -> **Sincronizado con el Hito 8 — Ciclos 8a + 8b (2026-06-16):** la costura **`Enricher`** está -> **construida** (§3) y suma el **13° subcomando `enrich`** (refs→DOI **+ co-citación** sobre -> OpenAlex, núcleo; **NO** transiciona el `CycleState`). El Enricher vive en el **núcleo sobre -> OpenAlex**, no en `[s2]` (ADR [0025](decisiones/0025-enricher-cocitacion-openalex.md)). La -> **co-citación es end-to-end**: `enrich` puebla `cited_by_id` desde las semillas aceptadas (vía -> `OpenAlexSource.fetch_citing_batch`, §2) y `Networks.quick` devuelve **4 o 5 redes** según haya -> `cited_by_id` (§10). Flag `--max-citing`. **Hito 8 completo.** -> -> **Sincronizado con labels/decorate — #25 (AS-BUILT, 2026-06-16):** se agregó la **capa frontera -> `decorate`** (§7.1, `networks/decorate.py`) entre los proyectores puros (§7) y el export/GUI: -> inyecta `label` legible + atributos de nodo (`year`/`is_seed`/`curation_status`/`degree_centrality`/ -> `community`) en los nodos. `Networks.quick`/`build` devuelven artefactos **decorados** (cableado en -> `facade.py:_build_artifact`); los proyectores **siguen puros** (ADR 0014). Cierra el hueco de la -> Nota 09 B3 (redes con id crudo, ilegibles en Gephi/VOSviewer). -> -> **Sincronizado con la tabla de clusters — #31 (AS-BUILT, 2026-06-17):** se agregó la función pura -> **`cluster_table(table, artifact)`** (§7.2, `networks/clusters.py`, re-exportada desde -> `networks/__init__.py`): resume cada comunidad de una red de **paper** (coupling/cocitación) en una -> fila (tamaño, conteos de curación, rango de años, top autores/keywords). **`b2g build`** ahora escribe -> `/networks//clusters.csv` (listas con separador `|`) cuando la red tiene comunidades, -> y el envelope `--json` suma `clusters_csv` **condicional** por red (§9/§convenciones CLI). -> -> **Sincronizado con el workspace — ADR [0029](decisiones/0029-workspace-por-investigacion.md) -> (AS-BUILT, 2026-06-16):** la unidad de persistencia es un **workspace = carpeta** (`workspace.json` -> + `library.duckdb` + `networks/`/`snapshots/`/`exports/`). `--store` pasó a **opcional** y se agregó -> **`--workspace`** (ambos opcionales, mutuamente excluyentes) con **resolución ambiente** (flag > env -> `B2G_WORKSPACE` > walk-up del cwd). Suma el **14° subcomando `init`**. El `.duckdb` suelto sigue -> válido (workspace degenerado). Ver §convenciones CLI. -> -> **Sincronizado con la curación a escala — #22 + #26 (AS-BUILT, 2026-06-16):** suma el **15° -> subcomando `curate`** (curación en lote vía CSV), con dos modos mutuamente excluyentes —`--dump` -> (escribe `curacion.csv`) y `--from-csv` (aplica decisiones en lote, idempotente)—. **Curación -> transversal:** no transiciona el `CycleState`. Cierra el hueco de la -> [Nota 09](Notas/09-sesion-qa-prueba-ecologia-valoraciones.md) B4/B5/P1 (no había dump CSV ni -> reimport en lote: la curación a escala no era viable). Ver §convenciones CLI. -> -> **Sincronizado con la capa declarativa NetworkSpec — Hito 9 (AS-BUILT, 2026-06-17):** `NetworkSpec` -> (§10) gana el campo **`resolution: float = 1.0`** (resolución de Louvain, fuera del `corpus_hash` — -> seed intacto, R2) y **`extra="forbid"`** (campo desconocido en el YAML → error accionable). Nueva -> función **`load_specs(redes.yaml)`** (carga/valida una lista de specs; clave raíz `networks:`) y el -> **16° subcomando `b2g networks --spec`** (construye cada red con `Networks.build` y el helper -> compartido `_write_artifacts`; mismo envelope que `build`; **NO** transiciona el `CycleState` ni -> sella `.corpus_hash`). `pyyaml` pasó a dependencia del núcleo (import perezoso). Ver §10 + -> §convenciones CLI. -> -> **Sincronizado con la capa declarativa de ecuación + `restore` — #33 / Ciclo 9a (AS-BUILT, 2026-06-17):** -> dos cambios de la capa declarativa (ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md)). -> **(1)** `b2g seed` gana un 2º modo declarativo **`--spec equation.yaml`** (mutuamente excluyente con -> `--equation`): carga la ecuación de un YAML con el modelo **`EquationSpec`** + loader -> `load_equation_spec` (`sources/equation.py`, Pydantic `extra="forbid"`, mismo patrón de errores que -> `load_specs`; §2). Los campos `min_year`/`max_year` ya existen en el modelo **(en Ciclo 9a aún no -> filtraban; desde el Ciclo 10 SÍ filtran contra OpenAlex — ver el banner de Ciclo 10 abajo)**. **(2)** Nuevo -> **17° subcomando `b2g restore --from-corpus `** (`cli/commands/restore.py`): rehidrata un -> corpus **ya curado** desde un parquet **sin red** (inverso de `snapshot`; lee con `CORPUS_SCHEMA`, -> `Corpus.from_arrow`, merge+persist), preserva la curación (`decision`/`curation_status`/`is_seed`) y -> transiciona el `CycleState` a **`FILTERED`** (reusa la transición permisiva `filter`, ADR 0016; deja -> correr `build`/`networks` sin re-forrajeo). **NO** existe `seed --from-corpus` (la rehidratación es -> `restore`). En Ciclo 9a `seed --from-bib` estaba diferido; el **Ciclo 10 lo construyó** (ver banner -> abajo). Ver §2 + §convenciones CLI. -> -> **Sincronizado con el corpus de ejemplo + gate R2 — #33 / Ciclo 9b (AS-BUILT, 2026-06-17):** -> se materializa la convención **`examples/`** (§convención `examples/`) y se construye el primer -> ejemplo, **`examples/valoraciones/`** (corpus curado congelado de 137 filas + `equation.yaml` de -> procedencia + `README.md` + script de regeneración), excepción acotada al `.gitignore` de datos -> de usuario. Es el **caso real reproducible sin red** del gate #33: se rehidrata con -> `b2g restore --from-corpus examples/valoraciones/corpus.parquet` → `build` → `networks`/`clusters`. -> Un **gate R2** (`tests/unit/test_example_r2_gate.py`, 7 tests) verifica `corpus_hash` estable + -> composición de comunidades Louvain estable entre corridas (cierra el agujero R2 de la -> [Nota 09](Notas/09-sesion-qa-prueba-ecologia-valoraciones.md)). **#33 cerrado / 9a+9b completos**. -> (En 9b, `seed --from-bib` y `examples/bibtex/` quedaban diferidos —issue #50—; el **Ciclo 10 los -> construyó**, ver banner abajo.) Ver -> [ADR 0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md) §AS-BUILT 9b. -> -> **Sincronizado con el segundo camino de seed (BibTeX) + filtro de año — Ciclo 10 (AS-BUILT, 2026-06-17, -> cierra issue #50):** des-diferido lo que 9a había postergado (ADR -> [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md) §AS-BUILT Ciclo 10). **(1)** `b2g seed` -> pasa a **TRES modos** mutuamente excluyentes: `--equation` / `--spec` / **`--from-bib `** -> (siembra desde BibTeX local **sin red**, `run_seed_from_bib` → `BibtexSource.load`; `SEEDED`/reseed; -> exit 3 si falta `bibtexparser`; combinar `--from-bib` con flags OpenAlex → exit 1). **(2)** -> `--min-year`/`--max-year` **ahora filtran de verdad** contra OpenAlex -> (`from_publication_date`/`to_publication_date` en el `filter`; expuestos como flags en `--equation` y -> como campos del YAML en `--spec`, paridad 1:1). **(3)** Nuevo ejemplo **`examples/bibtex/`** (`sample.bib` -> + README con receta 100% CLI) que demuestra el camino BibTeX. Ver §2 + §convenciones CLI + §convención -> `examples/`. -> -> **Sincronizado con `examples/valoraciones/` rehecho CLI-puro — Ciclo B (AS-BUILT, 2026-06-17):** -> materializa el principio **CLI-puro** del PO (ADR -> [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md) §AS-BUILT Ciclo B). `build_corpus.py` -> **eliminado**: el ejemplo se arma y reproduce **100% por CLI** (`seed --spec equation.yaml` -> `max_results 80` → `curate --from-csv curacion.csv` 10 `accepted` → `enrich --max-citing 25` → -> `snapshot`). Corpus = **~80 filas** (70 `candidate` + 10 `accepted` enriquecidas), **co-citación -> presente** (rala) — antes 137 filas / co-citación vacía (9b). Nuevo artefacto congelado -> **`curacion.csv`** (receta determinista de curación). Gate R2 ajustado (piso `n>=50`, -> `test_cocitacion_con_datos` con 5 redes). **La procedencia de un ejemplo deja de ser un script y -> pasa a ser la receta CLI del README + `equation.yaml` + `curacion.csv`** (supersede la convención de -> 9b/§2.1). Ver §convención `examples/` (§2.1). -> -> **Sincronizado con los remanentes del modelo workspace — #32 (AS-BUILT, 2026-06-17):** cierra lo -> que el ADR [0029](decisiones/0029-workspace-por-investigacion.md) dejó "fuera de corte". -> **`b2g snapshot` y `b2g export`** se resuelven por ambiente: `--out-dir` pasó de obligatorio a -> **override OPCIONAL**; sin él, `snapshot` escribe en **`/snapshots/`** y `export` en -> **`/exports/`** (resolución vía `resolve_workspace`, igual que `build`). **`b2g status`** suma el campo aditivo -> **`data["networks_cache_stale"]: bool`** + un `warnings` accionable cuando el `networks/.corpus_hash` -> sellado **no coincide** con el `corpus_hash` del corpus vivo (aviso "ejecutá `b2g build`"; **NO** -> regenera — invalidación por hash, no build-system, ADR 0029). `schema="1"` intacto. `Workspace` ganó -> `read_networks_corpus_hash()` e `is_networks_cache_stale(live_hash)` (los accessors -> `snapshots_dir`/`exports_dir`/`networks_dir` ya existían). Ver §convenciones CLI. -> -> **Sincronizado con la eliminación de `--store` — [#75](https://github.com/complexluise/bib2graph/issues/75) (BREAKING, 2026-06-17):** -> la opción global **`--store` se ELIMINA por completo** del CLI (ya no registrada en Click; pasarla -> da el error estándar `No such option: --store`). El **modo degenerado** (`.duckdb` suelto sin -> `workspace.json`) **deja de existir**: la única unidad canónica es la carpeta con `workspace.json`, -> y un `.duckdb` legacy se adopta con **`b2g init .`**. La resolución ambiente pierde la rama -> `--store`: `--workspace` > `B2G_WORKSPACE` > walk-up del cwd. Ver §convenciones CLI y ADR 0029 -> (enmienda 2026-06-17). -> -> **Sincronizado con el preprocesamiento automático en la ingesta — #88 (AS-BUILT, 2026-06-18, ADR -> [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)):** `seed`/`seed_from_bib`/`chain`/ -> `restore` aplican **`normalize` + dedup fuzzy automáticamente** sobre el corpus completo mergeado -> (helper `cli/_ingest.py::normalize_and_dedup`), así que **dejan el corpus siempre normalizado y -> deduplicado cross-biblioteca** (§6 + §11). Persisten con **`persist_replace`** (§4.1), no upsert. -> Nuevo **18° subcomando `b2g thesaurus --from `** (único paso explícito del preproc, -> transversal al FSM). **`rapidfuzz` pasa al núcleo; el extra `[dedup]` se elimina.** `build`/`networks` -> siguen puros (el corpus ya entra deduplicado). Ver §6, §11, §4.1 y el listado de subcomandos. -> -> **Sincronizado con la capa de servicios neutral — Hito G1 del MVP GUI (AS-BUILT, 2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** se materializa la **capa de -> servicios neutral `src/bib2graph/service/`** (§0). G1 **sube EL CONTRATO** desde `cli/`: el envelope -> versionado (`build_envelope`/`ENVELOPE_SCHEMA_VERSION`), la jerarquía de errores tipados (`B2GError` -> + subclases) y el **mapeo puro error→código** (`code_for`) ahora viven en `service/` (agnóstico de -> transporte: sin `print`/`sys.exit`/Click/FastAPI). `cli/_envelope.py` y `cli/_errors.py` pasan a -> **re-exportar** ese contrato y conservan solo el I/O del adaptador (`emit`/`emit_human`/`handle_errors`). -> **El contrato externo del CLI no cambia** (envelope `schema="1"`, exit codes 0–5, ADR 0021): los -> imports `from bib2graph.cli._envelope import build_envelope` / `from bib2graph.cli._errors import -> B2GError, …` resuelven a los **mismos objetos**. Es la primera mitad del ports & adapters del ADR 0028 -> (el resto —`api/`, `b2g gui`, `frontend/`, lecturas `get_scent`/`get_network`/`search`— sigue siendo -> TARGET, no construido). Ver §0. -> -> **Sincronizado con las 6 lecturas de servicio — Hito G2 del MVP GUI (AS-BUILT, 2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** `src/bib2graph/service/reads.py` -> suma las **6 lecturas read-only** que la SPA necesita y el CLI nunca expuso —`get_workspace`, -> `list_rounds`, `get_paper`, `get_scent`, `get_network`, `compare_rounds`— cada una sobre un -> `Workspace` resuelto, devolviendo `dict`/`list[dict]` serializable o lanzando `B2GError` (sin red, sin -> mutación, sin transición de ciclo). **El contrato externo del CLI no cambia** (`test_cli.py` intacto), -> así que **no requiere ADR nuevo**. Resuelve las bifurcaciones del encuadre como recomendado: ronda = -> snapshot sellado, scent = score de acoplamiento + vecinos, `get_network` = red de la ronda viva -> (cache por snapshot y `mutated_hubs` diferidos). El resto de la epic GUI (API/`b2g gui`/`frontend/`, -> G3–G5) **sigue TARGET**. Ver §0.1. -> -> **Sincronizado con la API local + `b2g gui` — Hito G3 del MVP GUI (AS-BUILT, 2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** se construye la **API local -> FastAPI** (`src/bib2graph/api/`, §0.2): adaptador **delgado** sobre `service/` que expone **7 -> endpoints** (6 lecturas de §0.1 + 1 escritura de curación), con **token Bearer efímero** (sin/ -> inválido → **401**) y el **mapeo código→HTTP** del ADR 0028 §7 (`0`→200, `1`→400, `2`→422, `3`→501, -> `4`→502, `5`→**409**; excepción inesperada → **500** `INTERNAL_ERROR`). Reusa `service.build_envelope` -> y `service.code_for` (no reimplementa el contrato; el envelope `schema="1"` viaja íntegro en el body). -> El paquete `api/` **no importa de `cli/`**; el núcleo **no importa `fastapi`** (import perezoso). Sube -> a `service/curate.py` la **orquestación de accept/reject** (`accept_papers`/`reject_papers`/ -> `curate_paper`, `decided_at` inyectado en la frontera): `run_accept`/`run_reject` del CLI quedan como -> **shims que delegan** (firma intacta). Entra el **19º subcomando `b2g gui`** (extra `[gui]` = `fastapi` -> + `uvicorn`; exit 3 si falta; bind `127.0.0.1`; sirve la SPA buildeada si existe — el frontend G4 aún -> no). **El contrato externo del CLI no cambia** (`test_cli.py` intacto) — no requiere ADR nuevo. La SPA -> (`frontend/`, G4) y el empaquetado (G5) **siguen TARGET**. Ver §0.2. -> -> **Sincronizado con la SPA `frontend/` + wiring del token — Hito G4 del MVP GUI (AS-BUILT, 2026-06-18, -> ADR [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** se construye la **SPA** -> (`frontend/`, React 18 + Vite + TS + Cytoscape/fcose + Zustand + Tailwind + TanStack Query, **pnpm**), -> que consume los **7 endpoints reales** de §0.2 (cliente que des-envuelve el envelope `schema="1"`, -> ramea por `error.code` **string** y manda `Authorization: Bearer `). **Cambia el wiring del -> token de `b2g gui`** (§0.2 abajo): la SPA necesita el token para autenticarse, así que `b2g gui` ya -> **no solo lo imprime** —ahora lo **inyecta en el `index.html` servido** (`cli/commands/gui.py:: -> _make_index_response` reemplaza el placeholder `__B2G_TOKEN__`; ruta **`GET /`** sirve el HTML con -> token **sin** exigir Bearer, y `StaticFiles` —`html=False`— sirve los assets); el frontend lo lee de -> `window.__B2G_TOKEN__`. El contrato HTTP de los 7 endpoints (§0.2) **no cambia**. Solo quedaba -> TARGET el empaquetado (G5) —ya AS-BUILT, banner siguiente—. Ver §0.2. -> -> **Sincronizado con el empaquetado — Hito G5 del MVP GUI (AS-BUILT, 2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)):** el wheel **vendorea el build del -> frontend** (`src/bib2graph/gui/static/`, gitignored) vía `[tool.hatch.build.targets.wheel.force-include]` -> de hatchling → `b2g gui` funciona **sin Node** desde el wheel; `ci.yml` suma el job `frontend` -> (lint/test/build JS, corre siempre) y `publish-testpypi.yml` hace `pnpm build` antes del `uv build` -> (`release-please.yml` no se tocó). **No cambia ningún contrato** (CLI ni HTTP) — no requiere ADR nuevo. -> **Con G5, los 5 hitos G1–G5 del MVP GUI están AS-BUILT**; lo único pendiente es el **gate #34** -> (validación con un tercero, criterio de aceptación de producto, **no** es construcción). -> -> **Sincronizado con la resolución DOI→`source_id` (flujo BibTeX e2e) — issues #110/#112 (AS-BUILT, -> ADR [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)):** se cierra el **GAP-1** del flujo -> BibTeX: los papers sembrados con `seed --from-bib` traen `doi` pero **no `source_id`**, y sin -> `source_id` los comandos `enrich`/`chain` devuelven **0**. Suma el **20° subcomando `b2g resolve`** -> (`cli/commands/resolve.py` → `service/resolve.py::resolve_dois`): filtra papers con `doi != NULL` -> **AND** `source_id IS NULL`, consulta OpenAlex (batcheado, `fetch_dois_to_openalex_ids`) y puebla -> `source_id`; **idempotente** (los que ya tienen `source_id` no se tocan) y **NO transiciona el -> `CycleState`** (ortogonal al lazo, igual que `enrich`). Además, **`seed --from-bib` gana el flag -> `--resolve`** que encadena la resolución en el mismo comando reusando el store ya abierto -> (`service/resolve.py::_resolve_dois_on_store`, **sin reabrir el `.duckdb`** — el reopen en el mismo -> proceso corrompía las UDFs de DuckDB → segfault exit 139, #110/#93). **GAP-2 / #112:** `--email` -> pasa a estar **permitido con `--from-bib`** cuando se usa junto a `--resolve` (se propaga al polite -> pool en la resolución). Solo `source_id` (no `external_ids`, diferido #120). Ver §2 + §convenciones -> CLI. +> La superficie pública —núcleo, costuras, capa de servicios neutral `service/`, y el **CLI de 10 +> verbos** (ADR [0037](decisiones/0037-superficie-cli-10-verbos-ciclo.md)/[0038](decisiones/0038-destino-verbos-huerfanos-0037.md))— +> está **construida**; lo marcado **`futuro`** está declarado pero no implementado (no falsamente +> prometido). Las firmas de abajo se verifican contra `src/bib2graph/`. ## Convenciones @@ -259,289 +29,218 @@ - Estado de implementación: **`v1`** vs **`futuro`** (declarado, NO implementado — marcado como tal, no falsamente prometido; lección 5 de v0). -### Convenciones del CLI agente-native (ADR 0010 / 0021; construido en el Hito 6) - -El CLI `b2g` (paquete `bib2graph.cli`, entry point `b2g = "bib2graph.cli:main"`) está -**construido** con el contrato del ADR [0021](decisiones/0021-cli-agente-native-contrato.md). Cada -subcomando lleva `--json` (envelope estable/versionado) y exit codes (`0` éxito · `1` uso · `2` -datos · `3` dependencia · `4` red · `5` store/snapshot corrupto o bloqueado). **Sin estado entre -invocaciones:** el estado vive en el `library.duckdb` del **workspace** (opción global **opcional** -`--workspace`; `--store` fue eliminada en [#75](https://github.com/complexluise/bib2graph/issues/75), -ver abajo). - -**Set de 20 subcomandos** (decisión del PO, ADR 0021 §A — **amplía** este doc, que antes listaba 9 -y dejaba `accept`/`reject` como "solo programático"; el 12° `monitor` se agregó en el cleanup -pre-v0.3; el 13° `enrich` en el Ciclo 8a, ADR -[0025](decisiones/0025-enricher-cocitacion-openalex.md); el 14° `init` con el workspace, ADR -[0029](decisiones/0029-workspace-por-investigacion.md); el 15° `curate` con la curación a escala, -#22 + #26; el 16° `networks` con la capa declarativa YAML, Hito 9; el 17° `restore` con la -rehidratación de corpus curado sin red, Ciclo 9a, ADR -[0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md); el 18° `thesaurus` con el -preprocesamiento automático en la ingesta, #88, ADR -[0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md); el 19° **`gui`** con la API -local + frontend, Hito G3 del MVP GUI, ADR -[0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md); el 20° **`resolve`** con la -resolución DOI→`source_id` del flujo BibTeX e2e, issues #110/#112, ADR -[0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)): - -- `seed`, `chain`, **`filter`** (filtros PRISMA deterministas: año/tipo/idioma/citas **con conteo - en cada paso**), `build`, `export`, `snapshot`, **`status`** (expone el ciclo: estado actual, - transiciones disponibles y conteos por `curation_status`). **AS-BUILT R3 (2026-06-16):** el ciclo - es el FSM cíclico `SEEDED/FORAGED/FILTERED/BUILT/MONITORED` (dominio en `bib2graph.cycle`) con - **`reseed`**/contador de ronda; `status` **muestra `accept`/`reject` como acción siempre-disponible** - (`curation_available`, curación transversal) + la **ronda** (`round`), campos aditivos que mantienen - `schema="1"`. **AS-BUILT workspace (ADR 0029):** `status` suma el campo aditivo - `workspace: {root, source}` (la raíz resuelta y de dónde salió — flag/env/cwd); `schema="1"` - intacto. **AS-BUILT #32 (2026-06-17):** suma también el campo aditivo `networks_cache_stale: bool` - (+ `warnings` accionable cuando la cache de `networks/` quedó obsoleta respecto al corpus vivo; - avisa, NO regenera — ver §`build`/`export`/`snapshot` abajo). **AS-BUILT #54 (2026-06-17):** `status` - suma el campo aditivo `referenced_not_fetched` (nº de IDs que el backward chaining observó sin - materializar — tabla `referenced_but_not_fetched`, §4/§5; `schema="1"` intacto), y `b2g chain` suma - `observed_refs_count` a su envelope JSON. `inspect`, `validate`. -- **`seed`** (ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md), Ciclo 9a + Ciclo - 10 AS-BUILT 2026-06-17): tiene **exactamente TRES modos mutuamente excluyentes** (exactamente uno - requerido; pasar más de uno o ninguno → error de uso, exit 1): - - **`--equation ''`** — ecuación cruda en la línea de comandos (modo OpenAlex directo, con red). - - **`--spec equation.yaml`** — la misma siembra OpenAlex parametrizada por un YAML versionable - (clave raíz `equation:`; modelo `EquationSpec`, §2). Equivale a `--equation` + flags (mismo - `executed_query`). - - **`--from-bib `** — siembra desde un archivo BibTeX local, **sin red** (segundo - camino de seed, cierra issue #50). Usa `BibtexSource.load` (`run_seed_from_bib` en - `cli/commands/seed.py`); marca los papers `is_seed=True` / `curation_status='candidate'` y - transiciona a `SEEDED` (o reseed → ronda++ si ya había estado, igual que los otros modos). El - envelope `--json` lleva `{papers_added, total_papers, round, reseeded}` — **sin** `executed_query` - ni `translation_report` (no aplican a BibTeX). Si falta `bibtexparser` (extra `[bibtex]`) → - **`DependencyError`, exit 3** (patrón `[dedup]`); archivo inexistente / `.bib` mal formado → - `DataError`, exit 2. - - **`--resolve` (solo con `--from-bib`; issues #110/#112, ADR - [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)):** tras cargar el `.bib`, **encadena la - resolución DOI→`source_id`** en el mismo comando (equivale a correr `b2g resolve` a continuación, - abajo). Cierra el **GAP-1** del flujo BibTeX e2e: sin `source_id`, `enrich`/`chain` darían 0. - Reusa el **store ya abierto** por seed (`service/resolve.py::_resolve_dois_on_store`) en vez de - reabrir el `.duckdb`: el reopen en el mismo proceso corrompía las UDFs de DuckDB → segfault - (exit 139, #110/#93). Cuando se pasa `--resolve`, el envelope `--json` **suma** `data["resolve"] = - {resolved, total_with_doi, already_resolved, total_papers}` a las métricas de seed. **`--email` - pasa a estar PERMITIDO con `--from-bib` cuando está `--resolve`** (se propaga al polite pool en la - resolución; cierra GAP-2 / #112). **Reglas de uso (exit 1):** `--email` + `--from-bib` **sin** - `--resolve` → error (`--email` solo sirve si hay resolución); `--resolve` **sin** `--from-bib` → - error (sugiere `b2g resolve` para un corpus existente). - - **No existe `seed --from-corpus`** (la rehidratación de un parquet curado es `restore`, abajo). - Flags ergonómicos de OpenAlex (#14 + #30, **solo con `--equation`/`--spec`**): **`--max-results INT`** - propaga a `OpenAlexSource(max_results=...)` —sin flag, el default del source = 200— para exploración - con muestras chicas (Nota 09 B1); **`--exclude TEXT`** (repetible) son **negaciones quirúrgicas**: - cada término se inyecta **dentro** de la única expresión de búsqueda como - `title_and_abstract.search:((query) AND NOT "")` (el campo **no se repite**; el `AND NOT` - va adentro del paréntesis) y queda en el - `translation_report` del `SeedResult` (ejercicio consciente, query visible); ignorado con `--native` - (query cruda); **`--min-year INT`/`--max-year INT`** (Ciclo 10) **filtran de verdad** contra OpenAlex - agregando `from_publication_date:-01-01` y/o `to_publication_date:-12-31` como - predicado de filtro **separado por coma, fuera** de la expresión `search` (sintaxis idiomática de - rango; reportado en el `translation_report`). - Con `--spec`, todos estos parámetros vienen del YAML (paridad 1:1 flag ⇄ campo). **Combinar los flags - de OpenAlex `--exclude`/`--max-results`/`--native`/`--min-year`/`--max-year` con `--from-bib` → error - de uso, exit 1** (falla fuerte, no ignora en silencio). **`--email` es la excepción** (#112): se - permite con `--from-bib` cuando va junto a `--resolve` (ver `--resolve` arriba); `--email` + - `--from-bib` sin `--resolve` → error. En modo `--native`, `--min-year`/`--max-year` no se aplican - (nativo = sin traducción). -- **`restore`** (ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md), Ciclo 9a, 17° - subcomando): **rehidrata un corpus ya curado desde un parquet, SIN red** — inverso de `snapshot`, - como `load` es a `dump`. **`--from-corpus `** (requerido) lee el parquet con el schema - canónico (`CORPUS_SCHEMA`), lo hidrata con `Corpus.from_arrow`, hace merge con el corpus existente y - persiste; **cero llamadas a `OpenAlexSource`, cero red**. **Preserva la curación** del parquet - (`decision`/`curation_status`/`is_seed`: el merge respeta el `curation_status` más reciente, D3). - **Transiciona el `CycleState` a `FILTERED`** (el corpus ya pasó curación ⇒ `build`/`networks` corren - sin re-forrajeo ni re-filtrado; reusa la transición permisiva `filter` de la FSM, ADR 0016 — válida - desde cualquier estado, incluido un store vacío `None`). La ronda se normaliza con - `max(loop_round(), 1)` (evita ronda 0 en bases legacy pre-R3). `data` = `{papers_loaded, - total_papers, state, round}`; `--json` con `schema="1"`. Errores accionables: parquet inexistente o - con schema no canónico → `DataError` (exit 2). **No** es semilla: es restaurar estado terminado (por - eso vive aparte de `seed`). El caso real reproducible que rehidrata `restore` es el corpus - congelado bajo **`examples/valoraciones/`** (ver §convención `examples/` abajo). -- **`accept`** / **`reject`** (decisión del PO, ADR 0021 §A): curación programática por `--ids`, - ahora **subcomandos CLI de primera clase** (no solo API de librería), para que un agente cure la - biblioteca viva por subprocess (historia C4). **AS-BUILT #22/#26:** la curación **a escala** ya no es - uno-a-uno —el subcomando **`curate`** (abajo) hace dump/import CSV en lote—; la **curación - interactiva rica y la GUI siguen siendo futuro**. Ver [`ROADMAP.md`](ROADMAP/README.md) Hito 6. -- **`monitor`** (cleanup pre-v0.3): re-chequea OpenAlex por **citantes nuevos** del corpus (forward - chaining **batcheado**, AS-BUILT #21: reusa `fetch_citing_batch` con cap por semilla, scope - `is_seed`), mergea los candidatos nuevos a la biblioteca viva y **transiciona a `MONITORED`** vía - `apply_transition(state, "monitor", round)` (paso 8 del ciclo, Ellis). `data` = - `{new_candidates, total_papers, loop_state, round}`; `--email` para el polite pool; `--json` con - `schema="1"`. **Sin pre-check de capacidad** (a diferencia de `chain`): instancia `OpenAlexSource` - fijo, que **siempre** tiene `fetch_citing` (asimetría deliberada con `chain`, que acepta - `--direction` variable y sí pre-chequea). Errores accionables: sin corpus/estado previo → - `DataError` (exit 2). Con `monitor`, **`MONITORED` deja de ser inalcanzable**. -- **`enrich`** (Hito 8 = Ciclos 8a + 8b, ADR - [0025](decisiones/0025-enricher-cocitacion-openalex.md)): corre el `OpenAlexEnricher` (§3) sobre - la biblioteca viva en **2 pasadas**. **8a:** resuelve `references_id`→`references_doi` (batching - por OR) y registra el `EnricherRef` en el `Manifest` (idempotente). **8b:** la pasada de - **co-citación** trae los citantes de las **semillas aceptadas** y **mergea sus `openalex_id` en - `cited_by_id`** (unión idempotente; no crece el corpus). Flags: `--email` (polite pool), - `--api-key` (opcional), **`--max-citing INTEGER`** (tope de citantes **por semilla**, acota el - fetch), `--json`. `data` = `{enriched, references_resolved, ...}`. **NO transiciona el - `CycleState`** (ortogonal al lazo): se puede enriquecer en cualquier estado sin perturbar el FSM. - `build` sigue puro/sin red. -- **`thesaurus`** (#88, ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), 18° - subcomando): aplica un **thesaurus multilingüe curado** al corpus —**`--from `** - (requerido, JSON formato ADR 0011)— sobrescribiendo `keywords_id` con los conceptos canónicos del - mapa (`Preprocessor.apply_thesaurus`, §6). Es el **único paso explícito del preproc**: requiere el - mapeo del usuario, por eso no es automático (a diferencia de `normalize`+dedup, que corren solos en - la ingesta). **NO transiciona el `CycleState`** (transversal al lazo, igual que `enrich`/`curate`/ - `networks`). Persiste con **`persist_replace`** (§4.1): el thesaurus reemplaza los `keywords_id` del - corpus completo; el upsert-concat reintroduciría los canónicos viejos junto a los nuevos si el mapeo - cambió. `data` = `{keywords_mapped, keywords_total, aliases_loaded, applied_at}`; `--json` con - `schema="1"`. Errores accionables: thesaurus inexistente o con formato inválido → `DataError` - (exit 2). -- **`init`** (ADR [0029](decisiones/0029-workspace-por-investigacion.md)): **scaffold de un - workspace**. `b2g init ` crea `/` con `workspace.json` + `library.duckdb` + - `networks/`/`snapshots/`/`exports/`; **`b2g init .`** inicializa el cwd. Si la carpeta ya es un - workspace → error (`WorkspaceExistsError`). **NO transiciona** el `CycleState`. `data` = - `{root, name, ...}`; `--json` con `schema="1"`. -- **`curate`** (#22 + #26, AS-BUILT 2026-06-16): **curación en lote vía CSV** —cierra el hueco de la - [Nota 09](Notas/09-sesion-qa-prueba-ecologia-valoraciones.md) B4/B5/P1 (la curación a escala no era - viable con `accept`/`reject` por `--ids` uno a uno). **Dos modos mutuamente excluyentes** (exactamente - uno; pasar ambos o ninguno → error de uso, exit 1): - - **`--dump`** escribe un CSV revisable offline (Excel/Calc). Default - `/exports/curacion.csv`; **`--out`** lo override. **`--scope [candidates|seeds|all]`** - (default `candidates`) elige qué papers volcar: `candidates` = forrajeados a revisar - (`curation_status == 'candidate'` **AND** `is_seed == False`, **excluye semillas** —arregla #72, - donde el dump arrastraba seeds); `seeds` = semillas originales (`is_seed == True`); `all` = todo el - corpus. **`--all`** queda como **alias deprecado de `--scope all`** (tiene precedencia si se pasan - ambos). Sin candidatos (scope `candidates`/`seeds` vacío) → error accionable que sugiere `--scope all` - o `b2g chain`. Columnas (16, orden estable): `id, source_id, title, year, authors, venue, doi, - keywords, cited_by_count, references_count, is_seed, openalex_url, scent_score, cluster, decision, - note`. **Todas read-only salvo `decision` y `note`** (las editables por el humano). `venue` sale de - `source`; `keywords` se une con `" | "` (igual que `authors`); `openalex_url` es una **columna - derivada OpenAlex-específica**: se construye `https://openalex.org/` solo cuando el - `source_id` parece un ID de OpenAlex (`W…`), si no queda vacía. **`cited_by_count`/`references_count` hoy salen vacías**: - no existen como escalares en el schema canónico de 23 columnas, así que la columna queda como - placeholder para llenado manual (limitación conocida, no falla). `decision` refleja el - `curation_status` actual (`candidate`→`undecided`, `accepted`→`accepted`, `rejected`→`rejected`). - `data` = `{csv_path, papers_exported, columns}`. - - **`--from-csv `** aplica las decisiones en lote y persiste: `accepted`→`accept`, - `rejected`→`reject`, `undecided`→no-op (case-insensitive). **Idempotente** (reimportar el mismo CSV - = mismo `corpus_hash`; el reloj `decided_at` se inyecta en la **frontera CLI**, R2/ADR 0017, fuera - de la identidad). **Validación accionable** (exit 2): CSV sin `id`/`decision` → error que nombra las - columnas requeridas; `decision` con un valor fuera de `{accepted, rejected, undecided}` → error con - los valores válidos. **IDs huérfanos** (en el CSV pero no en el corpus) **NO se aplican** y se - reportan en `not_found_count` + aviso humano (cierra el no-op silencioso). `data` = - `{accepted_count, rejected_count, skipped_count, not_found_count, total_rows}` —los `*_count` de - accept/reject cuentan papers **efectivamente** encontrados y marcados, no filas del CSV. - - **`note` es advisory:** hace round-trip en el dump pero **se ignora al importar** (`ProvenanceEvent` - no tiene campo de anotación; persistirla → ADR futuro). **`scent_score` best-effort** (vacío hasta - que el Forager guarde `scent` en provenance) y **`cluster` siempre vacío** (integración con redes - diferida). **Curación TRANSVERSAL: `curate` NO transiciona el `CycleState`** (disponible en cualquier - estado del lazo, igual que `accept`/`reject`; ADR 0016 enmendado R3). `--json` con `schema="1"`. -- **`networks`** (Hito 9, AS-BUILT 2026-06-17): **capa declarativa** — construye redes desde un YAML - versionable. **`b2g networks --spec `** carga la lista de specs con `load_specs` (§10; - clave raíz `networks:`), construye cada red con `Networks.build` y escribe artefactos con el helper - compartido **`_write_artifacts`** (extraído de `build.py`): mismos GraphML + `metrics.json` + - `clusters.csv` que `build`, en `//`. **`--out-dir`** override (default - `/networks/`); resolución de store/workspace idéntica a `build` (`resolve_workspace`). - `--json` con `schema="1"`, mismo formato que `build` (lista de redes en `data["networks"]`, con - `clusters_csv` condicional). **Ejecución ad-hoc transversal al lazo: NO transiciona el `CycleState` - ni sella `networks/.corpus_hash`** (mismo criterio que `enrich`/`curate`). Errores accionables: - YAML malformado / spec inválida → `DataError` (exit 2); falta `python-louvain` → `DependencyError` - (exit 3). -- **`gui`** (Hito G3 del MVP GUI, AS-BUILT 2026-06-18, ADR - [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md), 19° subcomando): **levanta la API - local FastAPI** (§0.2) con `uvicorn` y sirve la SPA buildeada de `gui/static/` si existe (AS-BUILT G4; - el build local lo genera, el wheel lo incluirá en G5). Genera un **token Bearer efímero** - (`secrets.token_urlsafe(32)`), lo **inyecta en el `index.html` servido** (ruta `GET /`, placeholder - `__B2G_TOKEN__` → `window.__B2G_TOKEN__`; ver §0.2 "Wiring del token") e imprime URL + token al - arrancar. Flags: **`--host`** (default `127.0.0.1`, local-first — no expone red), **`--port`** (default - `8765`), **`--no-browser`** (no abre el browser). Requiere el extra **`[gui]`** (`fastapi` + `uvicorn`, - import perezoso): si falta → `DependencyError`, **exit 3** con sugerencia `uv sync --extra gui`. **NO - transiciona** el `CycleState`. La API es un adaptador delgado sobre `service/` (reusa el envelope - `schema="1"` + `code_for`, no reimplementa el contrato); el mapeo código→HTTP y la auth viven en §0.2. -- **`resolve`** (issues #110/#112, AS-BUILT, ADR - [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md), 20° subcomando): **resuelve los DOIs del - corpus a IDs de OpenAlex (`source_id`)** — cierra el **GAP-1** del flujo BibTeX e2e. Los papers - sembrados con `seed --from-bib` traen `doi` pero **no `source_id`**; sin `source_id`, - `enrich`/`chain` devuelven **0**. `resolve` filtra los papers con `doi != NULL` **AND** - `source_id IS NULL`, consulta OpenAlex (batcheado, `OpenAlexSource.fetch_dois_to_openalex_ids` vía - `service/resolve.py::resolve_dois`) y **puebla `source_id`** en esas filas; persiste con - `persist_replace`. **Idempotente:** los papers que ya tienen `source_id` no se tocan (re-correr da - el mismo resultado). Solo `source_id` (no `external_ids`, diferido #120). Flags: **`--email`** - (polite pool de OpenAlex, recomendado), **`--json`** (envelope `schema="1"`) y la resolución de - workspace por ambiente (`--workspace` global, igual que los demás). `data` = - `{resolved, total_with_doi, already_resolved, total_papers}`. **NO transiciona el `CycleState`** - (ortogonal al lazo, igual que `enrich`). Errores accionables: falla de red contra OpenAlex → - `NetworkError` (exit 4); store bloqueado → `StoreError` (exit 5). La misma resolución se puede - encadenar en la siembra con `seed --from-bib --resolve` (§`seed` arriba), que reusa el store abierto - sin reabrir el `.duckdb` (`_resolve_dois_on_store`). - -**`--workspace` global (OPCIONAL).** Va en el grupo `b2g`, **antes** del subcomando. Una -investigación = un **workspace** (carpeta marcada por `workspace.json`; ADR -[0029](decisiones/0029-workspace-por-investigacion.md), AS-BUILT). El estado vive en su -`library.duckdb`; el CLI es stateful **vía archivo**, no vía proceso. - -- **`--workspace `** apunta a la raíz de un workspace. **`--store` fue ELIMINADA del CLI - ([#75](https://github.com/complexluise/bib2graph/issues/75), BREAKING):** ya no está registrada - como opción global, así que pasarla produce el **error estándar de Click** (`No such option: - --store`). El **modo degenerado** (`.duckdb` suelto sin `workspace.json`) **dejó de existir**: un - `.duckdb` legacy se adopta con **`b2g init .`** en su carpeta. -- **Resolución ambiente** cuando no se pasa `--workspace` (patrón git/cargo), precedencia de mayor a - menor: (1) `--workspace` explícito, (2) `B2G_WORKSPACE` (variable de entorno), (3) **walk-up** del - cwd buscando `workspace.json`. Sin ninguno → **error accionable** que sugiere `b2g init`. - -**`build` y `export` separados** (decisión del PO, ADR 0021 §B): `build` computa `Networks.quick` -(4 redes) y escribe artefactos a `/networks//` (+ transiciona a `BUILT`); -`export --format graphml|csv` **relee** esos artefactos (fuente resuelta vía `ws.networks_dir`) y -los serializa (sin transición). **AS-BUILT #32 (2026-06-17):** `export --out-dir` pasó a **override -OPCIONAL** — sin él, escribe en **`/exports/`** (resolución ambiente como `build`). -**AS-BUILT #31 (2026-06-17):** `build` también escribe **`clusters.csv`** (tabla de -resumen de comunidades, §7.2) en `//` **solo** para redes de **paper** con -comunidades detectadas (listas con separador `|`); en el envelope `--json`, cada entrada de -`data["networks"]` suma `clusters_csv` (ruta del archivo) **condicionalmente** —solo cuando ese -archivo se generó—. **Qué artefactos emite cada red:** **todas** escriben `network.graphml` + -`metrics.json`; **`clusters.csv` lo emiten ÚNICAMENTE las redes de paper** —`bibliographic_coupling` -y `cocitation`— (con comunidades). Las redes `author_collab`, `institution_collab` y -`keyword_cooccurrence` **NO** emiten `clusters.csv` **por diseño**: sus nodos ya son -autores/instituciones/keywords, y `cluster_table` (§7.2) resume comunidades **de papers** cruzando -nodo→corpus por `Col.ID` — ese mapeo no existe para nodos que no son papers, así que devuelve `[]` -(no crash) y el comando omite el archivo. Lo mismo aplica a `b2g networks --spec` (comparten -`_write_artifacts`). - -**`build --corpus-scope [all|accepted|seeds_only]` (AS-BUILT #56):** filtra el corpus por estado de -curación **antes** de proyectar (vía `Corpus.scoped`, §1.2). **Default `all`** = corpus completo -(opt-in, sin cambio de comportamiento). `accepted` = semillas (`is_seed=True`) + papers aceptados; -`seeds_only` = solo semillas. El `networks/.corpus_hash` se sella con el hash del corpus **FILTRADO** -(no del vivo completo), y `clusters.csv`/`decorate` reflejan exactamente ese subset (sin drift). Si el -scope deja **0 papers**: **exit 0** + `warning` accionable ("corré `b2g curate`… o usá -`--corpus-scope=all`") — **no** es error; escribe `networks/` vacío con `.corpus_hash` vacío. El -envelope `--json` suma `data["corpus_scope"]` (y `warnings`). **NO confundir con `NetworkSpec.scope` -(§10):** ejes distintos. `--corpus-scope` filtra el **corpus entero** por curación (un input al -`build`); `NetworkSpec.scope` (`full`/`seeds_only`) es **por-red declarativa** sobre `is_seed`. - -**`snapshot` (AS-BUILT #32, 2026-06-17):** `b2g snapshot` sella una foto reproducible del estado vivo -(parquet + `manifest.json`, ADR 0017). **`--out-dir` pasó a override OPCIONAL** — sin él, escribe en -**`/snapshots/`** (resolución ambiente vía `resolve_workspace`, igual que `build`). No -transiciona el `CycleState`. - -**Staleness de la cache de redes (AS-BUILT #32, 2026-06-17):** `b2g status` suma el campo aditivo -`data["networks_cache_stale"]: bool` (`schema="1"` intacto) y, cuando es `true`, un `warnings` -accionable ("ejecutá `b2g build`"). Lo dispara que el `networks/.corpus_hash` **sellado** por el -último `build` **no coincida** con el `corpus_hash` del corpus vivo (calculado con el **mismo** -`compute_corpus_hash(corpus.to_arrow())` que `build` usa para sellar → sin falsos positivos). Si la -cache **no existe** (nunca se corrió `build`), **no** es stale. `status` **avisa, NO regenera**: -invalidación por hash, **no** un build-system (ADR [0029](decisiones/0029-workspace-por-investigacion.md)). - -**Transiciones automáticas del ciclo** (ADR 0021 §F; AS-BUILT R3): `seed`→`SEEDED`, `chain`→`FORAGED`, -`filter`→`FILTERED`, `build`→`BUILT`, **`monitor`→`MONITORED`** (cleanup pre-v0.3), -**`restore`→`FILTERED`** (Ciclo 9a, ADR 0030: el corpus restaurado ya pasó curación; reusa la -transición permisiva `filter`); -`accept`/`reject`/**`curate`**/`export`/`snapshot`/`status`/`inspect`/`validate`/**`enrich`**/**`networks`** -**no transicionan** (`curate` es curación transversal; `enrich` y `networks` son ortogonales al lazo, -ADR 0025 / Hito 9). El estado -destino lo dicta `bib2graph.cycle.apply_transition` -(fuente única de verdad; los comandos no hardcodean el destino). `seed` con **estado previo** se trata -como **`reseed`** (loop-back a `SEEDED`, ronda++, acumula sobre lo curado). - -**Envelope JSON común y versionado** (ADR 0021 §C): en modo `--json`, cada subcomando emite **un -objeto JSON** con `schema="1"`: +### Convenciones del CLI agente-native (ADR 0010 / 0021) + +El CLI `b2g` (paquete `bib2graph.cli`, entry point `b2g = "bib2graph.cli:main"`) cumple el contrato del +ADR [0021](decisiones/0021-cli-agente-native-contrato.md). Cada subcomando lleva `--json` (envelope +estable/versionado, también activable con **`B2G_JSON=1`**, ver §Envelope JSON) y exit codes (`0` éxito +· `1` uso · `2` datos · `3` dependencia · `4` red · `5` store/snapshot corrupto o bloqueado). **Sin +estado entre invocaciones:** el estado vive en el `library.duckdb` del **workspace** (opción global +**opcional** `--workspace`; `--store` fue eliminada en #75). + +**Superficie 0.10.0 — 10 verbos del ciclo + 3 grupos noun-verb + `skill`** (ADR +[0037](decisiones/0037-superficie-cli-10-verbos-ciclo.md)/[0038](decisiones/0038-destino-verbos-huerfanos-0037.md)/[0039](decisiones/0039-skill-comando-meta-distribucion.md)). +La superficie mapea 1:1 el ciclo (*más es menos*); el conteo es **verificable contra `b2g --help`**: + +- **10 verbos del ciclo:** `init`, `seed`, `chain`, `curate` (grupo), `build`, `read` (grupo), + `export`, `snapshot` (grupo), `status`, `validate`. (El par EXPORT/SNAPSHOT cuenta como uno; ADR 0037.) +- **3 grupos noun-verb:** `read {list,stats,show,top}`, `curate {dump,apply,accept,reject,filter}`, + `snapshot {create,restore}`. Un grupo **sin subcomando** imprime ayuda y sale **exit 0**; el `command` + del envelope usa la **ruta completa** (`"read list"`). +- **1 comando meta** fuera del set de 10 (no es un paso del ciclo): **`skill add`** (ADR 0039). +- **Aliases deprecados** (vivos con aviso a stderr, retiro **0.11.0**): `accept`, `reject`, `filter`, + `inspect`, `monitor`, `networks`, `enrich`, `restore`, `resolve` (ver §Avisos de deprecación). + **`thesaurus` NO es alias: se retiró por completo** (su capacidad es `build --thesaurus`, #164). + +**`status`** expone el ciclo: estado actual del FSM (`SEEDED/FORAGED/FILTERED/BUILT/MONITORED`, dominio +en `bib2graph.cycle`), `transitions_available`, `curation_available` (`accept`/`reject` siempre +disponibles, curación transversal), `round` (contador de ronda con `reseed`), conteos por +`curation_status`, `workspace: {root, source}`, `networks_cache_stale: bool` (+ `warnings` accionable +cuando la cache de `networks/` quedó obsoleta — avisa, NO regenera) y `referenced_not_fetched` (nº de +IDs que el backward chaining observó sin materializar; §4/§5). Todos campos aditivos, `schema="1"` +intacto. **`validate`** chequea la consistencia del workspace (read-only). + +**`init`** (ADR [0029](decisiones/0029-workspace-por-investigacion.md)): scaffold de un workspace. +`b2g init ` crea `/` con `workspace.json` + `library.duckdb` + +`networks/`/`snapshots/`/`exports/`; **`b2g init .`** inicializa el cwd (adopta un `.duckdb` legacy). Si +la carpeta ya es workspace → `WorkspaceExistsError`. **NO transiciona.** `data = {root, name, ...}`. + +**`seed`** (ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md)): **TRES modos +mutuamente excluyentes** (exactamente uno; ninguno o más de uno → exit 1): + +- **`--equation ''`** — ecuación cruda (modo OpenAlex directo, con red). +- **`--spec equation.yaml`** — la misma siembra parametrizada por un YAML versionable (clave raíz + `equation:`, modelo `EquationSpec`, §2; paridad 1:1 flag ⇄ campo). +- **`--from-bib `** — siembra desde BibTeX local **sin red** (`BibtexSource.load`); + `is_seed=True`/`candidate`, transiciona a `SEEDED` (o reseed → ronda++). `data = {papers_added, + total_papers, round, reseeded}` (sin `executed_query`/`translation_report`). Falta `bibtexparser` + (`[bibtex]`) → `DependencyError` exit 3; archivo inexistente / `.bib` mal formado → `DataError` exit 2. + - **`--resolve`** (solo con `--from-bib`): tras cargar, encadena la resolución DOI→`source_id` (= + correr `b2g resolve`) reusando el store abierto; suma `data["resolve"]`. **`--email`** se permite + con `--from-bib` solo junto a `--resolve` (se propaga al polite pool). + +Flags OpenAlex (**solo con `--equation`/`--spec`**): **`--max-results INT`** (default del source 200; +muestras chicas); **`--exclude TEXT`** (repetible) = negaciones quirúrgicas inyectadas **dentro** de la +única expresión `title_and_abstract.search:((query) AND NOT "")` (campo no repetido), en el +`translation_report`; **`--min-year`/`--max-year`** filtran contra OpenAlex +(`from_publication_date`/`to_publication_date` como predicado separado por coma, fuera del `search`); +**`--native`** = query cruda (sin traducción; min/max-year no aplican). **Combinar cualquier flag +OpenAlex con `--from-bib` → exit 1** (salvo `--email` junto a `--resolve`). **No existe +`seed --from-corpus`** (rehidratar un parquet curado es `snapshot restore`). + +**`chain`** (paso CHAIN): expande el corpus con candidatos rankeados por *information scent* +(forward/backward batcheado, §5). **`--direction [backward|forward|both]`** (default `both`), +**`--depth`** (solo 1), **`--max-candidates`**, **`--max-citing`** (presupuesto de citantes por semilla +en forward, default 50), **`--email`**, **`--preview`** (dry-run sin red ni transición: backward exacto +desde `references_id`; forward exacto solo si hay `cited_by_id`). Transiciona a **`FORAGED`** y corre +**automática la pasada refs→DOI** (§Enricher absorbido): el `--json` suma `data["enrichment"]`. `data = +{candidates_found, new_candidates, total_papers, direction, depth, ranking_preview, observed_refs_count, +loop_state, round, enrichment}`. + +- **`--since` (forrajeo incremental, absorbe `monitor`):** trae **solo citantes desde** una fecha + (**ISO `YYYY-MM-DD`** o atajo `90d`/`6m`/`1y`, parseado en `cli/_options.py::parse_since`). **Fuerza + forward** y transiciona a **`MONITORED`**. `backward + --since` → exit 1; `both + --since` → la ventana + aplica solo al tramo forward. Sin corpus/estado previo → `DataError` exit 2 (sugiere `b2g seed`). **No + existe estado `CHAINED`.** El alias `monitor` delega aquí. + +**Enricher absorbido en `chain`/`build` (#162):** el `OpenAlexEnricher` (§3) no es verbo propio. La +pasada **refs→DOI** corre automática en `chain`; la pasada **co-citación** (`cited_by`) corre automática +en `build` cuando hay semillas aceptadas (no-op de red sin ellas). Por eso **`build` ya NO es +estrictamente "sin red"** (ADR 0025 enmendado). Ambos suman `data["enrichment"]`. El alias `b2g enrich` +corre ambas pasadas y **NO transiciona**. + +**`curate {dump,apply,accept,reject,filter}`** (grupo noun-verb, #155). **La transición la define el +VERBO:** solo **`curate filter`→`FILTERED`**; el resto transversal. **BREAKING:** la forma-flag +`curate --dump`/`--from-csv`/`--all` fue **eliminada sin alias**. Lógica fuente única en +`service/curate.py`. + +- **`curate dump`** escribe un CSV revisable offline. **`--out`** override (default + `/exports/curacion.csv`); **`--scope [candidates|seeds|all]`** (default `candidates`: + `candidate AND NOT is_seed`; `seeds` = `is_seed`; `all` = todo). Sin candidatos → error que sugiere + `--scope all`/`b2g chain`. Columnas (16, orden estable): `id, source_id, title, year, authors, venue, + doi, keywords, cited_by_count, references_count, is_seed, openalex_url, scent_score, cluster, decision, + note` — **editables solo `decision`/`note`**. `cited_by_count`/`references_count`/`scent_score`/`cluster` + salen vacías (placeholders, no fallan). `data = {csv_path, papers_exported, columns}`. +- **`curate apply `** aplica decisiones en lote (`accepted`→accept, `rejected`→reject, + `undecided`→no-op; case-insensitive). **Idempotente** (`decided_at` inyectado en la frontera CLI, R2). + CSV sin `id`/`decision` o `decision` inválida → `DataError` exit 2. IDs huérfanos → `not_found_count` + + aviso (no no-op silencioso). `data = {accepted_count, rejected_count, skipped_count, not_found_count, + total_rows}`. **`note` se ignora en apply** (advisory). +- **`curate accept --ids ... [--by NOMBRE]`** / **`curate reject --ids ... [--by NOMBRE]`** — por ID + (uno-a-uno o lote). Comparten `accept_papers`/`reject_papers` con los verbos sueltos `accept`/`reject` + (alias deprecados). +- **`curate filter`** (`--year-gte`/`--year-lte`, `--language`, `--type`, `--min-citations`): aplica + inclusión/exclusión PRISMA **marcando `rejected`** (no borra) con conteo por paso. **Transiciona a + `FILTERED`.** Comparte `filter_corpus(store_path, *, year_gte, year_lte, language, type_in, + min_citations, decided_at)` con el verbo suelto `filter`. + +**`build` y `export` separados** (ADR 0021 §B). `build` computa `Networks.quick` (4-5 redes) y escribe +a `/networks//` (transiciona a `BUILT`); `export --format graphml|csv` **relee** esos +artefactos (`ws.networks_dir`) y los serializa (sin transición). **`export --out-dir`** override +opcional (default `/exports/`). + +`build` tiene **dos modos**: **quick** (sin `--spec`) y **declarativo** (**`build --spec `**: +`load_specs` con clave raíz `networks:` → `Networks.build` por red; helper único `_build_from_spec_file`). +**Ambos transicionan a `BUILT` y sellan `networks/.corpus_hash`** (decisión D1; a diferencia del alias +`networks`, que es transversal). Flags: + +- **`--scope [all|accepted|seeds]`** (default `all`): filtra el corpus por curación **antes** de + proyectar (`Corpus.scoped`, §1.2). `accepted` = `is_seed` + aceptados; `seeds` = solo semillas. El + `.corpus_hash` se sella con el corpus **filtrado**; `clusters.csv`/`decorate` reflejan ese subset. + Scope con **0 papers** → **exit 0** + `warning` (no error). **No confundir con `NetworkSpec.scope`** + (§10, por-red sobre `is_seed`). **`--corpus-scope [all|accepted|seeds_only]`** = alias deprecado + (oculto en `--help`, vocab interno; precede a `--scope` si se pasan ambos). +- **`--min-weight N`** (solo quick): descarta aristas con peso < N. Con `--spec` se usa el `min_weight` + por-red del YAML; pasarlo junto a `--spec` emite warning y se ignora. +- **`--thesaurus `** (#164): aplica un thesaurus multilingüe (JSON ADR 0011) sobre + `keywords_id` **antes** de scopear/proyectar, persiste con `persist_replace` (§4.1) y suma + `data["thesaurus"] = {keywords_mapped, keywords_total, aliases_loaded, applied_at}`. Inexistente/mal + formado → `DataError` exit 2. +- **`--email` / `--max-citing INT`**: parametrizan la pasada `cited_by` (co-citación; ver Enricher + absorbido). + +**Artefactos por red:** todas escriben `network.graphml` + `metrics.json`; **`clusters.csv` solo las +redes de paper** (`bibliographic_coupling`, `cocitation`) con comunidades (las de +autor/institución/keyword devuelven `[]` y omiten el archivo, por diseño). **Diagnóstico de red-vacía:** +`build` reusa `predict_build_preview` (la **misma** fuente que `status`, no-divergencia por-corpus) y lo +emite en `data["empty_networks"]` (lista de `{kind, reason, fix_command}`, separada de `data["warnings"]` +corpus-level). **`--json.data`:** `networks_built`, `artifacts_dir`, `corpus_hash`, `scope` (token CLI), +`corpus_scope` (vocab interno, backward-compat), `networks` (con `clusters_csv` condicional), `warnings`, +`empty_networks`, `maturity` (ver Apéndice), `enrichment`, `thesaurus` (si se pasó `--thesaurus`). + +**`snapshot {create, restore}`** (grupo noun-verb, #163). Fuente única en `service/snapshot.py`. La +transición la define el verbo. + +- **`snapshot create`** (= ex `snapshot` plano, BREAKING sin alias): sella una foto reproducible + (parquet + `manifest.json`, ADR 0017). **`--out-dir`** override opcional (default + `/snapshots/`). **NO transiciona.** `data = {snapshot_dir, corpus_hash, total_papers, + schema_version, maturity}`. +- **`snapshot restore --from-corpus `** (= ex verbo plano `restore`): **rehidrata un corpus ya + curado SIN red** (lee con `CORPUS_SCHEMA`, `Corpus.from_arrow`, merge+dedup+persist; cero llamadas a + OpenAlex). **Preserva la curación** (`decision`/`curation_status`/`is_seed`, D3). **Transiciona a + `FILTERED`** (reusa la transición permisiva `filter`; válida desde cualquier estado, incluido store + vacío). Parquet inexistente o schema no canónico → `DataError` exit 2. `data = {papers_loaded, + total_papers, state, round}`. El verbo suelto `restore` es alias deprecado (`command="restore"`). + +**`read {list,stats,show,top}`** (grupo noun-verb, #156/#157): lectura pura del corpus (no transiciona). +Lógica en `service/reads.py` (§0.1). + +- **`read list`** — filtros AND combinables: `--query TEXT` (substring case-insensitive sobre el + **título**), `--status {candidate,accepted,rejected}`, `--seeds`/`--candidates` (por `is_seed`), + `--year INT`. `data = {papers: [{id, title, year, curation_status, is_seed}], count}`. +- **`read stats --group-by {status,year,is_seed}`** (default `status`): conteos agrupados. `data = + {group_by, total, groups: [{key, count}]}`. `--group-by` inválido → exit 1 (UsageError de `Choice`). +- **`read show --id `**: delega en `get_paper` (resuelve **id | doi | source_id**, prioridad + id>doi>source_id, ADR 0036). `data` = la fila completa del corpus (~14 campos). `--id` sin match → + `DataError` exit 2. +- **`read top`** — la **salida de investigación**: dos bloques sobre redes recomputadas en lectura (**no + requiere `build`**). **`--top N`/`-n`** (default 10), **`--kind`** (`Choice` sobre los 5 `NetworkKind`, + **default `bibliographic_coupling`** porque es robusto en el one-shot frío: no necesita + `chain --forward`). `data = {kind, top, central: [{id, title, degree_centrality, community?}], + cocitation: [{source, source_title, target, target_title, weight}], reason?, fix_command?, maturity}`. + `central` = top N nodos de `--kind` por `degree_centrality`; `cocitation` = **SIEMPRE** la red + cocitation, top N aristas por `weight`. **Honest-empty (exit 0, no error):** cocitación vacía (sin + `cited_by_id`) → bloque `[]` + `reason`/`fix_command` (de `predict_build_preview`). `--kind` inválido → + exit 1; `n <= 0` o red que falla genuinamente → `DataError` exit 2. + +**`skill add [--user|--project] [--force]`** (comando meta, ADR +[0039](decisiones/0039-skill-comando-meta-distribucion.md)): **instala la skill de Claude Code end-user** +que enseña al agente a usar bib2graph (los 10 verbos + el one-shot `init→seed→chain→build→read`). La +skill viaja **vendoreada en el wheel** bajo `src/bib2graph/skill/` (`SKILL.md` + `reference/`, fuente +commiteada vía `packages = ["src/bib2graph"]`): el version-lock skill==cli garantiza que la skill enseñe +los verbos que el CLI expone. `skill add` **copia** la skill al directorio del cliente: **`--user`** +(default) → `~/.claude/skills/bib2graph/`, **`--project`** → `.claude/skills/bib2graph/`. **Idempotente**; +si el destino existe y difiere falla accionable y **`--force`** pisa. **Funciona SIN workspace** y emite +`--json` `schema="1"` **sin transición de FSM**. La skill es markdown sin dependencias Python (la IA está +en el Claude Code del usuario, no en el producto; ADR 0022). `data = {install_path, scope, installed, +already_present, skill_md, reference_dir, how_to}`. + +**`resolve`** (alias deprecado): resuelve los DOIs del corpus a `source_id` de OpenAlex (cierra el GAP del +flujo BibTeX: sin `source_id`, `chain` da 0). Filtra `doi != NULL AND source_id IS NULL`, consulta +OpenAlex (`OpenAlexSource.fetch_dois_to_openalex_ids` vía `service/resolve.py::resolve_dois`) y puebla +`source_id`; **idempotente**, persiste con `persist_replace`. **`--email`** (polite pool). `data = +{resolved, total_with_doi, already_resolved, total_papers}`. **NO transiciona.** Red caída → `NetworkError` +exit 4; store bloqueado → `StoreError` exit 5. Encadenable en `seed --from-bib --resolve`. + +**`networks --spec` / `inspect`** (alias deprecados): `networks --spec ` construye redes desde +el YAML pero **NO transiciona ni sella `.corpus_hash`** (ad-hoc transversal) — usá `build --spec` +(paso BUILD pleno). `inspect` lo absorben `read show` (papers) y `status` (manifest/FSM). + +**`--workspace` global (OPCIONAL).** Va en el grupo `b2g`, **antes** del subcomando. **`--store` fue +ELIMINADA** (#75, BREAKING): pasarla da el error estándar de Click (`No such option`). El modo degenerado +(`.duckdb` suelto) **dejó de existir**; un `.duckdb` legacy se adopta con `b2g init .`. **Resolución +ambiente** (precedencia): (1) `--workspace` explícito, (2) `B2G_WORKSPACE` (env), (3) **walk-up** del cwd +buscando `workspace.json`. Sin ninguno → error accionable que sugiere `b2g init`. + +**Transiciones automáticas del ciclo** (ADR 0021 §F): `seed`→`SEEDED` (con estado previo = `reseed`, +ronda++), `chain`→`FORAGED`, `chain --since`→`MONITORED`, `curate filter`→`FILTERED`, `build`→`BUILT`, +`snapshot restore`→`FILTERED`. El resto (`read`, `export`, `snapshot create`, `status`, `validate`, +`curate {dump,apply,accept,reject}`, los alias `enrich`/`networks`/`resolve`) **no transiciona**. El +estado destino lo dicta `bib2graph.cycle.apply_transition` (fuente única; los comandos no hardcodean el +destino). + +**Envelope JSON común y versionado** (ADR 0021 §C): en modo `--json`, cada subcomando emite **un objeto +JSON** con `schema="1"`: ```json { @@ -555,36 +254,98 @@ objeto JSON** con `schema="1"`: } ``` -En error conocido: `ok=false`, `data={}`, `error={"code": , "message": }`. Los -exit codes se mapean **por tipo de error** (ADR 0021 §D): `DataError`→2, `ImportError`/ -`DependencyError`/`NotImplementedError`→3, `httpx.HTTPError`→4, `StoreLockedError`/`OSError`→5. -**R5:** `AttributeError` ya **no** se mapea en el decorador (un bug real no se disfraza de "capacidad -faltante"); la capacidad-de-source-faltante se convierte en `DependencyError` con un **pre-check -`hasattr` en el comando** (p. ej. `chain` antes del `Forager`). Un `AttributeError` inesperado se -propaga limpio. +En error conocido: `ok=false`, `data={}`, `error={"code": , "message": }`. Los exit +codes se mapean **por tipo de error** (ADR 0021 §D): `DataError`→2, `ImportError`/`DependencyError`/ +`NotImplementedError`→3, `httpx.HTTPError`→4, `StoreLockedError`/`OSError`→5. `AttributeError` **no** se +mapea (un bug real no se disfraza de "capacidad faltante"); la capacidad-de-source-faltante se convierte +en `DependencyError` con un **pre-check `hasattr` en el comando** (p. ej. `chain` antes del `Forager`). -**Borde: el error de uso sale SIN envelope.** Ante un error de uso (p. ej. una opción requerida -faltante, una opción desconocida como `--store` —eliminada en #75—, o ningún workspace resoluble), -Click aborta el parseo **antes** de entrar al comando: se emite el mensaje de uso de Click en **stderr** y -exit code `1`, **sin** envelope JSON. El envelope versionado solo cubre errores que ocurren -**dentro** de la ejecución del comando. +**Borde: el error de uso sale SIN envelope.** Ante una opción requerida faltante, una opción desconocida +(p. ej. `--store`) o ningún workspace resoluble, Click aborta el parseo **antes** de entrar al comando: +mensaje de uso en **stderr** + exit 1, **sin** envelope. El envelope solo cubre errores **dentro** de la +ejecución del comando. ---- +**stdout puro en modo JSON (ENFORCED, #151).** En modo JSON (por `--json` o `B2G_JSON`) stdout emite +**exactamente una línea** (el envelope), también en el camino de error (`ok=false` → envelope en stdout). +El texto humano va a **stderr**. + +**`B2G_JSON` — modo JSON por entorno (#151).** Además de `--json` (post-verbo: `b2g --json`), el +modo JSON se activa con `B2G_JSON` truthy (`1`/`true`/`yes`, case-insensitive) en **todos** los comandos. +Precedencia: `--json` explícito gana; no existe `--no-json`. Recomendación agents-first: `export +B2G_JSON=1` una vez y correr el ciclo sin repetir el flag. Aditivo: envelope/exit codes/FSM no cambian. + +**Apéndice — bloque `maturity` del one-shot (#160, ADR 0037 §f).** Los artefactos del camino **one-shot** +llevan un bloque **aditivo** `data["maturity"]` que **se autodeclara borrador sin pulir** (honestidad por +construcción), para que ni un agente que optimiza por `exit 0` ni un humano apurado confundan un one-shot +con un resultado terminado. **`schema="1"` intacto.** -## 0. Capa de servicios `service/` — contrato neutral compartido (AS-BUILT G1, ADR 0028) +```json +"maturity": {"curated": false, "scope": "all", "saturated": false, "empty_networks": []} +``` + +**Forma estable: SIEMPRE 4 claves** (orden y tipos fijos): + +| clave | tipo | regla de derivación | +|---|---|---| +| `curated` | `bool` | `true` si el corpus **completo** (pre-scope) tiene ≥1 paper con `curation_status` ∈ {`accepted`, `rejected`}. Independiente del scope y del FSM. | +| `scope` | `str` \| `null` | el **token CLI** (`all`/`accepted`/`seeds`, no el vocab interno `seeds_only`). En `snapshot create` y `read top` es `"all"`. | +| `saturated` | `bool` | **`false` constante** en one-shot (no sobre-afirmar; gancho futuro: convergencia de `referenced_refs_count()`). | +| `empty_networks` | `list[str]` | **solo los tokens `kind`** de las redes vacías (`reason`/`fix_command` no se duplican: viven en `data["empty_networks"]`). | + +Aparece **siempre** en `build` (incl. early-return de corpus vacío), `snapshot create` y `read top`; +**ausente** en `read list`/`read stats`/`read show`. Lo calcula la función pura +`service.maturity.compute_maturity(corpus, *, scope, empty_network_kinds)` (§0). + +### Avisos de deprecación (ADR [0038](decisiones/0038-destino-verbos-huerfanos-0037.md) P1) + +La consolidación 0.10.0 retira solapamientos **sin romper de una**: los nombres viejos siguen +funcionando durante 0.10.x con un **aviso de deprecación**, y **se eliminan en 0.11.0** (criterio por +versión, no fecha). El helper único es `cli/_deprecation.py::emit_deprecation`. + +**Formato canónico** (exacto): + +```text +AVISO: '' está deprecado y se eliminará en 0.11.0; usá ''. +``` + +- **Canal: stderr SIEMPRE** (modo humano y modo `--json`), nunca stdout — preserva el stdout puro de + una línea-envelope (#151). En `--json`, el mismo mensaje se propaga además al **`warnings[]` + top-level** del envelope (no a `data`), enhebrado vía `build_envelope(..., warnings=[msg])`. +- **No cambia el contrato:** el alias delega en la misma lógica de servicio (fuente única) y conserva + su `command`/envelope; `schema="1"`, exit codes y FSM intactos. + +**Los 9 verbos deprecados** (alias vivo con aviso → forma canónica): -> **AS-BUILT del Hito G1 del MVP GUI (2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)).** Documenta una decisión **ya -> tomada y firmada** (ADR 0028 Aceptada, PO 2026-06-18): el contrato del envelope/errores **sube** de -> `cli/` a una capa neutral. **El contrato externo del CLI no cambia** (envelope `schema="1"`, exit -> codes 0–5, ADR 0021) — por eso este movimiento **no requiere un ADR nuevo**. +| Alias deprecado | Forma canónica | +|---|---| +| `b2g accept` | `b2g curate accept` | +| `b2g reject` | `b2g curate reject` | +| `b2g filter` | `b2g curate filter` | +| `b2g inspect` | `b2g read show` (papers) / `b2g status` (manifest/FSM) | +| `b2g monitor` | `b2g chain --since` | +| `b2g networks` | `b2g build --spec` | +| `b2g enrich` | `b2g chain` (refs→DOI) + `b2g build` (co-citación) | +| `b2g restore` | `b2g snapshot restore` | +| `b2g resolve` | `b2g seed --resolve` | -`src/bib2graph/service/` es la **capa de servicios neutral** de la que CLI (y, como TARGET, la API) -son adaptadores delgados (ADR 0028, inversión de dependencia ports & adapters). G1 sube **EL CONTRATO** -que antes vivía en `cli/_envelope.py`/`cli/_errors.py`. Las **lecturas read-only de la SPA** -(`get_scent`/`get_network`/`compare_rounds`/…) **se construyeron en G2** (`service/reads.py`, AS-BUILT -2026-06-18 — ver §0.1). La migración de la **orquestación** (`run_`) a `service/` **sigue siendo -TARGET** (no construida en G1/G2). +**Además** (mismo corte 0.11.0): + +- **Entry-point `bib2graph` → `b2g`** (`main_bib2graph_alias` emite el aviso y delega en `main`). +- **Opción `build --corpus-scope` → `build --scope`** (deprecación de **flag**, oculta en `--help`; + el vocab viejo `seeds_only` sigue aceptado y tiene precedencia si se pasan ambos). + +**`thesaurus` NO está en esta lista:** se **retiró por completo** (sin alias). Su capacidad vive como +`b2g build --thesaurus ` (#164, ver §`build`). + +--- + +## 0. Capa de servicios `service/` — contrato neutral compartido (ADR 0028) + +`src/bib2graph/service/` es la **capa de servicios neutral** de la que el CLI es un adaptador delgado +(ADR [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md), inversión de dependencia ports & +adapters). Aloja **el contrato** (envelope versionado + jerarquía de errores + mapeo error→código) y +las **lecturas read-only del corpus** (`service/reads.py`, §0.1, que consume el grupo CLI `read`). El +contrato externo del CLI (envelope `schema="1"`, exit codes 0–5, ADR 0021) **no cambia**. **Invariante de neutralidad de transporte (estricta).** `service/` es **agnóstica de transporte**: **sin `print`, `sin sys.exit`, sin Click, sin FastAPI**. Es el límite que mantiene el contrato @@ -630,6 +391,18 @@ def code_for(exc: BaseException) -> int: httpx.HTTPError → 4. Excepción no mapeada → TypeError (el llamador decide). Lo usan la capa de servicio y los adaptadores para derivar exit code / HTTP status sin duplicar la política.""" + + +# service/maturity.py — bloque maturity del one-shot (#160, ADR 0037 §f / 0038 P3) +def compute_maturity( + corpus: Corpus, *, scope: str | None, empty_network_kinds: list[str] +) -> dict[str, Any]: + """Bloque maturity para el --json de build/snapshot create/read top (ver Apéndice maturity). + Función PURA, sin I/O. Devuelve EXACTAMENTE 4 claves: + {curated: bool, scope: str|None, saturated: bool, empty_networks: list[str]}. + curated = corpus tiene ≥1 paper con curation_status ∈ {accepted, rejected}; + saturated = False constante (one-shot never over-claims; gancho futuro referenced_refs_count); + empty_networks = solo los kind (reason/fix_command NO se duplican).""" ``` **Adaptadores (el contrato se re-exporta, no se duplica).** `cli/_envelope.py` y `cli/_errors.py` @@ -637,27 +410,23 @@ hacen `from bib2graph.service... import ...` y re-exportan los **mismos objetos* existentes del CLI y los tests (`from bib2graph.cli._envelope import build_envelope`, `from bib2graph.cli._errors import B2GError, DataError, …`) siguen funcionando sin cambios. El decorador `handle_errors` (CLI) conserva su propia escalera `try/except` por tipo de error + el -`sys.exit` y la emisión del envelope de error; `code_for` es el mapeo puro disponible para los -adaptadores (incluida la API TARGET, que lo traducirá a HTTP status, ADR 0028 §7). El mapeo de -`code_for` y el de `handle_errors` describen la **misma política** (ADR 0021 §D). - -### 0.1 Lecturas de servicio `service/reads.py` — las 6 lecturas de la SPA (AS-BUILT G2, ADR 0028) - -> **AS-BUILT del Hito G2 del MVP GUI (2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)).** Documenta una decisión **ya tomada**: -> G2 expone en `service/` las lecturas read-only que la SPA necesita y que el CLI **nunca tuvo como -> subcomando** (scent, red por kind, rondas, diff de rondas, paper por id). **El contrato externo del CLI -> no cambia** (`tests/unit/test_cli.py` intacto) — por eso este movimiento **no requiere un ADR nuevo**. -> Forma del encuadre y bifurcaciones resueltas: [`ROADMAP/05-gui.md`](ROADMAP/05-gui.md) §G2. - -`src/bib2graph/service/reads.py` expone **6 funciones de lectura** (re-exportadas desde -`bib2graph.service.__init__`). Cada una recibe un **`Workspace` ya resuelto** (la resolución ambiente -vive en el adaptador CLI, ADR 0029), abre el store **read-only**, y devuelve un `dict`/`list[dict]` -**serializable** o lanza un `B2GError` tipado. **Sin red, sin mutación, sin transición de ciclo**; -determinismo R2 (mismo corpus → misma lectura). Decisiones de producto resueltas (bifurcaciones -B-G2-1/2/3): **ronda = snapshot sellado** (no el contador `loop_round`), `get_scent` = **score de -acoplamiento real + vecinos** (no 4 paneles cosméticos del mock), `get_network` = **red de la ronda -viva recomputada** (cache `networks/` por snapshot diferida a G3). +`sys.exit` y la emisión del envelope de error; `code_for` es el mapeo puro disponible para cualquier +adaptador. El mapeo de `code_for` y el de `handle_errors` describen la **misma política** (ADR 0021 §D). + +### 0.1 Lecturas de servicio `service/reads.py` — lecturas read-only del corpus + +`src/bib2graph/service/reads.py` expone las **lecturas read-only del corpus** que consume el **grupo +CLI `read`** (`list_papers`, `corpus_stats`, `get_paper`, `get_top` — §Grupo `read`), re-exportadas +desde `bib2graph.service.__init__`. Cada una recibe un **`Workspace` ya resuelto** (la resolución +ambiente vive en el adaptador CLI, ADR 0029), abre el store **read-only**, y devuelve un +`dict`/`list[dict]` **serializable** o lanza un `B2GError` tipado. **Sin red, sin mutación, sin +transición de ciclo**; determinismo R2 (mismo corpus → misma lectura). + +El módulo conserva además funciones de lectura más ricas (`get_workspace`, `list_rounds`, `get_scent`, +`get_network`, `compare_rounds`) que hoy ningún comando consume (su poda opcional es trabajo de +limpieza, [#191](https://github.com/complexluise/bib2graph/issues/191)). Decisiones de modelado: +**ronda = snapshot sellado** (no el contador `loop_round`), `get_scent` = **score de acoplamiento real ++ vecinos**, `get_network` = **red de la ronda viva recomputada**. ```python def get_workspace(ws: Workspace) -> dict[str, Any]: @@ -673,12 +442,16 @@ def list_rounds(ws: Workspace) -> list[dict[str, Any]]: Entrada viva: {id="live", round, loop_state, total_papers}. Raises StoreError. Ronda = snapshot (B-G2-1 Opción A); el contador loop_round se ve en la entrada "live".""" -def get_paper(ws: Workspace, paper_id: str) -> dict[str, Any]: - """Fila del corpus (CORPUS_SCHEMA) por id. Devuelve: +def get_paper(ws: Workspace, ident: str) -> dict[str, Any]: + """Fila del corpus (CORPUS_SCHEMA) resuelta por identidad source-agnóstica + (ADR 0036): `ident` matchea contra **id | doi | source_id**, con prioridad + `id` > `doi` > `source_id` (devuelve el primer match). Devuelve: {id, source_id, doi, title, year, abstract, is_seed, curation_status, authors_raw, authors_id, keywords_id, references_id, cited_by_id, provenance (list, parseada del JSON)}. - Raises DataError si el id no existe; StoreError si el store falla.""" + Raises DataError si `ident` no matchea ningún id/doi/source_id; + StoreError si el store falla. `read show --id` delega en esta lectura + (§Convenciones CLI · grupo `read`).""" def get_scent(ws: Workspace, paper_id: str) -> dict[str, Any]: """Score de acoplamiento bibliográfico real + vecinos compartidos (B-G2-2). Devuelve: @@ -707,114 +480,43 @@ def compare_rounds(ws: Workspace, round_a: str, round_b: str) -> dict[str, Any]: solo aparecen si ambos snapshots tienen networks//metrics.json, que hoy no se materializa por snapshot)}. Raises DataError si un snapshot no existe o no tiene corpus.parquet; StoreError si el store falla.""" -``` -**Nota de fidelidad al núcleo.** Los campos del mock `app/src/` que el núcleo no sostiene **no se -inventaron**: `get_paper` expone `authors_raw`/`authors_id` (no objetos autor con ORCID), `get_scent` -no emite los 4 paneles cosméticos, `get_network` no entrega `modularity` ni un id de red persistido, y -`compare_rounds` deja `mutated_hubs=[]` mientras no haya redes por snapshot. El encuadre campo-por-campo -y los "mock-no-sostenido" están en [`ROADMAP/05-gui.md`](ROADMAP/05-gui.md) §G2. - -### 0.2 API local `api/` — la frontera HTTP de la SPA (AS-BUILT G3, ADR 0028) - -> **AS-BUILT del Hito G3 del MVP GUI (2026-06-18, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md)).** Documenta una decisión **ya -> tomada**: la API local es un **adaptador de transporte** sobre `service/` (§0/§0.1). **El contrato -> externo del CLI no cambia** (`tests/unit/test_cli.py` intacto; envelope `schema="1"`, exit codes 0–5, -> ADR 0021) — por eso este movimiento **no requiere un ADR nuevo**. Encuadre y bifurcaciones resueltas: -> [`ROADMAP/05-gui.md`](ROADMAP/05-gui.md) §G3. - -`src/bib2graph/api/` es la **API local FastAPI**: un adaptador **delgado** sobre la capa de servicios -neutral (§0). **No reimplementa lógica ni contrato** —reusa `service.build_envelope` y `service.code_for`— -y **no importa de `cli/`**; ambos frontends cuelgan de `service/`. El **núcleo no importa `fastapi`**: -todo `fastapi`/`uvicorn` se importa **perezosamente** dentro de `create_app`/`run_gui`, y vienen en el -extra **`[gui]`** = `fastapi` + `uvicorn` (§7 de [`ARCHITECTURE.md`](ARCHITECTURE.md)). - -**Fábrica de la app.** `create_app(ws, *, token, cors_origins=None) -> FastAPI` (`api/app.py`, re-export -en `api/__init__.py`) monta los routers, el CORS (default `http://localhost:5173` + -`http://127.0.0.1:5173`, el Vite dev-server de G4), la seguridad y dos *exception handlers* globales -(`B2GError` y `Exception`). El `Workspace` se inyecta una sola vez (**singleton por proceso**, no se -resuelve por request — la resolución ambiente vive en el adaptador CLI `b2g gui`). - -**Endpoints (7).** Cada lectura llama a la función homónima de `service/reads.py` (§0.1) y la escritura a -`service/curate.py`; el resultado se envuelve con `build_ok_response` (envelope `ok=true`, HTTP 200). -Las lecturas que devuelven lista se envuelven en un dict con clave semántica (`rounds`) para respetar la -firma `build_envelope(data: dict)`. - -| Método | Ruta | Servicio (`service/`) | Forma de `data` | -|---|---|---|---| -| GET | `/api/workspace` | `reads.get_workspace(ws)` | dict de estado del workspace (§0.1) | -| GET | `/api/rounds` | `reads.list_rounds(ws)` | `{"rounds": [...]}` (snapshots + entrada `live`) | -| GET | `/api/paper/{id}` | `reads.get_paper(ws, id)` | fila del corpus (§0.1) | -| GET | `/api/paper/{id}/scent` | `reads.get_scent(ws, id)` | score de acoplamiento + vecinos (§0.1) | -| GET | `/api/network/{kind}` | `reads.get_network(ws, kind)` | `{nodes, edges, metrics}` (§0.1) | -| GET | `/api/compare?a=&b=` | `reads.compare_rounds(ws, a, b)` | diff de rondas (§0.1) | -| POST | `/api/paper/{id}/curate` | `curate.curate_paper(ws.library_path, id, decision=…)` | `{accepted_count\|rejected_count, ids}` | - -El body del POST es `{"decision": "accepted"|"rejected"}` (modelo Pydantic `CurateRequest`); otra -`decision` → `DataError` (422). El endpoint de curación toma el **`WriteLock` global serializado** (una -escritura a la vez, ADR 0028 §6 / ADR 0019) e **inyecta `decided_at` en la frontera API** (`datetime.now(UTC)`; -R2/ADR 0017) — el servicio nunca llama `datetime.now()`. La curación de un paper **es una mutación -puntual** y por eso toma `ws.library_path` (la ruta al `.duckdb`), no el workspace completo. - -**Autenticación — Bearer token efímero** (`api/security.py`, Nota 12 C.3). El token se genera en el -arranque de `b2g gui` con `secrets.token_urlsafe(32)` y se inyecta en `create_app`. Cada endpoint -depende de `require_token` (`api/deps.py`), que lee `Authorization: Bearer ` (esquema -`HTTPBearer(auto_error=False)`) y, si **falta o es inválido**, lanza `HTTPException(status_code=401)`. La -verificación usa `secrets.compare_digest` (tiempo constante). El **401 de auth es del adaptador HTTP**, -no del contrato de exit codes 0–5 (la auth no existe en el CLI). - -**Mapeo código→HTTP** (`api/envelopes.py`, ADR 0028 §7). Los *exception handlers* convierten la -excepción en `JSONResponse` con el envelope `schema="1"` íntegro en el body (la SPA lee `error.code`, no -depende del status); el status sale de `code_for(exc)` (la misma política pura de §0) traducido así: - -| Exit code (contrato) | Error | HTTP | -|---|---|---| -| 0 | éxito | **200** | -| 1 | `UsageError` | **400** | -| 2 | `DataError` | **422** | -| 3 | `DependencyError` | **501** | -| 4 | `NetworkError` | **502** | -| 5 | `StoreError` (bloqueado/corrupto) | **409** | -| — | excepción **inesperada** (no mapeada por `code_for`) | **500** (`error.code = "INTERNAL_ERROR"`) | - -El **500** es deliberadamente distinto del 409: una excepción no mapeada es un **bug interno**, no un -conflicto de store; devolver 409 sugeriría a la SPA reintentar (`code_for` lanza `TypeError` ante una -excepción no mapeada y el handler la convierte en 500). El **401 de auth** corta **antes** de llegar a -este mapeo (es la dependencia `require_token`). - -**Operaciones largas (v1, ADR 0028 §6).** Ejecución **síncrona** + lock global serializado; jobs -async/SSE de progreso **diferidos** (no en v1; el `5`→409 es el caso "store ocupado", el **retry -cross-process queda diferido** — B-G3-3). - -**Migración de la orquestación de curación a `service/`.** Con G3, `service/curate.py` **sube desde -`cli/`** la orquestación de `accept`/`reject`: expone `accept_papers(store_path, ids, *, by, decided_at)`, -`reject_papers(...)` (gemelas) y `curate_paper(store_path, paper_id, *, decision, by, decided_at)` (wrapper -de un solo paper que valida `decision ∈ {accepted, rejected}`). Todas verifican que los ids existan -(`DataError` si faltan), abren el store con `_open_writable` (`StoreLockedError`/`OSError` → `StoreError`) -e **inyectan `decided_at`** desde la frontera. **`run_accept`/`run_reject` (CLI) quedan como shims que -delegan**, con su firma intacta (`by="cli"`, `decided_at` inyectado), así que `test_cli.py` no cambia. - -**Subcomando `b2g gui`.** Ver [`ARCHITECTURE.md`](ARCHITECTURE.md) §6.3 y §convenciones CLI (19° -subcomando): levanta `uvicorn.run` sobre `create_app`, sirve la SPA buildeada de `gui/static/` **si -existe**, imprime URL + token, bind `127.0.0.1` (default puerto 8765). Falta del extra `[gui]` → -`DependencyError` (exit 3, mensaje accionable `uv sync --extra gui`). - -**Wiring del token (AS-BUILT G4, B-G4-3).** La SPA necesita el token Bearer para autenticarse, así que -`b2g gui` **no lo entrega solo por stdout**: cuando el frontend está buildeado (`gui/static/index.html` -existe), monta una ruta **`GET /`** (`serve_index`) que lee ese `index.html` y reemplaza el placeholder -**`__B2G_TOKEN__`** con el token efímero (`cli/commands/gui.py::_make_index_response` → -`HTMLResponse`); **`GET /` no exige Bearer** (es el bootstrap del HTML, no un endpoint de datos). Los -**assets** (JS/CSS/fuentes) los sirve `StaticFiles(directory=gui/static, html=False)` montado en `/` -**sin** modificación. El frontend lee el token de **`window.__B2G_TOKEN__`** (inyectado en el HTML) y lo -manda en `Authorization: Bearer ` a los 7 endpoints. Si el frontend **no** está buildeado, `b2g -gui` avisa por stderr y deja **solo la API** disponible. *(Reemplaza el plan del encuadre G4 §5, que -preveía `StaticFiles(..., html=True)`: el AS-BUILT usa `html=False` + ruta `GET /` propia para poder -inyectar el token.)* ---- +# --- Lecturas detrás del grupo CLI `read` (#156/#157; ver §Grupo `read`) --- + +def list_papers(ws: Workspace, *, query=None, status=None, is_seed=None, year=None) -> dict[str, Any]: + """Lista mínima del corpus con filtros AND (todos opcionales). Devuelve: + {papers: [{id, title, year, curation_status, is_seed}], count: int}. + query = substring case-insensitive sobre el título; status = curation_status exacto; + is_seed True/False; year exacto. Raises StoreError. (Detrás de `read list`.)""" + +def corpus_stats(ws: Workspace, *, group_by="status") -> dict[str, Any]: + """Conteos agrupados por status (default) | year | is_seed. Devuelve: + {group_by, total, groups: [{key, count}]}. Raises DataError si group_by inválido; + StoreError si el store falla. (Detrás de `read stats`.)""" + +def get_top(ws: Workspace, *, n=10, kind="bibliographic_coupling") -> dict[str, Any]: + """Salida de investigación (#157): nodos centrales + pares de co-citación con título, + sobre redes recomputadas (NO requiere `build`; mismo camino que get_network). Devuelve: + {kind, top, central: [{id, title, degree_centrality, community?}], + cocitation: [{source, source_title, target, target_title, weight}], reason?, fix_command?, + maturity}. + `central` = top n nodos de la red `kind` por degree_centrality desc (título completo en redes + de paper; label de entidad en author/institution/keyword). `cocitation` = SIEMPRE la red + cocitation, top n aristas por weight desc. + Honest-empty: co-citación vacía (sin cited_by_id) → bloque [] + reason/fix_command + (de predict_build_preview), NO error. `maturity` (aditivo, #160, ver Apéndice `maturity`): + SIEMPRE presente, scope="all", empty_networks=["cocitation"] si la co-citación quedó vacía. + Raises DataError si kind inválido, n <= 0, o la red + falla genuinamente; StoreError si el store falla. (Detrás de `read top`.)""" +``` + +**Nota de fidelidad al núcleo.** Las lecturas no inventan campos que el núcleo no sostiene: +`get_paper` expone `authors_raw`/`authors_id` (no objetos autor con ORCID), `get_network` no entrega +`modularity` ni un id de red persistido, y `compare_rounds` deja `mutated_hubs=[]` mientras no haya +redes por snapshot. -## 1. Modelo de dominio — `Corpus` (núcleo, v1) +## 1. Modelo de dominio — `Corpus` Wrapper sobre un **`TabularBackend`** (Protocol) cuyo contenido es una **tabla Arrow** (`pa.Table`) con schema fijo por paper, validada con **Pydantic v2** (ADR 0006). El `Corpus` **delega las @@ -833,14 +535,6 @@ D1/D2/D3) son **contrato que cada backend cumple** (InMemory en Python, DuckDB e `corpus.to_arrow()` es el puente estable a los proyectores/analizadores puros (§7–§8): **solo cambia el contenedor, no el núcleo de análisis**. -> **Nota de construcción:** el rework del **Hito 1.5 está hecho** (ver [`ROADMAP.md`](ROADMAP/README.md), -> "Hito 1.5"). El `Corpus` ya **delega en `self._backend: TabularBackend`** (no guarda `self._table`); -> el `InMemoryBackend` (núcleo puro, semántica de valor) está implementado en -> `src/bib2graph/backends/`. El `DuckDBBackend` (costura por defecto) **también está construido** -> (Hito 3, `src/bib2graph/backends/duckdb.py`). El núcleo **no importa `duckdb`**: `DuckDBBackend` y -> `DuckDBStore` se exponen por **carga perezosa** (PEP 562, `__getattr__`) — `import bib2graph` no -> arrastra duckdb. - **Símbolos públicos del Hito 1/1.5** (`from bib2graph import ...`): `Corpus`, `Manifest`, `CorpusSnapshot`, `SchemaError` (la excepción de contrato que lanzan `Corpus.from_arrow()` y `add_paper()` al violarse el schema canónico), y —del rework del Hito 1.5— `TabularBackend` @@ -874,31 +568,12 @@ cambia el contenedor, no el núcleo de análisis**. El schema exacto vive en `bib2graph.schemas`. La validación se hace en `Corpus.from_arrow()` y en cada `Source.seed()/load()`. -> **Tabla lateral `external_ids(paper_id, engine, id)` (ADR -> [0036](decisiones/0036-identidad-source-id-agnostica-doi-ancla.md), opción C — INFRA PRESENTE, SIN -> POBLAR):** el backend expone los métodos `external_ids_for(paper_id)` y `all_external_ids()` -> (`src/bib2graph/backends/base.py`) para registrar, 1↔N, los IDs que cada motor (OpenAlex, Semantic -> Scholar, …) asignó al mismo paper, unificados por el DOI como ancla. **Hoy esta tabla NO se puebla -> todavía**: su consumo —el cruce/deduplicación **cross-motor**— está diferido a la llegada del 2º -> motor (follow-up [#120](https://github.com/complexluise/bib2graph/issues/120)). La identidad y la -> dedup actuales se resuelven solo por el `id` canónico (DOI primero; ver §1.1 *Identidad*). - -> **TARGET (capa base, ADR [0023](decisiones/0023-capa-constants-modelos-schema.md), Hito R1):** los -> nombres de columna salen de `bib2graph.constants.Col(StrEnum)` y `curation_status` de -> `CurationStatus(StrEnum)` (fuente única; matan los string-literals dispersos). `PaperRow` (Pydantic) -> es la **única** definición de fila y `CORPUS_SCHEMA` (Arrow) se **deriva/verifica** de ella (no -> duplicada a mano). El evento de `provenance` es un **`ProvenanceEvent(BaseModel)`** con parseo que -> **falla ruidoso** ante JSON corrupto. Se **mantiene** "`Paper`/`Author`/… = vistas derivadas, no -> tipos". -> -> **AS-BUILT (identidad vs procedencia, ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md) -> enmendado, Hito R2 ✅ 2026-06-16):** el `corpus_hash` (D2) se computa **solo sobre contenido -> bibliográfico**, **excluyendo** `provenance`/timestamps (la procedencia audita, no identifica; -> `curation_status` **sí** entra, es contenido curado). Por eso dos corridas que aceptan los mismos -> ids dan el **mismo** hash. `accept`/`reject` (y los filtros `apply_filter`/`apply_filters`) **reciben -> el instante** (`decided_at`) inyectado desde la frontera CLI; el núcleo usa `datetime.now(UTC)` solo -> como **fallback de librería** cuando no se inyecta `decided_at` (fuera de la identidad, no rompe la -> reproducibilidad). *(El §1.2 abajo conserva la nota histórica del AS-BUILT v0.2 roto.)* +> **Tabla lateral `external_ids(paper_id, engine, id)`** (ADR +> [0036](decisiones/0036-identidad-source-id-agnostica-doi-ancla.md), opción C — **infra presente, sin +> poblar**): el backend expone `external_ids_for(paper_id)`/`all_external_ids()` para registrar 1↔N los +> IDs que cada motor asignó al mismo paper (unificados por DOI). Su consumo (cruce cross-motor) está +> diferido a la llegada del 2º motor (#120); hoy la identidad/dedup se resuelven solo por el `id` +> canónico (DOI primero). **`provenance` es un log append-only** (ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md), D4), no un objeto único: la columna `string` guarda un JSON que es una **lista de eventos**. Cada @@ -958,8 +633,9 @@ class Corpus: OR `curation_status == 'accepted'`; `'seeds_only'` = `is_seed == True`. Scope inválido → `ValueError` accionable. Determinista: dos llamadas con el mismo scope dan corpora con el mismo `corpus_hash` (subset estable). `'all'` reusa el backend; los otros materializan el - filtro en un `InMemoryBackend`. Lo usa `b2g build --corpus-scope` para sellar el hash del - corpus FILTRADO. Issue #56. **NO confundir con `NetworkSpec.scope`** (§10): aquel es un + filtro en un `InMemoryBackend`. Lo usa `b2g build --scope` (vocab CLI `seeds`→`seeds_only`; + alias deprecado `--corpus-scope` usa este vocab interno) para sellar el hash del + corpus FILTRADO. Issue #56 / #159. **NO confundir con `NetworkSpec.scope`** (§10): aquel es un eje por-red (`full`/`seeds_only`) sobre `is_seed`; `scoped()` filtra el corpus entero por curación antes de proyectar.""" @@ -996,30 +672,24 @@ class Corpus: Robusta ante cualquier `PYTHONHASHSEED`. Ver ADR 0013.""" ``` -**Notas de contrato** (Hito 1, ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md)): +**Notas de contrato** (ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md)): -- **`__eq__` es por `corpus_hash`, no por `pa.Table.equals`:** dos `Corpus` con el mismo contenido - en distinto orden de filas (o de elementos de listas) son iguales. El `corpus_hash` hashea solo - el contenido de la tabla, nunca campos volátiles del Manifest (D2). **AS-BUILT (Hito R2, ADR 0017 - enmendado, ✅ 2026-06-16):** el hash **excluye `provenance`/timestamps** (identidad = contenido - bibliográfico; la procedencia audita, no identifica) pero **incluye `curation_status`** (contenido - curado). *(Histórico v0.2 roto: incluía `provenance` con timestamps → rompía la reproducibilidad - bit a bit; R2 lo corrigió.)* Ver la nota AS-BUILT de §1.1. +- **`__eq__` es por `corpus_hash`, no por `pa.Table.equals`:** dos `Corpus` con el mismo contenido en + distinto orden de filas (o de listas) son iguales. El `corpus_hash` **excluye `provenance`/timestamps** + (identidad = contenido bibliográfico; la procedencia audita, no identifica) pero **incluye + `curation_status`** (contenido curado), nunca campos volátiles del Manifest (D2). - **`merge` emite filas en orden determinista** (primera aparición): habilita diffs y snapshots reproducibles. Es idempotente: `c.merge(c) == c`. -**Backend y estado del lazo** (2º giro, ADR [0015](decisiones/0015-corpus-tabular-backend.md) / +**Backend y estado del lazo** (ADR [0015](decisiones/0015-corpus-tabular-backend.md) / [0016](decisiones/0016-maquina-estados-lazo.md)): -- **Las mutaciones se delegan al `TabularBackend`.** D1/D2/D3 son contrato que cada backend - cumple: `InMemoryBackend` en Python, `DuckDBBackend` por SQL `UPDATE`/`MERGE` por `id`. El - `corpus_hash` (D2) se computa siempre sobre `to_arrow()`, nunca sobre detalles del backend. -- **El `LoopState`** (`SEEDED → FORAGED → FILTERED → BUILT`, transiciones permisivas) vive en el - **backend persistente** (`DuckDBBackend`), **no** en el `Corpus` efímero. **Una investigación = - un archivo `.duckdb`**. El `LoopState` y su persistencia **están construidos** (Hito 3: enum - `StrEnum` + tabla `loop_state_log` append-only; estado actual = última fila); se exponen vía - `DuckDBBackend.loop_state()`/`set_loop_state()` (ver §4). El comando `b2g status` que lo presenta - llega en el Hito 6. +- **Las mutaciones se delegan al `TabularBackend`.** D1/D2/D3 son contrato que cada backend cumple + (InMemory en Python, DuckDB por SQL). El `corpus_hash` (D2) se computa siempre sobre `to_arrow()`. +- **El `CycleState`** (`SEEDED → FORAGED → FILTERED → BUILT → MONITORED`, transiciones permisivas) vive + en el **backend persistente** (`DuckDBBackend`), no en el `Corpus` efímero (tabla `loop_state_log` + append-only; estado actual = última fila), expuesto vía `loop_state()`/`set_loop_state()` (§4) y + `b2g status`. ### 1.3 `Manifest` y `CorpusSnapshot` @@ -1049,22 +719,17 @@ class CorpusSnapshot: def corpus(self) -> Corpus: ... ``` -**Notas de contrato** (Hito 1, ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md); D5/D6): +**Notas de contrato** (ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md); D5/D6): -- **`corpus_hash` se calcula al sellar.** El Manifest del `Corpus` en memoria lleva - `corpus_hash=""` (placeholder); el hash real (D2) se computa en `snapshot()` y vive en el - `CorpusSnapshot.manifest`. No tratar el hash del Manifest en memoria como autoritativo. -- **Obligatorios vs default** (D5): `schema_version`, `corpus_hash`, `lib_version`, `created_at` - no tienen default; el resto sí (`equations=[]`, `chaining=None`, `preprocessors=[]`, - `filters=[]`, `enrichers=[]`, `openalex_version=None`). -- **R5 — `lib_version` desconocida = `"unknown"`** (cambio de comportamiento): si - `importlib.metadata` no resuelve la versión instalada de `bib2graph`, el fallback es **`"unknown"`**, - no `"0.0.0"`. Una versión inventada entraba al `Manifest` y mentía sobre la reproducibilidad; `"unknown"` - es honesto. -- **`schema_version`** (D6): en Hito 1 solo se escribe y se round-tripea (sin lógica de rechazo - por incompatibilidad; queda para un hito posterior con migraciones sobre el store vivo). +- **`corpus_hash` se calcula al sellar:** el Manifest en memoria lleva `corpus_hash=""` (placeholder); + el hash real (D2) se computa en `snapshot()` y vive en `CorpusSnapshot.manifest`. +- **Obligatorios** (D5): `schema_version`, `corpus_hash`, `lib_version`, `created_at`; el resto con + default. Si `importlib.metadata` no resuelve la versión instalada, `lib_version = "unknown"` (no + `"0.0.0"` inventado — honesto sobre la reproducibilidad). +- **`schema_version`** (D6): se escribe y round-tripea; el rechazo por incompatibilidad + migraciones + sobre el store vivo es futuro. -### 1.4 `TabularBackend` (Protocol) e `InMemoryBackend` (núcleo, v1) +### 1.4 `TabularBackend` (Protocol) e `InMemoryBackend` El **contenedor** del `Corpus` es un `TabularBackend` (Protocol `@runtime_checkable`); el `Corpus` **delega** en él (ADR [0015](decisiones/0015-corpus-tabular-backend.md)). El núcleo depende **solo @@ -1106,14 +771,6 @@ class TabularBackend(Protocol): def referenced_refs(self) -> pa.Table: ... # los IDs observados (ref_id, cycle_round, observed_at) ``` -> **AS-BUILT #54 (2026-06-17) — `referenced_but_not_fetched`.** El backward chaining (§5) dejó de -> crear filas-fantasma `[candidate:W...]` en el `corpus`. Sus IDs observados se registran en esta -> tabla append-only (hermana de `loop_state_log`), implementada por `DuckDBBackend` (DDL + migración -> liviana + copia en snapshot/`_clone`) e `InMemoryBackend`. **No entra al `corpus_hash`** (tabla -> aparte, no columna del corpus → no toca el schema de [ADR 0013](decisiones/0013-identidad-hash-merge-corpus.md); -> coherente con [ADR 0017](decisiones/0017-reproducibilidad-historia-snapshot.md): es estado, no -> contenido). Materializar un observado a fila real vía `fetch_works_by_ids` (#55) está diferido a #71. - | Implementación | Estado | Notas | |----------------|--------|-------| | `InMemoryBackend` | **v1** | **Núcleo puro, sin I/O.** *Working set* efímero y backend de los tests (el núcleo se testea sin DuckDB). Semántica de valor; hereda la lógica del Hito 1 (mutación en Python sobre listas de dicts, table-rebuild). No persiste. | @@ -1182,8 +839,8 @@ class EquationSpec(BaseModel): exclude: list[str] = [] # #30 — AND NOT "…" DENTRO de la search:((query) AND NOT "…") max_results: int | None = None # #14 — tope (None → default del source, 200) native: bool = False # passthrough crudo a OpenAlex (sin traducción) - min_year: int | None = None # DECLARADO, AÚN NO FILTRA (ver nota) - max_year: int | None = None # DECLARADO, AÚN NO FILTRA (ver nota) + min_year: int | None = None # filtra: from_publication_date contra OpenAlex + max_year: int | None = None # filtra: to_publication_date contra OpenAlex def load_equation_spec(path: str | Path) -> EquationSpec: """Carga/valida la EquationSpec desde un YAML (clave raíz `equation:`). @@ -1192,106 +849,59 @@ def load_equation_spec(path: str | Path) -> EquationSpec: citando archivo + campo. Importación perezosa de PyYAML.""" ``` -> **`min_year`/`max_year`: filtran de verdad (Ciclo 10, 2026-06-17).** En el corte 9a estos campos -> estaban en `EquationSpec` pero `OpenAlexSource.seed` no los aplicaba; el **Ciclo 10 los conectó**: -> `_translate`/`seed` agregan `from_publication_date:-01-01` y/o -> `to_publication_date:-12-31` al `filter` de OpenAlex (sintaxis idiomática de rango, -> concatenada con coma) y lo reportan en el `translation_report`. Expuestos además como flags -> **`--min-year`/`--max-year`** en `b2g seed --equation` (paridad 1:1 con el YAML); en `--native` no -> se aplican. Todos los campos (`query`/`exclude`/`max_results`/`native`/`min_year`/`max_year`) mapean -> 1:1 al `run_seed`: la capa declarativa empaqueta los flags ya soportados. - | Implementación | Estado | Notas | |----------------|--------|-------| -| `OpenAlexSource` | **v1 (construido, Hito 4)** | **Referencia/backbone**, sobre `httpx`. Entrega mínimo + enriquecimiento: refs inline + afiliaciones per-autor + instituciones; `cited_by_id` queda **diferido** al chaining/`Enricher` (no se trae en el seed). Traducción **passthrough** —envuelve la ecuación en `title_and_abstract.search:(...)` y **reporta** los límites WoS (NEAR/comodín/tags) sin traducirlos; el traductor WoS→OpenAlex es v0.2. Flag `native=True` (query cruda). **Negaciones (`exclude`, #30):** `seed(..., exclude=[...])` y `_translate(exclude=...)` inyectan cada `AND NOT ""` **DENTRO** de la única expresión `title_and_abstract.search:((query) AND NOT "")` (el campo **no se repite**; el filtro de año queda como predicado separado por coma **fuera** de la expresión `search`) y lo **reportan en el `translation_report`** (query visible); comillas internas saneadas; **ignorado con `native=True`**. *(Sintaxis **validada contra OpenAlex real** vía test `@pytest.mark.network`, 2026-06-17: la forma vieja con el campo repetido devolvía 0 resultados.)* Credenciales inyectadas (arg → `OPENALEX_API_KEY` → `~/.openalex/credentials` → polite pool; ADR 0012). Cursor paging con tope `max_results` (param de `__init__`, default 200; **`b2g seed --max-results INT`** lo propaga para exploración con muestras chicas, Nota 09 B1). Puebla `Manifest.openalex_version` (header o fecha del fetch; ADR 0017). `transport` inyectable (tests con `MockTransport`, sin red en CI). | -| `BibtexSource` | **v1, secundaria (construido, Hito 4)** | Sembrar desde *pearls* vía `load()`. Extra **`[bibtex]`** (import perezoso de `bibtexparser`, ADR 0005); acceso defensivo (fix del bug T1: campos faltantes sin `KeyError`). Mínimo universal. `seed()` lanza `NotImplementedError` (BibTeX no siembra por ecuación). **R5:** un `.bib` con error de parseo grave → `ValueError` accionable (antes lo tragaba en silencio); un `.bib` sin entradas válidas / con entradas omitidas por falta de título → `UserWarning` (no no-op silencioso). Carga bulk con `from_arrow`. | +| `OpenAlexSource` | **v1** | **Referencia/backbone**, sobre `httpx`. Entrega mínimo + enriquecimiento (refs inline + afiliaciones per-autor + instituciones; `cited_by_id` lo puebla el chaining/Enricher, no el seed). Traducción **passthrough**: envuelve la ecuación en `title_and_abstract.search:(...)` y **reporta** los límites WoS (NEAR/comodín/tags) sin traducirlos. Flag `native=True` (query cruda). **Negaciones (`exclude`):** cada `AND NOT ""` se inyecta **dentro** de la única expresión `search:((query) AND NOT "")` (campo no repetido; el filtro de año queda como predicado separado por coma, fuera del `search`) y se reporta en el `translation_report`; ignorado con `native`. Credenciales inyectadas (arg → `OPENALEX_API_KEY` → `~/.openalex/credentials` → polite pool; ADR 0012). Cursor paging con tope `max_results` (default 200). Puebla `Manifest.openalex_version` (ADR 0017). `transport` inyectable (tests sin red). | +| `BibtexSource` | **v1, secundaria** | Sembrar desde *pearls* vía `load()`. Extra **`[bibtex]`** (import perezoso de `bibtexparser`); acceso defensivo (campos faltantes sin `KeyError`). Mínimo universal. `seed()` lanza `NotImplementedError`. `.bib` con error grave → `ValueError`; sin entradas válidas → `UserWarning` (no no-op silencioso). Carga bulk con `from_arrow`. | | `ScieloSource` / `RedalycSource` / `LaReferenciaSource` | futuro | Fuentes regionales, mínimo universal. Declaradas, no implementadas (ADR 0018). | | `RisSource` / `CsvSource` | futuro | No implementados. | -> **AS-BUILT #78 (2026-06-17) — el forward materializa metadata REAL (ADR -> [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) §AS-BUILT #78).** Se agrega -> `OpenAlexSource.fetch_citing_batch_with_works` (abajo): el forward chaining (§5) deja de persistir -> placeholders `[candidate:W...]` y materializa filas reales conservando la metadata que -> `fetch_citing_batch` ya traía y descartaba (**cero red extra**). `fetch_citing_batch` queda intacto -> (thin wrapper). Gate verde, 645 tests. - -**Capacidades de `OpenAlexSource` fuera del Protocol `Source`** (específicas del backbone, no -contrato universal; las consumen el `Forager` y el `Enricher`): - -- **`fetch_citing(openalex_id) -> list[dict]`** (singular, Forager forward chaining): `GET - works?filter=cites:`, con retry/backoff ante 429/5xx (R5). No cambió en el Hito 8. -- **`fetch_citing_batch(ids, *, max_per_paper) -> dict[seed_id, list[citer_id]]`** (Hito 8b, ADR - [0025](decisiones/0025-enricher-cocitacion-openalex.md)): trae los citantes de un conjunto de - semillas **batcheando por OR** (`cites:W1|W2|...`, lotes ≤50), pagina por cursor y **atribuye - página a página** (cruza `referenced_works` del citante con el set objetivo, por short-id). Con - **presupuesto por semilla**: corta la paginación cuando **todas** las semillas del lote alcanzan - `max_per_paper` (acota el *fetch*, no solo la columna; **sin starvation** entre semillas; mata el - N+1 diferido de R5). Lo consume el `OpenAlexEnricher` (§3) para poblar `cited_by_id`. **AS-BUILT - #78 (2026-06-17): firma y contrato INTACTOS** —sigue devolviendo solo el mapeo de atribución— pero - internamente es un **thin wrapper** sobre `_fetch_citing_pages` que **descarta `works_map`** (la - metadata que ya viaja en la misma request). El Enricher 8b no cambia. -- **`fetch_citing_batch_with_works(ids, *, max_per_paper) -> tuple[dict[seed_id, list[citer_id]], dict[citer_id, work]]`** - (#78, 2026-06-17, Forager forward chaining): la **variante que conserva la metadata**. Misma red, - mismo batcheo/atribución/presupuesto que `fetch_citing_batch` (comparten `_fetch_citing_pages`), - pero devuelve además el `works_map` (`citer_id → work JSON con _FIELDS`) que `fetch_citing_batch` - tira. **Cero red extra**: la metadata ya venía en la query de citantes y antes se descartaba. La - consume `Forager._fetch_forward` para materializar filas REALES (título/año/autores) en vez de - placeholders `[candidate:W...]`. Ver §5 y ADR - [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) §AS-BUILT #78. -- **`fetch_dois_for(ids) -> dict`** (Hito 8a): resuelve `references_id`→DOI batcheando por OR (lotes - ≤100, `select=id,doi`). -- **`fetch_works_by_ids(ids) -> Corpus`** (#55): materializa works arbitrarios desde sus IDs OpenAlex, - batcheando por OR (`openalex_id:W1|W2|...`, lotes ≤100, reusa `_fetch_batch_select`). Devuelve un - `Corpus` con `is_seed=False`, `curation_status=CANDIDATE`, `provenance[action="fetched_by_id"]`. IDs - inexistentes se **omiten sin error**; orden **determinista** (filas ordenadas por `id` canónico); - lista vacía → `Corpus` vacío **sin tocar la red**. Es el primitivo que materializa lo observado por - el backward chaining (ver #54). Reusa `_work_to_row` parametrizado (`is_seed`/`action`), que centraliza - el mapeo JSON→Arrow para `seed`/`fetch_citing`/`fetch_works_by_ids`/forward chaining (sin duplicar). - **AS-BUILT #78 (2026-06-17): `_work_to_row` ganó `chaining_hop: int | None = None` y - `source_tag: str = "openalex"`** (defaults backward-compat → los callers viejos no cambian); el - forward chaining lo invoca con `chaining_hop=1, source_tag="chaining:forward"` para materializar - citantes reales. *(Validado contra OpenAlex real vía test `@pytest.mark.network`.)* - -**Reporte de cobertura/calidad** (concepto declarado, concreto **v0.2+**; ADR 0018): por -seed/source, mide % de refs resueltas, % con DOI, distribución idioma/región y completitud del -enriquecimiento. Alimenta el juicio humano de **cuándo cambiar de Source** y acota la -incertidumbre del ranking por *information scent* sobre datos parciales. Se declara como contrato -en v0.1 (función pura sobre `pa.Table`), sin cablearse vacío (lección 5). - -### 2.1 Convención `examples/` — corpus de ejemplo commiteado (AS-BUILT #33 / 9b · CLI-puro Ciclo B, 2026-06-17) +**Capacidades de `OpenAlexSource` fuera del Protocol `Source`** (específicas del backbone; las consumen +el `Forager` y el `Enricher`): + +- **`fetch_citing(openalex_id) -> list[dict]`** (singular, forward chaining): `GET works?filter=cites:`, + con retry/backoff ante 429/5xx. +- **`fetch_citing_batch(ids, *, max_per_paper, since=None) -> dict[seed_id, list[citer_id]]`**: trae los + citantes **batcheando por OR** (`cites:W1|W2|...`, lotes ≤50), pagina por cursor y **atribuye página a + página** con **presupuesto por semilla** (corta cuando todas alcanzan `max_per_paper`; sin starvation). + **`since`** filtra a los publicados desde esa fecha (`from_publication_date`; lo usa `chain --since`). + Lo consume el Enricher para poblar `cited_by_id`. +- **`fetch_citing_batch_with_works(ids, *, max_per_paper, since=None) -> tuple[dict[...], dict[citer_id, + work]]`**: variante que **conserva la metadata** (`works_map`) que la misma request ya trae (cero red + extra). La consume el Forager para materializar filas reales en el forward (no placeholders). +- **`fetch_dois_for(ids) -> dict`**: resuelve `references_id`→DOI batcheando por OR (≤100, `select=id,doi`). +- **`fetch_works_by_ids(ids) -> Corpus`**: materializa works desde sus IDs OpenAlex (batcheo OR ≤100). + Devuelve un `Corpus` con `is_seed=False`, `candidate`, `provenance[action="fetched_by_id"]`; IDs + inexistentes se omiten sin error; orden determinista; lista vacía → `Corpus` vacío sin tocar la red. Es + el primitivo que materializaría lo observado por el backward chaining. Centraliza el mapeo JSON→Arrow + vía `_work_to_row` (parametrizado por `is_seed`/`action`/`chaining_hop`/`source_tag`). + +**Reporte de cobertura/calidad** (concepto declarado, concreto **futuro**; ADR 0018): por seed/source, +mide % de refs resueltas, % con DOI, distribución idioma/región y completitud del enriquecimiento; +alimenta el juicio de cuándo cambiar de Source. Se declara como contrato (función pura sobre `pa.Table`), +sin cablearse vacío. + +### 2.1 Convención `examples/` — corpus de ejemplo commiteado `examples/` es la **única** excepción al `.gitignore` de datos de usuario (ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md)): un corpus real, curado y reducido -(CC0/OpenAlex) commiteado al árbol para servir de **caso real reproducible sin red** (gate #33 → -epic GUI #34). Reglas: - -- **Un ejemplo = una carpeta de propósito ÚNICO** (`examples//`), autocontenida; no se - mezclan tipos de artefacto. Cada carpeta lleva: - - **`corpus.parquet`** — corpus curado y congelado (con `decision`/`curation_status`/`is_seed` - ya marcados), schema canónico `CORPUS_SCHEMA`. **Parquet/CSV, NUNCA `.duckdb`** (la biblioteca - viva es estado mutable no determinista; el parquet es export sellado y diff-friendly, ADR - 0006/0009/0017). - - **`equation.yaml`** — la ecuación de procedencia (cargable con `EquationSpec`, §2). Documenta - "de qué búsqueda salió este corpus"; **no** es el comando del gate. - - **`curacion.csv`** *(cuando el ejemplo pasa por curación)* — las decisiones de curación - congeladas que `b2g curate --from-csv` consume: **receta determinista** de curación (aplicarlo - al corpus sembrado produce el mismo estado, independiente de cuándo se corra). - - **`README.md`** — qué demuestra y con qué comandos se arma/reproduce. **Es la procedencia:** - la **receta CLI** documentada (armado con red + reproducción offline), no un script. -- **Cómo se restaura:** `b2g restore --from-corpus examples//corpus.parquet` (§2.`restore`) - rehidrata el corpus **sin red** en el `library.duckdb` de un workspace temporal, preserva la - curación y transiciona a `FILTERED`; luego `build` → `networks`/`clusters` corren localmente. -- **`.gitignore`:** `!examples/` trackea el ejemplo; `examples/**/*.duckdb` lo protege de que un - store vivo se cuele. El resto de la política de datos de usuario no cambia. +(CC0/OpenAlex) commiteado al árbol como **caso real reproducible sin red**. Reglas: + +- **Un ejemplo = una carpeta de propósito único** (`examples//`), autocontenida, con: + **`corpus.parquet`** (curado y congelado, schema `CORPUS_SCHEMA`; **parquet/CSV, NUNCA `.duckdb`**), + **`equation.yaml`** (ecuación de procedencia, `EquationSpec`), **`curacion.csv`** (decisiones de + curación congeladas que `b2g curate apply` consume — receta determinista) y **`README.md`** (la + procedencia: la **receta CLI**, no un script). +- **Cómo se restaura:** `b2g snapshot restore --from-corpus examples//corpus.parquet` rehidrata + el corpus **sin red**, preserva la curación y transiciona a `FILTERED`; luego `build` corre localmente. +- **`.gitignore`:** `!examples/` trackea el ejemplo; `examples/**/*.duckdb` protege de que un store vivo + se cuele. - **Ejemplos existentes:** - - **`examples/valoraciones/`** (Ciclo B, AS-BUILT 2026-06-17): **~80 filas** (70 `candidate` + - 10 `accepted` enriquecidos), armado **100% por CLI** (sin script): `seed --spec equation.yaml` - (`max_results: 80`) → `curate --from-csv curacion.csv` → `enrich --max-citing 25` → `snapshot`. - **Co-citación presente** (rala) + coupling/author/institution/keyword sustanciales. Verificado por - el gate R2 `tests/unit/test_example_r2_gate.py` (`corpus_hash` estable + comunidades Louvain - estables entre corridas; piso `n>=50`, las 5 redes con datos). Se rehidrata con - `b2g restore --from-corpus`. Procedencia = receta CLI del README + `equation.yaml` + `curacion.csv`. - - **`examples/bibtex/`** (Ciclo 10, AS-BUILT 2026-06-17): un `sample.bib` chico (10 entradas, con + - **`examples/valoraciones/`**: ~80 filas (70 `candidate` + 10 `accepted`), armado **100% por CLI** + (`seed --spec equation.yaml` → `curate apply curacion.csv` → `build --max-citing 25` → + `snapshot create`). Co-citación presente (rala) + las otras 4 redes sustanciales. Verificado por el + gate R2 (`tests/unit/test_example_r2_gate.py`: `corpus_hash` + comunidades Louvain estables). + - **`examples/bibtex/`**: un `sample.bib` chico (10 entradas, con variedad deliberada de campos faltantes para ejercitar el parser defensivo) + `README.md` con la receta 100% CLI (`b2g init` → `b2g seed --from-bib examples/bibtex/sample.bib` → `b2g build`). Demuestra el segundo camino de seed (BibTeX local, sin red). El `.bib` queda trackeado por la @@ -1302,12 +912,12 @@ epic GUI #34). Reglas: ## 3. Costura `Enricher` — señal extra (opt-in, ya NO estructural) Con OpenAlex como backbone, refs y citantes **ya vienen en el corpus** (ADR 0007). El `Enricher` -queda opt-in para **resolver `references` a DOI** y el **segundo nivel de fetch** (poblar -`cited_by_id` ≡ citantes compartidos — ver decisión F en ADR -[0025](decisiones/0025-enricher-cocitacion-openalex.md)) que habilita la **co-citación -end-to-end** (Hito 8 completo). El `Enricher` vive en el **núcleo, -sobre OpenAlex** (ADR 0025, decisión B), **no** en el extra `[s2]` (ese DoD pre-giro queda superado; -`[s2]` se reserva para un futuro `SemanticScholarEnricher` de señal adicional). +queda opt-in para **resolver `references` a DOI** y el **segundo nivel de fetch** (poblar `cited_by_id` +≡ citantes compartidos) que habilita la **co-citación end-to-end**. Vive en el **núcleo sobre OpenAlex** +(ADR [0025](decisiones/0025-enricher-cocitacion-openalex.md)), **no** en `[s2]` (reservado para un +futuro `SemanticScholarEnricher`). **No se invoca por verbo propio** (#162): la pasada refs→DOI corre +automática en `chain` y la de co-citación en `build` (helper único `cli/_enrich.py::enrich_corpus`); el +verbo `b2g enrich` sobrevive como alias deprecado. ```python @runtime_checkable @@ -1319,7 +929,7 @@ class Enricher(Protocol): | Implementación | Estado | Aporta | |----------------|--------|--------| -| `OpenAlexEnricher` | **v1, opt-in (Hito 8 = Ciclos 8a + 8b construidos)** | `enrich(corpus)` hace **2 pasadas**. **8a (refs→DOI):** reúne los `references_id` únicos, los resuelve **batcheando por OR** (lotes ≤100, `openalex_id:W1\|W2\|...`, `select=id,doi`) y rellena `references_doi` por lookup; registra `EnricherRef(name="openalex_references_doi", …)` en el `Manifest` (idempotente: reemplaza por nombre, no duplica). **8b (co-citación):** para las **semillas aceptadas** (`is_seed=True AND curation_status=accepted`) trae sus citantes vía `OpenAlexSource.fetch_citing_batch` (§2) y **mergea los `openalex_id` de los citantes en `cited_by_id`** (unión idempotente). **NO** materializa citantes como filas (no crece el corpus; decisión A). Constructor con **`max_citing_per_paper`** (tope **por semilla**, acota el fetch). Frontera: el Source hace I/O + atribución + acotamiento; el Enricher **solo une**. | +| `OpenAlexEnricher` | **v1, opt-in** | `enrich(corpus)` hace **2 pasadas**. **refs→DOI:** resuelve los `references_id` únicos batcheando por OR (≤100, `select=id,doi`), rellena `references_doi` y registra un `EnricherRef` idempotente en el `Manifest`. **co-citación:** para las **semillas aceptadas** trae sus citantes vía `OpenAlexSource.fetch_citing_batch` (§2) y **mergea sus `openalex_id` en `cited_by_id`** (unión idempotente); **no** materializa citantes como filas (no crece el corpus). Constructor con **`max_citing_per_paper`** (tope por semilla). Frontera: el Source hace I/O + atribución; el Enricher **solo une**. | | `SemanticScholarEnricher` | futuro | señal de citas adicional (reserva del `[s2]`, no estructural) | | `CrossRefEnricher` / `ScopusEnricher` | futuro | No implementados. | @@ -1327,20 +937,17 @@ class Enricher(Protocol): ## 4. Costura `Store` / backend de persistencia (biblioteca viva) -Tras el 2º giro (ADR [0015](decisiones/0015-corpus-tabular-backend.md)), la persistencia por -defecto es el **`DuckDBBackend`** del `Corpus`: DuckDB deja de ser un `Store` que persiste un -`Corpus` Arrow aparte y pasa a ser el **backend por defecto** del `Corpus` (mutaciones por SQL -`UPDATE`/`MERGE` por `id`). El `Store` sigue siendo la **costura/punto de extensión** para -destinos externos opt-in (Zotero, Neo4j). El `LoopState` (ADR 0016) vive en el backend -persistente. - -El contrato `TabularBackend` (Protocol) y su firma completa viven en **§1.4** (núcleo): `to_arrow`, -`add_paper`, `merge(other_table: pa.Table)`, `apply_curation(ids, *, action, by)`, `filter_view`, -`corpus_hash`, `__len__`, `__eq__` y —AS-BUILT #54 (2026-06-17)— `add_referenced_refs(ref_ids, *, -cycle_round)`/`referenced_refs_count()`/`referenced_refs()` (tabla hermana append-only -`referenced_but_not_fetched`, fuera del `corpus_hash`; §1.4 + §5). El `DuckDBBackend` lo implementa en -SQL (Hito 3); el `Store` de abajo es la costura de persistencia/intercambio **externa**, distinta del -backend del `Corpus`. +La persistencia por defecto es el **`DuckDBBackend`** del `Corpus` (ADR +[0015](decisiones/0015-corpus-tabular-backend.md)): no un `Store` que persiste un `Corpus` Arrow +aparte, sino el **backend por defecto** del `Corpus` (mutaciones por SQL). El `Store` sigue siendo la +**costura/punto de extensión** para destinos externos opt-in (Zotero, Neo4j). El `CycleState` (ADR 0016) +vive en el backend persistente. + +El contrato `TabularBackend` (Protocol) y su firma completa viven en **§1.4** (`to_arrow`, `add_paper`, +`merge`, `apply_curation`, `filter_view`, `corpus_hash`, `__len__`, `__eq__`, y la tabla hermana +append-only `referenced_but_not_fetched` vía `add_referenced_refs`/`referenced_refs_count`/ +`referenced_refs`, fuera del `corpus_hash`; §1.4 + §5). El `Store` de abajo es la costura de +persistencia/intercambio **externa**, distinta del backend del `Corpus`. ```python class Store(Protocol): @@ -1366,7 +973,7 @@ class Store(Protocol): > (subclase de `OSError`); el CLI (Hito 6) lo mapea al exit code `5`. Multi-escritor concurrente es > post-v1.0. -### 4.1 `DuckDBStore` — fachada de costura + extensiones del backend (Hito 3, construido) +### 4.1 `DuckDBStore` — fachada de costura + extensiones del backend `DuckDBStore(path)` (en `bib2graph.stores.duckdb`, re-exportado perezosamente como `bib2graph.DuckDBStore`) implementa el Protocol `Store` (`persist`/`load`) delegando en un @@ -1386,13 +993,25 @@ class DuckDBStore: ``` > **`persist_replace` vs `persist` (#88, ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)).** -> La **ingesta automática** (`seed`/`seed_from_bib`/`chain`/`restore`) y **`b2g thesaurus`** persisten +> La **ingesta automática** (`seed`/`seed_from_bib`/`chain`/`restore`) y la pasada **`build +> --thesaurus`** (#164) persisten > con **`persist_replace`** (→ `DuckDBBackend.overwrite_corpus`, DELETE+INSERT reasignando `_seq` > desde 0, ADR 0024), porque ya tienen el corpus **completo, normalizado y deduplicado** en memoria y > el upsert-concat D3 (`persist`) **reintroduciría** las variantes que el dedup cross-biblioteca acaba > de colapsar. **`persist`/upsert queda intacto** para el resto de los llamadores (caso "mismo paper > desde dos fuentes", D3). Ambos preservan las tablas hermanas. +**Procedencia del `Manifest` persistida entre cargas.** Los bloques de procedencia del `Manifest` +(§1.3) que no son contenido del corpus se guardan en **tablas hermanas** del `.duckdb` y `DuckDBStore.load()` +los **reconstruye** al rehidratar, para que sobrevivan a un ciclo persist/load: + +- **`manifest.filters`** ⇄ tabla **`filter_log`** — vía `DuckDBBackend.persist_filter_steps()` / + `load_filter_steps()` (#126). +- **`manifest.enrichers`** ⇄ tabla **`enricher_log`** — vía `DuckDBBackend.persist_enricher_refs()` / + `load_enricher_refs()` (#141), **mismo patrón** que `filters`: la pasada de enriquecimiento + (`chain` refs→DOI, `build` co-citación) sella sus `EnricherRef` y `load()` los recompone, así el + snapshot reporta qué enriquecimiento se aplicó sin re-correrlo. + **Extensiones del `DuckDBBackend`, FUERA del Protocol `Store`/`TabularBackend`** (se acceden vía `store.backend.…`): son específicas de DuckDB y no parte del contrato genérico: @@ -1406,8 +1025,7 @@ class DuckDBBackend: def query(self, sql: str) -> pa.Table: ... # consulta SQL de SOLO lectura sobre el corpus ``` -**El ciclo es un concepto de dominio puro** (`bib2graph.cycle`, **AS-BUILT R3, 2026-06-16**); el -backend **solo lo persiste**: +**El ciclo es un concepto de dominio puro** (`bib2graph.cycle`); el backend **solo lo persiste**: ```python # bib2graph/cycle.py — dominio puro, sin DuckDB (ADR 0016 enmendado, R3) @@ -1424,67 +1042,34 @@ El estado + la **ronda** se persisten en `loop_state_log` (append-only; estado a columna `round`); las transiciones son **permisivas** (ADR 0016: no se bloquea ningún salto). `reseed` es de **primera clase** (loop-back a `SEEDED` + ronda++, acumula sobre lo curado); `seed.py` lo cablea cuando hay estado previo. **Fuente única de verdad:** `chain`/`filter`/`build` derivan su destino de -`apply_transition`, no de un literal. **`MONITORED`** es **alcanzable** desde el cleanup pre-v0.3: el -comando **`b2g monitor`** lo dispara (`apply_transition(state, "monitor", round)`, paso 8 del ciclo). +`apply_transition`, no de un literal. **`MONITORED`** es **alcanzable** vía **`b2g chain --since`** +(#158, forrajeo incremental; el alias deprecado `b2g monitor` delega), que dispara +`apply_transition(state, "monitor", round)` (paso 8 del ciclo). El comando `b2g status` consume `loop_state()`/`loop_round()`/`available_transitions()` y expone `curation_available`/`round` (ver §convenciones CLI). -> **Alias `LoopState` retirado (cleanup pre-v0.3):** el código usa **solo `CycleState`** (de -> `bib2graph.cycle`). El alias transicional `LoopState = CycleState` de `backends/duckdb.py` **se -> eliminó** (también de `stores/duckdb.py`); los call-sites migraron a `CycleState`. - > **Carga perezosa (PEP 562):** `DuckDBBackend` y `DuckDBStore` se exponen vía `__getattr__` en > `bib2graph/__init__.py`, de modo que **`import bib2graph` NO importa `duckdb`** (el núcleo > permanece puro y testeable sin DuckDB). Solo `bib2graph.DuckDBBackend` / `bib2graph.DuckDBStore` -> cargan el módulo bajo demanda. `CycleState` (y su alias `LoopState`) y `StoreLockedError` se +> cargan el módulo bajo demanda. `CycleState` y `StoreLockedError` se > importan desde `bib2graph.backends.duckdb` (o `bib2graph.stores.duckdb`); `bib2graph.cycle` > (`CycleState`/`apply_transition`/`available_transitions`/`CURATION_ACTIONS`) es **núcleo puro**, sin > DuckDB. --- -## 5. Núcleo — Forrajeo / chaining (asistencia algorítmica, SIN IA — construido, Hito 5 + R4) - -> **AS-BUILT R4 (2026-06-16) — ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) -> enmendado / [0022](decisiones/0022-producto-sin-ia-generativa.md)):** el *information scent* es -> **estructura bibliométrica determinista** que consume el primitivo público `collect_item_to_papers` -> de los proyectores (§7), **sin LLM ni embeddings**. El forrajeo (costura) **depende del núcleo de -> proyección** (puro), nunca al revés. El producto **no usa IA generativa**. -> `explain_candidate`/`foraging/explain.py`/`[llm]` quedaron **eliminados**. - -> **AS-BUILT #54 (2026-06-17) — ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) -> §AS-BUILT #54:** el backward chaining **deja de persistir placeholders** en el corpus. Los IDs -> observados salen por `RankedCandidates.observed_refs` y se persisten en la tabla hermana -> `referenced_but_not_fetched` (§4), **fuera del `corpus_hash`** (arregla la contaminación previa). -> El **forward arrastra el mismo footgun** (placeholder de título), abierto como **#78** — NO está -> limpio. Materializador on-demand diferido a #71. Gate verde, 636 tests. - -> **AS-BUILT #78 (2026-06-17) — ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) -> §AS-BUILT #78:** el **forward chaining ya NO persiste placeholders** `[candidate:W...]` — materializa -> **filas REALES** (título/año/autores) con la metadata que `fetch_citing_batch` ya traía de la red y -> descartaba (opción A1, **cero red extra**, vía el método nuevo `fetch_citing_batch_with_works`, §2). -> `_build_forward_candidate_row` eliminado. **Asimetría deliberada** (no incoherencia): el backward -> *observa sin materializar* (refs numerosas, no curadas, sin metadata local → `referenced_but_not_fetched`), -> el forward *materializa* (citantes pocos, acotados por cap, **se curan**, metadata ya en la request). -> Regla común: **el corpus nunca contiene placeholders**. Con esto, el materializador on-demand #71 -> queda **solo para backward**. Gate verde, **645 tests**, verifier PASA. +## 5. Núcleo — Forrajeo / chaining (asistencia algorítmica, SIN IA) El *information scent* es **estructura bibliométrica de cita con el corpus** (ADR -[0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md), AS-BUILT R4). Es una **función pura** -sobre el primitivo `collect_item_to_papers` (índice `{ref → corpus-papers que lo citan}`): +[0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)). Es una **función pura** sobre el +primitivo `collect_item_to_papers` (índice `{ref → corpus-papers que lo citan}`): - **Backward** (puro, local): scent = **fuerza de co-citación con el corpus** = nº de corpus-papers - que listan al candidato en `references_id` (cuántos corpus-papers co-citan al candidato). No toca la - red (las referencias ya vienen en el corpus tras el seed). -- **Forward** (requiere red): scent = **fuerza de citación directa al corpus** = nº de corpus-papers a - los que el candidato cita directamente (señal primaria, robusta: siempre > 0 para un citante real). - Exige traer los citantes vía `source.fetch_citing(...)` (ver abajo). El acoplamiento bibliográfico - queda como señal secundaria. *(El AS-BUILT inicial midió **acoplamiento puro** y degenera a 0 con - referencias ralas; se **corrigió a citación directa dentro de R4** — `compute_forward_scent` calcula - `forward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}|` y emite con `direct > 0`. Ver ADR - 0020 AS-BUILT.)* -- **Centralidad** del candidato: **diferida** (viz); el DoD "y/o" se cumple con - co-citación + citación-directa. + que listan al candidato en `references_id`. No toca la red (las refs ya vienen en el corpus). +- **Forward** (requiere red): scent = **fuerza de citación directa al corpus** + (`forward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}|`, emite con `direct > 0`) — señal + primaria robusta. Exige traer los citantes vía `source.fetch_citing(...)`. +- **Centralidad** del candidato: **diferida** (viz). El ranking es descendente por scent con **desempate por `id` ascendente** (estable ante cualquier `PYTHONHASHSEED`). @@ -1511,10 +1096,14 @@ class Forager: SIN filtrar curation_status) con by_direction['forward']=0 y forward_requires_fetch=True; el conteo de citantes reales solo llega con chain(). NO muta el corpus.""" - def chain(self, corpus: Corpus, *, direction: Direction = "both") -> "RankedCandidates": + def chain(self, corpus: Corpus, *, direction: Direction = "both", + since: date | None = None) -> "RankedCandidates": """Computa candidatos (curation_status='candidate', is_seed=False) rankeados por scent. Devuelve SOLO los candidatos nuevos (no mergeados): el humano hace - corpus.merge(ranked.corpus). NO muta el corpus de entrada. Sella Manifest.chaining.""" + corpus.merge(ranked.corpus). NO muta el corpus de entrada. Sella Manifest.chaining. + `since` (#158, forrajeo incremental): propaga a fetch_citing_batch(since=) → + from_publication_date en OpenAlex; solo afecta el tramo forward. Lo usa `b2g chain --since` + (transición a MONITORED).""" class GrowthPreview(BaseModel): estimated_new: int # total estimable localmente (forward=0 si requiere fetch) @@ -1540,59 +1129,26 @@ class RankedCandidates(BaseModel): # (En el AS-BUILT v0.2 existía como stub gateado en [llm]; la remediación lo borra.) ``` -**Notas de contrato** (Hito 5, ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)): - -- **Forward chaining requiere `source.fetch_citing_batch(ids, *, max_per_paper)`** (§2). **R5:** el - **comando `chain` hace un pre-check `hasattr(source, "fetch_citing")`** y lanza `DependencyError` - accionable (exit 3) si el source no lo soporta —el forager queda agnóstico de la capa - CLI/`_errors`; un `AttributeError` genuino dentro del chaining ya no se disfraza de "source sin - forward". **No se amplió el Protocol `Source`** (§2) — `fetch_citing`/`fetch_citing_batch` son - capacidad de `OpenAlexSource`, no contrato universal. Una `Source` de solo-mínimo (ADR 0018) no - habilita forward chaining. -- **AS-BUILT (#21, 2026-06-16) — forward chaining batcheado + cap por semilla, scope `is_seed`.** El - `Forager.chain(direction="forward"/"both")` **ya no hace N+1** (`fetch_citing` por fila): **reusa - `OpenAlexSource.fetch_citing_batch`** (§2 — batcheo OR ≤50 + presupuesto por semilla + retry/backoff), - matando el N+1 de requests. El `Forager.__init__` toma **`max_citing_per_paper`** (tope de citantes - **por semilla**, default **50**); el CLI lo expone como **`--max-citing`** en `b2g chain`. **El - alcance del forward es `is_seed=True`** —**todas** las semillas sembradas, **SIN** filtrar por - `curation_status`— porque el chaining corre **antes** de la curación (ciclo - `SEEDED→FORAGED→…`→ curación transversal; las semillas nacen `candidate`; ADR - [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md), Nota 09). **La restricción a - semillas `accepted` NO es del Forager — es del `Enricher`** (Hito 8b, §3, post-curación). No - confundir: el Forager expande la frontera (pre-curación, sobre `is_seed`); el Enricher pobla - `cited_by_id` para la co-citación (post-curación, sobre `accepted`). *(El drift inverso —documentar - el Forager forrajeando solo `accepted`— fue la causa del bug que este AS-BUILT cierra.)* -- **`preview` del forward sin red (#21):** estima el **nº de semillas a forrajear** (`is_seed`) sin - emitir requests, manteniendo `forward_requires_fetch=True` (el conteo de citantes reales solo llega - con `chain`). `b2g monitor` usa este mismo forward batcheado. -- **`fetch_citing` (singular) con retry/backoff** ante 429/5xx (`_fetch_all_with_retry`: exponential - backoff, 3 intentos) sigue disponible; el forward del Forager lo consume ahora vía la variante - **batcheada** `fetch_citing_batch`. -- **AS-BUILT (#54, 2026-06-17) — el backward NO materializa stubs: observa sin contaminar.** El - backward chaining **ya no crea filas-fantasma `[candidate:W...]` en el corpus** (revierte el - comportamiento de Hito 5 — la promesa de "no contaminan" era **falsa**: los stubs llegaron a ser - ~la mitad del corpus y entraban al `corpus_hash`; Notas 09/12). Los IDs observados por el ranking - backward salen por **`RankedCandidates.observed_refs: list[str]`** (orden de ranking, respeta - `max_candidates`) y `b2g chain` los persiste en la tabla hermana **`referenced_but_not_fetched`** - (§4), **fuera** del `corpus` y del `corpus_hash`. La materialización on-demand (rehidratar un - observado a fila real vía `fetch_works_by_ids`, #55) está **diferida a #71** (con #78 cerrado, #71 - queda **solo para backward**). El **forward sí materializa** filas en `.corpus` —y **con #78 - (2026-06-17) lo hace con metadata REAL** (título/año/autores), **ya no** con placeholders - `[candidate:W...]`: la metadata viaja en la misma request de citantes (`fetch_citing_batch_with_works`, - §2). Asimetría deliberada: el backward observa, el forward materializa (ver §AS-BUILT #78 arriba). -- **`preview` y `chain` no mutan** el corpus de entrada (semántica de valor). +**Notas de contrato** (ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)): + +- **Forward chaining requiere `source.fetch_citing_batch(ids, *, max_per_paper)`** (§2, capacidad de + `OpenAlexSource`, **no** del Protocol `Source` — una source de solo-mínimo no habilita forward). El + comando `chain` hace un **pre-check `hasattr`** y lanza `DependencyError` (exit 3) si el source no lo + soporta (un `AttributeError` genuino no se disfraza de "source sin forward"). +- **Forward batcheado + cap por semilla:** `fetch_citing_batch` batchea por OR (≤50) con presupuesto por + semilla (`max_citing_per_paper`, default 50 — CLI `--max-citing`), sin N+1. El **alcance del forward es + `is_seed=True`** (todas las semillas, **sin** filtrar `curation_status`): el chaining precede a la + curación. La restricción a `accepted` es del **Enricher** (co-citación, §3), no del Forager. +- **Backward observa sin contaminar:** no crea filas-fantasma en el corpus; los IDs observados salen por + **`RankedCandidates.observed_refs`** y `b2g chain` los persiste en la tabla hermana + **`referenced_but_not_fetched`** (§4), **fuera del `corpus_hash`**. **Forward sí materializa** filas con + metadata real (vía `fetch_citing_batch_with_works`, §2; cero red extra). Asimetría deliberada. +- **`preview` y `chain` no mutan** el corpus de entrada (semántica de valor). `fetch_citing` (singular, + con retry/backoff ante 429/5xx) sigue disponible; el forward lo consume vía la variante batcheada. --- -## 6. Núcleo — `Preprocessor` + filtros PRISMA (v1 — construido, Hito 5; auto-ingesta #88) - -> **Sincronizado con el preprocesamiento automático en la ingesta — #88 (AS-BUILT, 2026-06-18, -> ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)):** `normalize` (+ el dedup -> fuzzy de §11) corre **automáticamente en cada ingesta** vía el helper de frontera -> `cli/_ingest.py::normalize_and_dedup` (sobre el corpus completo mergeado). `apply_thesaurus`, que -> requiere el mapeo curado del usuario, queda como el **único paso explícito** del preproc: -> **`b2g thesaurus --from `** (18° subcomando, transversal, NO transiciona el FSM; -> §convenciones CLI). Ambos métodos aceptan `applied_at` inyectado desde la frontera (R2, ADR 0017). +## 6. Núcleo — `Preprocessor` + filtros PRISMA ```python class Preprocessor: @@ -1610,7 +1166,7 @@ class Preprocessor: multilingüe CURADO (en/es/pt), dict canónico→aliases en JSON o Path a ese JSON. Determinista e idempotente (ADR 0011). SIN fallback semántico/LLM (ADR 0011 enmendado / 0022): lo que no matchea queda fuera, sin inventar conceptos con un modelo. Paso EXPLÍCITO - (`b2g thesaurus`), NO automático: requiere el mapeo del usuario (ADR 0031).""" + (flag `b2g build --thesaurus`, #164), NO automático: requiere el mapeo del usuario (ADR 0031).""" ``` **Filtros de inclusión/exclusión** (funciones puras, flujo PRISMA; ADR @@ -1630,31 +1186,24 @@ def apply_filters(corpus: Corpus, criteria: list[FilterCriterion]) -> tuple[Corp (reemplaza: una corrida = una secuencia PRISMA). Devuelve (corpus_final, [FilterStep, ...]).""" ``` -**Notas de contrato** (Hito 5, ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)): - -- **Los filtros MARCAN `rejected`, NO borran** (decisión del PO): un paper excluido queda en la - tabla con `curation_status='rejected'` vía `corpus.reject(...)` (con el criterio como - `provenance`/`decided_by`), nunca se borra. Coherente con la biblioteca viva (ADR - [0009](decisiones/0009-biblioteca-viva-duckdb.md), C4) y el `provenance` append-only (ADR 0013, - D4): la exclusión es curación **reversible y auditable**. -- **Conteo PRISMA por paso**: cada `FilterStep` lleva `count_before`/`count_after` contando los - papers **no-rejected** (candidate + accepted) antes/después del filtro. -- **`keywords_id` es post-thesaurus**: hasta que se corre `apply_thesaurus`, `keywords_id` no es - autoritativa (puede estar cruda o vacía); los proyectores de co-ocurrencia de keywords (§7) - deben correr **después** del thesaurus. -- **R5 — campo/operador desconocido LANZA** (cambio de comportamiento): un `FilterCriterion` con un - `field` no soportado, o un `op` inválido para ese campo, ahora lanza `ValueError` accionable (lista - los campos/operadores válidos). **Antes era un no-op silencioso** (`return True` → el criterio no - filtraba nada, escondiendo el error). Esto endurece el flujo PRISMA (sin exclusiones perdidas en - silencio). -- **Símbolos públicos del Hito 5** (`from bib2graph import ...`): `Forager`, `GrowthPreview`, - `RankedCandidates`, `Preprocessor`, `FilterCriterion`, `apply_filters`. `apply_filter` (singular) - se importa desde `bib2graph.filters`. *(`explain_candidate` **se elimina** en la remediación — - ADR 0022, Hito R4: ya no es parte de la superficie pública.)* +**Notas de contrato** (ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)): + +- **Los filtros MARCAN `rejected`, NO borran:** un paper excluido queda en la tabla con + `curation_status='rejected'` vía `corpus.reject(...)` (con el criterio en `provenance`), nunca se + borra. La exclusión es curación **reversible y auditable** (biblioteca viva, ADR 0009/0013). +- **Conteo PRISMA por paso:** cada `FilterStep` lleva `count_before`/`count_after` sobre los papers + **no-rejected** (candidate + accepted). +- **`keywords_id` es post-thesaurus:** los proyectores de co-ocurrencia de keywords (§7) deben correr + **después** de `apply_thesaurus`. +- **Campo/operador desconocido LANZA** `ValueError` accionable (lista los válidos); no es no-op + silencioso (endurece el flujo PRISMA, sin exclusiones perdidas). +- **Símbolos públicos** (`from bib2graph import ...`): `Forager`, `GrowthPreview`, `RankedCandidates`, + `Preprocessor`, `FilterCriterion`, `apply_filters`. `apply_filter` (singular) desde + `bib2graph.filters`. --- -## 7. Núcleo — `Projector` (funciones puras, v1) +## 7. Núcleo — `Projector` (funciones puras) ```python class Projector(Protocol): @@ -1680,26 +1229,24 @@ completo** (crítica #2). La **co-citación** es la más cara (segundo nivel de - **Tipo de nodo** (D2): co-autoría / instituciones / co-word → la **entidad** es el nodo (`authors_id` / `institutions_id` / `keywords_id`); acoplamiento / co-citación → el **paper** (`id`) es el nodo. -- **Co-citación:** el `CoCitationProjector` **no cambia** (decisión F, ADR 0025): cuenta - **`cited_by_id` compartido** = los **citantes compartidos** de la metodología (la frase "citantes - con sus citas" ≡ `cited_by_id` compartido). Proyecta con scope `seeds_only`. La co-citación - **completa es end-to-end** (Hito 8 ✅): `b2g enrich` (8b) puebla `cited_by_id` con el 2º nivel de - fetch del `OpenAlexEnricher` (ADR 0007/0025), y `Networks.quick` la incluye cuando esa columna - está poblada (§10). -- **Los proyectores siguen PUROS — NO setean atributos de nodo** (ADR 0014, AS-BUILT #25): producen +- **Co-citación:** el `CoCitationProjector` cuenta **`cited_by_id` compartido** = los **citantes + compartidos** de la metodología (la frase "citantes con sus citas" ≡ `cited_by_id` compartido). + Proyecta con scope `seeds_only`. La co-citación es **end-to-end**: la pasada `cited_by` que corre + automática en `build` (cuando hay aceptadas) puebla `cited_by_id` con el 2º nivel de fetch del + `OpenAlexEnricher` (ADR 0007/0025), y `Networks.quick` la incluye cuando esa columna está poblada (§10). +- **Los proyectores siguen PUROS — NO setean atributos de nodo** (ADR 0014): producen un `nx.Graph` con **ids crudos** como nodos (`doi:…`, `I185261750`, un ORCID), **sin** `label`. La legibilidad (label + atributos) la inyecta la **capa `decorate` (§7.1)**, que es la **frontera** - entre la proyección pura y el export/GUI. Esta separación es deliberada (ADR 0014). + entre la proyección pura y el export. Esta separación es deliberada (ADR 0014). --- -## 7.1 Frontera — `decorate` (label legible + atributos de nodo, v1, AS-BUILT #25) +## 7.1 Frontera — `decorate` (label legible + atributos de nodo) `bib2graph.networks.decorate` es la **capa de frontera** entre los proyectores puros (§7) y los -exportadores (§9) / la GUI. Los proyectores devuelven grafos con **ids crudos** como nodos y **sin -atributos**; `decorate` transforma esos ids en **labels legibles** e inyecta atributos de -curación/comunidad/centralidad. Cierra el hueco de la Nota 09 B3 (las redes salían con `id` crudo, -ilegibles en Gephi/VOSviewer/Cytoscape). +exportadores (§9). Los proyectores devuelven grafos con **ids crudos** como nodos y **sin atributos**; +`decorate` transforma esos ids en **labels legibles** e inyecta atributos de +curación/comunidad/centralidad, para que las redes no salgan ilegibles en Gephi/VOSviewer/Cytoscape. ```python LABEL_MAX_CHARS: int = 60 # tope del label de paper; título largo → truncado + "..." @@ -1745,17 +1292,12 @@ es la única capa que sabe de labels. --- -## 7.2 Núcleo — `cluster_table` (resumen de comunidades, v1, AS-BUILT #31) +## 7.2 Núcleo — `cluster_table` (resumen de comunidades) `bib2graph.networks.cluster_table` es una **función pura** que cruza los nodos de una red con el -corpus para producir **una fila de resumen por comunidad**. Es el insumo tabular de la composición de -clusters (quién/qué/cuándo cae en cada comunidad), legible offline (Excel/Calc) y la base del -`clusters.csv` que escribe `b2g build` (§convenciones CLI). - -> **Con `b2g build --corpus-scope` (#56):** el `clusters.csv` se computa sobre el corpus **FILTRADO**, -> así que sus filas/conteos reflejan **solo** los nodos del subset (`accepted` / `seeds_only`), no el -> corpus vivo completo. `build` pasa el mismo corpus filtrado a `cluster_table`, por lo que `size` y -> los `*_count` cuadran con los nodos del grafo (sin drift). +corpus para producir **una fila de resumen por comunidad** (quién/qué/cuándo cae en cada comunidad), +base del `clusters.csv` que escribe `b2g build`. Con `--scope`, `build` le pasa el corpus **filtrado**, +así que sus conteos cuadran con los nodos del grafo (sin drift). ```python def cluster_table(table: pa.Table, artifact: NetworkArtifact) -> list[dict[str, Any]]: @@ -1764,15 +1306,9 @@ def cluster_table(table: pa.Table, artifact: NetworkArtifact) -> list[dict[str, no es de paper o si no hay comunidades. Orden determinista por `cluster` ascendente.""" ``` -`networks/__init__.py` re-exporta `cluster_table`. - -**Restricción a redes de paper (V1):** solo aplica a los kinds cuyo nodo es un `Col.ID` -(`bibliographic_coupling` / `cocitation`); para `author_collab`, `institution_collab` y -`keyword_cooccurrence` las comunidades agrupan entidades distintas a papers y la misma tabla no tiene -sentido en V1 → devuelve `[]` (no crash). Si `artifact.communities is None` también devuelve `[]`. -**Consecuencia en el CLI:** por esto **`clusters.csv` se emite ÚNICAMENTE para `bibliographic_coupling` -y `cocitation`** (§convenciones CLI / §9); las otras tres redes escriben `network.graphml` + -`metrics.json` pero **no** `clusters.csv`. +`networks/__init__.py` re-exporta `cluster_table`. **Solo aplica a redes de paper** +(`bibliographic_coupling`/`cocitation`); para autor/institución/keyword devuelve `[]` (no crash), por +eso `clusters.csv` se emite **únicamente** para esas dos redes. **Columnas de cada fila** (orden estable): @@ -1788,22 +1324,18 @@ y `cocitation`** (§convenciones CLI / §9); las otras tres redes escriben `netw | `top_authors` | `list[str]` | hasta 5 autores más frecuentes, de **`authors_raw`** | | `top_keywords` | `list[str]` | hasta 5 keywords más frecuentes, de **`keywords_id`** (post-thesaurus) | -**Notas de contrato** (#31, AS-BUILT 2026-06-17): +**Notas de contrato:** -- **Cruce por `Col.ID`, no `source_id`** (lección B6 de la - [Nota 09](Notas/09-sesion-qa-prueba-ecologia-valoraciones.md)): el nodo del grafo **es** un `Col.ID` - (`doi:…`/`src:…`); indexar por `source_id` (`W…`) daría 0 cruces. Un nodo sin match en el corpus **suma al - `size`** pero no aporta año/autores/keywords. -- **Determinista** (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)): el top de - autores/keywords se ordena por **`(-frecuencia, nombre alfabético ascendente)`** — desempate - explícito que hace el resultado reproducible **independiente** del método de clustering - (louvain/label_prop/greedy) y de `PYTHONHASHSEED`. -- **Pura:** sin red, sin `duckdb`; opera sobre `corpus.to_arrow()` + el `NetworkArtifact`. Combina con - `community_composition` (§8, % por categoría) que mira el atributo, no la composición bibliográfica. +- **Cruce por `Col.ID`, no `source_id`:** el nodo del grafo **es** un `Col.ID` (`doi:…`/`src:…`); + indexar por `source_id` daría 0 cruces. Un nodo sin match en el corpus suma al `size` pero no aporta + año/autores/keywords. +- **Determinista** (ADR 0017): el top de autores/keywords se ordena por `(-frecuencia, nombre asc)`, + reproducible independiente del método de clustering y de `PYTHONHASHSEED`. +- **Pura:** sin red ni `duckdb`. Combina con `community_composition` (§8, % por categoría del atributo). --- -## 8. Núcleo — `Analyzer` (funciones puras, v1) +## 8. Núcleo — `Analyzer` (funciones puras) ```python def network_metrics(g: nx.Graph) -> dict: @@ -1854,7 +1386,7 @@ class QualityThresholds(BaseModel): --- -## 9. Núcleo — `Exporter` (v1) +## 9. Núcleo — `Exporter` ```python class Exporter(Protocol): @@ -1884,7 +1416,7 @@ class CsvExporter: ... # v1 — nodos.csv + aristas.csv para pandas --- -## 10. Capa declarativa — `NetworkSpec` (v0.2, capa declarativa AS-BUILT Hito 9) +## 10. Capa declarativa — `NetworkSpec` ```python class NetworkSpec(BaseModel): @@ -1934,103 +1466,56 @@ class Networks: """Arma las specs razonables y devuelve sus artefactos (caso 'investigador, baja fricción'). Devuelve **4 o 5 redes**: coupling (full), co-autoría, institución, co-word siempre; la **co-citación** se incluye (→5) cuando el corpus tiene `cited_by_id` poblado - (tras `b2g enrich`, Hito 8b) y se **omite graceful** (log) si está vacío (→4). + (tras la pasada `cited_by` de `build`) y se **omite graceful** (log) si está vacío (→4). Los artefactos vienen **decorados** (label legible + atributos de nodo, §7.1).""" ``` -**Modo quick** (v1) cubre baja fricción; **modo spec** (Hito 9, YAML) cubre el pipeline declarativo -versionable, vía `load_specs(redes.yaml)` + `Networks.build` por red (y el subcomando `b2g -networks --spec`, §convenciones CLI). - -**Sub-redes temáticas (`keyword_filter`, issue #113):** cada red declarada puede acotar el corpus a -un tema antes de proyectar — útil para comparar sub-redes (p. ej. T4 vs T7) sin pre-filtrar el corpus -entero ni escribir scripts. El match es **ANY** (un paper entra si algún término matchea), por -**substring case-insensitive** sobre `keywords_raw` (display names). `None`/`[]` = sin filtro. +**Modo quick** cubre baja fricción; **modo spec** (YAML) cubre el pipeline declarativo versionable, vía +`load_specs(redes.yaml)` + `Networks.build` por red (subcomando `build --spec`, §convenciones CLI). + +**Notas de contrato** (ADR [0014](decisiones/0014-proyeccion-redes-pesos-asortatividad.md)): + +- **`Networks.quick` arma 4 o 5 redes:** coupling `full`, co-autoría, institución y co-word **siempre** + (4); suma la **co-citación** (→5) cuando el corpus tiene `cited_by_id` poblado (2º nivel de fetch del + Enricher, ADR 0025), y la omite avisando por log (→4) si esa columna está vacía. +- **Artefactos decorados:** `Networks.build`/`quick` devuelven artefactos con `label` legible + atributos + de nodo (vía `decorate`, §7.1); los proyectores (§7) siguen puros (ADR 0014). El símbolo público + re-exportado desde `bib2graph` es `NetworkArtifact` (`NetworkSpec` se importa desde `bib2graph.networks`). +- **`resolution`** (Louvain) e **`keyword_filter`** (issue #113, sub-red temática: filtra el corpus a los + papers cuyo `keywords_raw` matchee ANY substring case-insensitive antes de proyectar) son params de + spec, **fuera del `corpus_hash`** (como `min_weight`/`scope`): el seed de Louvain sigue siendo función + pura del `corpus_hash` (R2). +- **`load_specs`** (clave raíz `networks:` = lista; cada entrada se valida con `NetworkSpec(**entry)`) + da errores accionables (`ValueError`): YAML malformado, falta de raíz, entrada no-dict, o + `ValidationError` citando archivo + `red #` + campo. + +**Campos válidos de cada entrada del YAML** (`kind` obligatorio, resto con default; `extra="forbid"` → +campo desconocido se rechaza con `ValueError`; **`name:` NO es un campo** — anotá con comentario `#`): + +| Campo | Valores / tipo | Default | +|---|---|---| +| `kind` | `bibliographic_coupling` · `cocitation` · `author_collab` · `institution_collab` · `keyword_cooccurrence` | **(obligatorio)** | +| `min_weight` | `int` | `1` | +| `min_year` / `max_year` | `int` | `null` | +| `scope` | `full` · `seeds_only` | `full` | +| `clustering` | `louvain` · `label_prop` · `greedy_modularity` · `null` | `louvain` | +| `resolution` | `float` (solo Louvain) | `1.0` | +| `assortativity_attribute` | `str` (p. ej. `region`) | `null` | +| `layout` | `spring` · `kamada_kawai` · `circular` · `null` | `null` | +| `keyword_filter` | `list[str]` (ANY substring sobre `keywords_raw`) | `null` | ```yaml networks: - - kind: keyword_cooccurrence - keyword_filter: ["complex", "ecolog"] # papers con keywords tipo "Complexity"/"Ecological..." - kind: bibliographic_coupling - keyword_filter: ["assessment"] + min_weight: 2 + resolution: 1.5 + - kind: keyword_cooccurrence + keyword_filter: ["complex", "ecolog"] ``` -**Notas de contrato** (Hito 2, ADR [0014](decisiones/0014-proyeccion-redes-pesos-asortatividad.md)): - -- **`Networks.quick` arma 4 o 5 redes** (D3, AS-BUILT Hito 8b): coupling `full`, co-autoría, - institución y co-word **siempre** (4); suma la **co-citación** (→5) cuando el corpus tiene - `cited_by_id` poblado por `b2g enrich` (2º nivel de fetch del `OpenAlexEnricher`, ADR 0025), y la - **omite avisándolo por log** (→4) si esa columna está vacía (no se corrió `enrich`). El - `CoCitationProjector` también queda disponible vía - `Networks.build(corpus, NetworkSpec(kind="cocitation"))`. -- **`NetworkSpec` es un hook mínimo en v1** (modelo Pydantic ya consumido por `build`/`quick`); el - símbolo público re-exportado desde `bib2graph` es `NetworkArtifact` (no `NetworkSpec`, que se - importa desde `bib2graph.networks`). -- **AS-BUILT Hito 9 (2026-06-17) — capa declarativa YAML.** `NetworkSpec` gana `model_config = - ConfigDict(extra="forbid")` (campo desconocido → error) y el campo **`resolution: float = 1.0`** - (resolución de Louvain, propagada por `_build_artifact` a - `community_louvain.best_partition(..., resolution=...)`; **ignorada** en `label_prop`/ - `greedy_modularity`). `resolution` queda **fuera del `corpus_hash`** (es param de spec, no de - contenido — como `min_weight`/`scope`), así que el seed de Louvain sigue siendo función pura del - `corpus_hash` (R2) y la reproducibilidad/equivalencia de comunidades se mantienen. **`load_specs`** - (en `networks/spec.py`, re-exportada desde `bib2graph.networks`) carga la lista de specs desde un - YAML con clave raíz `networks:`; cada entrada se valida con `NetworkSpec(**entry)`. Errores - accionables (`ValueError`): YAML malformado, falta de raíz `networks:`, entrada no-dict, y - `ValidationError` citando archivo + `red #` (0-based) + campo. Ejemplo `redes.yaml`: - - ```yaml - networks: - - kind: bibliographic_coupling - min_weight: 2 - resolution: 1.5 - - kind: author_collab - clustering: label_prop - ``` - - Se ejecuta vía el subcomando **`b2g networks --spec redes.yaml`** (§convenciones CLI). DoD del - Hito 9 cubierto, incluida la **equivalencia build≡quick** (nodos + aristas + comunidades). - - **Ejemplo mínimo de `networks.yaml` válido** (copiable) — clave raíz `networks:` = lista; lo único - obligatorio de cada entrada es **`kind`**: - - ```yaml - networks: - - kind: bibliographic_coupling - ``` - - **Campos válidos de cada entrada** (nombres exactos; `kind` obligatorio, el resto con default): - - | Campo | Valores / tipo | Default | Obligatorio | - |---|---|---|---| - | `kind` | `bibliographic_coupling` · `cocitation` · `author_collab` · `institution_collab` · `keyword_cooccurrence` | — | **sí** | - | `min_weight` | `int` (peso mínimo de arista) | `1` | no | - | `min_year` | `int` | `null` | no | - | `max_year` | `int` | `null` | no | - | `scope` | `full` · `seeds_only` | `full` | no | - | `clustering` | `louvain` · `label_prop` · `greedy_modularity` · `null` | `louvain` | no | - | `resolution` | `float` (solo Louvain; ignorado en los demás) | `1.0` | no | - | `assortativity_attribute` | `str` (atributo categórico, p. ej. `region`) | `null` | no | - | `layout` | `spring` · `kamada_kawai` · `circular` · `null` | `null` | no | - - **`NetworkSpec` usa `extra="forbid"`:** cualquier campo fuera de la tabla **se rechaza** con un - `ValueError` accionable (no se ignora en silencio). Error común: **`name:` NO es un campo** — no hay - forma de nombrar una red en el spec; usá un comentario YAML (`#`) si querés anotarla. -- **AS-BUILT #25 — artefactos decorados:** `_build_artifact` (en `facade.py`) aplica `decorate` - (§7.1) sobre el grafo, así que `build`/`quick` devuelven artefactos con `label` legible + atributos - de nodo (`year`/`is_seed`/`curation_status`/`degree_centrality`/`community`) listos para el export - y la GUI. Los proyectores (§7) siguen puros (ADR 0014). - --- -## 11. Deduplicación fuzzy — AUTOMÁTICA en la ingesta (`rapidfuzz` núcleo, v1 — construido, Hito 7 + #88) - -> **Sincronizado con el preprocesamiento automático en la ingesta — #88 (AS-BUILT, 2026-06-18, -> ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)):** el dedup deja de ser -> "función de librería sin subcomando" (corte 0026) y pasa a ejecutarse **automáticamente en cada -> ingesta** (`seed`/`seed_from_bib`/`chain`/`restore`). **`rapidfuzz` pasa al núcleo** -> (`[project.dependencies]`); **el extra `[dedup]` se ELIMINA** y el import deja de ser perezoso. El -> **algoritmo** de 0026 (token_sort_ratio + Union-Find + canónico) **no cambia**: cambia *quién lo -> invoca y cuándo*. `b2g thesaurus` es el único paso explícito del preproc (§convenciones CLI, §6). +## 11. Deduplicación fuzzy — AUTOMÁTICA en la ingesta (`rapidfuzz` núcleo) **Dedup fuzzy determinista** con `rapidfuzz` (núcleo desde #88): el complemento aproximado de la normalización conservadora del `Preprocessor` (§6). Las funciones siguen exportadas desde @@ -2043,7 +1528,7 @@ normalización conservadora del `Preprocessor` (§6). Las funciones siguen expor def normalize_and_dedup(corpus: Corpus, *, applied_at: datetime | None = None) -> Corpus: """normalize → deduplicate_authors(0.92) → deduplicate_keywords(0.90), en ese orden, sobre el corpus COMPLETO YA MERGEADO (existing + incoming) ⇒ dedup CROSS-BIBLIOTECA. NO aplica thesaurus - (eso es el paso explícito `b2g thesaurus`). `applied_at` se inyecta desde la frontera (R2).""" + (eso es el flag explícito `b2g build --thesaurus`, #164). `applied_at` se inyecta desde la frontera (R2).""" # Funciones de librería (ADR 0026, intactas; ahora invocadas por el helper, no a mano) def deduplicate_authors(corpus: Corpus, *, threshold: float = 0.92) -> Corpus: @@ -2054,137 +1539,94 @@ def deduplicate_keywords(corpus: Corpus, *, threshold: float = 0.90) -> Corpus: """Colapsa variantes de `keywords_id` fuera del thesaurus por similitud de cadenas.""" ``` -**Notas de contrato** (Hito 7 + #88, ADR [0026](decisiones/0026-dedup-fuzzy-determinista.md) / +**Notas de contrato** (ADR [0026](decisiones/0026-dedup-fuzzy-determinista.md) / [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)): - **Automático en la ingesta, cross-biblioteca:** las cuatro rutas (`seed`/`seed_from_bib`/`chain`/`restore`) hacen `existing.merge(incoming)` → `normalize_and_dedup(corpus_completo)` → `store.persist_replace(...)`. Corre sobre el corpus **completo** (no el lote) para deduplicar contra toda la biblioteca acumulada; se persiste con - **`persist_replace`** (DELETE+INSERT, §4.1) porque el upsert-concat D3 (`persist`) reintroduciría - las variantes colapsadas. `build`/`networks` siguen **puros** (el corpus ya entra deduplicado). -- **`threshold` por-campo** (autores `0.92` / keywords `0.90`; **ambas** lo reciben): se compara con - `rapidfuzz.fuzz.token_sort_ratio` (0–100) contra `threshold * 100`. Umbrales fijos en - `cli/_ingest.py` (ADR 0031). -- **Determinista e idempotente:** los pares ≥ umbral forman **componentes conexas** vía Union-Find - (iteración ordenada); el **canónico** del cluster es la variante más frecuente (papers distintos) - con desempate por `id` ascendente; se preserva el **orden de primera aparición** y **nunca se toca - `_raw`**. Mismo corpus + threshold + versión de `rapidfuzz` → mismo resultado (verificado - cross-`PYTHONHASHSEED`); converge en una pasada. **NO usa IA** (similitud de cadenas, no - semántica/LLM; ADR [0022](decisiones/0022-producto-sin-ia-generativa.md)). -- **`rapidfuzz` en el núcleo (#88):** `rapidfuzz>=3,<4` en `[project.dependencies]`; el import de - `preprocessors/dedup.py` es de nivel de módulo (ya **no** perezoso, ya **no** hay extra `[dedup]` - ni `uv sync --extra dedup`). Registra un `PreprocRef` en el `Manifest` con - `{library, rapidfuzz_version, scorer, threshold, n_clusters_collapsed}` (reproducibilidad a igual - versión del scorer, ADR 0017). -- **Campos en V1:** autores + keywords. **Instituciones diferidas** (`institutions_id` no está - normalizada determinísticamente hoy). `splink` (record-linkage probabilístico) **diferido a - post-V1** (ADR 0026). -- **Diferido a la epic GUI (#34):** la **revisión asistida de clusters ambiguos** (sugerir N - canónicos → el humano elige, determinista vía scores de `rapidfuzz`, **sin IA generativa**) requiere - superficie interactiva; hoy el dedup automático aplica el canónico determinista sin confirmar - (ADR 0031). **Deuda conocida:** el dedup por ingesta es O(n²) sobre el corpus completo - (optimización futura) y `test_run_seed_from_bib_reseed_incrementa_ronda` queda **skip** (#93, - crash `BibDataString`/`pyparsing` en reseed mismo-proceso; no afecta el CLI real). + **`persist_replace`** (§4.1) porque el upsert-concat D3 reintroduciría las variantes colapsadas. + `build` sigue **puro** (el corpus ya entra deduplicado). +- **`threshold` por-campo** (autores `0.92` / keywords `0.90`): `rapidfuzz.fuzz.token_sort_ratio` (0–100) + contra `threshold * 100`. Umbrales fijos en `cli/_ingest.py`. +- **Determinista e idempotente:** los pares ≥ umbral forman **componentes conexas** vía Union-Find; el + **canónico** del cluster es la variante más frecuente (desempate por `id` ascendente); se preserva el + **orden de primera aparición** y **nunca se toca `_raw`**. Mismo corpus + threshold + versión de + `rapidfuzz` → mismo resultado (verificado cross-`PYTHONHASHSEED`); converge en una pasada. **NO usa + IA** (similitud de cadenas, no semántica/LLM; ADR 0022). Registra un `PreprocRef` en el `Manifest` + (`{library, rapidfuzz_version, scorer, threshold, n_clusters_collapsed}`). +- **`rapidfuzz` en el núcleo:** `rapidfuzz>=3,<4` en `[project.dependencies]` (ya no hay extra `[dedup]`). +- **Campos en V1:** autores + keywords. **Instituciones diferidas**; `splink` (record-linkage + probabilístico) diferido a post-V1 (ADR 0026). **Deuda conocida:** el dedup por ingesta es O(n²) sobre + el corpus completo (optimización futura). La **revisión asistida de clusters ambiguos** (sugerir N + canónicos determinista vía scores, sin IA → el humano elige) requiere superficie interactiva y no está; + hoy el dedup aplica el canónico determinista sin confirmar. --- ## 12. Ejemplo de uso (ecuación → biblioteca viva → redes) -`DuckDBStore` se importa desde `bib2graph` (re-export **perezoso** vía PEP 562, §4.1): `import -bib2graph` no arrastra duckdb. `OpenAlexSource`/`BibtexSource`/`Networks`/`GraphMLExporter` están -**construidos** (Hitos 2 y 4). - -### 12.1 Hoy (v0.1) — corre con lo construido +### 12.1 Por CLI agente-native (el camino canónico) -De una ecuación a las redes, curando a mano (sin forrajeo ni CLI todavía): +Se **inicia el workspace una vez** y, trabajando **dentro** de su carpeta, los comandos se resuelven por +ambiente. Con `B2G_JSON=1`, una línea JSON por comando (un agente corre el ciclo sin repetir `--json`): -```python -from pathlib import Path -from bib2graph import OpenAlexSource, DuckDBStore, Networks, GraphMLExporter - -# 1) Sembrar desde una ecuación consciente (query ejecutada + reporte de límites visibles) -seed = OpenAlexSource(email="luis@sostaina.com").seed( - '"unequal ecological exchange" OR "intercambio ecológico desigual"') -print(seed.executed_query); print("\n".join(seed.translation_report)) - -# 2) Curar a mano (juicio humano): las semillas entran como `candidate` -corpus = seed.corpus.accept(ids=[...]).reject(ids=[...]) - -# 3) Persistir en la biblioteca viva (DuckDB; acumula entre corridas) + snapshot reproducible -store = DuckDBStore(Path("biblioteca.duckdb")) # 1 archivo = 1 investigación (ADR 0015/0016) -store.persist(corpus) -snap = store.load().snapshot(Path("snapshots/ied-2026-06-15")) - -# 4) Redes (acoplamiento sobre corpus completo, co-autoría, instituciones, co-word) + export -for art in Networks.quick(snap.corpus): - GraphMLExporter().export(art.graph, art.metrics, out_dir=Path(f"redes/{art.spec.kind}")) +```bash +b2g init ied # crea ./ied/ (workspace.json + library.duckdb + …) +cd ied # a partir de acá el workspace se resuelve por cwd +export B2G_JSON=1 +b2g seed --equation '"unequal ecological exchange"' --max-results 50 \ + --exclude "blockchain" --email tu@correo.org # --exclude (repetible): negaciones en el translation_report +b2g chain --direction both --max-candidates 300 # → FORAGED (+ pasada refs→DOI automática) +b2g curate dump # vuelca candidatos a exports/curacion.csv (revisar offline) +b2g curate apply curacion.csv # aplica accepted/rejected en lote +b2g build --max-citing 50 --email tu@correo.org # → BUILT; co-citación (cited_by) sobre las aceptadas +b2g read top --kind bibliographic_coupling # salida de investigación (nodos centrales + co-citación) +b2g export --format graphml # serializa networks/ a exports/ +b2g snapshot create # foto reproducible (parquet + manifest.json) +b2g status # CycleState + round + curation_available + workspace ``` -### 12.2 Con forrajeo y thesaurus (Hito 5 — construido) +Migración de un `.duckdb` legacy: corré **`b2g init .`** en su carpeta para adoptarlo como workspace. +El **modo declarativo** se invoca con **`b2g build --spec redes.yaml`** (carga `load_specs` → `Networks.build` +por red, escribe `networks//`, transiciona a `BUILT` y sella `.corpus_hash`). -`Forager`/`Preprocessor`/filtros están **construidos** (Hito 5). El *information scent* es -frecuencia de enlace; `preview` opera sin red (forward → `chain`); los filtros marcan `rejected`: +### 12.2 Como librería Python + +El mismo dominio sin CLI (el núcleo es puro y testeable; el forrajeo y el store hacen I/O): ```python +from pathlib import Path from bib2graph import ( - OpenAlexSource, Forager, Preprocessor, FilterCriterion, apply_filters, + OpenAlexSource, Forager, Preprocessor, DuckDBStore, Networks, GraphMLExporter, + FilterCriterion, apply_filters, ) -# 2') Forrajear: candidatos rankeados por information scent (depth=1, preview SIN red) -forager = Forager(OpenAlexSource(email="luis@sostaina.com"), depth=1, max_candidates=300) -prev = forager.preview(seed.corpus) # backward exacto; forward → chain -print(prev.estimated_new, prev.forward_requires_fetch) # p. ej. 142 True -ranked = forager.chain(seed.corpus) # forward fetchea citantes -# AS-BUILT #54: el backward observa sin materializar → ranked.observed_refs (no van a ranked.corpus); -# AS-BUILT #78: el forward materializa filas con metadata REAL (no placeholders). Ver §5. +# 1) Sembrar (query ejecutada + reporte de traducción visibles) +seed = OpenAlexSource(email="tu@correo.org").seed( + '"unequal ecological exchange" OR "intercambio ecológico desigual"') +print(seed.executed_query, "\n".join(seed.translation_report)) + +# 2) Forrajear (depth=1; backward observa sin materializar, forward materializa filas reales) +forager = Forager(OpenAlexSource(email="tu@correo.org"), depth=1, max_candidates=300) +ranked = forager.chain(seed.corpus) -# 3') Curar lo forrajeado, normalizar y aplicar el thesaurus multilingüe determinista +# 3) Curar + normalizar + thesaurus determinista corpus = seed.corpus.merge(ranked.corpus).accept(ids=[...]).reject(ids=[...]) corpus = Preprocessor().normalize(corpus) -corpus = Preprocessor().apply_thesaurus(corpus, Path("thesaurus_ied.json")) # puebla keywords_id +corpus = Preprocessor().apply_thesaurus(corpus, Path("thesaurus_ied.json")) -# 4') Filtrar (PRISMA): marca rejected, NO borra; sella Manifest.filters con los conteos +# 4) Filtrar (PRISMA: marca rejected, no borra) + persistir + snapshot + redes corpus, steps = apply_filters(corpus, [ FilterCriterion(field="year", op="gte", value=2010), FilterCriterion(field="language", op="in", value=["en", "es", "pt"]), ]) -for s in steps: - print(s.name, s.count_before, "→", s.count_after) -``` - -### 12.3 Por CLI agente-native (Hito 6 — construido) - -El mismo flujo, sin escribir Python, vía `b2g`: se **inicia el workspace una vez** y, trabajando -**dentro** de su carpeta, los comandos se resuelven por ambiente (sin `--store` repetido). Una línea -JSON por comando: - -```bash -b2g init ied # crea ./ied/ (workspace.json + library.duckdb + networks/…) -cd ied # a partir de acá el workspace se resuelve por cwd -b2g seed --equation '"unequal ecological exchange"' --max-results 50 \ - --exclude "blockchain" --exclude "machine learning" \ - --email luis@sostaina.com --json # --max-results: muestra chica · --exclude (repetible): negaciones, quedan en el translation_report -b2g chain --direction both --max-candidates 300 --max-citing 50 --json -b2g filter --year-gte 2010 --language en --language es --json -b2g accept --ids doi:abc123 --ids doi:def456 --json -b2g build --json # escribe networks/ + sella networks/.corpus_hash -b2g export --format graphml --out-dir redes/ --json -b2g status --json # CycleState + round + curation_available + workspace + conteos -``` - -Migración de un `.duckdb` legacy: corré **`b2g init .`** en su carpeta para adoptarlo como -workspace (`--store` y el modo degenerado fueron eliminados en -[#75](https://github.com/complexluise/bib2graph/issues/75)). - -El **modo declarativo** (`b2g networks --spec redes.yaml`, `NetworkSpec` desde YAML) está -**construido** (Hito 9, AS-BUILT 2026-06-17): un YAML versionable describe qué redes calcular. - -```bash -# Hito 9: pipeline declarativo desde un YAML versionable (dentro del workspace) -b2g networks --spec redes.yaml --json # carga load_specs(redes.yaml) → Networks.build por red → - # escribe networks// (GraphML + metrics.json + clusters.csv) +store = DuckDBStore(Path("ied/library.duckdb")); store.persist(corpus) +snap = store.load().snapshot(Path("ied/snapshots/ied")) +for art in Networks.quick(snap.corpus): + GraphMLExporter().export(art.graph, art.metrics, out_dir=Path(f"ied/networks/{art.spec.kind}")) ``` -`b2g networks` es **ad-hoc / transversal al lazo**: **NO** transiciona el `CycleState` ni sella -`networks/.corpus_hash` (igual criterio que `enrich`/`curate`). Ver §convenciones CLI. +`DuckDBStore` se importa desde `bib2graph` (re-export **perezoso** vía PEP 562, §4.1): `import bib2graph` +no arrastra duckdb. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38a8ddf..92cfe26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,48 +1,12 @@ -# ARQUITECTURA — bib2graph (objetivo / north star) - -> Arquitectura **deseada** de la V1, no un estado as-built. El as-built de v0 (con sus -> problemas) está en [`Notas/03-referencia/arquitectura-v0.md`](Notas/03-referencia/arquitectura-v0.md) -> y NO debe tomarse como objetivo. Fecha: 2026-06-15. -> -> Reconcilia este doc con el **giro** (`Notas/04`–`06`) y el [`PRD.md`](PRD.md) reescrito. -> Decisiones que lo sustentan, en [`decisiones/`](decisiones/): tabla canónica Arrow -> [0006](decisiones/0006-tabla-canonica-y-networkspec.md); **OpenAlex backbone** -> [0007](decisiones/0007-openalex-backbone.md); **wedge = forrajeo** -> [0008](decisiones/0008-wedge-forrajeo.md); **biblioteca viva en DuckDB** -> [0009](decisiones/0009-biblioteca-viva-duckdb.md); **agente-native columna** -> [0010](decisiones/0010-agente-native-columna.md); **thesaurus** -> [0011](decisiones/0011-thesaurus-multilingue.md). El método bibliométrico está en -> [`metodología.md`](Notas/metodología.md). -> -> **AS-BUILT vs TARGET (importante):** este doc describe el diseño **objetivo** tras el red-team -> de la [Nota 06](Notas/06-critica-as-built-v0.2.md). Donde el código v0.2 difiere del objetivo, -> el bloque está marcado **`TARGET`** (lo que debe construirse) y/o **`AS-BUILT v0.2`** (lo que hay -> hoy). La tanda de **remediación** que cierra esa brecha está secuenciada **por dependencia** en el -> [`ROADMAP.md`](ROADMAP/README.md) (Hitos **R1–R5**), antes de los hitos nuevos: **R1** cimientos -> (constants/modelos/schema), **R2** identidad-vs-procedencia (hash/reloj/Louvain), **R3** ciclo -> (`cycle.py`/`reseed`/curación transversal), **R4** scent bibliométrico (proyectores; retiro de -> IA), **R5** robustez (bulk-load/UTF-8/footguns). -> -> **Decisión bloqueada por el PO (2026-06-15) — el producto NO usa IA generativa.** La -> "inteligencia" que asiste el forrajeo es **estructura bibliométrica como *information scent***, -> **determinista y reproducible** (acoplamiento / co-citación / centralidad sobre el corpus), **sin -> LLM ni embeddings**. Se **elimina** `explain_candidate`, el módulo `foraging/explain.py` y el -> extra `[llm]`; la **"máquina de tensiones" / sensemaking asistido por IA se quita del alcance por -> completo** (no se difiere: se borra del producto). El sensemaking sigue siendo **humano**, -> asistido por las redes — no por IA. Queda **un solo** sentido de "AI-in-the-loop": el *desarrollo* -> es asistido por IA; el *producto* no usa IA. Ver ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) -> (scent bibliométrico), [0008](decisiones/0008-wedge-forrajeo.md) (tensiones removidas) y -> [0022](decisiones/0022-producto-sin-ia-generativa.md) (el producto no usa IA generativa). -> -> **Cambios mayores respecto a la versión previa de este doc:** la fuente de referencia pasó de -> **BibTeX a OpenAlex** (ADR 0007); la persistencia por defecto pasó de **snapshot inmutable / -> InMemoryStore** a un **`Store` stateful en DuckDB** (biblioteca viva; ADR 0009), con el -> snapshot demotado a *export*; se agregaron al núcleo el **forrajeo/chaining** y el **thesaurus -> multilingüe**; se incorporó una **capa base de vocabulario + modelos** (`constants` / `schemas`, -> con `ProvenanceEvent` consolidado en `schemas.py` —no en un `models.py` aparte—, ADR -> [0023](decisiones/0023-capa-constants-modelos-schema.md)) y el **ciclo como -> dominio puro** (`cycle.py` con FSM cíclico, ADR 0016 enmendado); y se **retiró la rama de IA** -> generativa (ADR 0022). +# ARQUITECTURA — bib2graph + +> Diseño de la librería: un **núcleo puro rodeado de costuras**, apoyado en una capa base de +> vocabulario y modelos. El **producto NO usa IA generativa** (ADR +> [0022](decisiones/0022-producto-sin-ia-generativa.md)): la asistencia del forrajeo es **estructura +> bibliométrica determinista** (*information scent*), sin LLM ni embeddings. El desarrollo sí es +> asistido por IA; el producto no. El "porqué" de cada decisión vive en los ADR de +> [`decisiones/`](decisiones/) (linkeados en cada sección); el método en +> [`Notas/metodología.md`](Notas/metodología.md); los contratos públicos en [`API.md`](API.md). ## 1. Idea en un párrafo @@ -50,44 +14,23 @@ y modelos**. La **capa base** (`constants`, `schemas`; ADR [0023](decisiones/0023-capa-constants-modelos-schema.md)) es la **fuente única** de nombres de columna, estados de curación, tipos de red y del evento de procedencia (`ProvenanceEvent` vive en -`schemas.py`, no en un `models.py` separado) — todo el resto depende de ella. El **núcleo puro** opera sobre un `Corpus` en memoria (una **tabla canónica Arrow**) y nunca -hace red ni servidores: proyecta el corpus a redes, las analiza y las exporta, normaliza/cura la -tabla, y **modela el ciclo** de investigación como una máquina de estados de dominio (`cycle.py`, -ADR [0016](decisiones/0016-maquina-estados-lazo.md)). Alrededor hay costuras: **`Source`** (sembrar +`schemas.py`, no en un `models.py` separado) — todo el resto depende de ella. El **núcleo puro** opera +sobre un `Corpus` en memoria (una **tabla canónica Arrow**) y nunca hace red ni servidores: proyecta el +corpus a redes, las analiza y las exporta, normaliza/cura la tabla, y **modela el ciclo** de +investigación como una máquina de estados de dominio (`cycle.py`, ADR +[0016](decisiones/0016-maquina-estados-lazo.md)). Alrededor hay costuras: **`Source`** (sembrar el corpus por **ingesta de doble puerta** —ecuación de búsqueda **o** archivo `.bib`, ambas **primarias**; ADR [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)— con *OpenAlex* como -motor de extracción de referencia, intercambiable), el -**forrajeo/chaining** (expandir el corpus rankeando candidatos por *information scent* — **estructura -bibliométrica determinista, sin IA**), **`Store`** (persistir — *DuckDB stateful por defecto*: la -**biblioteca viva**) y `Enricher` (señal extra, opt-in). El flujo **no es lineal**: es el **ciclo -iterativo** de exploración (sembrar → forrajear → curar → la idea muta → re-sembrar), y la -biblioteca viva en DuckDB es el sustrato que lo sostiene entre corridas. - -> **PARCIALMENTE CONSTRUIDO (2026-06-18) — frontends de frontera + capa de servicios neutral, ADR -> [0027](decisiones/0027-pivote-posicionamiento-gui-local.md)/[0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) -> (Aceptados; GUI gateada por [#34](https://github.com/complexluise/bib2graph/issues/34)).** La **capa -> de servicios neutral `src/bib2graph/service/` ya existe** (G1: contrato subido desde `cli/`; G2: -> 6 lecturas read-only en `service/reads.py`, AS-BUILT 2026-06-18), la **API local FastAPI** -> (`src/bib2graph/api/`) **ya está construida** (G3, AS-BUILT 2026-06-18: 7 endpoints + token Bearer + -> mapeo código→HTTP + 19º subcomando `b2g gui`) y la **SPA `frontend/` también está construida** (G4, -> AS-BUILT 2026-06-18: React 18 + Vite + TS + Cytoscape/fcose + Zustand + Tailwind + TanStack Query, -> **pnpm**, dirección visual D-2; consume los 7 endpoints reales; el token se inyecta en el `index.html` -> servido). El **empaquetado** (G5, AS-BUILT 2026-06-18: el wheel vendorea el build del frontend vía -> `force-include` + job CI JS, §4.4/§7) **cierra el build del MVP** — **los 5 hitos G1–G5 están -> AS-BUILT**. Lo único pendiente es el **gate #34** (un tercero usa la GUI sin ayuda), que **no es -> construcción** sino el criterio de aceptación de producto de la epic. -> **ESTADO 0.8 — la GUI quedó ROTA A PROPÓSITO ([#117](https://github.com/complexluise/bib2graph/issues/117)):** -> el rename de columna a **`source_id`** (ADR 0036) la rompió de forma deliberada; **no funciona hoy** -> hasta que la API/SPA se actualicen al nuevo nombre. El "AS-BUILT G1–G5" describe lo que se construyó -> en 0.6, no que la GUI corra en 0.8. Sobre ese núcleo + costuras se -> montan **tres frontends de frontera** — **CLI** -> (`b2g`, Click, la columna agente-native, ADR 0010/0021) · **API local** (FastAPI, opt-in `[gui]`) · -> **SPA** (frontend "tool for thought" en `frontend/`). Los tres **convergen en una capa de servicios -> neutral** `src/bib2graph/service/` (agnóstica de transporte: sin `print`, `sys.exit`, Click ni -> FastAPI) que contiene **la orquestación** (lo que hoy es `run_`) **+ el contrato** (envelope -> `schema="1"`, jerarquía de errores `B2GError`, mapeo error→código) **subido desde `cli/`**. CLI y API -> son **adaptadores delgados** sobre `service/`; ninguno importa al otro. El **contrato externo -> (`schema="1"`, exit codes) NO cambia**. Detalle en §4.4 y §6.3. +motor de extracción de referencia, intercambiable), el **forrajeo/chaining** (expandir el corpus +rankeando candidatos por *information scent* — **estructura bibliométrica determinista, sin IA**), +**`Store`** (persistir — *DuckDB stateful por defecto*: la **biblioteca viva**) y `Enricher` (señal +extra, opt-in). El flujo **no es lineal**: es el **ciclo iterativo** de exploración (sembrar → +forrajear → curar → la idea muta → re-sembrar), y la biblioteca viva en DuckDB es el sustrato que lo +sostiene entre corridas. + +La frontera de frontera es el **CLI `b2g`** (Click, agente-native, ADR 0010/0021): la columna que +humanos y agentes usan para correr el ciclo. No hay GUI ni servidor web en la librería (la experiencia +visual library-centric vive en un producto separado; ADR [0040](decisiones/0040-retiro-gui-local.md)). ## 2. Vista de alto nivel @@ -116,43 +59,16 @@ biblioteca viva en DuckDB es el sustrato que lo sostiene entre corridas. │ (stateful) │ corridas, log de procedencia + estado del ciclo │ │ (CycleState + ronda; dominio en cycle.py, ADR 0016). │ DuckDBStore │ Snapshot = export sellado. 1 archivo = 1 escritor - │ = fachada │ (single-writer, ADR 0019). Store/Zotero(1.1)/Neo4j - │ Store→Zotero │ = costura externa opt-in, NO la persistencia primaria. + │ = fachada │ (single-writer, ADR 0019). Store/Zotero/Neo4j + │ │ = costura externa opt-in, NO la persistencia primaria. └─────────────┘ ``` El **`DuckDBBackend` es el backend por defecto del `Corpus`** (ADR -[0015](decisiones/0015-corpus-tabular-backend.md)), no un `Store` separado: persiste, muta por SQL -`UPDATE`/`MERGE` por `id` y aloja el `LoopState` (ADR -[0016](decisiones/0016-maquina-estados-lazo.md)). El **`DuckDBStore` es su fachada** de costura -(`persist`/`load`); la costura `Store` sigue siendo el punto de extensión externo -(`ZoteroStore`/`Neo4jStore`, opt-in). Lo marcado `Zotero(1.1)`, `Neo4j` son costuras -secundarias/futuras (la **ingesta `.bib` SÍ es puerta primaria**, ADR 0035; no es costura secundaria). La **máquina de tensiones** (sensemaking asistido por IA) **se retiró del -producto** (ADR [0008](decisiones/0008-wedge-forrajeo.md) / [0022](decisiones/0022-producto-sin-ia-generativa.md)): -el sensemaking lo hace el **humano**, leyendo las redes — no hay IA generativa en el producto. Solo -se publica lo que existe. - -> **TARGET (2026-06-18) — frontends → capa de servicios neutral, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) (Aceptado; gateado por -> [#34](https://github.com/complexluise/bib2graph/issues/34) — NO implementado).** El diagrama de -> arriba es el **flujo de datos del núcleo**. La GUI agrega tres frontends de frontera que **convergen -> en una capa de servicios neutral** (no en `run_` directo: ese ajuste es la corrección del -> 2026-06-18 sobre el encuadre original — `run_` solo devuelve el payload `data`, mientras el -> contrato vivía en `cli/`): -> -> ``` -> SPA (JS, grafo-lienzo) ──HTTP/JSON──► API local (FastAPI, 127.0.0.1, opt-in [gui]) -> CLI b2g (Click) ──────────────────────────────────────┐ │ -> ▼ ▼ -> capa de servicios NEUTRAL src/bib2graph/service/ -> (orquestación = lo que hoy es run_ -> + contrato: envelope schema="1", errores B2GError, mapeo→código) -> ▼ -> NÚCLEO PURO (corpus, cycle, projectors, analyzer) + costuras -> ``` -> -> CLI y API son **adaptadores delgados** de `service/`; el CLI traduce el código a exit code y la API a -> HTTP status (§6.3, §4.4). El contrato externo (`schema="1"`, exit codes) **no cambia**. +[0015](decisiones/0015-corpus-tabular-backend.md)), no un `Store` separado: persiste, muta por SQL y +aloja el `CycleState` (ADR [0016](decisiones/0016-maquina-estados-lazo.md)). El **`DuckDBStore` es su +fachada** de costura; la costura `Store` (`ZoteroStore`/`Neo4jStore`) es el punto de extensión externo +opt-in. El sensemaking lo hace el **humano** leyendo las redes — no hay IA generativa en el producto. ## 3. El núcleo (puro, sin red ni servidores) @@ -168,34 +84,24 @@ pipeline. Su contenido es **una sola tabla Arrow** (`pa.Table`) con schema fijo validada por el wrapper público con **Pydantic v2** (ADR 0006). `Paper`/`Author`/`Keyword`/ `Institution` **no son tipos del modelo**: son **vistas derivadas** vía `groupby + explode`. -**El `Corpus` se respalda en un `TabularBackend` (Protocol) y delega las mutaciones** (ADR +El `Corpus` se respalda en un `TabularBackend` (Protocol) y **delega las mutaciones** (ADR [0015](decisiones/0015-corpus-tabular-backend.md)): `InMemoryBackend` (puro, tests + working set efímero) o `DuckDBBackend` (biblioteca viva por defecto, mutación por SQL `UPDATE`/`MERGE` por `id`). El **núcleo no importa `duckdb`**: depende del Protocol. `corpus.to_arrow()` es el **puente estable a los proyectores/analizadores puros** — solo cambia el *contenedor*, no el núcleo de análisis. Las reglas de identidad/hash/merge (ADR [0013](decisiones/0013-identidad-hash-merge-corpus.md), D1/D2/D3) son contrato que cada backend -cumple a su manera. - -**AS-BUILT R5 — bulk-load (Nota 06 RAÍZ 3):** los loaders (seed/load OpenAlex, BibTeX, Forager) -construyen la tabla Arrow **de una vez** con `Corpus.from_arrow` (precomputando los `id` con el helper -`corpus._rows_with_ids`), en vez del loop `add_paper`/`_clone` que **re-upserteaba la tabla entera por -fila** (O(n²)). Cargar un corpus mediano deja de ser cuadrático. - -**Columnas** (esquema completo en [`API.md`](API.md) §1 — *pendiente de reconciliar*): - -- Identidad/metadatos: `id` (interno estable; hash de `doi`/`source_id`, ADR 0036), `source_id` - (id del motor de extracción, agnóstico —antes `openalex_id`), `doi`, `title`, `year`, - `abstract`, `source`, `language`, `publisher`, `research_areas`. -- **Estado de pipeline / curación** (no contamina la entidad): `is_seed` (bool), - `curation_status` (`candidate` / `accepted` / `rejected`), `provenance` (JSON: ecuación, salto - de chaining, fuente, fecha, decisión humana — base del **log de procedencia**, ADR 0009). -- **Relaciones de entrada** (datos crudos): `authors_raw` / `authors_id`, - `authors_affiliations` (**per-autor**, de OpenAlex), `keywords_raw` / `keywords_id`, - `institutions_raw` / `institutions_id`, `references_id`/`references_doi` y `cited_by_id` - (**de OpenAlex**, ya no de un Enricher — ADR 0007). -- **Relaciones derivadas** (las producen los Proyectores, NO viven en el corpus): - `BIB_COUPLED_WITH`, `CO_CITED_WITH`, `COLLABORATED_WITH`, `CO_OCCURRENCE`. +cumple a su manera. Los loaders (seed, BibTeX, Forager) construyen la tabla Arrow **de una vez** con +`Corpus.from_arrow` (carga bulk, no upsert por fila). + +**Columnas** (esquema completo en [`API.md`](API.md) §1): identidad/metadatos (`id` interno estable +—hash de `doi`/`source_id`, ADR 0036—, `source_id` agnóstico al motor, `doi`, `title`, `year`, +`abstract`, `source`, `language`, …); **estado de pipeline/curación** que no contamina la entidad +(`is_seed`, `curation_status` ∈ {`candidate`,`accepted`,`rejected`}, `provenance` = log append-only, +ADR 0009); **relaciones de entrada** crudas (`authors_*`, `keywords_*`, `institutions_*`, +`references_id`/`references_doi`, `cited_by_id` — **de OpenAlex**, ya no de un Enricher, ADR 0007). +Las **relaciones derivadas** (`BIB_COUPLED_WITH`, `CO_CITED_WITH`, `COLLABORATED_WITH`, +`CO_OCCURRENCE`) las producen los Proyectores y **no viven en el corpus**. ### 3.2 `Projector` — corpus → red @@ -209,30 +115,23 @@ Toma un `Corpus` y devuelve un `networkx.Graph` ponderado: | colaboración de instituciones | instituciones vía co-firmas | `institutions_id` | barato | | co-ocurrencia de keywords | keywords juntas en un paper | `keywords_id` (normalizadas por thesaurus) | barato | -**Verdad de dependencias (ADR 0007):** con OpenAlex como backbone, las referencias y los -citantes **ya vienen en el corpus**; el `Enricher` deja de ser estructural. El **acoplamiento** -(barato, mira hacia adelante, usa refs que las semillas ya traen) es **ciudadano de primera** -(crítica #2). La **co-citación** sigue siendo la más cara: necesita los citantes *con sus -propias citas* (segundo nivel de fetch). El acoplamiento opera sobre el **corpus completo**, no -solo `is_seed` (rediseño validado en el sandbox IED). **AS-BUILT (Hito 8b ✅):** ese 2º nivel ya está -cableado end-to-end — `b2g enrich` puebla `cited_by_id` (vía `OpenAlexSource.fetch_citing_batch`, -§4.2) y `Networks.quick` incluye la co-citación cuando esa columna está poblada. +Con OpenAlex como backbone, las referencias y los citantes **ya vienen en el corpus**; el `Enricher` +deja de ser estructural. El **acoplamiento** (barato, mira hacia adelante, usa refs que las semillas +ya traen) es **ciudadano de primera**. La **co-citación** sigue siendo la más cara: necesita los +citantes *con sus propias citas* (segundo nivel de fetch, que puebla `cited_by_id`). El acoplamiento +opera sobre el **corpus completo**, no solo `is_seed`. La co-citación end-to-end está cableada: la +pasada de enriquecimiento puebla `cited_by_id` y `Networks.quick` incluye la red cuando esa columna +tiene datos. ### 3.3 `Analyzer` — red → resultados -Funciones puras sobre `networkx.Graph`: - -- **Métricas de red:** densidad, componentes, clustering. -- **Centralidad:** grado, intermediación. -- **Comunidades:** Louvain, propagación, modularidad voraz (con score). Louvain depende de - `python-louvain`: se **declara** y, si falta, **falla fuerte** (lección 7). -- **Asortatividad** (validado en el sandbox IED): por un **atributo categórico configurable** - (p. ej. región geográfica) y **por grado**, más la **composición de cada comunidad** por ese - atributo. Las métricas que dependen de un **proxy** (p. ej. afiliación por-paper vs per-autor) - se reportan **con el disclaimer del proxy** ("fácil pero consciente"). El atributo y sus - categorías son **config del usuario**, no umbrales hardcodeados (crítica #5). -- **Informe de calidad** de la co-citación según [`metodología.md`](Notas/metodología.md) §4, con - umbrales **configurables**. +Funciones puras sobre `networkx.Graph`: **métricas** (densidad, componentes, clustering), +**centralidad** (grado, intermediación), **comunidades** (Louvain —declara `python-louvain`, falla +fuerte si falta—, propagación, modularidad voraz), **asortatividad** (por un **atributo categórico +configurable** —p. ej. región— y por grado, más la **composición de cada comunidad**; las métricas +sobre un **proxy** se reportan con su disclaimer; el atributo es config del usuario, no hardcodeado) e +**informe de calidad** de la co-citación ([`metodología.md`](Notas/metodología.md) §4, umbrales +configurables). ### 3.4 `Exporter` — resultados → archivos @@ -244,37 +143,26 @@ Orquestación pura sobre la costura `Source`: dado el corpus actual, computa can **backward chaining** (referencias de las semillas) y **forward chaining** (citantes), y los **rankea por *information scent***. El *information scent* es **estructura bibliométrica determinista y reproducible**, **sin LLM ni embeddings** (ADR -[0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) actualizado; +[0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md); [0022](decisiones/0022-producto-sin-ia-generativa.md)): el forrajeo **consume el núcleo de -proyección** (§3.2, primitivo `collect_item_to_papers`) — un candidato rankea por cuánto se co-cita -(backward) o cita directamente (forward) respecto del corpus curado. - -- **`AS-BUILT R4` (2026-06-16):** el scent consume el primitivo público `collect_item_to_papers` - de `networks/projectors.py` (lo que la [Nota 05](Notas/05-ciclo-investigacion-humano.md) §4 - promete): el forrajeo (costura) **depende del núcleo de proyección** (puro), nunca al revés. - Sigue siendo **función pura y determinista** (mismo corpus → mismo ranking). - - **Backward** = **fuerza de co-citación con el corpus**: `|{Pi ∈ corpus : X ∈ Pi.references_id}|` - (cuántos corpus-papers co-citan al candidato; es la columna de `X` en la matriz de co-citación). - - **Forward** = **fuerza de citación directa al corpus** (señal primaria): a cuántos corpus-papers - cita el candidato directamente — robusta, siempre > 0 para un citante real. - `forward_score(Y) = |{ref ∈ Y.references_id : ref ∈ corpus_ids}|` (emite con `direct > 0`). *(El - AS-BUILT inicial de R4 implementó el forward como **acoplamiento puro**, que degenera a 0 con - referencias ralas; se **corrigió a citación directa dentro de R4** — ver ADR - [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md) AS-BUILT.)* - - **Centralidad** de red del candidato: **diferida** (viz); el DoD "y/o" se cumple con - co-citación + citación-directa. - -Reglas (ADR 0008, nota 07): **profundidad 1 por defecto** (`depth>1` lanza `NotImplementedError`); -**preview de crecimiento** ("sumaría ~N papers") **sin red** —backward exacto local; forward no -estimable sin fetch (`forward_requires_fetch`)— y **tope** (`max_candidates`) configurable antes de -traer; **pool cortés** de OpenAlex. Forward exige `source.fetch_citing(...)` (capacidad de -`OpenAlexSource`, **no** del Protocol `Source`). **No hay paso de IA:** `explain_candidate`, el -módulo `foraging/explain.py` y el extra `[llm]` quedan **eliminados** (ADR 0022) — el "porqué" de un -candidato lo explica la **estructura visible** (con qué del corpus se acopla/co-cita), no un LLM. - -> **Sesgo de confirmación (Nota 06, rigor):** rankear por estructura ya presente refuerza lo central -> y popular (efecto Mateo). El scent es ayuda de **priorización**, no de **exhaustividad**: la -> exhaustividad PRISMA la sostienen los filtros y el conteo de exclusiones, no el scent. +proyección** (§3.2, primitivo `collect_item_to_papers`), nunca al revés. Es función pura y +determinista (mismo corpus → mismo ranking). + +- **Backward** = **fuerza de co-citación con el corpus** (cuántos corpus-papers listan al candidato en + `references_id`; no toca la red). **Observa sin materializar:** los IDs van a la tabla hermana + `referenced_but_not_fetched`, fuera del `corpus_hash`, no a filas-fantasma del corpus. +- **Forward** = **fuerza de citación directa al corpus** (señal primaria, robusta). **Materializa + filas reales** con la metadata que la query de citantes ya trae (cero red extra). Exige + `source.fetch_citing(...)` (capacidad de `OpenAlexSource`, no del Protocol `Source`). + +Reglas (ADR 0008): **profundidad 1 por defecto** (`depth>1` lanza `NotImplementedError`); **preview de +crecimiento sin red** (backward exacto local; forward no estimable sin fetch) y **tope** +(`max_candidates`) configurable; **pool cortés** de OpenAlex. **No hay paso de IA:** el "porqué" de un +candidato lo explica la **estructura visible**, no un LLM. + +> **Sesgo de confirmación:** rankear por estructura ya presente refuerza lo central y popular (efecto +> Mateo). El scent es ayuda de **priorización**, no de **exhaustividad**: la exhaustividad PRISMA la +> sostienen los filtros y el conteo de exclusiones, no el scent. ### 3.6 `Preprocessor` — normalización (núcleo) @@ -283,12 +171,9 @@ Determinístico e idempotente: canonicalización **conservadora** de nombres de **normalización de keywords vía thesaurus multilingüe** (en/es/pt; dict `canónico → aliases` en JSON portable; ADR 0011). Lo *fuzzy* (dedup aproximado de autores y keywords) corre **automáticamente en la ingesta** con `rapidfuzz` **en el núcleo** (ADR -[0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), #88 — **supersede** en parte -[0026](decisiones/0026-dedup-fuzzy-determinista.md): el dedup deja de ser función de librería y el -extra `[dedup]` se elimina, `rapidfuzz` pasa al núcleo; `splink` sigue diferido a post-V1). **No hay fallback -semántico/LLM del thesaurus** (ADR -[0011](decisiones/0011-thesaurus-multilingue.md) enmendado / 0022): el thesaurus es **curado y -determinista**; lo que no matchea queda fuera, sin inventar conceptos con un modelo. +[0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), #88). **No hay fallback +semántico/LLM del thesaurus** (ADR 0011/0022): el thesaurus es **curado y determinista**; lo que no +matchea queda fuera, sin inventar conceptos con un modelo. ## 4. Las costuras (puntos de extensión) @@ -307,170 +192,61 @@ redes parciales y lo reportan (no fallan). Esto habilita fuentes regionales (Sci Referencia) sin obligarlas a entregar lo que no tienen. **Implementación de referencia: OpenAlex** (ADR 0007): traduce la **ecuación de búsqueda** a una -query OpenAlex, muestra la **query ejecutada + reporte de traducción** (qué mapeó limpio, qué se -aproximó, qué se descartó), y trae mínimo + enriquecimiento completo (`references_id`, -`cited_by_id`, afiliaciones per-autor) y ancla `Manifest.openalex_version` (ADR -[0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). Power-users pueden pasar query -OpenAlex nativa (escape hatch). **La ingesta desde archivo `.bib` es una puerta primaria** (ingesta -de **doble puerta**: ecuación **o** `.bib`; ADR -[0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)) para sembrar desde *pearls* conocidos -(`BibtexSource`, acceso defensivo a campos; el sandbox documenta un bug de `bibtexparser` que exige un -pre-procesador). La ingesta `.bib` **resuelve DOI→`source_id`** contra el motor de extracción -(`b2g resolve` / `seed --from-bib --resolve`) para reconciliar las *pearls* con el corpus. -SciELO/Redalyc/La Referencia, RIS/CSV: futuras, no publicadas. Un -**reporte de cobertura/calidad** por seed/source (concreto v0.2+, ADR 0018) mide qué tan completa -es la fuente y alimenta el juicio de cuándo cambiar de `Source`. +query OpenAlex, muestra la **query ejecutada + reporte de traducción**, y trae mínimo + +enriquecimiento (`references_id`, afiliaciones per-autor; `cited_by_id` lo puebla el chaining) y ancla +`Manifest.openalex_version` (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). +Power-users pueden pasar query OpenAlex nativa. **La ingesta desde archivo `.bib` es una puerta +primaria** (doble puerta: ecuación **o** `.bib`; ADR 0035) para sembrar desde *pearls* conocidos +(`BibtexSource`, acceso defensivo a campos), y **resuelve DOI→`source_id`** contra el motor +(`seed --from-bib --resolve`). SciELO/Redalyc/La Referencia, RIS/CSV: futuras. Un **reporte de +cobertura/calidad** por seed/source alimenta el juicio de cuándo cambiar de `Source`. ### 4.2 `Enricher` — señal extra (opt-in, núcleo sobre OpenAlex) Con OpenAlex como backbone, **deja de ser estructural** (ADR 0007). Vive en el **núcleo sobre -OpenAlex** (no en `[s2]`; ADR [0025](decisiones/0025-enricher-cocitacion-openalex.md)). El **Hito 8 -está completo** (Ciclos 8a + 8b): `OpenAlexEnricher.enrich` hace **2 pasadas**. **8a** — **resolver -`references_id` a DOI canónico** (OpenAlex las da como URLs internas — T8 del sandbox; batching por -OR, idempotente vía `EnricherRef` en el `Manifest`). **8b** — el **segundo nivel de fetch** habilita -la **co-citación end-to-end**: trae los citantes de las **semillas aceptadas** (vía -`OpenAlexSource.fetch_citing_batch`: batcheo OR ≤50 con presupuesto por semilla) y **mergea sus -`source_id` en `cited_by_id`** (unión idempotente); **solo puebla `cited_by_id`**, no crece el -corpus (decisión A). El tope `max_citing_per_paper` **acota el fetch por semilla**. El subcomando -`b2g enrich` (flag `--max-citing`) es propio y **no transiciona el `CycleState`**. S2/CrossRef/Scopus: -futuras (`[s2]` reservado para señal adicional). Reglas: config inyectada (nunca embebida), sin ramas -muertas, rate limit y reintentos sin perder papers. +OpenAlex** (no en `[s2]`; ADR [0025](decisiones/0025-enricher-cocitacion-openalex.md)). +`OpenAlexEnricher.enrich` hace **2 pasadas**: (a) **resolver `references_id` a DOI canónico** +(OpenAlex las da como URLs internas; batching por OR, idempotente vía `EnricherRef` en el `Manifest`) +y (b) el **segundo nivel de fetch** que habilita la **co-citación end-to-end** (trae los citantes de +las **semillas aceptadas** vía `OpenAlexSource.fetch_citing_batch` y **mergea sus `source_id` en +`cited_by_id`**; solo puebla la columna, no crece el corpus). El tope `max_citing_per_paper` acota el +fetch por semilla. En la superficie 0.10.0 estas pasadas corren **automáticas** dentro de `chain` +(refs→DOI) y `build` (co-citación, cuando hay aceptadas); por eso `build` ya **no es estrictamente +"sin red"** (ADR 0025 enmendado, §6.3). S2/CrossRef/Scopus: futuras (`[s2]` reservado para señal +adicional). Reglas: config inyectada, sin ramas muertas, rate limit y reintentos sin perder papers. ### 4.3 `Store` / backend de persistencia (biblioteca viva) **Por defecto: `DuckDBBackend` stateful** (ADR 0009 reencuadrado por -[0015](decisiones/0015-corpus-tabular-backend.md)): la **biblioteca viva** es el **backend por -defecto del `Corpus`**, no un `Store` aparte. Persiste el contenido Arrow **entre corridas**, más -tablas de **procedencia, decisiones de curación** (aceptar/rechazar) y el **`LoopState`** (ADR -[0016](decisiones/0016-maquina-estados-lazo.md)). Muta por SQL `UPDATE`/`MERGE` por `id` (no copia -en memoria). **Cleanup pre-v0.3:** el `merge` ya **no interpola ids crudos** en el SQL (eliminado el -`CASE WHEN`/`IN (...)` con f-strings); lee las filas y **ordena en Python** por orden de aparición -antes de reinsertar (orden determinista D3 preservado, sin construir SQL con datos). Soporta query -SQL. Es **núcleo**, no extra. **Una investigación = un workspace** (carpeta autocontenida con su -`library.duckdb` marcada por `workspace.json`; AS-BUILT ADR -[0029](decisiones/0029-workspace-por-investigacion.md), enmienda #75: la carpeta es la **única** -unidad canónica —el modo degenerado del `.duckdb` suelto fue eliminado—); el `library.duckdb` sigue -siendo single-writer (concurrencia diferida, ADR -[0019](decisiones/0019-concurrencia-diferida.md)). - -El **snapshot** es un **export sellado** del estado vivo (ver §6.2), no la persistencia en sí; -`ParquetStore` puede servir como **formato de export/intercambio**. La costura `Store` sigue -siendo el punto de extensión para destinos externos: **`ZoteroStore`** (sincronizar la biblioteca -con una colección Zotero) es **opt-in en V1.1** (`[zotero]`); **`Neo4jStore`** es adaptador opt-in -post-V1 (`[neo4j]`): un destino más, **ya no el sustrato** (ADR 0002). - -> **AS-BUILT (2026-06-16) — workspace por investigación, ADR -> [0029](decisiones/0029-workspace-por-investigacion.md):** la **unidad de persistencia es el -> workspace = una carpeta** (`workspace.json` + `library.duckdb` + `networks/` + `snapshots/` + -> `exports/`), formalizando la convención emergente de que `build` ya escribía `/networks/`. -> El corpus/procedencia/curación/loop-state siguen en el `.duckdb`; redes/exports = cache regenerable -> sellada por `corpus_hash` (`b2g build` graba `networks/.corpus_hash`); el snapshot sigue siendo lo -> reproducible (§6.2). El `.duckdb` suelto sigue válido como **workspace degenerado** (sin migración -> forzada). Enmienda 0009/0019; single-writer sin cambios. **Acotado en este corte:** -> `snapshot`/`export` aún usan `--out-dir` explícito; la staleness solo sella el hash (sin aviso ni -> regeneración automática todavía). -> -> **SUPERADO (#75, 2026-06-17):** el modo degenerado se eliminó — la carpeta con `workspace.json` es -> la **única** unidad canónica y un `.duckdb` legacy se adopta con `b2g init .` (ver ADR 0029, -> enmienda 2026-06-17). - -### 4.4 `LocalApiServer` / API local (costura opt-in, `[gui]`) — `AS-BUILT (G3)` · SPA `frontend/` `AS-BUILT (G4)` · empaquetado `AS-BUILT (G5)` - -> **ROTA EN 0.8 A PROPÓSITO ([#117](https://github.com/complexluise/bib2graph/issues/117)):** el rename -> de columna a **`source_id`** (ADR 0036) rompió la API/SPA; **la GUI NO funciona hoy** hasta que se -> actualicen al nuevo nombre. Lo que sigue describe el AS-BUILT de 0.6 (lo construido), no que corra en 0.8. -> -> **AS-BUILT (2026-06-18) — Hitos G3 + G4 + G5 del MVP GUI, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) (Aceptado; GUI gateada por -> [#34](https://github.com/complexluise/bib2graph/issues/34)).** La **capa de servicios neutral -> `src/bib2graph/service/` existe** (G1: contrato subido; G2: 6 lecturas en `service/reads.py`), la -> **API local FastAPI `src/bib2graph/api/` está construida** (G3): adaptador delgado que expone los -> **7 endpoints** (6 lecturas + 1 curación), con **token Bearer efímero** y el **mapeo código→HTTP** del -> ADR 0028 §7 (abajo); entra el **19º subcomando `b2g gui`** (§6.3) y el extra **`[gui]`** (§7). Y la -> **SPA `frontend/` también está construida** (G4): React 18 + Vite + TS estricto + Cytoscape/fcose + -> Zustand + Tailwind + TanStack Query (**pnpm**), dirección visual **D-2 "Observatorio"**, que consume los -> 7 endpoints reales (cliente que des-envuelve `schema="1"`, `error.code` string, header Bearer). El -> **wiring del token** se cableó en G4 (B-G4-3): `b2g gui` **inyecta el token en el `index.html` -> servido** (ruta `GET /` + `_make_index_response`; el frontend lo lee de `window.__B2G_TOKEN__`) — ver -> [`API.md`](API.md) §0.2. El **empaquetado** (G5) **también está construido**: el wheel **vendorea el -> build del frontend** (`src/bib2graph/gui/static/`, gitignored) vía `force-include` de hatchling -> (`pyproject.toml`), con job CI JS y `pnpm build` antes del `uv build` en `publish-testpypi.yml` (§7) — -> `b2g gui` funciona **sin Node** desde el wheel. Con G5, **los 5 hitos G1–G5 están AS-BUILT**. Lo único -> pendiente es el **gate #34** (validar el caso real con un tercero, ADR 0027): es el criterio de -> aceptación de producto de la epic, AL FINAL — **no** es construcción. El prototipo `app/server/` (con su `envelope()` propio -> duplicado) es *throwaway* y se **retira** a favor del contrato neutral. Contrato exacto de la API en -> [`API.md`](API.md) §0.2. - -La **API local** es una **costura nueva de servidor** (no del núcleo): un adaptador **delgado** sobre -la capa de servicios neutral `src/bib2graph/service/`, en `src/bib2graph/api/` (FastAPI). No -reimplementa lógica ni contrato: **reusa `service.build_envelope` (`schema="1"`), la jerarquía -`B2GError` y el mapeo puro `service.code_for`** que `service/` sube desde `cli/`, y traduce el **código -del contrato a HTTP status**. **`api/` NO importa de `cli/`** (ambos cuelgan de `service/`). - -- **Fábrica de la app (AS-BUILT G3):** `create_app(ws, *, token, cors_origins=None)` (`api/app.py`) - monta los routers (`routers/reads.py` con los 6 GET, `routers/curate.py` con el POST), CORS y dos - *exception handlers* globales (`B2GError` + `Exception`). El `Workspace` se inyecta como **singleton - por proceso** (la resolución ambiente vive en `b2g gui`). -- **Local-first, sin hosting:** bind a **`127.0.0.1`** + **token Bearer efímero** (no expone red; ADR - 0027). Sin token / token inválido → **401** (dependencia `require_token`, `api/deps.py` + `secrets.compare_digest`). -- **Import perezoso:** el núcleo **no importa `fastapi`/`uvicorn`**; solo el adaptador API y el - subcomando `b2g gui` los importan (dentro de `create_app`/`run_gui`), y vienen en el extra **`[gui]`** (§7). -- **Funciones de lectura que el CLI nunca expuso (AS-BUILT G2, servidas por la API en G3):** - `service/reads.py` añade las lecturas que la SPA necesita y no mapean 1:1 a subcomandos —`get_workspace`, - `list_rounds`, `get_paper`, `get_scent`, `get_network` (por kind, ronda viva), `compare_rounds` (diff de - rondas, el diferenciador)— por eso la convergencia es en **servicios**, no en **comandos**. Ver - [`API.md`](API.md) §0.1/§0.2. -- **Escritura — curación (AS-BUILT G3):** `POST /api/paper/{id}/curate` llama a `service/curate.py` - (`curate_paper`, que sube desde `cli/` la orquestación de accept/reject; `run_accept`/`run_reject` - quedan como shims que delegan). Toma el **WriteLock global serializado** e inyecta `decided_at` en la - frontera API (R2/ADR 0017). -- **Mapeo código→HTTP (AS-BUILT G3)** (`api/envelopes.py`; el envelope viaja igual en el body — la SPA - lee `error.code`, no depende del status): `0`→200 · `1` (uso)→400 · `2` (datos)→422 · `3` - (dependencia)→501 · `4` (red)→502 · `5` (store bloqueado/corrupto)→**409**. **Excepción inesperada** - (bug interno, no mapeada por `code_for`) → **500** (`error.code = "INTERNAL_ERROR"`) — NO 409, para no - sugerirle a la SPA reintentar. -- **Operaciones largas (v1):** `seed`/`enrich`/`build` bloquean (red, Louvain) y el store es - **single-writer** (ADR [0019](decisiones/0019-concurrencia-diferida.md)). La API v1 es **síncrona** + - **lock global serializado** (una escritura a la vez). **Jobs async/SSE, retry cross-process y reabrir - 0019 quedan diferidos** (no en v1). - -El **frontend SPA** (`frontend/`, monorepo Vite/TS) **está construido (AS-BUILT G4)**; su build sale a -`src/bib2graph/gui/static/` (no se commitea — gitignoreado). El **empaquetado del wheel está construido -(AS-BUILT G5)**: ese build se **vendorea** al wheel vía `force-include` de hatchling (la GUI funciona sin -Node desde el wheel) y el CI tiene un job JS (lint/test/build) que corre siempre. El subcomando -**`b2g gui`** (ver §6.3, **AS-BUILT G3 + wiring del token G4**) es el adaptador de "arranque local": -levanta uvicorn sobre la API, **inyecta el token en el `index.html`** y sirve los assets pre-build **si -existen** (ruta `GET /` + `StaticFiles`, §4.4 banner / [`API.md`](API.md) §0.2), y abre el browser. +[0015](decisiones/0015-corpus-tabular-backend.md)): la **biblioteca viva** es el **backend por defecto +del `Corpus`**, no un `Store` aparte. Persiste el contenido Arrow **entre corridas**, más tablas de +procedencia, decisiones de curación y el **`CycleState`** (ADR +[0016](decisiones/0016-maquina-estados-lazo.md)). Muta por SQL, soporta query SQL. Es **núcleo**, no +extra. **Una investigación = un workspace** (carpeta autocontenida con su `library.duckdb` marcada por +`workspace.json` + `networks/`/`snapshots/`/`exports/`; ADR +[0029](decisiones/0029-workspace-por-investigacion.md)): la carpeta es la **única** unidad canónica +—`--store` y el modo degenerado fueron eliminados (#75); un `.duckdb` legacy se adopta con +`b2g init .`—; es single-writer (ADR [0019](decisiones/0019-concurrencia-diferida.md)). + +El **snapshot** es un **export sellado** del estado vivo (§6.2), no la persistencia; `ParquetStore` +sirve como formato de export/intercambio. La costura `Store` es el punto de extensión para destinos +externos opt-in: **`ZoteroStore`** (`[zotero]`, V1.1) y **`Neo4jStore`** (`[neo4j]`, post-V1) — un +destino más, **ya no el sustrato** (ADR 0002). ## 5. Flujo de datos (ciclo iterativo, no pipeline lineal) -``` -0. (humano) idea / pregunta difusa -1. Source(OpenAlex).seed(ecuación) ──► Corpus (semillas) + query registrada -2. Forrajeo.chain(corpus, depth=1) ──► candidatos rankeados por scent ◄─┐ -3. (humano) aceptar/rechazar + filtros ──► Corpus curado (status, conteos) │ - Preprocessor.normalize(corpus) ──► nombres + keywords (thesaurus) │ -4. (humano) la idea/ecuación MUTA ─────────────────────────────────────────────►─┘ (re-sembrar) -5. Store(DuckDB).persist(corpus) ──► biblioteca viva (entre corridas) -6. Projector.project(corpus) ──► networkx.Graph (5 redes) -7. Analyzer.analyze(graph) ──► métricas / comunidades / asortatividad / calidad -8. Exporter.export(...) · Store.snapshot()──► GraphML/CSV + snapshot reproducible -``` - -El lazo **2→3→4→1** (la query y la idea mutan; Bates/Ellis/Kuhlthau) es la propiedad central: -la biblioteca viva existe para que ese lazo no pierda lo acumulado (PRD §1–§2). - -La no-linealidad se modela como una **máquina de estados explícita** (ADR -[0016](decisiones/0016-maquina-estados-lazo.md) enmendado). Es un **concepto de dominio puro y -testeable** — el módulo **`bib2graph.cycle`**: el modelo de estados + las reglas de transición viven -en el núcleo; el **backend solo lo persiste** (**AS-BUILT R3, 2026-06-16**). +El flujo: `seed(ecuación)` → `chain(depth=1)` (candidatos por scent) → curar (accept/reject + +filtros) + `normalize` → **la idea muta** (re-sembrar) → `persist` (biblioteca viva) → +`project`/`analyze` → `export`/`snapshot`. El lazo **chain→curar→mutar→seed** (la query y la idea +mutan; Bates/Ellis/Kuhlthau) es la propiedad central: la biblioteca viva existe para que ese lazo no +pierda lo acumulado (PRD §1–§2). -`cycle.py` expone `CycleState` (`SEEDED/FORAGED/FILTERED/BUILT/MONITORED`), -`apply_transition(state, action, round) → (state, round)`, `available_transitions(state)` y -`CURATION_ACTIONS`. El enum de estados **dejó de vivir** en `backends/duckdb.py`; el backend persiste -el estado y la **ronda** en `loop_state_log` (`loop_round()` / `set_loop_state`). **Cleanup pre-v0.3:** -el alias transicional `LoopState = CycleState` **se retiró** (de `backends/duckdb.py` y -`stores/duckdb.py`); el código usa **una sola** clase, `CycleState`. +La no-linealidad se modela como una **máquina de estados explícita** de dominio puro y testeable +—el módulo **`bib2graph.cycle`** (ADR [0016](decisiones/0016-maquina-estados-lazo.md))—: el modelo de +estados + las reglas de transición viven en el núcleo; el **backend solo lo persiste**. `cycle.py` +expone `CycleState` (`SEEDED/FORAGED/FILTERED/BUILT/MONITORED`), `apply_transition(state, action, +round) → (state, round)`, `available_transitions(state)` y `CURATION_ACTIONS`. El backend persiste el +estado y la **ronda** en `loop_state_log`. FSM **cíclico** fiel a la [Nota 05](Notas/05-ciclo-investigacion-humano.md): @@ -484,12 +260,11 @@ SEEDED ──chain──► FORAGED ──filter──► FILTERED ──build - **`reseed` es transición de primera clase** ("la idea muta"): `apply_transition(state, "reseed", r) = (SEEDED, r+1)`. Lo cablea `seed.py`: si hay estado previo, la siembra es un re-sembrado (ronda++, - acumula sobre lo curado). Es lo que el ADR 0016 prometía y el AS-BUILT lineal no cumplía. + acumula sobre lo curado). - **Fuente única de verdad:** `chain`/`filter`/`build` **derivan** su estado destino de - `apply_transition` (no de un literal); un test domain-tied lo ata. -- **`MONITORED`** modela el paso 8 del ciclo (monitoreo) y es **alcanzable** desde el cleanup - pre-v0.3: el comando **`b2g monitor`** lo dispara (re-chequea OpenAlex por citantes nuevos del - corpus vía forward chaining, mergea los candidatos nuevos y transiciona). + `apply_transition`, no de un literal. +- **`MONITORED`** modela el paso 8 (monitoreo) y es **alcanzable** vía **`b2g chain --since`** + (forrajeo incremental: re-chequea OpenAlex por citantes nuevos del corpus). - **La curación es TRANSVERSAL:** `accept`/`reject` están disponibles **en cualquier estado**, **no transicionan**; `b2g status` las muestra **siempre** en `curation_available` (separado de `transitions_available`) y expone el contador de `round`. @@ -503,193 +278,106 @@ frontera** (CLI), no en el núcleo (§6.2). ### 6.1 Configuración inyectada - **Una sola fuente de configuración**, construida explícitamente y pasada a quien la necesita. - **Sin efectos de import** (en v0, importar seteaba `config.DATABASE_URL`). -- **Sin secretos embebidos** (en v0 había triple `DATABASE_URL` y clave S2 hardcodeada). + **Sin efectos de import** ni secretos embebidos. - Credenciales y el **email del pool cortés de OpenAlex** (y la **API key opcional**) se inyectan por config/CLI o entorno, nunca embebidos (ADR [0012](decisiones/0012-openalex-credenciales.md)). **Sin key, el `Source` funciona** (polite pool) **pero con límite** (tier gratis, ~100 créditos/día); la - **API key opcional sube el límite** para uso intensivo (#124) — como muchos servicios. + **API key opcional sube el límite** para uso intensivo (#124). ### 6.2 Persistencia por defecto: biblioteca viva en DuckDB + snapshot exportable -La persistencia por defecto es **stateful**: el `DuckDBBackend` conserva el corpus **entre -corridas** (ADR 0009/0015). Reproducibilidad por **historia auditable** (el log de procedencia: qué -ecuación, qué salto de chaining, qué decisión humana, cuándo) **+ snapshot exportable**, **no por -recómputo** (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)): re-ejecutar la -misma ecuación contra OpenAlex NO garantiza el mismo corpus (OpenAlex cambia en el tiempo). El -artefacto reproducible es el **snapshot**; el `openalex_version` del Manifest lo ancla a la -versión/fecha de OpenAlex usada. - -**Identidad (contenido) vs procedencia (auditoría)** (ADR -[0017](decisiones/0017-reproducibilidad-historia-snapshot.md), enmienda 2026-06-15): - -- **`AS-BUILT` (R2, ✅ 2026-06-16):** el `corpus_hash` se computa **solo sobre contenido - bibliográfico**, **excluyendo** `provenance`/`ProvenanceEvent` con sus timestamps (sigue - incluyendo `curation_status`, que es contenido curado). La **procedencia es un log append-only - fuera de la identidad** (sirve para auditar, no para identificar). Dos corridas que aceptan los - mismos ids dan ahora el **mismo** `corpus_hash` → el snapshot es reproducible bit a bit (cumple el - ADR 0017 y `facade.py`). El **reloj se inyecta en la frontera** (CLI): `accept`/`reject`/`filter` - reciben `decided_at`; el núcleo conserva un **fallback `datetime.now(UTC)`** para uso como librería - sin `decided_at` (no afecta la identidad, que excluye provenance — ADR 0017 punto 3). **Louvain** - corre con un `random_state` **derivado del content-hash** (`_louvain_seed_from_hash`) → comunidades - reproducibles. (`resolution` de Louvain **diferido a Hito 9**, NetworkSpec.) Ver ROADMAP **Hito R2**. -- **`HISTÓRICO — AS-BUILT v0.2` (roto, pre-R2):** `accept`/`reject` estampaban `datetime.now(UTC)` - en el evento de procedencia (reloj en el núcleo), y `compute_corpus_hash` hasheaba **todos** los - campos, incluido `provenance` con sus timestamps → dos corridas que aceptaban los mismos ids daban - `corpus_hash` distintos. R2 lo corrigió. - -El **snapshot** es un **export sellado** del estado vivo en un instante: `corpus.parquet` + un -`manifest.json` con `schema_version`, `corpus_hash`, `lib_version`, `openalex_version`/fecha, -`sources`, `chaining` (profundidad, topes), `preprocessors`, `filters` (conteos PRISMA), -`created_at`. Sirve para **reportar (PRISMA / vom Brocke) y reproducir**, y se versiona en -git-lfs/DVC. A diferencia del diseño previo, el snapshot **no es** la persistencia: es una **foto -derivable** de una biblioteca que sigue viva. +La persistencia por defecto es **stateful**: el `DuckDBBackend` conserva el corpus **entre corridas** +(ADR 0009/0015). Reproducibilidad por **historia auditable + snapshot exportable**, **no por +recómputo** (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)): re-ejecutar la misma +ecuación contra OpenAlex NO garantiza el mismo corpus (OpenAlex cambia en el tiempo). El artefacto +reproducible es el **snapshot**; el `openalex_version` del Manifest lo ancla a la versión/fecha usada. + +**Identidad (contenido) vs procedencia (auditoría)** (ADR 0017): el `corpus_hash` se computa **solo +sobre contenido bibliográfico** —**excluyendo** `provenance`/timestamps, **incluyendo** +`curation_status`—, así que dos corridas que aceptan los mismos ids dan el **mismo** hash (snapshot +reproducible bit a bit). La procedencia es un **log append-only fuera de la identidad** (audita, no +identifica). El **reloj se inyecta en la frontera** (CLI): `accept`/`reject`/`filter` reciben +`decided_at`; el núcleo usa `datetime.now(UTC)` solo como fallback de librería (no afecta la +identidad). **Louvain** corre con `random_state` derivado del content-hash → comunidades +reproducibles. + +El **snapshot** es un **export sellado** del estado vivo (`corpus.parquet` + `manifest.json` con +`schema_version`, `corpus_hash`, `lib_version`, `openalex_version`/fecha, `sources`, `chaining`, +`preprocessors`, `filters` con conteos PRISMA, `created_at`). Sirve para **reportar (PRISMA / vom +Brocke) y reproducir**; es una **foto derivable** de una biblioteca que sigue viva, no la persistencia. ### 6.3 CLI agente-native como columna primaria (ADR 0010 / 0021) -La CLI es **superficie primaria desde el primer comando**, no un adorno futuro: cada subcomando -con **doble salida** (humana + `--json` estable/versionado), **exit codes** claros (`0` éxito · -`1` uso · `2` datos · `3` dependencia faltante · `4` red no disponible · `5` store/snapshot -corrupto), **errores accionables**, `--help` rico y **eficiencia de tokens**. **Sin estado entre -invocaciones**: el estado vive en el `Store` DuckDB, no en la sesión. Tool schemas JSON / MCP son -trabajo posterior, pero la API se **diseña con estos principios desde el hito 1**. - -**As-built (Hito 6, ADR [0021](decisiones/0021-cli-agente-native-contrato.md)):** el CLI es un -**paquete `bib2graph.cli/`** (no un `cli.py` plano) con **3 capas**: - -1. **Capa Click** (`cli/__init__.py` + `cli/commands/.py`): el grupo `b2g` con la opción - global **opcional** `--workspace` (`--store` fue eliminada en #75), y un comando Click por - subcomando que sólo parsea flags y delega. -2. **Capa de funciones núcleo** (`run_(store_path, ...)` en cada módulo de comando): - **testeable sin Click**, contiene la lógica del subcomando. El ROADMAP testea esta función, no - el parser de Click. -3. **Capa de envelope/errores** (`cli/_envelope.py`, `cli/_errors.py`, `cli/_store.py`): el - envelope JSON versionado (`schema="1"`) compartido y el decorador `@handle_errors` que **mapea - errores a exit codes por tipo de excepción** (`DataError`→2, `ImportError`/`DependencyError`/ - `NotImplementedError`→3, `httpx.HTTPError`→4, `StoreLockedError`/`OSError`→5). **AS-BUILT R5:** - `AttributeError` ya **no** se captura en el decorador (no se disfraza un bug de "capacidad - faltante"); la capacidad-de-source-faltante se convierte en `DependencyError` mediante un - **pre-check `hasattr` en el borde** (p. ej. `chain.py` antes del `Forager`). Ver ADR 0021 §D. - -Son **19 subcomandos** (`seed`, `chain`, `filter`, `build`, `export`, `snapshot`, `status`, -`inspect`, `validate`, `accept`, `reject`, **`monitor`**, **`enrich`**, **`init`**, **`curate`**, -**`networks`**, **`restore`**, **`thesaurus`**, **`gui`**); el 18° **`thesaurus`** —único paso de -normalización explícito— lo sumó el ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md) -(#88, 2026-06-18), y el 19° **`gui`** —arranque de la API local— lo sumó el Hito G3 del MVP GUI (ADR -[0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md), AS-BUILT 2026-06-18, abajo). -`build`/`export` están -**separados** y el `CycleState` transiciona automáticamente por comando (ADR 0021). El 12° -**`monitor`** (cleanup pre-v0.3) re-chequea citantes nuevos del corpus (forward chaining) y -transiciona a `MONITORED`. El 13° **`enrich`** (Hito 8 = Ciclos 8a + 8b, ADR -[0025](decisiones/0025-enricher-cocitacion-openalex.md)) corre el `OpenAlexEnricher` (refs→DOI + -co-citación, flag `--max-citing`) y **no transiciona** el ciclo (ortogonal al lazo). El 14° -**`init`** (AS-BUILT ADR [0029](decisiones/0029-workspace-por-investigacion.md)) hace scaffold de un -workspace (carpeta + `workspace.json` + `library.duckdb` + `networks/`/`snapshots/`/`exports/`; -`b2g init .` inicializa el cwd) y **no transiciona** el ciclo. El 15° **`curate`** (#22 + #26) hace -curación a escala vía CSV (dump/import en lote, transversal: **no transiciona** el ciclo). El 16° -**`networks`** (Hito 9, AS-BUILT 2026-06-17) construye redes desde un YAML declarativo -(`b2g networks --spec `, `load_specs` + `Networks.build` por red, helper compartido -`_write_artifacts`) y **no transiciona** el ciclo ni sella `.corpus_hash` (transversal al lazo). El 17° -**`restore`** (Ciclo 9a, ADR [0030](decisiones/0030-ecuacion-declarativa-corpus-ejemplo.md)) rehidrata el -corpus desde un parquet curado **sin red** (inverso de `snapshot`; preserva `decision`/`curation_status`/ -`is_seed`) y transiciona a `FILTERED` (reusa la transición permisiva `filter`, ADR 0016). El error de uso (p. ej. -una opción desconocida como `--store` —eliminada en #75—, o ningún workspace resoluble) sale **sin -envelope** (Click aborta el parseo: stderr + exit 1). - -**AS-BUILT R5 — UTF-8 en la frontera (Nota 06 RAÍZ 3):** `main()` llama `_force_utf8()` (reconfigura -`sys.stdout`/`stderr` a UTF-8, con guarda por si la stream no es reconfigurable) **antes de que Click -lea nada**. Sin esto, el envelope `--json` (`ensure_ascii=False`) y `--help` corrompen acentos en la -consola cp1252 de Windows (`ecuaci�n`), rompiendo el contrato agente-native. **AS-BUILT R5 — store de -solo lectura:** `status`/`validate` usan `open_store_readonly` (`cli/_store.py`), que **no auto-crea** -el `.duckdb` ante un workspace mal apuntado (falla accionable); los comandos de escritura conservan -`open_store`. - -> **AS-BUILT (2026-06-16) — `--store` opcional + `--workspace` + `b2g init`, ADR -> [0029](decisiones/0029-workspace-por-investigacion.md):** con el modelo workspace, `--store` dejó de -> ser opción global **obligatoria** y pasó a **opcional**, y se agregó **`--workspace`** (ambos -> opcionales y **mutuamente excluyentes** — juntos = error de uso). La unidad es una **carpeta -> workspace** (`workspace.json`), resuelta por ambiente (patrón git/cargo: walk-up del cwd). -> Precedencia: `--workspace`/`--store` explícito > `B2G_WORKSPACE` (env) > workspace del cwd. Sin flag -> y sin workspace resoluble → error accionable que sugiere `b2g init`. Entró el subcomando **`b2g init -> `** (scaffold de la carpeta; `b2g init .` inicializa el cwd) → el conteo de subcomandos pasó de -> **13 a 14**. El `.duckdb` suelto sigue funcionando (workspace "degenerado", sin migración forzada). -> Es un cambio **suave/aditivo** del contrato (la resolución ambiente solo cubre el flag ausente). El -> `status` suma el campo aditivo `workspace: {root, source}` (`schema="1"` intacto). -> -> **SUPERADO (#75, 2026-06-17, BREAKING):** `--store` se **eliminó por completo** del CLI (pasarla da -> el error estándar de Click `No such option`) y el modo degenerado dejó de existir. Queda solo -> `--workspace` (opcional) + resolución ambiente; un `.duckdb` legacy se adopta con `b2g init .`. Ver -> ADR 0029 / 0021 (enmiendas 2026-06-17). - -> **AS-BUILT (2026-06-18) — el CLI es uno de tres frontends de frontera; `b2g gui`, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) (Aceptado; GUI gateada por -> [#34](https://github.com/complexluise/bib2graph/issues/34)).** El CLI deja de ser el único frontend: -> es **un adaptador** (junto con la API local AS-BUILT, §4.4) sobre la **capa de servicios neutral** -> `src/bib2graph/service/`. El contrato (envelope `schema="1"`, jerarquía `B2GError`, mapeo -> error→código) **subió** de `cli/` a `service/` (G1); el CLI conserva solo Click + -> `emit`/`emit_human` + `sys.exit`. El **contrato externo (`schema="1"`, exit codes 0–5) NO cambia** -> (enmienda 0021 sin romper el contrato; `test_cli.py` intacto). -> -> El subcomando **`b2g gui`** (G3, `cli/commands/gui.py`) levanta uvicorn sobre la API local FastAPI -> (§4.4), **inyecta el token en el `index.html` servido** (G4, ruta `GET /` + `_make_index_response`) y -> sirve los assets pre-build del frontend **si existen** (AS-BUILT G4) y abre el browser; es el -> adaptador de "arranque local". **Conteo: 19 `add_command`** en `src/bib2graph/cli/__init__.py` -> (verificado), con `gui` como **19º** subcomando (consistente con la lista AS-BUILT de §6.3 arriba, el -> ADR 0028 §3 y la [Nota 12](Notas/12-arquitectura-gui-encuadre.md) punto 6). La **SPA** (`frontend/`) -> está **AS-BUILT (G4)** y el **empaquetado** del wheel está **AS-BUILT (G5)** (§4.4/§7): con eso los 5 -> hitos G1–G5 del MVP GUI están construidos; solo queda el gate #34 (no es construcción). +La CLI es **superficie primaria**: cada subcomando con **doble salida** (humana + `--json` +estable/versionado, también activable con `B2G_JSON=1`), **exit codes** claros (`0` éxito · `1` uso · +`2` datos · `3` dependencia faltante · `4` red no disponible · `5` store/snapshot corrupto), +**errores accionables**, `--help` rico y **eficiencia de tokens**. **Sin estado entre invocaciones**: +el estado vive en el `library.duckdb` del workspace, no en la sesión. + +El CLI es un **paquete `bib2graph.cli/`** de **3 capas**: Click (`cli/commands/.py`: parsea flags +y delega), funciones núcleo (`run_(...)`: testeables sin Click) y envelope/errores +(`cli/_envelope.py`, `cli/_errors.py`: el decorador `@handle_errors` mapea errores a exit codes por +tipo de excepción). El contrato (envelope, jerarquía `B2GError`, mapeo error→código) vive en la **capa +de servicios neutral** `src/bib2graph/service/` (agnóstica de transporte); el CLI es un adaptador +delgado sobre ella. + +**Superficie 0.10.0 — 10 verbos del ciclo + 3 grupos noun-verb + `skill`** (ADR +[0037](decisiones/0037-superficie-cli-10-verbos-ciclo.md)/[0038](decisiones/0038-destino-verbos-huerfanos-0037.md)/[0039](decisiones/0039-skill-comando-meta-distribucion.md)). +La superficie mapea 1:1 el ciclo (*más es menos*); el conteo es verificable contra `b2g --help`. Los +**10 verbos** son `init`, `seed`, `chain`, `curate`, `build`, `read`, `export`, `snapshot`, `status`, +`validate` (`curate`/`read`/`snapshot` son **grupos noun-verb**; un grupo sin subcomando imprime ayuda +y sale exit 0, y el `command` del envelope usa la ruta completa). Fuera del set, **`skill add`** (ADR +0039) instala la skill de Claude Code (vendoreada en el wheel, version-lock skill==cli). + +**Destino de los verbos huérfanos del 0037** (ADR 0038): `monitor`→`chain --since`; `enrich`→`chain` +(refs→DOI) + `build` (co-citación); `thesaurus` retirado → `build --thesaurus`; `networks`→`build +--spec`; `inspect`→`read show`/`status`; `restore`→`snapshot restore`; `resolve`→`seed --resolve`; +`accept`/`reject`/`filter`→`curate {accept,reject,filter}`. Todos siguen vivos como **alias** con aviso +a stderr + `warnings[]` (retiro **0.11.0**), salvo `thesaurus` que se retiró sin alias. El entry-point +`bib2graph`→`b2g` y la opción `build --corpus-scope`→`build --scope` van en el mismo corte. + +**Transiciones automáticas:** `seed`→`SEEDED` (con estado previo = `reseed`, ronda++), +`chain`→`FORAGED`, `chain --since`→`MONITORED`, `curate filter`→`FILTERED`, `build`→`BUILT`, +`snapshot restore`→`FILTERED`; el resto transversal. El **contrato de salida** (envelope `schema="1"`, +exit codes, FSM) no cambia. **Artefactos one-shot honestos:** el `--json` de `build`, `snapshot +create` y `read top` lleva un bloque aditivo **`maturity`** que autodeclara el resultado como borrador +sin pulir. Contrato vivo de la superficie en [`API.md`](API.md) §Convenciones CLI. + +`main()` fuerza `sys.stdout`/`stderr` a UTF-8 **antes de que Click lea nada** (sin esto, el envelope +`--json` con `ensure_ascii=False` corrompe acentos en la consola cp1252 de Windows). ## 7. Layout de dependencias (extras) ``` -core pyarrow, pydantic, networkx, click, tqdm, +core pyarrow, pydantic, networkx, click, tqdm, pyyaml, duckdb, rapidfuzz, (siempre; biblioteca viva + backbone + dedup fuzzy determinista en ingesta) -[zotero] pyzotero ─┐ -[s2] (cliente Semantic Scholar; reservado para │ costuras / capacidades opcionales - señal adicional, NO el Enricher —ADR 0025) │ -[neo4j] neomodel / driver oficial │ (futuras marcadas como no -[viz] matplotlib, seaborn │ implementadas) ─┘ +[bibtex] bibtexparser ─┐ siembra desde .bib (puerta primaria, + │ import perezoso) +[zotero] pyzotero │ +[s2] (cliente Semantic Scholar; reservado para │ costuras / capacidades opcionales + señal adicional, NO el Enricher —ADR 0025)│ (futuras marcadas como no +[neo4j] neomodel / driver oficial │ implementadas) +[viz] matplotlib, seaborn ─┘ ``` -> El extra **`[dedup]` se eliminó** (ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), -> #88): `rapidfuzz` pasó al **núcleo** porque el dedup ahora es automático en la ingesta (supersede en -> parte ADR [0026](decisiones/0026-dedup-fuzzy-determinista.md) / la enmienda `[dedup]` de ADR -> [0005](decisiones/0005-dependencias-extras.md)). - -El extra **`[llm]` se elimina** (ADR [0022](decisiones/0022-producto-sin-ia-generativa.md)): el -producto no usa IA generativa, así que no hay cliente LLM ni para forrajeo ni para thesaurus. - -> **AS-BUILT G3 + G5 (2026-06-18) — extra `[gui]` + empaquetado, ADR -> [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) (Aceptado; GUI gateada por -> [#34](https://github.com/complexluise/bib2graph/issues/34)).** El extra **`[gui]` = `fastapi` + -> `uvicorn`** (ADR [0005](decisiones/0005-dependencias-extras.md)) **ya existe** (`pyproject.toml`), -> **import perezoso**: el núcleo no importa `fastapi`; solo el adaptador `api/` y el subcomando `b2g -> gui` (AS-BUILT G3) los usan. Cierra la deuda del prototipo `app/` (instalados a mano, no declarados). -> **Empaquetado AS-BUILT (G5):** el **wheel incluye el frontend buildeado** (`src/bib2graph/gui/static/`, -> gitignored) vía `[tool.hatch.build.targets.wheel.force-include]` de hatchling → `b2g gui` funciona -> **sin Node** desde el wheel; el CI tiene un **job `frontend`** (lint/test/build JS, corre siempre) y -> `publish-testpypi.yml` hace `pnpm build` **antes** del `uv build` (sin esto el wheel publicado saldría -> mudo). `release-please.yml` no se tocó. El frontend SPA (`frontend/`) está **AS-BUILT (G4)**; su build -> es lo que G5 vendorea al wheel. **Con G5, los 5 hitos G1–G5 del MVP GUI están AS-BUILT**; solo queda el -> gate #34 (validación con un tercero, no construcción). - -`python-louvain` se **declara** (núcleo o extra de análisis), nunca usado sin declarar (lección -7). `notebook`/Jupyter es **solo dev**, jamás runtime (ADR 0005). - -**Capa base de vocabulario + modelos** (ADR [0023](decisiones/0023-capa-constants-modelos-schema.md), -`TARGET`): por debajo de todo, `bib2graph.constants` (`Col(StrEnum)`, `CurationStatus(StrEnum)`, -`NetworkKind`) es la **fuente única** de nombres de columna/estados/tipos de red (mata los ~62 -string-literals dispersos en 14 archivos, Nota 06 CONSTANTS); `ProvenanceEvent(BaseModel)` —definido -en `schemas.py`, no en un `models.py` separado— es la fuente única del evento de procedencia (Nota 06 -MODELS); `schemas.py` aloja también la **única** definición de fila (`PaperRow` ⇄ `CORPUS_SCHEMA` -derivado/verificado, no duplicado a mano). Se **mantiene** la -decisión "`Paper`/`Author`/`Keyword`/`Institution` = vistas derivadas, no tipos". El grafo de -dependencias va **de abajo hacia arriba**: `constants/schemas` → núcleo puro (`corpus`, `cycle`, -`projectors`, `analyzer`) → costuras (`sources`, `foraging` [consume el núcleo de proyección], -`stores`) → `cli`. El núcleo nunca depende de una costura. Ver ROADMAP **Hito R1**. - -## 8. Por qué este diseño (mapa a las lecciones de v0) +No hay extra **`[gui]`**: la GUI local (FastAPI + SPA) se retiró de la librería (ADR +[0040](decisiones/0040-retiro-gui-local.md), #190). No hay extra **`[llm]`**: el producto no usa IA +generativa (ADR 0022). No hay extra **`[dedup]`**: `rapidfuzz` pasó al núcleo porque el dedup es +automático en la ingesta (ADR 0031). `python-louvain` se **declara**, nunca usado sin declarar; +`notebook`/Jupyter es **solo dev**, jamás runtime (ADR 0005). + +**Capa base de vocabulario + modelos** (ADR [0023](decisiones/0023-capa-constants-modelos-schema.md)): +por debajo de todo, `bib2graph.constants` (`Col(StrEnum)`, `CurationStatus(StrEnum)`, `NetworkKind`) +es la **fuente única** de nombres de columna/estados/tipos de red; `ProvenanceEvent(BaseModel)` +—definido en `schemas.py`— es la fuente única del evento de procedencia; `schemas.py` aloja también la +**única** definición de fila (`PaperRow` ⇄ `CORPUS_SCHEMA` derivado/verificado). `Paper`/`Author`/ +`Keyword`/`Institution` = vistas derivadas, no tipos. El grafo de dependencias va **de abajo hacia +arriba**: `constants/schemas` → núcleo puro (`corpus`, `cycle`, `projectors`, `analyzer`) → costuras +(`sources`, `foraging` [consume el núcleo de proyección], `stores`) → `service` → `cli`. El núcleo +nunca depende de una costura. + +## 8. Por qué este diseño (cada decisión evita un anti-patrón de v0) | Decisión arquitectónica | Anti-patrón de v0 que evita | |-------------------------|-----------------------------| @@ -701,49 +389,20 @@ dependencias va **de abajo hacia arriba**: `constants/schemas` → núcleo puro | Solo publicar lo real | Clientes CrossRef/Scopus inicializados y nunca consultados | | Config inyectada, sin side-effects | Triple `DATABASE_URL`, clave S2 embebida | | Declarar lo que se importa | `python-louvain` usado pero ausente de `pyproject.toml` | -| Fallar/avisar accionable, nunca no-op silencioso (R5) | `except` anchos que tragan bugs; ramas/params muertos; versión inventada en el Manifest | - -> **AS-BUILT R5 — footguns cerrados (Nota 06, catálogo de secundarios).** R5 eliminó los anti-patrones -> que **enmascaran fallos**: el `except Exception` de `detect_communities` (`facade.py`) que tragaba el -> error (ahora solo `ImportError` se re-lanza, lo demás se propaga); el `AttributeError`→exit-3 -> "engañoso" (→ pre-check en el borde, §6.3); la **rama muerta** de `_errors.py` (`OSError` con `if/else` -> que hacía lo mismo); el **filtro PRISMA / `.bib` con campo-op/parseo desconocido = no-op silencioso** -> (ahora `ValueError`/warning accionable); el **param muerto `g`** de `cocitation_quality_report`; el -> fallback `_lib_version` `"0.0.0"` (versión inventada en el `Manifest` → `"unknown"`, honesto); y el -> `Literal` duplicado de `NetworkSpec.kind` (→ `NetworkKind`, fuente única). Principio: **sin no-ops -> silenciosos** — el comportamiento silencioso pasa a fallar/avisar accionable o se elimina la rama muerta. +| Fallar/avisar accionable, nunca no-op silencioso | `except` anchos que tragan bugs; ramas/params muertos | + +Principio transversal: **sin no-ops silenciosos** — el comportamiento silencioso pasa a fallar/avisar +accionable, o se elimina la rama muerta. ## 9. Tensiones resueltas -1. **Representación interna del corpus:** ✅ tabla Arrow única + wrapper Pydantic (ADR 0006). -2. **Fuente de referencia:** ✅ **OpenAlex** como **motor de extracción de referencia** - (intercambiable; ADR 0007/0036); la **ingesta `.bib` es puerta primaria** (doble puerta, ADR - 0035), no secundaria. El Enricher deja de ser estructural. -3. **Biblioteca viva vs. snapshot inmutable** (abierta en Nota 04 §6.2): ✅ **biblioteca viva - stateful en DuckDB**; el snapshot pasa a **export** (ADR 0009). Tras el 2º giro, ese sustrato es - el **`DuckDBBackend` del `Corpus`** (backend por defecto, no un `Store` aparte; ADR 0015) y - reproducir = re-leer el snapshot, no re-correr la ecuación (ADR 0017). Resuelta a nivel modelo de - datos. -4. **Wedge** (abierto en Nota 05 §6): ✅ **forrajeo asistido** por estructura bibliométrica - determinista; la **máquina de tensiones se retira del producto** (ADR 0008/0022), no se difiere. -5. **Agente-native:** ✅ **columna primaria** desde el hito 1 (ADR 0010), ya no extra futuro. -6. **Normalización multilingüe de keywords:** ✅ **thesaurus curado determinista** en V1; fuzzy a - v0.2 (ADR 0011). -7. **Driver Neo4j:** ✅ irrelevante al modelo; adaptador opt-in post-V1. -8. **`NetworkSpec`:** hook `Networks.build` desde v0.1; API congelada en v0.2 (ADR 0006). - -## 10. Estado de la documentación - -Los canónicos — [`PRD.md`](PRD.md), este doc, [`API.md`](API.md), [`ROADMAP.md`](ROADMAP/README.md) y los -[ADR 0007–0011](decisiones/) — están **reconciliados** con el giro, y luego con el **2º giro** (ADR -[0015](decisiones/0015-corpus-tabular-backend.md)–[0019](decisiones/0019-concurrencia-diferida.md): -`Corpus` sobre `TabularBackend` con `DuckDBBackend` por defecto, `LoopState`, reproducibilidad por -snapshot, `Source` agnóstico, single-writer). El contrato del CLI agente-native está en el ADR -[0021](decisiones/0021-cli-agente-native-contrato.md). Las notas de proceso ya promovidas viven en -[`_archivo/`](_archivo/). Implementación por hitos: **Hitos 0–6 + 1.5 construidos** (núcleo, -biblioteca viva, fuentes, forrajeo y el CLI `b2g`). Tras el **red-team de la -[Nota 06](Notas/06-critica-as-built-v0.2.md)** y el **nuevo modelo conceptual bloqueado por el PO** -(scent bibliométrico sin IA, FSM cíclico, identidad-vs-procedencia, capa constants/schemas), este doc -describe el **TARGET**; la brecha con el AS-BUILT se cierra con la **tanda de remediación R1–R5** del -[`ROADMAP.md`](ROADMAP/README.md), **antes** de los Hitos 7–11. (Ya no se afirma "v0.2 con capacidades -completas": ese claim era parte de la sobre-venta que la Nota 06 corrigió.) +1. **Representación del corpus:** tabla Arrow única + wrapper Pydantic (ADR 0006). +2. **Fuente de referencia:** **OpenAlex** como motor de extracción intercambiable (ADR 0007/0036); la + **ingesta `.bib` es puerta primaria** (ADR 0035); el Enricher deja de ser estructural. +3. **Biblioteca viva vs. snapshot inmutable:** **biblioteca viva stateful en DuckDB** (ADR 0009/0015); + el snapshot pasa a **export**; reproducir = re-leer el snapshot (ADR 0017). +4. **Wedge:** **forrajeo asistido** por estructura bibliométrica determinista; la máquina de tensiones + por IA se **retira del producto** (ADR 0008/0022). +5. **Agente-native:** **columna primaria** (ADR 0010). **Keywords:** **thesaurus curado determinista** + (ADR 0011). **Neo4j:** adaptador opt-in post-V1, no sustrato (ADR 0002). **`NetworkSpec`:** hook + `Networks.build` + capa declarativa YAML (`load_specs`, `build --spec`). diff --git a/docs/Notas/21-descomposicion-tests-poda.md b/docs/Notas/21-descomposicion-tests-poda.md new file mode 100644 index 0000000..1b4611c --- /dev/null +++ b/docs/Notas/21-descomposicion-tests-poda.md @@ -0,0 +1,217 @@ +# 21 — Descomposición de información de la suite de tests (hacia una poda) + +> ⚠️ **NOTA DE ANÁLISIS — no es decisión ni ADR.** Diagnóstico de la suite de tests del 2026-06-28, +> motivado por que la suite cruzó las **~1238 pruebas recolectadas** (68 archivos). El objetivo es +> entender *qué información aporta cada test* para decidir **qué se puede quitar sin perder cobertura +> real**. El análisis se hizo leyendo los 68 archivos por clúster temático (6 lecturas paralelas). +> Las eliminaciones concretas listadas son **candidatas**: cada una se confirma borrando el test, +> corriendo el gate y verificando que `--cov` no pierde líneas/ramas únicas (ver §6). Documentos +> hermanos: [`06-critica-as-built-v0.2.md`](06-critica-as-built-v0.2.md), [`19-qa-cli-superficie-limites.md`](19-qa-cli-superficie-limites.md). + +## 0. El dato que dispara la nota + +La suite pasó de **645 tests verdes (v0.3, 2026-06-16)** a **~1238 recolectados hoy** —casi el doble— +mientras la superficie pública se **consolidó** (10 verbos CLI, ADR 0038/0037). Cuando los tests +crecen al doble y la superficie *baja*, la diferencia no es cobertura nueva: es **acumulación**. La +mayor parte de ese exceso tiene una causa estructural identificable (§4), no es ruido aleatorio. + +## 1. El marco: descomposición parcial de información aplicada a tests + +Tomo prestado el lente de la *partial information decomposition* (único / redundante / sinérgico) y le +agrego un cuarto eje propio del software, **tensión**. Cada test, frente al conjunto, cae en: + +- **ÚNICO** — es el único que cubre un invariante. Si lo borrás, una rama/contrato queda sin red. + **No se toca.** Es la mayoría sana de la suite (`dedup`, `decorate`, `preprocessors`, `projectors`, + `analyzer`, `compute_id`, `r1`/`r4` puros, contratos de backend). +- **REDUNDANTE** — verifica *el mismo invariante* que otro(s), en la misma o distinta capa. El segundo + ejemplar no agrega información; agrega costo de runtime y de mantenimiento. **Candidato a poda o + fusión.** +- **SINÉRGICO** — *parece* duplicado pero solo tiene valor **en conjunto**: un par aislamiento↔integración, + o una guarda negativa que sola no dice nada. **No se toca aunque tiente** — borrar la mitad rompe la + garantía sin que ningún test se ponga rojo (peor que un duplicado: un falso ahorro). +- **EN TENSIÓN** — acoplado a implementación frágil (strings de copy, internals privados, versión de + Click, IDs de OpenAlex, paths de import para `patch`). Pasa hoy pero **pelea contra cada refactor**. + No es candidato a *borrar* sino a **refactorizar** (extraer constantes, testear contrato no forma); + algunos —las "lápidas"— sí a borrar. + +La poda barata vive en **REDUNDANTE**. La poda peligrosa (la que hay que *evitar*) es confundir +**SINÉRGICO** con redundante. La deuda que conviene saldar de paso es **TENSIÓN**. + +## 1.bis Criterio del PO (adoptado 2026-06-28) + +El PO fijó la política que gobierna la poda. **No es DRY ciego.** + +1. **Defensa en profundidad SELECTIVA.** Un invariante = un test *en general*, **pero se conserva la + redundancia en capas** (unit + backend + e2e) donde el invariante es el corazón del producto: + **`corpus_hash`/reproducibilidad y la FSM del ciclo**. → De §2.4 solo se eliminan las copias + *byte-idénticas*; la cobertura en capas de lo crítico **se mantiene**. La reproducibilidad es la + tesis de un tool de investigación: ahí la redundancia es seguro, no desperdicio. +2. **Tests de forma → conservar consolidados.** Neutralidad-AST, reloj inyectado, pureza del núcleo se + **mantienen** como guardia de arquitectura (ADR 0028, R2), pero se **fusionan en 1 parametrizado** y se + hacen menos frágiles (§2.7). Las "lápidas" (asserts de ausencia de símbolos viejos) son lo de menor valor. +3. **Deprecación: la migración cerró.** ADR 0038 consolidó los verbos → retirar los `*_corre_y_delega`, + dejar **solo** el test del aviso de deprecación (§2.1). +4. **Mocks de red → adelgazar + promover.** Recortar los asserts de forma-de-query duplicados y mover la + cobertura real a más tests `@network` fuera del gate (§4.5). + +## 2. Redundante — el inventario de poda (lo accionable) + +Agrupado por patrón, con cita `archivo::test`. Estimación gruesa: **~70–100 tests** podables/fusionables +(~6–8% de la suite) sin perder un solo invariante. + +### 2.1 Migración noun-verb (ADR 0038) duplicada: el viejo verbo + el nuevo conviven +La epic de huérfanos #37 ya consolidó los verbos. Los tests que protegían la transición ahora **duplican +al verbo canónico**: +- `test_deprecation_aliases.py` — los `test_*_corre_y_delega` (accept/reject/filter/restore/networks) + re-ejercen funcionalidad ya cubierta por `test_cli.py` y `test_cli_read.py`. **Lo único con valor + propio es el aviso de deprecación** (stderr + `warnings[]`). El resto (~15-20 de ~40) es poda directa + una vez aceptada la deprecación. +- Transición `restore → FILTERED` triplicada: `test_restore.py::test_run_restore_transiciona_a_filtered` + + `test_snapshot_grp.py::test_snapshot_restore_transiciona_a_filtered` (+ su variante `_desde_seeded`, + que no siembra estado previo → idéntica). +- `run_snapshot` con `out_dir` por tres lados: `test_snapshot_grp.py::test_snapshot_create_crea_archivos` + ≈ `::test_run_snapshot_importable_desde_cli_snapshot` ≈ + `test_workspace_remanentes.py::test_snapshot_sin_outdir_usa_workspace_snapshots_dir`. + +### 2.2 El mismo invariante repetido por cada superficie (helper puro + N copias) +Cuando hay un helper puro bien testeado, repetir su semántica en cada comando que lo usa es redundante; +basta **un test de "presencia + forma" por superficie**: +- **`maturity`**: `TestComputeMaturity` ya cubre `curated`/`saturated` a fondo; `TestBuildMaturity`, + `TestSnapshotMaturity`, `TestReadTopMaturity` repiten casi literal `curated_false/true`,`saturated_false` + (~6 tests podables). +- **`next_best_action`**: el FSM puro (`test_status_10.py::TestNextBestAction`, 6 casos) y luego + `TestRunStatusCamposAditivos::test_next_best_action_*` (4 casos) re-mapean lo mismo vía `run_status`. + Dejar 1 smoke de cableado. + +### 2.3 "stdout = una sola línea JSON" (#151) y `schema=="1"` re-aseverados +Hay un guard parametrizado canónico (`test_cli_json_option.py::test_anti_regresion_stdout_max_una_linea_con_json`, +16 comandos) y además el helper `_assert_one_json_line` ya valida "1 línea" en *cada* test de read. Los +tests dedicados son duplicado del helper: +- `test_cli_read.py::test_cli_read_{list,show,stats}_stdout_una_linea_json`, + `test_cli_read_top.py::test_cli_read_top_stdout_una_linea_json` **y su gemelo `_cocitacion_vacia`** + (mismo assert, el "vacía" no cambia nada). +- `test_build_absorber_networks.py::test_json_stdout_una_sola_linea` ≈ `::test_json_warnings_en_envelope_no_en_stdout`. +- `schema=="1"`: los 5 `test_schema_1_intacto` de `test_deprecation_aliases.py` ya los cubre + `_assert_one_json_stdout` en cada test de su clase. + +### 2.4 `corpus_hash`/reproducibilidad verificado en 5 capas +El invariante de identidad es el más sobre-cubierto del repo (señal de que es el más importante — sano +tener red unitaria + e2e, pero no 5 copias): +- contrato unitario: `test_r2_reproducibility.py`; a nivel backend: `test_backends.py::test_corpus_hash_estable` + / `::test_corpus_hash_order_independent`; e2e sobre parquet real: `test_example_r2_gate.py::test_corpus_hash_estable_entre_cargas` + ≈ `test_idempotencia_pipeline_bitabit.py::test_corpus_hash_identico_en_dos_cargas_arrow` (**idénticos** → uno sobra). +- "Louvain reproducible" duplicado: `test_r2…::test_networks_build_louvain_reproducible` ≈ + `test_example_r2_gate…::test_comunidades_estables_entre_corridas`. +- Recomendación: **conservar 1 unitario (r2) + 1 e2e (gate del corpus real); fusionar el resto.** + +### 2.5 La familia `enrich` (la mayor concentración de deuda) +Co-citación probada en memoria, vía store, y absorbida en chain/build — con los mismos invariantes +repetidos 3-4 veces: +- Idempotencia triplicada (`test_enrichers_8b::test_enrich_cited_by_idempotente` el más débil → eliminable; + lo subsume `test_enrich_cocitacion_integrado::…_idempotente_corpus_hash…`). +- Tope `max_citing` ×4, "sin seeds → no-op" ×3, "no pierde papers" ×3, conteo de redes 4-vs-5 en ≥4 sitios, + contrato de claves de `run_enrich` ×3. +- **Estrategia**: concentrar co-citación en `test_enrich_cocitacion_integrado.py` (store) + + `test_enrich_absorb.py` (chain/build), y recortar de `test_enrichers_8b.py` lo ya cubierto end-to-end + (~6-8 tests). + +### 2.6 Contrato `BibtexSource.load` repartido en 3 archivos +`test_sources.py::test_bibtex_load_{is_seed_true,curation_status_candidate,campos_faltantes_none,sin_keyerror}` +son **subconjunto estricto** de `test_bibtex_source_contrato.py::test_campos_…_persisten` (que ya asevera +`is_seed`, `curation_status` y todos los campos). + mutua-exclusión de modos `seed` duplicada literal entre +`test_seed_from_bib.py` y `test_equation_spec.py`. + +### 2.7 Tests de neutralidad-AST copiados textualmente +El mismo `ast.walk` buscando `click`/`fastapi`/`sys.exit`/`print` está copiado en +`test_service.py`, `test_service_reads.py`, `test_api.py`, `test_cli_read.py` y `test_cli_read_top.py`. +**Consolidar en un único test parametrizado sobre todos los módulos de `service/`** (~4 tests → 1). + +### 2.8 Misceláneos cross-archivo +`NetworkKind` como fuente única: `test_r1_constants` vs `test_r5_robustness` (mismo contrato) · +paridad build≡networks doble (`test_build_absorber_networks::test_paridad_artefactos…` ⊂ `…_multiples_redes`) · +"networks no toca el FSM" en `test_build_absorber_networks` y `test_networkspec_yaml` · scope-token CLI +duplicado dentro de `test_build_absorber_networks` · `status` cache fresca vs sin-cache (mismo assert final). + +## 3. Sinérgico — lo que NO hay que tocar aunque parezca duplicado + +Estos pares/tríos **se sostienen mutuamente**; borrar la mitad deja un agujero que ningún rojo delata: +- **Aislamiento ↔ integración**: `Forager.preview` con MagicMock + `run_chain(preview=True)` con DuckDB; + `predict_build_preview` puro + `TestNoDivergenciaPreviewVsBuild` (ata la predicción al `Networks.build` + real); co-citación en memoria (`8b`) + persistencia store (`cocitacion_integrado`, lo dice su docstring). +- **Paridad de backends** (`test_external_ids.py`, `test_backends.py`): los bloques memory/duckdb *parecen* + espejo, pero el contrato del ADR 0036/0013 **es** "ambos backends, mismo comportamiento" — la clase + `Paridad` no existe sin los dos lados. +- **Guardas negativas**: `test_clusters.py::test_cruce_por_col_id_no_openalex_id` (detecta cruce por la + columna equivocada) solo vale junto a los `test_cluster_table_*_count`. +- **Capstone + gates**: `test_oneshot_readiness.py` (e2e, prueba que el ciclo *fluye*) + `r1-r5` (prueban + *por qué* cada paso es correcto). El e2e sin gates no localiza la causa de un fallo; los gates sin e2e + no garantizan la integración. **Los `r1-r5` NO se solapan entre sí** (r1 tipos, r2 identidad, r3 FSM, + r4 scent, r5 robustez) — son cinco capas distintas, todas únicas. +- **Paridad seed+resolve** (`test_parity_resolve_paths.py`): no prueba comportamiento, prueba + *no-divergencia* entre dos caminos — su valor es ser la red antes de retirar el verbo. (Pero ver §4.5: + está en tensión con su propio destino.) + +## 4. Tensión — las causas sistémicas (refactor, no solo borrado) + +Las redundancias de §2 no son casuales: salen de **cinco patrones** que conviene atacar en la raíz. + +1. **Aserciones por substring sobre copy en español.** `"deprecad" in stderr`, `"50" in reason`, + `"ignora" in stderr`, `"0 papers" in warning`, y los `fix_command == "b2g build --thesaurus "` + literales repetidos en ~6 sitios de `test_status_10.py`. Cualquier reescritura del mensaje los rompe + sin que cambie el comportamiento. → **comparar contra constantes exportadas, no literales.** +2. **Acoplamiento a la separación stdout/stderr de Click 8.4.1.** ~6 tests de + `test_build_absorber_networks.py` dependen de ese comportamiento de versión; un bump de Click los + rompe en bloque. +3. **`patch()` sobre rutas de import internas.** `test_r3_commands_domain.py`, + `test_cli.py::test_exit_code_*`, `test_r5_robustness.py` parchean + `bib2graph.networks.facade.Networks.quick`, `…sources.openalex.OpenAlexSource`, `_projector_for_kind`. + Mover un import interno rompe el test sin cambiar comportamiento observable. `test_oneshot_readiness.py` + es el caso extremo: exige que `chain.py`/`resolve.py` importen `OpenAlexSource` *dentro* de la función + y replica la firma del constructor — frágil y caro (el test más lento del repo). +4. **"Lápidas" (tests que aseveran ausencia).** `test_foraging.py::test_build_forward_candidate_row_eliminado`, + `TestExplainCandidateRetirado`, `::test_modulo_explain_no_existe`. Verifican que algo *ya no existe*: + valor decreciente, fricción permanente con cualquier refactor. Candidatos a borrar tras un ciclo. +5. **Mocks que dan falsa confianza.** Toda la familia de exclusiones de OpenAlex aseverando sobre la query + *mockeada* — el propio `test_openalex_exclude_integration.py` documenta que el mock **no detecta** el + bug real (campo repetido → 0 resultados) y solo el test `@network` (fuera del gate) protege de verdad. + La query-forma está además duplicada unit+integración, acoplada al string exacto de `_translate`. + +Contradicciones puntuales a resolver: +- **§4.5** `test_parity_resolve_paths.py` y los `test_resolve.py::*_resolve_envelope` importan `run_resolve` + de un verbo **en retirada** (#164/#165). El test que *guarda* la retirada se romperá *con* la retirada. +- `test_example_r2_gate.py` (`assert exists`, falla duro) vs `test_idempotencia_pipeline_bitabit.py` + (`pytest.skip`) ante **el mismo** parquet faltante → comportamiento contradictorio; y ambos con asserts + de tamaño (`50 ≤ n ≤ 200`) que rompen si se regenera el corpus aunque el código esté bien. +- `_seed_store` definido dos veces en `test_clusters.py`; docstrings que aún mencionan `--store` (eliminado + #75) en `test_smoke.py`/`test_r5`. + +## 5. Síntesis: ¿de dónde salieron los ~600 tests de más? + +No de cobertura nueva. De **tres acumuladores**: +- **migraciones que dejaron el test viejo + el nuevo** (noun-verb ADR 0038, retiro de verbos #164/#165), +- **el reflejo de "un test por superficie"** cuando el invariante ya vive en un helper puro (maturity, + json-una-línea, schema, next_best_action, neutralidad-AST), +- **el invariante estrella sobre-cubierto** (`corpus_hash` en 5 capas, co-citación ×4). + +La poda sana es **fusionar/parametrizar**, no recortar a ciegas: cada patrón de §2 colapsa N tests en 1 +parametrizado conservando todos los casos. Y el ahorro real de mantenimiento no está en el conteo sino en +**§4**: extraer constantes y dejar de testear forma de implementación rinde más que borrar 80 tests. + +## 6. Cómo ejecutar la poda con seguridad (DoD por candidato) + +Por cada test marcado en §2, antes de borrar: +1. `uv run pytest --cov=bib2graph --cov-report=term-missing` **antes** (línea base de líneas/ramas). +2. Borrar/fusionar el candidato. +3. Re-correr: **si la cobertura de líneas/ramas no baja**, el test era redundante → eliminar. **Si baja**, + era único (o sinérgico) → revertir, era un falso positivo del análisis. +4. Gate completo verde: `uv run ruff check . && uv run ruff format --check . && uv run mypy src && uv run pytest`. + +Orden sugerido (mayor ahorro / menor riesgo primero): §2.7 (neutralidad-AST, 4→1) · §2.3 (json/schema) · +§2.2 (maturity/next_best_action) · §2.5 (familia enrich) · §2.1 (noun-verb, tras confirmar deprecación) · +§2.4 y §2.6 al final (tocan invariantes sensibles, confirmar caso por caso). **§3 no se toca. §4 es +trabajo aparte (refactor), no poda.** + +--- + +*Nota generada a partir de una lectura por clústeres de los 68 archivos de `tests/`. Las citas +`archivo::test` son verificables; los conteos son estimaciones para priorizar, no cifras exactas.* diff --git a/docs/PRD.md b/docs/PRD.md index 430e0c4..8253641 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,77 +1,37 @@ # PRD — bib2graph -> Documento de Requisitos de Producto de la **V1** de `bib2graph`. Reescribe el PRD anterior -> (que describía una librería BibTeX→redes con Semantic Scholar como enricher estructural y -> Neo4j como preocupación central) tras el **giro** documentado en `Notas/04`–`07` y la -> demolición de [`critica-base.md`](Notas/critica-base.md). Fecha: 2026-06-15 (reconciliado con el 2º -> giro). -> -> Documentos hermanos: la dirección "IA in the loop" en -> [`Notas/04-direccion-ia-in-the-loop.md`](Notas/04-direccion-ia-in-the-loop.md), el ciclo de -> investigación humano en [`Notas/05-ciclo-investigacion-humano.md`](Notas/05-ciclo-investigacion-humano.md), -> el método bibliométrico en [`metodología.md`](Notas/metodología.md), y las decisiones en -> [`decisiones/`](decisiones/) — en particular [ADR 0007](decisiones/0007-openalex-backbone.md) -> (OpenAlex backbone). -> -> ✅ **Reconciliación hecha:** `ARCHITECTURE.md`, `API.md` y `ROADMAP.md` ya están alineados con -> este PRD y los ADR 0007–0011 (OpenAlex backbone, biblioteca viva en DuckDB, forrajeo, -> agente-native, thesaurus). El `ROADMAP.md` ata cada hito a las historias del §7 con criterios -> de aceptación. Los ADR 0001–0006 son **registro histórico** (inmutables): los puntos superados -> quedan marcados como tales por los ADR 0007–0011, no se reescriben. -> -> ⚠️ **Reconciliación pendiente con el modelo nuevo (2026-06-15, ADR -> [0022](decisiones/0022-producto-sin-ia-generativa.md)/[0023](decisiones/0023-capa-constants-modelos-schema.md)):** -> tras el red-team del AS-BUILT ([Nota 06](Notas/06-critica-as-built-v0.2.md)) el PO bloqueó que el -> **producto NO usa IA generativa**: el *information scent* es **bibliométrico determinista vía -> proyectores** (sin LLM/embeddings); la **"máquina de tensiones" se RETIRA** (no se difiere a v2: se -> borra); `explain_candidate`/`[llm]` se **eliminan**; el sensemaking de tensiones es **humano** -> (asistido por las redes). Donde abajo este PRD aún dice "inserción de IA", "paso opcional de IA", -> "máquina de tensiones a v2" o "fallback fuzzy `[llm]`", **leerlo bajo esta corrección** (las §2/§5/§6/§7 -> marcan los puntos afectados). El principio "IA in the loop, NOT human in the loop" se reencuadra a -> **"asistencia algorítmica determinista, no IA; el juicio humano no se automatiza"**. -> -> ✅ **Reconciliado con el 2º giro (2026-06-15):** este PRD incorpora los ADR -> [0015](decisiones/0015-corpus-tabular-backend.md)–[0019](decisiones/0019-concurrencia-diferida.md) -> (breaking change). En síntesis: la persistencia por defecto es el **`DuckDBBackend` del `Corpus`** -> (no un `Store` aparte), con `DuckDBStore` como **fachada de costura** (0015); el lazo es una -> **máquina de estados explícita** (`LoopState`, 0016); **reproducir = re-leer el snapshot, no -> re-correr la ecuación** (0017); el contrato `Source` es **agnóstico** (mínimo universal vs -> enriquecimiento opcional, habilita fuentes regionales, 0018); y la **concurrencia single-writer** -> es límite conocido (0019). El §8 ("modelo de datos") deja de ser una reconciliación *pendiente* y -> pasa a registrar la decisión adoptada. +> Documento de Requisitos de Producto de `bib2graph`: una **librería de Python** + **CLI delgada +> agente-native** que convierte una **ecuación de búsqueda** en una **biblioteca viva y curada** de +> literatura y la proyecta a **redes bibliométricas**. El **producto NO usa IA generativa** (ADR +> [0022](decisiones/0022-producto-sin-ia-generativa.md)): la asistencia del forrajeo es estructura +> bibliométrica determinista (*information scent*); el desarrollo sí es asistido por IA. Diseño en +> [`ARCHITECTURE.md`](ARCHITECTURE.md); contratos en [`API.md`](API.md); método en +> [`Notas/metodología.md`](Notas/metodología.md). ## 1. Qué es -`bib2graph` V1 es una **librería de Python instalable** y una **CLI delgada agente-native** -construida sobre ella, que convierte una **ecuación de búsqueda** —el artefacto estándar y -reproducible de la ciencia— en una **biblioteca viva y curada** de literatura, y la proyecta a -**redes bibliométricas** listas para analizar (co-citación, acoplamiento bibliográfico, -co-autoría, co-ocurrencia de palabras clave, instituciones). +`bib2graph` es una **librería de Python instalable** y una **CLI delgada agente-native** construida +sobre ella, que convierte una **ecuación de búsqueda** —el artefacto estándar y reproducible de la +ciencia— en una **biblioteca viva y curada** de literatura, y la proyecta a **redes bibliométricas** +listas para analizar (co-citación, acoplamiento bibliográfico, co-autoría, co-ocurrencia de palabras +clave, instituciones). El **motor de extracción de referencia** es **OpenAlex** ([ADR 0007](decisiones/0007-openalex-backbone.md)), -pero es **intercambiable**: la identidad **no** se ancla en OpenAlex sino en el **DOI** (la columna -es **`source_id`** —id del motor, agnóstica— y el `id` interno se deriva del DOI; ADR +pero es **intercambiable**: la identidad **no** se ancla en OpenAlex sino en el **DOI** (la columna es +**`source_id`** —id del motor, agnóstica— y el `id` interno se deriva del DOI; ADR [0036](decisiones/0036-identidad-source-id-agnostica-doi-ancla.md)), con apertura a **otros motores** -(p. ej. Semantic Scholar). El -camino **no es un pipeline lineal** sino un **ciclo iterativo** (Bates / Ellis / Kuhlthau, ver -§2): se siembra desde la ecuación, se hace chaining rankeado por estructura, se diferencia y -cura, y **la ecuación y la idea mutan** — se vuelve a sembrar con otra pregunta. Asumir un flujo -lineal "query → resultados → fin" contradice a Bates/Ellis/Kuhlthau a la vez -([`Notas/05`](Notas/05-ciclo-investigacion-humano.md) §3). El corpus **vive y persiste** entre -esas iteraciones en **DuckDB** desde la V1.0 (no es el export de una sola corrida): es el -**sustrato que hace posible el lazo** — se acepta/rechaza, crece y se cultiva en el tiempo. Tras -el 2º giro, ese sustrato es el **`DuckDBBackend` del `Corpus`** (el backend por defecto, no un -`Store` aparte; ADR [0015](decisiones/0015-corpus-tabular-backend.md)), y el lazo es una **máquina -de estados explícita** (`LoopState`: `SEEDED → FORAGED → FILTERED → BUILT`, ADR -[0016](decisiones/0016-maquina-estados-lazo.md)): **una investigación = un archivo `.duckdb`**, su -estado se consulta con `b2g status`. - -> **AS-BUILT — ADR [0029](decisiones/0029-workspace-por-investigacion.md) (2026-06-16; enmienda -> BREAKING #75, 2026-06-17):** "una investigación = un archivo" evolucionó a "una investigación = un -> **workspace** (carpeta `workspace.json` + db + redes/snapshots/exports)", con `b2g init` + resolución -> ambiente (`--workspace` opcional > `B2G_WORKSPACE` > walk-up del cwd). La carpeta con -> `workspace.json` es la **única** unidad canónica: `--store` y el modo degenerado del `.duckdb` suelto -> fueron **eliminados** (#75); un `.duckdb` legacy se adopta con `b2g init .`. +(p. ej. Semantic Scholar). El camino **no es un pipeline lineal** sino un **ciclo iterativo** (Bates / +Ellis / Kuhlthau, ver §2): se siembra desde la ecuación, se hace chaining rankeado por estructura, se +diferencia y cura, y **la ecuación y la idea mutan** — se vuelve a sembrar con otra pregunta. El corpus +**vive y persiste** entre esas iteraciones en **DuckDB** (no es el export de una sola corrida): es el +**sustrato que hace posible el lazo** — se acepta/rechaza, crece y se cultiva en el tiempo. Ese +sustrato es el **`DuckDBBackend` del `Corpus`** (el backend por defecto, no un `Store` aparte; ADR +[0015](decisiones/0015-corpus-tabular-backend.md)), y el lazo es una **máquina de estados explícita** +(`CycleState`: `SEEDED → FORAGED → FILTERED → BUILT → MONITORED`, ADR +[0016](decisiones/0016-maquina-estados-lazo.md)). **Una investigación = un workspace** (carpeta +`workspace.json` + `library.duckdb` + `networks/`/`snapshots/`/`exports/`; ADR +[0029](decisiones/0029-workspace-por-investigacion.md)), arrancado con `b2g init` y resuelto por +ambiente; su estado se consulta con `b2g status`. *El final siguen siendo las redes; lo nuevo es **cómo se llega a ellas** (forrajeo asistido) y que **la colección vive** (berry growing).* @@ -100,54 +60,45 @@ y conserve una **biblioteca viva reproducible**. La contribución (y la tesis del paper, [`Notas/05`](Notas/05-ciclo-investigacion-humano.md) §5) es **re-instrumentar el ciclo humano clásico** con un método donde la **estructura bibliométrica funciona como *information scent*** (forrajeo asistido, **determinista y reproducible, sin IA -generativa**), **sin desplazar el juicio humano**. -Mapeo del ciclo de 9 pasos (05 §3–4) sobre la V1: +generativa**), **sin desplazar el juicio humano**. Mapeo del ciclo de 9 pasos (05 §3–4) sobre el +producto: -| Paso del ciclo | En la V1 | +| Paso del ciclo | En bib2graph | |---|---| | **0** · Idea / pregunta difusa | **Humano** — no se automatiza | -| **1–3** · Semillas → chaining/forrajeo → browsing/diferenciar | **Núcleo de V1** (asistencia algorítmica nº1: bibliometría = *information scent*, **determinista, sin IA**) | +| **1–3** · Semillas → chaining/forrajeo → browsing/diferenciar | **Núcleo** (asistencia algorítmica: bibliometría = *information scent*, **determinista, sin IA**) | | **4** · La query y la idea **mutan** | **Humano**; la herramienta lo soporta (re-sembrar, ecuaciones que evolucionan) | -| **5** · Organizar en evidencia | **Parcial** — las redes/métricas son la organización estructural; la matriz concepto×paper (Webster & Watson) no está en V1 | -| **6** · Sensemaking / tensiones | **Humano**, asistido por las redes (comunidades/centralidad/acoplamiento). La "máquina de tensiones" asistida por IA se **retiró** del producto (ADR 0008/0022), no es v2 | -| **7** · Curar la biblioteca | **V1** — biblioteca viva en DuckDB (berry growing); el *juicio* de qué curar es humano | -| **8** · Monitoreo / alertas de lo nuevo | **Futuro** (encaja sobre la biblioteca viva) | - -La **no-linealidad** (el lazo 2→3→4→1) es propiedad de primera clase, no un detalle: la -biblioteca viva existe precisamente para que la idea pueda mutar y volver a sembrarse sin perder -lo acumulado. Tras el 2º giro esa no-linealidad **deja de ser solo prosa** y se modela como una -**máquina de estados explícita** (`LoopState`: `SEEDED → FORAGED → FILTERED → BUILT`, con -**transiciones permisivas** —se puede re-sembrar desde casi cualquier estado; ADR -[0016](decisiones/0016-maquina-estados-lazo.md)). El `LoopState` vive en el archivo `.duckdb` (no -en el `Corpus` efímero) y se expone con `b2g status`: humanos e IAs comparten el mismo mapa del -lazo en vez de inferir el punto del ciclo a partir del contenido. +| **5** · Organizar en evidencia | **Parcial** — las redes/métricas son la organización estructural; la matriz concepto×paper (Webster & Watson) no está | +| **6** · Sensemaking / tensiones | **Humano**, asistido por las redes (comunidades/centralidad/acoplamiento). La "máquina de tensiones" asistida por IA quedó **fuera del producto** (ADR 0008/0022) | +| **7** · Curar la biblioteca | **Sí** — biblioteca viva en DuckDB (berry growing); el *juicio* de qué curar es humano | +| **8** · Monitoreo / alertas de lo nuevo | **`chain --since`** (forrajeo incremental → `MONITORED`); alertas más ricas son futuro | + +La **no-linealidad** (el lazo chain→curar→mutar→seed) es propiedad de primera clase: la biblioteca +viva existe precisamente para que la idea pueda mutar y volver a sembrarse sin perder lo acumulado. Se +modela como **máquina de estados explícita** (`CycleState`, con transiciones permisivas; ADR +[0016](decisiones/0016-maquina-estados-lazo.md)), que vive en el `library.duckdb` y se expone con +`b2g status`: humanos e IAs comparten el mismo mapa del lazo. ## 3. Para quién -- **Investigadoras/es y analistas** que hacen revisión de literatura o estudian la estructura - intelectual de un campo y quieren redes reproducibles para Gephi/VOSviewer (GraphML) o pandas - (CSV), **sin montar infraestructura**. -- **Fácil PERO consciente** (crítica #3): cómodo con la línea de comandos, pero **el primer - flujo de 10 minutos (ecuación → redes) es contrato de diseño**, no un tutorial posterior. La - superficie por defecto es diminuta. -- **Agentes / automatizaciones** que orquestan `bib2graph` por CLI (`--json`, exit codes), sin - GUI. - -No es una herramienta para usuario final no técnico: no hay GUI ni servicio web en V1. - -> **ENMIENDA (2026-06-18) — ADR [0027](decisiones/0027-pivote-posicionamiento-gui-local.md) -> (Aceptada).** El texto de arriba se conserva como historia, pero el alcance **cambia**: entra una -> **GUI local opt-in para el investigador semi-técnico** (tesista/docente), instalable por **pip/uv** -> (extra `[gui]`), **local-first** (corre en `127.0.0.1` sobre el workspace local, ADR 0029). El **CLI -> sigue siendo la columna agente-native** (ADR 0010/0021): la GUI es un frontend **adicional y par**, -> no un sucesor. **Hosting, servidor MCP y Claude-Web quedan FUERA** (descartados, no diferidos); -> binario/Tauri diferido a v2. La GUI sigue **gateada por la epic [#34](https://github.com/complexluise/bib2graph/issues/34)**: -> no se implementa hasta validar un **caso real reproducido por un tercero** (TARGET, no AS-BUILT). La -> arquitectura está en ADR [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md) y `ARCHITECTURE.md`. -> -> **ESTADO 0.8 — GUI rota a propósito ([#117](https://github.com/complexluise/bib2graph/issues/117)).** -> El rename de columna a **`source_id`** (ADR 0036) **rompió la GUI** de forma deliberada: **no está -> disponible hoy** hasta que se actualice (#117). Ningún lector debe asumir que la GUI funciona en 0.8. +bib2graph es **CLI / agente-native, sin GUI ni servicio web**. Su forma de uso por defecto es a través +de un agente (Claude Code u otro) que corre el ciclo; la skill de Claude Code (`b2g skill add`, ADR +[0039](decisiones/0039-skill-comando-meta-distribucion.md)) materializa el mensaje *"la mejor forma de +usar bib2graph es pedirle a Claude que lo use"*. Dos perfiles, en este orden: + +- **Investigador/a no-técnico vía IA (primero).** Hace revisión de literatura o estudia la estructura + intelectual de un campo, pero **no programa**: le pide a un agente que corra bib2graph por él. La + superficie agéntica (10 verbos que mapean el ciclo, `--json`, exit codes, mensajes accionables) está + diseñada para que el agente conduzca el ciclo end-to-end y entregue redes reproducibles para + Gephi/VOSviewer (GraphML) o pandas (CSV), **sin montar infraestructura**. +- **Técnico que hurga (segundo).** Cómodo con la línea de comandos y con leer/extender la librería: + corre `b2g` a mano, scriptea el ciclo, o importa el paquete Python. **Fácil PERO consciente:** la + ecuación de búsqueda es ciudadana de primera clase y queda registrada; la superficie por defecto es + diminuta. + +No es una herramienta con GUI ni servicio gestionado: el core es CLI/agente-native sobre la biblioteca +viva. La experiencia visual library-centric vive en un **producto separado**, fuera de bib2graph (ADR +[0040](decisiones/0040-retiro-gui-local.md)). ## 4. Propuesta de valor @@ -155,9 +106,8 @@ No es una herramienta para usuario final no técnico: no hay GUI ni servicio web traduce a una query OpenAlex, se **muestra la query exacta ejecutada** y un **reporte de traducción** (qué mapeó limpio, qué se aproximó, qué se descartó). Eso *es* el ejercicio bibliotecario y lo que hace el resultado reportable (PRISMA / vom Brocke). Las **exclusiones - quirúrgicas** (`b2g seed --exclude`, negaciones `AND NOT …` por término) son parte de ese - ejercicio consciente: quedan en el reporte de traducción, no se aplican en silencio. Para - exploración con muestras chicas, `--max-results` acota el fetch. + quirúrgicas** (`b2g seed --exclude`, negaciones `AND NOT …`) quedan en el reporte, no se aplican en + silencio. `--max-results` acota el fetch para exploración con muestras chicas. - **Biblioteca viva, no mapa one-shot.** El corpus se cura y crece en el tiempo (berry growing), persistido en DuckDB. El investigador **posee** su colección. - **Forrajeo asistido.** Chaining backward/forward sobre OpenAlex, con candidatos **rankeados @@ -167,302 +117,214 @@ No es una herramienta para usuario final no técnico: no hay GUI ni servicio web filtros, conteos y hash; se puede **exportar un snapshot** (foto reproducible) desde el estado vivo. Reproducibilidad por **historia auditable + snapshot sellado**, no por inmutabilidad ni por recómputo: **reproducir = re-leer/re-sellar el snapshot, NO re-correr la ecuación** (ADR - [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). OpenAlex **cambia en el tiempo**, - así que la misma ecuación corrida en otra fecha devuelve otro corpus (eso es *re-investigar*, no - reproducir); el `openalex_version` del Manifest **ancla la foto** a la versión/fecha de OpenAlex - usada. -- **Agente-native como columna** (no adorno): doble salida (`--json`), exit codes claros, - errores accionables, sin estado entre invocaciones. + [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). OpenAlex **cambia en el tiempo**, así + que la misma ecuación en otra fecha devuelve otro corpus (eso es *re-investigar*); el + `openalex_version` del Manifest **ancla la foto** a la versión/fecha usada. +- **Agente-native como columna** (no adorno): doble salida (`--json`, también vía `B2G_JSON=1`), exit + codes claros, errores accionables, sin estado entre invocaciones. - **Sin infraestructura pesada.** DuckDB embebido, sin servidores; OpenAlex **funciona sin clave** (pool cortés con email en config) **pero con límite** (tier gratis, ~100 créditos/día); una **API - key opcional sube el límite** para uso intensivo, como muchos servicios (#124). + key opcional sube el límite** para uso intensivo (#124). ## 5. Alcance -### 5.1 Dentro de alcance (V1) +### 5.1 Dentro de alcance - **Sembrado de doble puerta** (ADR [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)): por **ecuación de búsqueda** (términos, campos, años, idioma, tipo) **o** por **ingesta de archivo `.bib`** (puerta primaria, no secundaria) y/o **papers semilla** (DOIs / IDs). La ingesta desde - `.bib` resuelve **DOI→`source_id`** contra el motor de extracción (`b2g resolve` / - `seed --from-bib --resolve`) para reconciliar las *pearls* con el corpus. + `.bib` resuelve **DOI→`source_id`** contra el motor de extracción (`seed --from-bib --resolve`) para + reconciliar las *pearls* con el corpus. - **Contrato `Source` agnóstico** (ADR [0018](decisiones/0018-source-agnostico-calidad.md)): - separa el **mínimo universal** que todo corpus necesita para existir (`id`, título, año, autores, - keywords — ya habilita co-autoría y co-ocurrencia de keywords) del **enriquecimiento opcional** - (referencias, citantes, afiliaciones per-autor, instituciones — habilita acoplamiento, - co-citación, instituciones y asortatividad). Una `Source` que solo da el mínimo es **ciudadana - legítima**: esto **habilita fuentes regionales** (SciELO / Redalyc / La Referencia) sin - obligarlas a entregar lo que no tienen; los proyectores de enriquecimiento producen redes - parciales y lo **reportan** (no fallan). El **reporte de cobertura/calidad** por seed/source se - **declara** como contrato en V1 y se concreta en **v0.2+**. + separa el **mínimo universal** que todo corpus necesita (`id`, título, año, autores, keywords — ya + habilita co-autoría y co-word) del **enriquecimiento opcional** (referencias, citantes, afiliaciones + per-autor, instituciones — habilita acoplamiento, co-citación, instituciones y asortatividad). Una + `Source` de solo-mínimo es **legítima**: **habilita fuentes regionales** (SciELO / Redalyc / La + Referencia) sin obligarlas a entregar lo que no tienen; los proyectores de enriquecimiento producen + redes parciales y lo **reportan** (no fallan). - **Traducción** de la ecuación a query OpenAlex con **query ejecutada visible + reporte de - traducción**, ambas **registradas** con la corrida. Incluye **negaciones quirúrgicas** - (`b2g seed --exclude`, repetible: cada `AND NOT "…"` va **dentro** de la única expresión - `title_and_abstract.search:((query) AND NOT "…")`, campo no repetido) que se - **reportan en el reporte de traducción** (ejercicio consciente, no silencioso), y - **`--max-results`** para acotar el fetch en exploración con muestras chicas. -- **Chaining asistido** backward/forward sobre OpenAlex; **profundidad 1 por defecto**, opt-in a - 2, con **preview de crecimiento** ("esta expansión sumaría ~N papers") y **tope** configurable. + traducción**, ambas **registradas**. Incluye **negaciones quirúrgicas** (`b2g seed --exclude`, + repetible) reportadas en el reporte de traducción, y **`--max-results`** para acotar el fetch. +- **Chaining asistido** backward/forward sobre OpenAlex; **profundidad 1 por defecto**, con **preview + de crecimiento** y **tope** configurable; **`chain --since`** para forrajeo incremental (paso de + monitoreo). - **Ranking por estructura** (acoplamiento/co-citación, centralidad) de los candidatos — *information scent* **bibliométrico determinista, sin IA** (ADR [0020](decisiones/0020-metodo-forrajeo-scent-filtros-reject.md)/[0022](decisiones/0022-producto-sin-ia-generativa.md)). -- *(RETIRADO, ADR 0022:)* el "paso opcional de IA que explica por qué un candidato es relevante" - (`explain_candidate`/`[llm]`) **se elimina** del producto. El "porqué" de un candidato lo explica la - **estructura visible** (con qué del corpus se acopla/co-cita), no un LLM. + El "porqué" de un candidato lo explica la **estructura visible** (con qué del corpus se acopla/co-cita), + no un LLM. - **Ejercicio bibliotecario**: dedup/normalización de autores/instituciones apoyada en IDs de - OpenAlex (DOI/ORCID/ROR); **normalización de keywords vía thesaurus multilingüe** (en/es/pt, - curado y auditable, formato JSON portable); **filtros de inclusión/exclusión** (año, tipo, - idioma, mínimo de citas) con **conteo en cada filtro** (estilo flujo PRISMA). -- **Biblioteca viva en DuckDB**: aceptar/rechazar candidatos; el corpus **persiste entre - corridas**, crece y se cura, con **log de procedencia** (qué ecuación, qué chaining, qué - decisión humana, cuándo). -- **Redes**: co-citación, acoplamiento bibliográfico (sobre el **corpus completo**, no solo - semillas), co-autoría, co-ocurrencia de keywords, instituciones → **métricas y comunidades** - (densidad, centralidades, Louvain/propagación/voraz; **asortatividad** por un atributo - categórico configurable y por grado; **composición de comunidades** por ese atributo) → - **export GraphML/CSV**. Las métricas que dependen de un **proxy** (p. ej. afiliación por-paper - vs per-autor) se reportan **con el disclaimer del proxy** (fácil pero consciente). -- **Nota de costo (honestidad):** la **co-citación** es la red más cara — requiere traer los - citantes de las semillas *con sus propias listas de citas* (un segundo nivel de fetch en - OpenAlex). El **acoplamiento bibliográfico** usa las referencias que las semillas ya traen, es - más barato y mira hacia adelante; por eso es ciudadano de primera (crítica #2). Validado con - datos reales en [`exploracion/informe_ied_lectura_2.md`](../exploracion/informe_ied_lectura_2.md) - (coupling sobre corpus completo = 646 aristas; co-citación aún requiere ese segundo nivel). -- **Snapshot exportable**: foto reproducible (ecuación, query, filtros, conteos, hash, - fecha/versión de OpenAlex) derivada del estado vivo, para reportar y reproducir. -- **CLI agente-native**: cada subcomando con `--json` y exit codes. - -### 5.2 Fuera de alcance / futuro (marcado explícito, NO en V1) - -- **Máquina de tensiones** (intención de cita asistida por IA: apoya / refuta / escuelas en - conflicto) → **RETIRADA del producto** (ADR - [0022](decisiones/0022-producto-sin-ia-generativa.md), 2026-06-15): **no se difiere a v2, se - borra**. El producto no usa IA generativa; el sensemaking de tensiones lo hace el **humano leyendo - las redes** (comunidades/centralidad/acoplamiento). Era el candidato a *moat* - ([`Notas/04`](Notas/04-direccion-ia-in-the-loop.md) §5); el diferenciador pasa a ser la **biblioteca - viva curada + estructura bibliométrica de primera clase + flujo abierto**, no una capa de IA. -- **Costura Zotero** (biblioteca viva externa) → **DESCARTADA (decisión del PO, 2026-06-17): no se - hace.** El **corazón de la persistencia en V1.0 es DuckDB nativo**, no Zotero; la GUI se construye - sobre el workspace local. No es backlog planificado: reabrible solo si aparece demanda real (p.ej. - round-trip con un Zotero existente), como hito nuevo con su propio encuadre. -- **Monitoreo / alertas de literatura nueva** (paso 8 del ciclo, estilo Litmaps) → futuro; - encaja sobre la biblioteca viva, pero no en V1. -- **Matriz concepto×paper** (Webster & Watson, paso 5) → futuro; en V1 la organización es vía - redes/métricas. -- **Fallback fuzzy/semántico del thesaurus por LLM/embeddings** → **RETIRADO** (ADR - [0022](decisiones/0022-producto-sin-ia-generativa.md)/[0011](decisiones/0011-thesaurus-multilingue.md) - enmendado): el thesaurus es **curado y determinista**; lo que no matchea queda fuera, sin inventar - conceptos con un modelo. El **dedup fuzzy determinista** (`rapidfuzz`, **en el núcleo**, automático en - la ingesta — ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), #88; el extra - `[dedup]` se eliminó) sí queda — no es semántico ni LLM. -- **Resolución de `references_doi` a DOI canónico** (OpenAlex las entrega como URLs internas) y - fetch de **citantes-con-citas** para co-citación → trabajo del `Enricher`, fuera del primer - flujo de V1. + OpenAlex (DOI/ORCID/ROR); **normalización de keywords vía thesaurus multilingüe** (en/es/pt, curado y + auditable, JSON portable — `build --thesaurus`); **filtros de inclusión/exclusión** (año, tipo, + idioma, mínimo de citas) con **conteo en cada filtro** (estilo flujo PRISMA — `curate filter`). +- **Biblioteca viva en DuckDB**: aceptar/rechazar candidatos (`curate accept/reject`, o en lote vía + CSV con `curate dump`/`curate apply`); el corpus **persiste entre corridas**, crece y se cura, con + **log de procedencia**. +- **Redes**: co-citación, acoplamiento bibliográfico (sobre el **corpus completo**, no solo semillas), + co-autoría, co-ocurrencia de keywords, instituciones → **métricas y comunidades** (densidad, + centralidades, Louvain/propagación/voraz; **asortatividad** por un atributo categórico configurable y + por grado; **composición de comunidades**) → **export GraphML/CSV**. Las métricas que dependen de un + **proxy** se reportan **con el disclaimer del proxy** (fácil pero consciente). +- **Nota de costo (honestidad):** la **co-citación** es la red más cara — requiere traer los citantes + de las semillas *con sus propias listas de citas* (segundo nivel de fetch en OpenAlex). El + **acoplamiento bibliográfico** usa las referencias que las semillas ya traen, es más barato y mira + hacia adelante; por eso es ciudadano de primera. +- **Snapshot exportable**: foto reproducible (ecuación, query, filtros, conteos, hash, fecha/versión de + OpenAlex) derivada del estado vivo, para reportar y reproducir. +- **CLI agente-native**: superficie de **10 verbos del ciclo** (`init, seed, chain, curate, build, + read, export, snapshot, status, validate`) + 3 grupos noun-verb (`read`/`curate`/`snapshot`) + + `skill add` (meta), cada subcomando con `--json` y exit codes (ADR + [0037](decisiones/0037-superficie-cli-10-verbos-ciclo.md)/[0038](decisiones/0038-destino-verbos-huerfanos-0037.md); + detalle en [`API.md`](API.md) §Convenciones CLI). + +### 5.2 Fuera de alcance / futuro + +- **Máquina de tensiones** (intención de cita asistida por IA: apoya / refuta / escuelas en conflicto) + → **fuera del producto** (ADR [0022](decisiones/0022-producto-sin-ia-generativa.md)): el producto no + usa IA generativa; el sensemaking de tensiones lo hace el **humano leyendo las redes** + (comunidades/centralidad/acoplamiento). El diferenciador es la **biblioteca viva curada + estructura + bibliométrica de primera clase + flujo abierto**, no una capa de IA. +- **GUI / web / servicio gestionado** → **fuera** (ADR [0040](decisiones/0040-retiro-gui-local.md)): el + core es CLI/agente-native sobre la biblioteca viva; el camino de adopción es la **skill** de Claude + Code (ADR 0039). La experiencia visual library-centric vive en el **producto separado**, fuera de + bib2graph. +- **Costura Zotero** → **descartada** (PO, 2026): el corazón de la persistencia es DuckDB nativo. + Reabrible solo si aparece demanda real, como hito nuevo con su propio encuadre. +- **Neo4j** → **descartado** como adaptador planificado; ya no es sustrato. Reabrible solo con demanda + real. +- **Matriz concepto×paper** (Webster & Watson, paso 5) → futuro; la organización es vía redes/métricas. +- **Fallback fuzzy/semántico del thesaurus por LLM/embeddings** → **fuera** (ADR 0022/0011): el + thesaurus es **curado y determinista**; lo que no matchea queda fuera, sin inventar conceptos. El + **dedup fuzzy determinista** (`rapidfuzz`, **en el núcleo**, automático en la ingesta — ADR + [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md)) sí queda — no es semántico ni LLM. - **Lectura de PDFs full-text** → futuro. -- **GUI / web / servicio gestionado** → fuera. - > **ENMIENDA (2026-06-18) — ADR [0027](decisiones/0027-pivote-posicionamiento-gui-local.md) - > (Aceptada).** Se **supersede parcialmente** este punto: entra una **GUI local opt-in** (extra - > `[gui]`, `127.0.0.1`, semi-técnicos), gateada por [#34](https://github.com/complexluise/bib2graph/issues/34) - > (TARGET; código tras el caso real validado por un tercero). Lo que **sigue fuera**: **web/hosting, - > servicio gestionado, servidor MCP y Claude-Web** (descartados); binario/Tauri **diferido a v2**. El - > CLI sigue siendo la columna (ADR 0010/0021). Arquitectura: ADR [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md). - > **En 0.8 la GUI quedó rota a propósito** por el rename a `source_id` (ADR 0036): **no funciona - > hoy** hasta que se actualice ([#117](https://github.com/complexluise/bib2graph/issues/117)). -- **WoS / Scopus / RIS / CSV como backbone** → el resto, `Source` futura. **BibTeX NO es - secundaria:** la **ingesta desde archivo `.bib`** es una **puerta primaria** de sembrado (ingesta - de doble puerta: ecuación **o** archivo; ADR [0035](decisiones/0035-ingesta-multipuerta-resolucion-doi.md)), - con **resolución DOI→`source_id`** (`b2g resolve` / `seed --from-bib --resolve`) que reconcilia - las *pearls* contra el motor de extracción. -- **Neo4j** → **DESCARTADO (decisión del PO, 2026-06-17): no se hace.** **Ya no es sustrato** y - tampoco se planifica como adaptador `Store` post-V1. Reabrible solo si aparece demanda real, como - hito nuevo. -- **Enricher Semantic Scholar como camino para co-citación** → innecesario: las referencias y - citantes vienen de OpenAlex ([ADR 0007](decisiones/0007-openalex-backbone.md)). +- **WoS / Scopus / RIS / CSV como backbone** → `Source` futura. **BibTeX NO es secundaria:** la + ingesta `.bib` es **puerta primaria** (doble puerta, ADR 0035), con resolución DOI→`source_id`. +- **Enricher Semantic Scholar para co-citación** → innecesario: refs y citantes vienen de OpenAlex. - **Concurrencia multi-escritor** → **limitación conocida, no defecto** (ADR - [0019](decisiones/0019-concurrencia-diferida.md)): DuckDB es single-writer, así que la V1 asume - **1 archivo `.duckdb` = 1 escritor** a la vez (lecturas concurrentes OK; varias investigaciones = - varios archivos). Abrir el mismo archivo para escribir desde dos procesos falla claro (exit code - `5`), no corrompe. Multi-escritor concurrente se resuelve post-v1.0 según demanda. + [0019](decisiones/0019-concurrencia-diferida.md)): DuckDB es single-writer (1 archivo = 1 escritor; + lecturas concurrentes OK; varias investigaciones = varios archivos). Abrir el mismo archivo para + escribir desde dos procesos falla claro (exit code `5`), no corrompe. Se resuelve post-1.0 según + demanda. ## 6. Principios de producto 1. **Fácil PERO consciente.** La ecuación es ciudadana de primera clase, explícita y registrada. 2. **Asistencia algorítmica determinista, NO IA en el producto** (ADR - [0022](decisiones/0022-producto-sin-ia-generativa.md)). El producto **no usa IA generativa**: la - única asistencia es el **scent bibliométrico** del forrajeo (acoplamiento/co-citación/centralidad, - determinista, reproducible). El **juicio humano** (formular la idea, dejarla mutar, decidir qué - curar, leer las tensiones) **no se automatiza**. "AI-in-the-loop" se refiere **solo** al - *desarrollo* asistido por IA (ver [`AI_DISCLOSURE.md`](../AI_DISCLOSURE.md)). + [0022](decisiones/0022-producto-sin-ia-generativa.md)). La única asistencia es el **scent + bibliométrico** del forrajeo (acoplamiento/co-citación/centralidad, determinista, reproducible). El + **juicio humano** (formular la idea, dejarla mutar, decidir qué curar, leer las tensiones) **no se + automatiza**. "AI-in-the-loop" se refiere **solo** al *desarrollo* asistido por IA (ver + [`AI_DISCLOSURE.md`](../AI_DISCLOSURE.md)). 3. **Núcleo puro, costuras opcionales.** La lógica bibliométrica no depende de servidores ni red. -4. **Configuración inyectada, nunca embebida.** Ningún secreto en el código, sin efectos de - import (lecciones 1 y 6 de v0). +4. **Configuración inyectada, nunca embebida.** Ningún secreto en el código, sin efectos de import. 5. **Contratos estables y tipados** entre costuras (sin *signature drift*). -6. **Solo se promete lo que existe** (lección 5: nada de clientes que se inicializan y nunca se - consultan). +6. **Solo se promete lo que existe** (nada de clientes que se inicializan y nunca se consultan). 7. **Agente-native como columna**, diseñada desde el primer comando — no un extra futuro. 8. **Reproducibilidad por historia auditable + snapshot exportable**, no por inmutabilidad. ## 7. Historias de usuario (épicas) > Definición de producto en historias, para extraer features y dejar claro **qué esperar**. -> Adaptadas de [`_archivo/06`](_archivo/06-definicion-producto-v1.md) (archivada) tras cerrar el wedge (forrajeo) -> y el modelo de datos (biblioteca viva en DuckDB). ### Épica A — Sembrar con ecuaciones de búsqueda (consciente y estándar) -- **A1** · Como investigador, quiero definir mi corpus con una **ecuación de búsqueda** - (términos, campos, años, idioma), para partir del artefacto estándar y reproducible. -- **A2** · Como investigador, quiero que la herramienta **traduzca mi ecuación a una consulta - OpenAlex y me muestre exactamente qué se ejecutó** (y sus límites), para ser consciente de qué - recupero. -- **A3** · Como investigador, quiero alternativamente sembrar con **papers semilla** (DOIs / IDs - / un export BibTeX), para cuando parto de *pearls* conocidos. -- **A4** · Como investigador, quiero que mi ecuación quede **registrada y versionada** con la - corrida, para reportarla (PRISMA / vom Brocke) y reproducirla. -- **A5** · Como investigador, quiero que mis **ecuaciones evolucionen entre iteraciones** - (berrypicking: la idea muta y vuelvo a sembrar) y que la **biblioteca viva acumule** a través - de esas versiones, para que el lazo del ciclo sea de primera clase y no una corrida tirada. +- **A1** · Definir el corpus con una **ecuación de búsqueda** (términos, campos, años, idioma), para + partir del artefacto estándar y reproducible. +- **A2** · Que la herramienta **traduzca la ecuación a una consulta OpenAlex y muestre exactamente qué + se ejecutó** (y sus límites), para ser consciente de qué se recupera. +- **A3** · Alternativamente sembrar con **papers semilla** (DOIs / IDs / un export BibTeX), para + cuando se parte de *pearls* conocidos. +- **A4** · Que la ecuación quede **registrada y versionada** con la corrida, para reportarla + (PRISMA / vom Brocke) y reproducirla. +- **A5** · Que las **ecuaciones evolucionen entre iteraciones** (berrypicking) y que la **biblioteca + viva acumule** a través de esas versiones, para que el lazo sea de primera clase. ### Épica B — Forrajear: chaining asistido por estructura bibliométrica (sin IA) -- **B1** · Como investigador, quiero **backward chaining** (las referencias de mis semillas) y - **forward chaining** (lo que las cita) automáticos sobre OpenAlex, para no hacer snowballing a - mano (Wohlin). -- **B2** · Como investigador, quiero **controlar la profundidad** del chaining (1 por defecto, - opt-in a 2) y ver un **preview de cuánto crece** el corpus antes de traer, para no hacerlo - explotar. -- **B3** · Como investigador, quiero que los candidatos vengan **rankeados por estructura - bibliométrica** (*information scent*: acoplamiento/co-citación, centralidad — **determinista, sin - IA**), para revisar primero lo más relevante. -- ~~**B4** · paso opcional de IA que explique por qué un candidato es relevante~~ → **RETIRADA** - (ADR [0022](decisiones/0022-producto-sin-ia-generativa.md)): el producto no usa IA generativa. El - "porqué" lo da la **estructura visible** (con qué del corpus se acopla/co-cita el candidato), no un - LLM. `explain_candidate`/`[llm]` se eliminan. +- **B1** · **Backward chaining** (las referencias de las semillas) y **forward chaining** (lo que las + cita) automáticos sobre OpenAlex, para no hacer snowballing a mano (Wohlin). +- **B2** · **Controlar la profundidad** (1 por defecto) y ver un **preview de cuánto crece** el corpus + antes de traer, para no hacerlo explotar. +- **B3** · Candidatos **rankeados por estructura bibliométrica** (*information scent*: + acoplamiento/co-citación, centralidad — **determinista, sin IA**), para revisar primero lo más + relevante. El "porqué" lo da la **estructura visible**, no un LLM. ### Épica C — Ejercicio bibliotecario y biblioteca viva (curar y conservar) -- **C1** · Como investigador, quiero **dedup y normalización** de autores/instituciones apoyada - en los IDs de OpenAlex (ORCID/ROR/DOI), para no pelear con variantes de nombres. -- **C2** · Como investigador, quiero **normalizar mis keywords con un thesaurus multilingüe** - (en/es/pt) curado y auditable, para que conceptos equivalentes en distintos idiomas colapsen en - la red de co-ocurrencia (p. ej. *intercambio ecológico desigual* ≡ *unequal exchange*) y no - queden dispersos. *(Sin fallback semántico/LLM: el thesaurus es determinista — ADR 0022/0011. El - dedup fuzzy determinista de keywords fuera del thesaurus corre automático en la ingesta con - `rapidfuzz` en el núcleo — ADR 0031, #88; ya no es el extra `[dedup]`.)* -- **C3** · Como investigador, quiero aplicar **criterios de inclusión/exclusión** (año, tipo, - idioma, mínimo de citas) y ver el **conteo en cada filtro**, para curar con trazabilidad - (estilo flujo PRISMA). -- **C4** · Como investigador, quiero **aceptar/rechazar** candidatos y que lo aceptado quede en - mi **biblioteca viva persistida en DuckDB**, que **crece entre corridas** con su log de - procedencia, para cultivar la colección (berry growing). *(La biblioteca viva es DuckDB nativo; la - sincronización con Zotero está descartada —decisión del PO, 2026-06-17— y solo se reabriría si hay - demanda real.)* +- **C1** · **Dedup y normalización** de autores/instituciones apoyada en los IDs de OpenAlex + (ORCID/ROR/DOI), para no pelear con variantes de nombres. +- **C2** · **Normalizar keywords con un thesaurus multilingüe** (en/es/pt) curado y auditable, para + que conceptos equivalentes en distintos idiomas colapsen en la red de co-ocurrencia (p. ej. + *intercambio ecológico desigual* ≡ *unequal exchange*). *(Sin fallback semántico/LLM: el thesaurus es + determinista; el dedup fuzzy determinista corre automático en la ingesta con `rapidfuzz`.)* +- **C3** · Aplicar **criterios de inclusión/exclusión** (año, tipo, idioma, mínimo de citas) y ver el + **conteo en cada filtro**, para curar con trazabilidad (estilo flujo PRISMA). +- **C4** · **Aceptar/rechazar** candidatos y que lo aceptado quede en la **biblioteca viva persistida + en DuckDB**, que **crece entre corridas** con su log de procedencia, para cultivar la colección. ### Épica D — Proyectar a redes (el final sigue siendo las redes) -- **D1** · Como investigador, quiero proyectar el corpus a **co-citación, acoplamiento - bibliográfico, co-autoría, co-ocurrencia de keywords e instituciones**, para analizar la - estructura intelectual del campo. -- **D2** · Como investigador, quiero **métricas y comunidades** (densidad, centralidades, - Louvain/propagación/voraz) sobre cada red. -- **D3** · Como investigador, quiero **asortatividad** (por un atributo categórico que yo defino - —p. ej. región geográfica— y por grado) y la **composición de cada comunidad** por ese - atributo, **con el disclaimer de si el atributo es un proxy** (p. ej. afiliación por-paper vs - per-autor), para leer asimetrías estructurales (Norte–Sur, escuelas en conflicto) sin tomar el - proxy por verdad. -- **D4** · Como investigador, quiero **exportar GraphML/CSV** para Gephi/VOSviewer y pandas. +- **D1** · Proyectar el corpus a **co-citación, acoplamiento bibliográfico, co-autoría, co-ocurrencia + de keywords e instituciones**, para analizar la estructura intelectual del campo. +- **D2** · **Métricas y comunidades** (densidad, centralidades, Louvain/propagación/voraz) sobre cada + red. +- **D3** · **Asortatividad** (por un atributo categórico definido por el usuario y por grado) y la + **composición de cada comunidad** por ese atributo, **con el disclaimer de si el atributo es un + proxy**, para leer asimetrías estructurales (Norte–Sur, escuelas) sin tomar el proxy por verdad. +- **D4** · **Exportar GraphML/CSV** para Gephi/VOSviewer y pandas. ### Épica E — Reproducibilidad y agente-native -- **E1** · Como investigador, quiero **exportar un snapshot reproducible** del estado vivo - (ecuación, query, fecha/versión de OpenAlex, profundidad, filtros, conteos, hash), para - auditar y reportar. -- **E2** · Como **agente/automatización**, quiero invocar cada paso por **CLI con `--json`** y - exit codes claros, para orquestar bib2graph sin GUI. - -## 8. Modelo de datos (reconciliado) - -La elección **biblioteca viva desde V1** (corpus stateful en DuckDB) era **incompatible con el -snapshot inmutable** que consagraban `ARCHITECTURE.md` §6.2 y el ADR 0006, y con el `InMemoryStore` -por defecto del ADR 0003. La reconciliación quedó cerrada por los ADR 0009 y, tras el 2º giro, -precisada por el ADR [0015](decisiones/0015-corpus-tabular-backend.md): - -- El **`Corpus` se respalda en un `TabularBackend` (Protocol)** y **delega las mutaciones** - (ADR [0015](decisiones/0015-corpus-tabular-backend.md)). La persistencia por defecto **no es un - `Store` con estado aparte**, sino el **`DuckDBBackend` del propio `Corpus`** (archivo `.duckdb`, - mutación por SQL `UPDATE`/`MERGE` por `id`), que conserva el corpus entre corridas con su **log de - procedencia**. El **`InMemoryBackend`** puro es el backend de los tests y del working set efímero - (el núcleo se testea sin DuckDB). El **`DuckDBStore` es la fachada de costura** (`persist`/`load`) - y el punto de extensión para destinos externos. -- El **`LoopState`** (ADR [0016](decisiones/0016-maquina-estados-lazo.md)) vive en ese backend - persistente: **una investigación = un archivo `.duckdb`**, con su estado del lazo. -- El **snapshot deja de ser el modelo de datos** y es un **export sellado derivable del estado - vivo** (foto reproducible para reportar). **Reproducir = re-leer ese snapshot, no re-correr la - ecuación** (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). -- **Zotero** queda **DESCARTADO (decisión del PO, 2026-06-17): no se hace**; nunca fue la - persistencia de 1.0 (DuckDB nativo lo es). Reabrible solo si aparece demanda real, como hito nuevo. - -Esta reconciliación ya está reflejada en `ARCHITECTURE.md` (§3.1, §4.3, §6.2), `API.md` (§1, §4) y -`ROADMAP.md` (Hitos 1.5/3). El estado de construcción —**Hitos 0–9 + 1.5 terminados** y la **tanda de -remediación R1–R5 completa** (2026-06-16)— vive en el `ROADMAP.md`: el terreno **pre-GUI está completo** -(Hito 10 viz absorbido en la epic GUI #34; Hito 11 Zotero/Neo4j descartado, PO 2026-06-17). - -## 9. Criterios de "V1 hecha" +- **E1** · **Exportar un snapshot reproducible** del estado vivo (ecuación, query, fecha/versión de + OpenAlex, profundidad, filtros, conteos, hash), para auditar y reportar. +- **E2** · Como **agente/automatización**, invocar cada paso por **CLI con `--json`** y exit codes + claros, para orquestar el ciclo completo (`init → seed → chain → curate → build → read → export`) + sin GUI. + +## 8. Modelo de datos + +- El **`Corpus` se respalda en un `TabularBackend` (Protocol)** y **delega las mutaciones** (ADR + [0015](decisiones/0015-corpus-tabular-backend.md)). La persistencia por defecto **no es un `Store` + con estado aparte**, sino el **`DuckDBBackend` del propio `Corpus`** (archivo `.duckdb`, mutación por + SQL `UPDATE`/`MERGE` por `id`), que conserva el corpus entre corridas con su **log de procedencia**. + El **`InMemoryBackend`** puro es el backend de los tests y del working set efímero. El **`DuckDBStore` + es la fachada de costura** (`persist`/`load`) y el punto de extensión para destinos externos. +- El **`CycleState`** (ADR [0016](decisiones/0016-maquina-estados-lazo.md)) vive en ese backend + persistente: **una investigación = un workspace** con su estado del lazo. +- El **snapshot** es un **export sellado derivable del estado vivo** (foto reproducible para reportar). + **Reproducir = re-leer ese snapshot, no re-correr la ecuación** (ADR + [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). + +Detalle del schema de columnas + la API del wrapper en [`API.md`](API.md) §1. + +## 9. Criterios de madurez - De una **ecuación de búsqueda** a un **GraphML** de al menos una red, **sin escribir código** y **sin servidores**. -- El **chaining** rankea candidatos por estructura, no por lista plana, con preview de - crecimiento. +- El **chaining** rankea candidatos por estructura, no por lista plana, con preview de crecimiento. - El corpus **persiste y crece entre corridas** en DuckDB, con log de procedencia. -- La corrida es **reportable**: se exporta un snapshot **sellado** (con la query OpenAlex visible y - el `openalex_version` que ancla la foto) que **otro investigador reproduce releyéndolo**, sin - volver a llamar a OpenAlex (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). +- La corrida es **reportable**: se exporta un snapshot **sellado** (con la query OpenAlex visible y el + `openalex_version` que ancla la foto) que **otro investigador reproduce releyéndolo**, sin volver a + llamar a OpenAlex (ADR [0017](decisiones/0017-reproducibilidad-historia-snapshot.md)). - Dedup/normalización funciona apoyada en OpenAlex **sin configuración manual de nombres**. -- Cada subcomando tiene `--json`. - -## 10. Métricas de éxito - -- El **primer flujo de 10 minutos** (ecuación → redes → export) corre **sin claves ni - infraestructura** —dentro del **tier gratis de OpenAlex** (~100 créditos/día, alcanza para una - prueba/uso liviano); para **corpus grandes** conviene una **API key opcional** que sube el límite (#124). -- El núcleo tiene **cobertura de tests unitarios** real sobre proyección, métricas, comunidades y - dedup (la testabilidad que v0 nunca tuvo). -- Un caso real se **reproduce** desde la ecuación, cumpliendo criterios de calidad - **configurables** por el usuario (no umbrales hardcodeados — crítica #5). Ya hay **evidencia - con datos reales**: el sandbox de **intercambio ecológico desigual (IED)** corrió el pipeline - end-to-end sobre 103 papers de OpenAlex, con 3/4 redes con estructura, thesaurus multilingüe y - asimetría Norte–Sur medible (ver - [`exploracion/informe_ied_lectura_2.md`](../exploracion/informe_ied_lectura_2.md)). El estudio - de semiconductores sigue como caso documentado en [`metodología.md`](Notas/metodología.md). -- Agregar una nueva `Source` o `Store` no requiere modificar el núcleo. - -## 11. Próximos pasos - -> ⚠️ **Corrección 2026-06-17:** el punto 1 es **planning histórico ya saldado** (los ADR 0007–0021 -> están escritos). Donde dice "tensiones a v2", leer **"tensiones RETIRADAS del producto"** (ADR -> 0022); el thesaurus es **determinista sin fallback fuzzy/LLM** (ADR 0011 enmendado). La **tanda de -> remediación R1–R5 ya está completa** (2026-06-16) y, sobre ella, los **Hitos 1–9 están construidos** -> (ver punto 3, actualizado). El **terreno pre-GUI está completo**: el próximo trabajo real es la -> **epic GUI #34** (la capa de lectura visual), no más backend. Estado vivo en el -> [`ROADMAP.md`](ROADMAP/README.md). - -1. **Nuevos ADRs** (architect), además del [0007](decisiones/0007-openalex-backbone.md) ya - redactado: wedge = forrajeo (~~tensiones a v2~~ → **retiradas**, ADR 0022); **biblioteca viva en - DuckDB** (supersede la premisa de 0003 y 0006); agente-native como columna; **thesaurus - multilingüe** (T6/T10 del sandbox; formato JSON portable, **determinista sin fallback LLM**). -2. ✅ `ARCHITECTURE.md`, `API.md` y `ROADMAP.md` **reconciliados** con este PRD (§8) y con los - ADR 0007–0011, y luego con el **2º giro** (ADR - [0015](decisiones/0015-corpus-tabular-backend.md)–[0019](decisiones/0019-concurrencia-diferida.md)). -3. ✅ Implementación por hitos en curso (coder): **Hitos 0–6 + 1.5 terminados** (núcleo del corpus - stateful sobre `TabularBackend`, proyectores/analizadores/export, biblioteca viva en DuckDB, - fuentes OpenAlex/BibTeX, forrajeo + `Preprocessor` + filtros PRISMA, y el **CLI agente-native - `b2g`** — 19 subcomandos (incl. `thesaurus`, ADR [0031](decisiones/0031-preprocesamiento-automatico-en-ingesta.md), y `gui`, ADR [0028](decisiones/0028-arquitectura-gui-api-capa-servicios.md), Hito G3), ADR [0021](decisiones/0021-cli-agente-native-contrato.md) + - [0025](decisiones/0025-enricher-cocitacion-openalex.md) (`enrich`, Ciclo 8a) + - [0029](decisiones/0029-workspace-por-investigacion.md) (`init` + workspace)). Con ello - v0.2 alcanza las capacidades del **flujo** `seed → … → export`. **El red-team de la - [Nota 06](Notas/06-critica-as-built-v0.2.md) corrige el claim "capacidades completas":** falta la - **tanda de remediación R1–R5** (modelo sin IA, identidad-vs-procedencia reproducible, FSM cíclico, - scent bibliométrico, robustez) **antes** de los Hitos 7–11. Tras R1–R5 se construyeron el **Hito 7 ✅** - (dedup fuzzy `rapidfuzz`), el **Hito 8 ✅** (`Enricher` OpenAlex: refs→DOI + co-citación end-to-end, - `enrich --max-citing`) y el **Hito 9 ✅** (`NetworkSpec` YAML + `b2g networks --spec` + `resolution` - Louvain, 2026-06-17): **Hitos 1–9 construidos**. Los **Hitos 10 (viz) y 11 (Zotero/Neo4j) fueron - reevaluados (2026-06-17, encuadre pre-GUI):** 10 se **difiere/absorbe en la epic GUI #34** (la GUI es - la capa de lectura visual; el export visual pre-GUI ya lo cubren `decorate`/`clusters.csv`) y 11 queda - **DESCARTADO (decisión del PO, 2026-06-17): Zotero/Neo4j no se hacen** —no son backlog planificado—, - reabrible como hito nuevo solo si aparece demanda real; no bloquea la GUI. Estado vivo - en el [`ROADMAP.md`](ROADMAP/README.md). +- Cada subcomando tiene `--json`; el ciclo completo lo puede conducir un agente. + +## 10. Caso de uso de referencia: la herramienta usándose a sí misma + +El caso de referencia de bib2graph es **el ciclo de investigación aplicado a la teoría que mejora +bib2graph**: la herramienta **se usa a sí misma como objeto de estudio** para iterar su propio diseño. +La literatura sobre **forrajeo de información, ciclo de investigación humano y bibliometría** (Bates, +Ellis, Kuhlthau, Pirolli, Wohlin, vom Brocke; ver [`Notas/05`](Notas/05-ciclo-investigacion-humano.md) +y [`metodología.md`](Notas/metodología.md)) es a la vez **el corpus que bib2graph procesa** y **la +fuente de los requisitos** del producto: sembrar la ecuación de esa teoría, forrajear sus referencias y +citantes, curar la biblioteca viva, proyectar las redes y leer las tensiones es **lo que valida que el +método sirve** — y cada vuelta del ciclo retroalimenta el diseño (qué falta en el forrajeo, qué red +falla, qué fricción tiene el flujo agéntico). El producto es honesto consigo mismo cuando su propio +desarrollo corre por el ciclo que predica. + +Esto tiene una consecuencia de proceso: las decisiones de producto y los hallazgos teóricos que surgen +de usar la herramienta entran por el flujo de siempre (nota → Discussion → ADR/issue), y los **docs +vivos** reflejan el resultado, no el debate. + +El caso **intercambio ecológico desigual (IED)** —el pipeline corrido end-to-end sobre papers reales de +OpenAlex, con redes con estructura, thesaurus multilingüe y asimetría Norte–Sur medible (ver +[`exploracion/informe_ied_lectura_2.md`](../exploracion/informe_ied_lectura_2.md))— queda como **caso +de validación interna histórico**: evidencia de que el método produce resultados con datos reales, **no +un criterio de release**. El estudio de semiconductores sigue como caso documentado en +[`metodología.md`](Notas/metodología.md). diff --git a/docs/ROADMAP/05-gui.md b/docs/ROADMAP/05-gui.md index 4b3d260..0d3ec27 100644 --- a/docs/ROADMAP/05-gui.md +++ b/docs/ROADMAP/05-gui.md @@ -1,9 +1,19 @@ -# ROADMAP · GUI local (Hitos G1–G5) — **MVP AS-BUILT (G1–G5), gate #34 pendiente** +# ROADMAP · GUI local (Hitos G1–G5) — **DEPRECADO: GUI retirada de la librería (ADR 0040, #190)** > ← Volver al [índice del ROADMAP](README.md) --- +> ⛔ **DEPRECADO (2026-06-28) — la GUI local se RETIRÓ de la librería** (ADR +> [0040](../decisiones/0040-retiro-gui-local.md), issue +> [#190](https://github.com/complexluise/bib2graph/issues/190); supersede 0027/0028). Se eliminan +> `b2g gui`, la API local FastAPI (`api/`), la SPA `frontend/` y el extra `[gui]`; el core es +> CLI/agente-native sobre la biblioteca viva. **Este documento queda como HISTORIA** (el porqué de +> haber construido G1–G5): describe un AS-BUILT que ya no existe. La **capa de servicios `service/`** +> que introdujo el G1/G2 **se conserva** (la usa el CLI). No tomar nada de abajo como estado actual. + +--- + > ✅ **MVP AS-BUILT (2026-06-18) — los 5 hitos de construcción (G1–G5) están construidos.** Toda esta > epic es la **GUI local "tool for thought"** > ([#34](https://github.com/complexluise/bib2graph/issues/34)), encuadrada por los ADR diff --git a/docs/ROADMAP/README.md b/docs/ROADMAP/README.md index 81d0cc9..d07d318 100644 --- a/docs/ROADMAP/README.md +++ b/docs/ROADMAP/README.md @@ -210,13 +210,13 @@ limpio y actual; el cuerpo de cada hito vive en su archivo: - **[04 · Lo que viene (Hitos 7–11 + costuras futuras)](04-lo-que-viene.md)** — dedup fuzzy, `Enricher` de co-citación, `NetworkSpec` YAML; viz (Hito 10 diferido a la GUI #34) y Zotero/Neo4j (Hito 11 descartado, PO 2026-06-17). **Lo que viene (hacia v1.0).** -- **[05 · GUI local (Hitos G1–G5)](05-gui.md)** — **MVP AS-BUILT (G1–G5), gate #34 pendiente** (ADR - [0027](../decisiones/0027-pivote-posicionamiento-gui-local.md)/[0028](../decisiones/0028-arquitectura-gui-api-capa-servicios.md), - Aceptados 2026-06-18): capa de servicios neutral + CLI/API adaptadores (G1), lecturas de servicio - (G2), API local FastAPI + extra `[gui]` + `b2g gui` (G3), frontend nuevo SPA (G4), empaquetado + - CI JS (G5) — **los 5 hitos de build están AS-BUILT**. Lo único pendiente es el **gate del tercero - (#34)**, que **no es construcción** (criterio de aceptación de producto, al final). La GUI **es** la - capa de lectura visual (épica D; Hito 10 viz absorbido aquí). +- **[05 · GUI local (Hitos G1–G5)](05-gui.md)** — ⛔ **DEPRECADO: GUI retirada de la librería** (ADR + [0040](../decisiones/0040-retiro-gui-local.md), issue + [#190](https://github.com/complexluise/bib2graph/issues/190); supersede + [0027](../decisiones/0027-pivote-posicionamiento-gui-local.md)/[0028](../decisiones/0028-arquitectura-gui-api-capa-servicios.md)). + Se eliminan `b2g gui`, la API local FastAPI, la SPA `frontend/` y el extra `[gui]`; el core es + CLI/agente-native sobre la biblioteca viva. El documento queda como **historia** del AS-BUILT G1–G5; + la capa de servicios `service/` se conserva (la usa el CLI). ## Trazabilidad historias ↔ hitos (resumen) diff --git a/docs/decisiones/0021-cli-agente-native-contrato.md b/docs/decisiones/0021-cli-agente-native-contrato.md index c7b74a8..c3147a6 100644 --- a/docs/decisiones/0021-cli-agente-native-contrato.md +++ b/docs/decisiones/0021-cli-agente-native-contrato.md @@ -271,3 +271,27 @@ mensaje custom de migración. La **única forma canónica** de apuntar a la pers `--workspace ` o la resolución ambiente (`B2G_WORKSPACE` > walk-up del cwd buscando `workspace.json`). El modo degenerado (`.duckdb` suelto) **deja de existir**: un `.duckdb` legacy se adopta como workspace con `b2g init .` en su carpeta. Es un **BREAKING change** del contrato CLI. + +## Enmienda — `B2G_JSON` activa el modo JSON por entorno (2026-06-27, [#151](https://github.com/complexluise/bib2graph/issues/151)) + +> Enmienda **aditiva** al §C (envelope). El flag `--json` y su forma **no cambian**; el envelope +> `schema="1"`, los exit codes (§D) y la FSM (§F) quedan **intactos**. Complementa la enmienda al §C +> que reclamó el ADR [0037](0037-superficie-cli-10-verbos-ciclo.md) (stdout puro). Implementado + +> verificado (gate verde). + +El §C declaraba que `--json` es **lo único** que el comando imprime en stdout. Esta enmienda fija dos +cosas que el AS-BUILT explicita: + +1. **stdout puro ENFORCED.** En modo JSON, stdout emite **exactamente la línea-envelope**, incluido + el **camino de error** (`ok=false` → envelope en stdout, no en stderr). El texto de modo humano va + a **stderr**. Antes era contrato declarado; ahora está enforced y testeado. +2. **Activación por entorno con `B2G_JSON`.** El modo JSON se activa también con la variable de + entorno **`B2G_JSON`** (truthy: `1`/`true`/`yes`, case-insensitive), con alcance a **todos** los + comandos (incl. `init`). **Precedencia:** `--json` explícito > `B2G_JSON`. **No hay `--no-json`.** + La **superficie de invocación del flag no cambia**: `--json` sigue siendo por-comando y + **post-verbo** (`b2g --json`), no se vuelve global. Caso de uso agents-first: `export + B2G_JSON=1` una vez por sesión y no repetir el flag. + +En código se unificó el flag vía un decorador compartido `@json_option` (`cli/_options.py`, con +`json_mode(local_flag)` que resuelve flag-o-entorno); es refactor interno, **no** cambia el contrato +externo. Documentado en `docs/API.md` §convenciones CLI (Envelope JSON / `B2G_JSON`). diff --git a/docs/decisiones/0025-enricher-cocitacion-openalex.md b/docs/decisiones/0025-enricher-cocitacion-openalex.md index 8a6cad7..36151c6 100644 --- a/docs/decisiones/0025-enricher-cocitacion-openalex.md +++ b/docs/decisiones/0025-enricher-cocitacion-openalex.md @@ -101,3 +101,29 @@ documentando que el 2º nivel se materializa como **`cited_by_id`** (8b): "citan > `OpenAlexSource.fetch_citing_batch` (batcheo OR ≤50 con presupuesto por semilla), > `Networks.quick` → 4/5 redes según `cited_by_id`. **13 subcomandos** (`enrich` incluido). > **365 tests verdes** (mypy/ruff limpios). + +--- + +> **Nota append-only — superficie 0.10.0: `enrich` deja de ser verbo y `build` ya no es estrictamente +> "puro/sin red" (2026-06-28, #162, ADR [0038](0038-destino-verbos-huerfanos-0037.md)).** +> Esta nota **no revierte** la decisión de 0025 (la capacidad del Enricher sigue viva, núcleo, +> opt-in); solo registra **dónde** vive ahora y un invariante que cambia: +> +> - **`enrich` deja de ser subcomando propio** (la decisión C de arriba — "`enrich` es subcomando CLI +> propio" — queda **superada como superficie**, no como capacidad). El verbo `enrich` pasa a **alias +> deprecado** (aviso a stderr, se elimina en 0.11.0) que delega en la misma lógica. La pasada +> **refs→DOI (8a)** corre **automática en `chain`** (forrajeo); la pasada **co-citación / `cited_by` +> (8b)** corre **automática en `build`** cuando hay semillas aceptadas. La implementación se unifica +> en el helper `cli/_enrich.py::enrich_corpus(corpus, source, *, max_citing, pass_name)` (pasadas +> `"refs_doi"`/`"cited_by"`/`"both"`), fuente única compartida por `chain`/`build`/el alias `enrich`. +> Ambos comandos suman un bloque **aditivo `data["enrichment"]`** al envelope `--json` +> (`refs_resolved`/`refs_total_unique` y/o `citing_new`/`citing_targets`, solo las claves de las +> pasadas ejecutadas). `build` suma además `--email` y `--max-citing`. +> - **`build` ya NO es estrictamente "puro/sin red".** La frase de la decisión ("`build` sigue +> puro/sin red") **deja de ser cierta**: `build` hace requests `cited_by` a OpenAlex **cuando hay +> semillas aceptadas**. **Por qué (decisión del PO):** la co-citación necesita las semillas +> **aceptadas**, que recién están disponibles en *build-time* (después de curar); correrla en `build` +> es lo que hace que el camino one-shot del ADR 0037 produzca la red de co-citación sin un verbo +> aparte. **Sin semillas aceptadas, `build` es no-op de red (cero requests)** y mantiene su pureza +> de proyección (los proyectores siguen puros, ADR 0014). La pasada solo *puebla `cited_by_id`*; no +> crece el corpus (decisión A de arriba, intacta). diff --git a/docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md b/docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md index b289c70..81e7c30 100644 --- a/docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md +++ b/docs/decisiones/0031-preprocesamiento-automatico-en-ingesta.md @@ -129,3 +129,22 @@ El preprocesamiento determinista se ejecuta **automáticamente en la ingesta**, > `DuckDBBackend.overwrite_corpus` (DELETE+INSERT, preservan tablas hermanas, reasignan `_seq`); > `Preprocessor.normalize`/`apply_thesaurus` aceptan `applied_at`. Deuda: O(n²) del dedup por > ingesta (optimización futura) y skip #93. + +--- + +> **Nota append-only — el "único paso explícito" `thesaurus` se retira como verbo (2026-06-28, #164, +> ADR [0038](0038-destino-verbos-huerfanos-0037.md)).** El punto 3 de la decisión arriba ("El +> thesaurus es el ÚNICO paso explícito del preproc — `b2g thesaurus --from `, 18° +> subcomando") **se revisa**: el verbo `thesaurus` **se elimina** (no queda ni como alias). El ADR 0038 +> ya lo anticipó (issue [#149](https://github.com/complexluise/bib2graph/issues/149), cerrada +> *invalid*); esta nota cierra el círculo en el 0031. +> +> - **La capacidad se preserva como flag `b2g build --thesaurus `** (consolidación +> cross-lingüe de keywords, ADR [0011](0011-thesaurus-multilingue.md) **intacto**): `build` aplica +> `Preprocessor.apply_thesaurus` sobre `keywords_id` **antes** de scopear y proyectar, persiste el +> corpus actualizado con `persist_replace` (punto 4, intacto) y suma un bloque aditivo +> `data["thesaurus"]` (`keywords_mapped`/`keywords_total`/`aliases_loaded`/`applied_at`). +> - **El resto del 0031 sigue vigente:** `normalize` + dedup **automáticos en la ingesta** (puntos 1, +> 2, 4, 5) no cambian; `rapidfuzz` sigue en el núcleo. Lo único que cae es el *verbo* explícito: la +> consolidación de keywords pasa de paso-CLI propio a flag de `build`. (Sutileza vs. el 0031: ya no +> hay "único paso explícito" del preproc; el thesaurus es ahora un *opt-in de `build`*, no un verbo.) diff --git a/docs/decisiones/0037-superficie-cli-10-verbos-ciclo.md b/docs/decisiones/0037-superficie-cli-10-verbos-ciclo.md new file mode 100644 index 0000000..93456f4 --- /dev/null +++ b/docs/decisiones/0037-superficie-cli-10-verbos-ciclo.md @@ -0,0 +1,286 @@ +# 0037 — Superficie CLI de 0.10.0: 10 verbos agents-first que mapean el ciclo de investigación; `status` como mapa + +- **Estado:** Aceptada +- **Fecha:** 2026-06-26 +- **Decidido por:** mixto. Las decisiones **(a)–(d)** —adoptar el set consolidado de **10 + verbos** como contrato de superficie 0.10.0, `curate`/`read` como **grupos noun-verb**, absorber + `monitor` en `chain --since`, y dar **aliases de retrocompat** durante una ventana de deprecación— + son **decisiones del Product Owner humano** (confirmadas sobre la propuesta de convergencia de la + Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127)). Las decisiones + **(e) preview por-red en `status`** y **(f) maturity-stamp del one-shot** surgieron como síntesis de + la IA y se habían anotado como *diferidas*; el **PO decidió incorporarlas a este ADR** (2026-06-26, + no posponerlas) por cerrar la trampa de red-vacía y la honestidad del one-shot. El **encuadre** que + ordena todo —*"la superficie ES el ciclo de investigación"* y *`status` es el mapa que el agente lee + para saber el próximo comando* (modelo mental tipo `git status`)— es **síntesis de la IA (Claude) + validada por el PO**. +- **Enmienda a:** [0021](0021-cli-agente-native-contrato.md) (Contrato del CLI agente-native). + Este ADR **consolida los 12 subcomandos del 0021 (y los que crecieron después) a 10 verbos**, + absorbiendo `monitor`, `inspect` y `networks` en verbos existentes y agrupando la curación y la + lectura como noun-verb. **No revierte** el 0021: el **envelope `schema="1"`, los exit codes y las + transiciones del FSM se preservan** (es reorganización **semántica** de la superficie, no ruptura + del contrato de salida). +- **Relacionada con:** [0010](0010-agente-native-columna.md) (CLI agente-native como columna + primaria — este ADR **reordena** su superficie sin tocar el principio), + [0016](0016-maquina-estados-lazo.md) (FSM cíclico del lazo: `status` sigue leyendo el mismo modelo), + [0029](0029-workspace-por-investigacion.md) (workspace por investigación: `init` declara el host + OpenAlex y resuelve por ambiente), [0035](0035-ingesta-multipuerta-resolucion-doi.md) (`--resolve` + DOI→OpenAlex ya previsto), [0036](0036-identidad-source-id-agnostica-doi-ancla.md) (identidad + source-agnóstica con DOI ancla: `seed --resolve` y el diagnóstico `source_id` se apoyan en eso), + [0032](0032-capa-servicios-duena-del-flujo.md)/[0033](0033-producto-library-centric-grafo-proyeccion.md) + (capa de servicios dueña del flujo / library-centric: la consolidación agents-first es la cara CLI + de ese mismo flujo). +- **No introduce IA** (coherente con [0022](0022-producto-sin-ia-generativa.md)): todo el + diagnóstico (`next_best_action`, red-vacía, readiness) es **determinista** —deriva del FSM y de + conteos del corpus, sin modelo generativo. +- **Origen:** Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127), + comentario *"Propuesta de convergencia: superficie agents-first para 0.10.0 (más es menos)"*. + Encuadre: [Nota 05](../Notas/05-ciclo-investigacion-humano.md) (el ciclo de investigación humano) y + la **nota de sesión 20** (`docs/Notas/20-ax-agente-cli-redes-vacias.md` — AX de un agente en frío: + *"redes vacías con cara de éxito"*; nota de sesión, **aún no versionada en `dev`**, citada como + encuadre empírico). +- **Issues:** [#76](https://github.com/complexluise/bib2graph/issues/76) (skill E2E depende de + `readiness`), [#132](https://github.com/complexluise/bib2graph/issues/132) (docs de usuario). + +## Contexto + +El ADR [0021](0021-cli-agente-native-contrato.md) fijó el contrato del CLI agente-native con un set +de subcomandos que **creció por acreción**: 11 originales → 12 con `monitor` (cleanup pre-v0.3) → +`init` (0029) → `thesaurus`/`restore` y demás. La superficie hoy ronda los **~20 subcomandos**, y la +Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127) catalogó **seis +solapamientos** que un agente —y un humano— tienen que desambiguar sin que el dominio lo justifique: + +1. **`build` vs `networks`** — dos puertas para construir redes (one-shot vs `--spec`). +2. **`inspect` (sin args) vs `status`** — dos formas de "ver dónde estoy". +3. **`inspect --id` vs una lectura de corpus** — leer un paper vive aparte de leer el corpus. +4. **`accept`/`reject`/`filter` sueltos** — la curación dispersa en tres verbos planos. +5. **`monitor` vs `chain`** — forrajeo incremental como comando aparte del forrajeo. +6. **el "doctor"/diagnóstico** tentado como comando nuevo — más superficie para decir lo que + `status` ya debería decir. + +Dos hechos de diseño ordenan la salida: + +- **La superficie ES el ciclo de investigación.** La [Nota 05](../Notas/05-ciclo-investigacion-humano.md) + modela el ciclo humano (Kuhlthau/Ellis/Bates/Pirolli/Wohlin): IDEA → SEMILLAS → CHAINING → + BROWSING/DIFERENCIAR → ORGANIZAR → SENSEMAKING → CURAR → MONITOREAR, **iterativo y no lineal**. Una + superficie que **mapea 1:1 ese ciclo** es legible sin manual: el verbo *es* el paso. +- **`status` es el mapa que el agente lee para saber el próximo comando** (modelo mental tipo + `git status`). La **nota de sesión 20** documenta la trampa de un + agente en frío: comandos que **devuelven éxito (`exit 0`) con redes vacías** —"cara de éxito" sin + resultado de investigación—. El agente no tiene de dónde inferir el próximo paso ni por qué la red + salió vacía. La cura no es un comando-doctor nuevo: es que `status` **diga el próximo mejor paso y + diagnostique** la red vacía como **campos del mismo envelope** que el agente ya parsea. + +La pregunta de 0.10.0 no es "qué comando falta" sino **"qué superficie hace legible el ciclo y deja +a `status` ser el mapa"** — *más es menos*. + +## Decisión + +**La superficie 0.10.0 es de 10 verbos que mapean el ciclo INIT → SEED → CHAIN → CURATE → BUILD → +READ → EXPORT/SNAPSHOT, con STATUS transversal como mapa.** Es reorganización **semántica** de la +superficie del 0021; el contrato de salida (envelope/exit/FSM) **no cambia**. + +### Los 10 verbos (cada uno = un paso del ciclo) + +| Verbo | Paso del ciclo | Qué hace / qué absorbe | +|-------|----------------|------------------------| +| `b2g init` | INIT | Crea el workspace; **declara el host OpenAlex y muestra el primer comando**; *folds* la fricción de la dependencia de red y el alias `bib2graph`≠`b2g`. | +| `b2g seed --from-bib FILE \| --from-equation Q` | SEED | **Dos puertas de ingesta** unificadas; `--resolve` (DOIs→OpenAlex, ADR 0035), `--email`, `--limit N`. | +| `b2g chain --back --forward` | CHAIN | Forrajeo con caps + filtros por defecto + progreso; **absorbe `monitor` como modo incremental `--since`** (decisión (c)). | +| `b2g curate {dump,apply,accept,reject,filter}` | CURATE | **Grupo noun-verb** (decisión (b)); absorbe `accept`/`reject`/`filter`; **quita el alias deprecado `--all`**. | +| `b2g build [--scope all\|accepted\|seeds] [--spec YAML] [--min-weight N]` | BUILD | Construye redes; **default corre SIN curar (one-shot)**; **warning + diagnóstico si una red sale vacía**; absorbe `networks` vía `--spec`. | +| `b2g read {list,stats,top,show}` | READ | **Grupo noun-verb** (decisión (b)); `list`/grep corpus, `stats --group-by`, **`top` = centrales + co-citación con título (salida de investigación, NUEVO)**, `show --id`; absorbe `inspect --id`. | +| `b2g export` / `b2g snapshot` | EXPORT/SNAPSHOT | Formatos extra / snapshot reproducible; **clarifica `export` vs la salida de `build`**. | +| `b2g status` | STATUS (transversal) | FSM + **readiness** + **`next_best_action`** + dependencies + **preview por-red "qué se materializa si actúo ahora"** (diagnóstico red-vacía en *status-time*, decisión (e)); **absorbe `inspect` sin args y el "doctor" SIN comando nuevo**, como **campos ADITIVOS** que preservan `schema="1"`. | +| `b2g validate` | — | Se mantiene sin cambios. | + +**Total: `init`, `seed`, `chain`, `curate`, `build`, `read`, `export`, `snapshot`, `status`, +`validate` = 10 verbos** (`export`/`snapshot` cuentan como el par de salida). + +### Sub-decisiones del PO + +- **(a) Set consolidado de 10 verbos como contrato de superficie 0.10.0.** Reemplaza/enmienda los + 12 subcomandos del 0021 (y la acreción posterior). Lo que existía como verbo plano se absorbe en el + verbo del paso de ciclo correspondiente. +- **(b) `curate` y `read` como grupos noun-verb** (no verbos planos). La curación (`dump`/`apply`/ + `accept`/`reject`/`filter`) y la lectura (`list`/`stats`/`top`/`show`) se agrupan por sustantivo; + los verbos planos `accept`/`reject`/`filter`/`inspect --id` quedan absorbidos. +- **(c) `monitor` se absorbe en `chain --since`** (no se mantiene separado). El forrajeo incremental + es el mismo forrajeo con una ventana temporal; deja de ser un verbo aparte (retira el 12° + subcomando del 0021 como verbo independiente, conserva su semántica → `MONITORED`). +- **(d) Aliases de retrocompat** para los comandos consolidados (`build`/`networks`, + `accept`/`reject`, `inspect`, `monitor`) durante una **ventana de deprecación**. No se rompe de una; + los nombres viejos siguen funcionando (con aviso) hasta cerrar la ventana. +- **(e) `status` da el preview por-red "qué se materializa si actúo ahora"** (modelo mental + `git status` *staged vs unstaged*). Para **cada red proyectable**, `status` reporta —**antes** de + correr `build`— si la construcción daría un grafo **no-vacío** y, si saldría vacía, la **causa + determinista** (p. ej. *"0/15 papers con `keywords_id`"*) y el **comando exacto** que lo arregla. + El diagnóstico de red-vacía deja de vivir **solo** en *build-time* (warning post-hoc) y pasa a estar + disponible en *status-time*: el agente/humano ve el resultado vacío **antes** de gastar el `build`. + Es la cura plena de la trampa de la Nota 20, no su mitigación. (Campo aditivo de `status`, + `schema="1"` intacto.) +- **(f) Maturity-stamp del one-shot.** Los artefactos del camino one-shot (`build`/`snapshot`/`read`) + llevan un bloque **`maturity`** aditivo en el `--json` que declara que el resultado es un **borrador + sin pulir**: si corrió sin curar (`curated:false`), el `scope`, si el corpus **no está saturado** + (forrajeo sin agotar), y las **redes vacías**. Así ni un agente que optimiza por `exit 0` ni un + humano apurado confunden un one-shot con un resultado terminado. Es honestidad **por construcción** + —vom Brocke/PRISMA hecho self-description del artefacto— y `status` aplicado a la salida. (Campo + aditivo, `schema="1"` intacto.) + +> **Nota de proceso (2026-06-26):** (e) y (f) surgieron como síntesis de la IA y se habían anotado +> como *diferidas* en la Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127#discussioncomment-17450871); +> el **PO decidió incorporarlas a 0037** (no posponerlas), porque cierran la trampa de red-vacía +> (e) y la condición de honestidad del one-shot (f) que motivan el ADR. Son del mismo género aditivo +> que el resto: no agregan superficie ni rompen el envelope. + +### Invariantes preservados (por qué esto NO rompe el contrato) + +- **Envelope `schema="1"`, exit codes y FSM del lazo se preservan** (ADR 0021 §C/§D/§F, 0016, 0010). + Reorganizar qué verbo invoca qué no cambia la forma de la salida ni el mapeo de errores. +- **`readiness`, `next_best_action` y el diagnóstico de red-vacía entran como campos ADITIVOS de + `status`** —no como comando nuevo—. Respeta el **invariante de subcomandos del 0021** y repite el + **mismo patrón que la enmienda R3** (que sumó `curation_available`/`round` al `data` de `status` + **sin bumpear `schema`**, decisión del PO 2026-06-16: campos nuevos no rompen agentes). +- **Stdout puro en `--json`** (enmienda menor al 0021 §C: una línea JSON por invocación, sin ruido). +- **`--resolve`** ya estaba previsto por ADR [0035](0035-ingesta-multipuerta-resolucion-doi.md). +- **Identidad source-agnóstica (DOI ancla)** viene de ADR + [0036](0036-identidad-source-id-agnostica-doi-ancla.md): `seed --resolve` y el diagnóstico + `source_id` del red-vacía se apoyan en esa identidad. + +### La historia one-shot (el ciclo legible sin curar) + +El default de `build` corre **sin curar** (one-shot); el agente sigue el ciclo leyendo +`status --json.data.next_best_action` en cada punto: + +```text +$ b2g init mi-investigacion # INIT → declara host OpenAlex, muestra el primer comando +$ b2g status --json # data.next_best_action = "seed" +$ b2g seed --from-bib refs.bib --resolve --email yo@ej.org + # SEED → ingesta + DOIs→OpenAlex (0035) +$ b2g status --json # data.next_best_action = "chain" +$ b2g chain --back --forward # CHAIN → forrajeo con caps + filtros por defecto +$ b2g status --json # next_best_action="build"; PREVIEW (e): red citación + # saldría VACÍA (0/15 resueltos) → corré seed --resolve +$ b2g build # BUILD → one-shot, SIN curar; el artefacto lleva maturity (f): + # {curated:false, scope:"all", empty_networks:[...]} +$ b2g status --json # data.next_best_action = "read" +$ b2g read top # READ → centrales + co-citación con título (salida de invest.) +$ b2g snapshot # SNAPSHOT → reproducible (maturity (f) viaja en el artefacto) +``` + +`status` es el **mapa**: en cada punto dice dónde está el lazo (FSM), qué está listo (`readiness`), +cuál es el próximo mejor paso (`next_best_action`) y, si algo no va a producir resultado, **lo +diagnostica antes** —el agente no se queda con una "cara de éxito" vacía. + +## Consecuencias + +**Lo que se gana** + +- **El ciclo se vuelve legible y one-shot.** La superficie mapea 1:1 los pasos de la + [Nota 05](../Notas/05-ciclo-investigacion-humano.md); un agente (o un humano) infiere el flujo sin + manual: el verbo *es* el paso. El default sin curar deja correr `init→seed→chain→build→read` de + punta a punta. +- **Menos superficie, menos solapamiento.** De ~20 subcomandos con 6 solapamientos a **10 verbos**; + `monitor`/`inspect`/`networks` y la curación/lectura dispersas dejan de ser puertas paralelas. +- **`status` como mapa cierra la trampa de la red vacía** (Nota 20) **sin comando nuevo**: + `next_best_action` + diagnóstico red-vacía + `readiness` viven como campos aditivos del envelope + que el agente ya parsea (habilita el skill E2E de [#76](https://github.com/complexluise/bib2graph/issues/76)). + +**Lo que cuesta** + +- **Ventana de deprecación a mantener** (decisión (d)): los aliases `build`/`networks`, + `accept`/`reject`, `inspect`, `monitor` siguen vivos —con aviso— hasta cerrarla. Es superficie + transitoria a documentar y testear, y una fecha/criterio de cierre a fijar. +- **`docs/API.md` a actualizar en la implementación.** Cambiar el contrato público de superficie + exige reflejar los 10 verbos, los grupos noun-verb, `chain --since`, los campos aditivos de + `status` y los aliases. *Este ADR registra ese cambio como consecuencia; la edición de `API.md` es + trabajo del `coder` en el hito de implementación, no parte de este ADR.* +- **Tests de contrato `--json` a ajustar.** Los golden/schema por comando del 0021 cambian de nombre + de comando y suman los campos aditivos de `status`; hay que reapuntarlos sin que el `schema` driftee + (sigue `"1"`). + +- **El preview por-red (e) cierra la trampa de red-vacía de raíz.** El agente/humano ve que `build` + daría una red vacía —y por qué, y cómo arreglarlo— **leyendo solo `status`, antes de actuar**. Lo + que la Nota 20 detectó como "éxito vacío" deja de ser posible: el diagnóstico está disponible en el + punto de decisión, no como warning post-hoc. +- **El maturity-stamp (f) hace el one-shot honesto por construcción.** El artefacto se autodeclara + borrador sin pulir; el one-shot es valioso como primera iteración **y** queda etiquetado como tal, + sin que un consumidor lo confunda con un resultado curado. + +**Lo que cuestan (e) y (f)** + +- **`status` debe computar el preview por-red sin correr `build`.** Necesita un estimador + **determinista y barato** (conteos de columnas `_id` pobladas por tipo de red) que prediga + vacío/no-vacío sin proyectar el grafo completo. Es conteo, no cómputo de red — pero es lógica nueva + a testear contra el `build` real (que no diverjan). +- **El `maturity` engorda el `data` de los artefactos one-shot.** Campo aditivo (no rompe + `schema="1"`), pero hay que definir su forma estable y sumarlo a los tests de contrato `--json`. + +## Alternativas + +- **Verbos planos en vez de grupos noun-verb.** Mantener `accept`/`reject`/`filter`/`inspect` como + verbos sueltos. Rechazada (decisión (b)): infla la superficie y dispersa la curación y la lectura en + varios verbos sin sustantivo común; el agrupamiento noun-verb (`curate …`, `read …`) hace legible + qué pertenece a qué paso del ciclo. +- **Mantener `monitor` como verbo separado.** Rechazada (decisión (c)): el forrajeo incremental es el + mismo forrajeo con ventana temporal; un verbo aparte duplica el solapamiento (5) del #127. + `chain --since` lo absorbe conservando la transición a `MONITORED`. +- **No dar aliases / romper de una.** Rechazada (decisión (d)): los nombres viejos están en skills y + scripts de agentes ya escritos; romperlos sin ventana paga un costo de migración abrupto sin + beneficio. La ventana de deprecación amortiza el cambio. +- **Un comando "doctor" nuevo para el diagnóstico.** Rechazada: agrega superficie para decir lo que + `status` —el mapa— debe decir. El diagnóstico entra como **campos aditivos** del envelope de + `status` (mismo patrón que R3 del 0021), respetando el invariante de subcomandos. + +## Enmienda 2026-06-27 (append-only) — D1: `build --spec` es un paso BUILD pleno (#159) + +> Anotación append-only (no revierte nada de arriba; mismo patrón que la enmienda 2026-06-27 al +> [0021](0021-cli-agente-native-contrato.md) §C). Decidida por el **PO** durante la implementación de +> la absorción `networks`→`build --spec` (decisión (a)/(d) + ADR +> [0038](0038-destino-verbos-huerfanos-0037.md)). + +La decisión (a) absorbió la capa declarativa de `networks` en **`build --spec`**, pero dejó implícito +un matiz que la implementación tuvo que resolver: **¿`build --spec` transiciona el FSM y sella el +hash, o se comporta como `networks` (ad-hoc, sin transición)?** El verbo `networks` —histórico— **no** +transicionaba ni sellaba `.corpus_hash` (transversal al lazo, como `enrich`/`curate`). + +**D1 (PO):** **`build --spec` SÍ transiciona a `BUILT` y sella `networks/.corpus_hash`** —es un paso +BUILD pleno, idéntico al `build` quick en lo que respecta al FSM—. El alias `networks` (en +deprecación, cierra 0.11.0 por 0038 P1) **conserva** su comportamiento histórico (no transiciona, no +sella). Es decir: lo que define la transición es **el verbo `build`**, no el modo (quick/spec). + +- **Por qué:** `build` *es* el paso BUILD del ciclo; correrlo con `--spec` sigue siendo materializar + redes en ese paso, así que debe avanzar el FSM y sellar la cache (coherente con la tabla de verbos + del 0037: `build → BUILT`). Mantener `--spec` sin transición reintroduciría la ambigüedad que la + absorción quería cerrar. +- **Frontera con #165:** el helper compartido `_build_from_spec_file` es la **fuente única** de carga + YAML + proyección para `build --spec` y `networks`; cuando `networks` se retire (0.11.0), no habrá + reconciliación pendiente. +- **Invariante:** envelope `schema="1"`, exit codes y la forma del FSM **no cambian**; D1 solo fija + *cuándo* se dispara la transición ya existente. Contrato AS-BUILT en + [`../API.md`](../API.md) §`build`. + +## Enmienda 2026-06-27 (append-only) — D2: en el grupo `curate` la transición la define el VERBO (#155) + +> Anotación append-only (gemela de la D1 de arriba; no revierte nada). Decidida por el **PO** durante +> la implementación del grupo noun-verb `curate` (decisión (b)), sub-issue +> [#155](https://github.com/complexluise/bib2graph/issues/155). + +La decisión (b) agrupó `dump`/`apply`/`accept`/`reject`/`filter` bajo `curate`. El cuerpo de arriba +trata la curación como **transversal** (ver tabla de verbos: "`accept`/`reject`/`filter`→`curate …`"), +y eso era cierto **como bloque** cuando eran verbos planos. Pero al volverse grupo surgió el mismo +matiz que la D1 resolvió para `build`: **¿el grupo `curate` es transversal entero, o la transición la +define cada verbo?** El verbo suelto `filter` —histórico— transicionaba a `FILTERED`. + +**D2 (PO):** **dentro del grupo `curate`, la transición del FSM la define el VERBO, no el grupo** +—mismo principio que la D1 (el verbo `build`, no el modo, define la transición)—. Concretamente: + +- **`curate filter` transiciona a `FILTERED`** (idéntico al verbo suelto `filter`; reusa + `apply_transition`, fuente única). +- **`curate dump` / `curate apply` / `curate accept` / `curate reject` son transversales** (NO + transicionan, disponibles en cualquier estado del lazo). + +Esto **matiza** —no revierte— la regla "curate es transversal" del cuerpo: era verdadera como bloque +de verbos planos; ahora es por-verbo. **Invariante:** envelope `schema="1"`, exit codes y la forma del +FSM **no cambian**; D2 solo fija *qué verbo del grupo* dispara la transición ya existente. La lógica es +fuente única en `service/curate.py` (`filter_corpus` con `decided_at` inyectado, neutralidad de +servicio ADR 0017). Contrato AS-BUILT en [`../API.md`](../API.md) §`curate`. diff --git a/docs/decisiones/0038-destino-verbos-huerfanos-0037.md b/docs/decisiones/0038-destino-verbos-huerfanos-0037.md new file mode 100644 index 0000000..56a7bc9 --- /dev/null +++ b/docs/decisiones/0038-destino-verbos-huerfanos-0037.md @@ -0,0 +1,269 @@ +# 0038 — Destino de los 5 verbos huérfanos del 0037 y parámetros de implementación: el conteo "10 verbos" se vuelve real + +- **Estado:** Aceptada +- **Fecha:** 2026-06-27 +- **Decidido por:** **Product Owner humano** (decisión tomada 2026-06-27). El destino de cada verbo + huérfano (`gui`/`enrich`/`restore`/`thesaurus`/`resolve`) y los tres parámetros de implementación + (versión de cierre de la ventana de deprecación, default de `build --scope`, dónde se fija la forma + del bloque `maturity`) son **decisiones del PO**. El **encuadre** —*"el conteo de 10 verbos del 0037 + era contable hasta despachar los huérfanos; este ADR lo vuelve real"*— es **síntesis de la IA + (architect) validada por el PO**. +- **Enmienda a:** [0037](0037-superficie-cli-10-verbos-ciclo.md) (Superficie CLI 0.10.0: 10 verbos + agents-first). Este ADR **completa** el 0037: el 0037 consolidó a 10 verbos los solapamientos que + catalogó la Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127), pero + **no nombró** cinco subcomandos que la superficie real tenía por acreción (`gui`, `enrich`, + `restore`, `thesaurus`, `resolve`). Sin despacharlos, el "10" del 0037 era **ficción contable**: el + CLI exponía más verbos de los que el ADR declaraba. Este ADR fija el destino de cada uno y **cierra + los parámetros que el 0037 dejó abiertos** (criterio de cierre de la ventana de deprecación (d), + default de `build --scope`, y dónde vive la forma estable del `maturity` (f)). **No revierte** el + 0037 ni el contrato: el **envelope `schema="1"`, los exit codes y el FSM del lazo se preservan** + (ADR [0021](0021-cli-agente-native-contrato.md) §C/§D/§F). +- **Relacionada con:** [0021](0021-cli-agente-native-contrato.md) (contrato del CLI: este ADR sigue + consolidando su superficie sin tocar el envelope), [0027](0027-pivote-posicionamiento-gui-local.md) + /[0028](0028-arquitectura-gui-api-capa-servicios.md) (`gui` se rige por estos ADR y queda **fuera** + del set de 10, como excepción explícita), [0035](0035-ingesta-multipuerta-resolucion-doi.md) + (`seed --resolve` como ruta única de resolución DOI→OpenAlex; retira `resolve` suelto), + [0030](0030-ecuacion-declarativa-corpus-ejemplo.md) (donde `restore` nació como verbo plano; pasa a + `snapshot restore`), [0031](0031-preprocesamiento-automatico-en-ingesta.md) (donde `thesaurus` + quedó como "único paso explícito"; este ADR **revisa esa parte** — ver Consecuencias), + [0011](0011-thesaurus-multilingue.md) (thesaurus de keywords), + [0025](0025-enricher-cocitacion-openalex.md) (Enricher opt-in: `enrich` se absorbe en + `chain`/`build`), [0029](0029-workspace-por-investigacion.md) (resolución por ambiente; el alias de + entry-point `bib2graph`→`b2g` entra a la ventana de deprecación). +- **No introduce IA** (coherente con [0022](0022-producto-sin-ia-generativa.md)): es reorganización + de superficie y fijación de parámetros; sin modelo generativo. +- **Origen:** revisión post-merge del [0037](0037-superficie-cli-10-verbos-ciclo.md) (PR + [#150](https://github.com/complexluise/bib2graph/pull/150)/[#153](https://github.com/complexluise/bib2graph/pull/153)): + al inventariar la superficie real contra los 10 verbos declarados, quedaron **5 subcomandos sin + asignar**. Decisión del PO 2026-06-27. +- **Issues:** [#149](https://github.com/complexluise/bib2graph/issues/149) (*"thesaurus.py no + implementa un tesauro"*, cerrada como **invalid**) — contexto del retiro de `thesaurus`. + +## Contexto + +El [0037](0037-superficie-cli-10-verbos-ciclo.md) declaró una superficie 0.10.0 de **10 verbos** que +mapean el ciclo de investigación, absorbiendo los seis solapamientos del +[#127](https://github.com/complexluise/bib2graph/discussions/127) (`networks`→`build --spec`, +`inspect`→`status`/`read`, `accept`/`reject`/`filter`→`curate …`, `monitor`→`chain --since`, +"doctor"→campos de `status`). Pero el 0037 razonó sobre los **solapamientos catalogados**, no sobre +el **inventario completo** del CLI. La superficie real —que el propio 0037 describe como "~20 +subcomandos por acreción"— incluía **cinco verbos que el ADR no nombró**: + +1. **`gui`** — lanza la GUI local (ADR [0027](0027-pivote-posicionamiento-gui-local.md) + /[0028](0028-arquitectura-gui-api-capa-servicios.md)), gateada por + [#34](https://github.com/complexluise/bib2graph/issues/34). +2. **`enrich`** — enriquecimiento opt-in (refs→DOI + co-citación) del Enricher, + ADR [0025](0025-enricher-cocitacion-openalex.md). +3. **`restore`** — restaura un corpus curado (sin red) desde snapshot, ADR + [0030](0030-ecuacion-declarativa-corpus-ejemplo.md). +4. **`thesaurus`** — paso de normalización de keywords, marcado en + [0031](0031-preprocesamiento-automatico-en-ingesta.md) como "único paso explícito (18° subcomando, + transversal)". +5. **`resolve`** — resolución DOI→OpenAlex como verbo suelto (la otra ruta, `seed --resolve`, ya + existe por ADR [0035](0035-ingesta-multipuerta-resolucion-doi.md)). + +Mientras estos cinco sigan vivos como verbos planos, **"10 verbos" es un número que el código +desmiente**: un agente que lista `--help` ve más superficie de la que el contrato declara, y vuelve +el mismo problema que el 0037 quería cerrar (superficie ilegible, puertas paralelas). La pregunta de +este ADR no es de diseño nuevo, sino de **higiene del contrato**: *¿dónde va cada huérfano para que +el conteo del 0037 sea verdad?* — y, de paso, **cerrar los tres parámetros que el 0037 dejó +abiertos**. + +## Decisión + +**Cada uno de los 5 verbos huérfanos se reabsorbe, se reparenta o se retira, de modo que la +superficie del ciclo quede en los 10 verbos del 0037 más `gui` como excepción explícita y +gobernada por su propio ADR.** Es reorganización **semántica**; el contrato de salida +(envelope/exit/FSM) **no cambia**. + +### Destino de los 5 huérfanos + +| Verbo | Destino | Por qué | +|-------|---------|---------| +| `gui` | **SE MANTIENE, fuera del set de 10.** No entra al ciclo CLI agents-first; no se retira. | Tiene su propio gobierno: ADR [0027](0027-pivote-posicionamiento-gui-local.md) (pivote GUI) y [0028](0028-arquitectura-gui-api-capa-servicios.md) (arquitectura GUI/API), gateado por [#34](https://github.com/complexluise/bib2graph/issues/34). Es una **superficie distinta** (lanzador de la GUI local), no un paso del ciclo agents-first; excluirla del conteo es honesto, no omitirla. | +| `enrich` | **Se absorbe en `build`/`chain`** (deja de ser verbo suelto). | El enriquecimiento (refs→DOI + co-citación, ADR [0025](0025-enricher-cocitacion-openalex.md)) es parte del forrajeo (`chain`) y de la materialización de redes (`build`), no un paso aparte del ciclo. Un verbo independiente duplica superficie para lo que el paso correspondiente ya hace. | +| `restore` | **Pasa a `snapshot restore`** (noun-verb del grupo `snapshot`). | `restore` es la operación inversa de `snapshot`: ambos son reproducibilidad de corpus. Agruparlos bajo el sustantivo `snapshot` (mismo patrón noun-verb que `curate …`/`read …` del 0037) hace legible que pertenecen al mismo paso EXPORT/SNAPSHOT. **Conserva la capacidad** que fijó el ADR [0030](0030-ecuacion-declarativa-corpus-ejemplo.md); solo cambia el nombre. | +| `thesaurus` | **Se retira (verbo eliminado).** | La issue [#149](https://github.com/complexluise/bib2graph/issues/149) (cerrada **invalid**) constató que `thesaurus.py` *"no implementa un tesauro"*. Como verbo explícito carga una promesa que el código no cumple. **Revisa la parte del [0031](0031-preprocesamiento-automatico-en-ingesta.md) que lo nombró "único paso explícito"** (ver Consecuencias). | +| `resolve` | **Se retira como verbo suelto.** Su ruta única es `seed --resolve`. | El ADR [0035](0035-ingesta-multipuerta-resolucion-doi.md) ya definió la resolución DOI→OpenAlex como **servicio compartido** expuesto vía `seed --resolve` (ya implementado). Un `resolve` plano es una segunda puerta para lo mismo: exactamente el tipo de solapamiento que el 0037 retira. | + +**Resultado:** la superficie del ciclo queda en los **10 verbos del 0037** +(`init`, `seed`, `chain`, `curate`, `build`, `read`, `export`/`snapshot`, `status`, `validate`), con +`snapshot restore` como noun-verb dentro de `snapshot`, **más `gui`** como excepción explícita +gobernada por 0027/0028. El número "10" del 0037 deja de ser contable y pasa a ser **verificable +contra `--help`**. + +### Parámetros de implementación fijados por el PO + +- **(P1) La ventana de deprecación cierra en la versión `0.11.0` (criterio por versión).** Los + aliases de retrocompat introducidos por el 0037 (d) —`networks`, `accept`, `reject`, `inspect`, + `monitor`— **más** `resolve` (retirado aquí) **y el alias de entry-point `bib2graph`→`b2g`— siguen + funcionando con aviso de deprecación durante 0.10.x y **se eliminan en 0.11.0**. Esto **cierra el + cabo suelto del 0037 (d)**, que dejó "fecha/criterio de cierre a fijar". Criterio = **versión**, no + fecha calendario. +- **(P2) El default de `build --scope` es `all`.** Coherente con la historia one-shot del 0037 (el + default corre **sin curar**) y con el ejemplo de `maturity` del propio 0037 (`scope:"all"`). +- **(P3) La forma estable del bloque `maturity` (0037 (f)) la fija el architect como apéndice en + [`docs/API.md`](../API.md) durante la implementación** —no en este ADR—. Aquí solo se registra + **dónde** vive esa especificación; su schema concreto (campos, tipos) es trabajo del hito de + implementación, como campo **aditivo** que preserva `schema="1"`. + +### Invariantes preservados + +- **Envelope `schema="1"`, exit codes y FSM del lazo intactos** (ADR 0021, 0016, 0010). Mover/retirar + verbos no cambia la forma de la salida ni el mapeo de errores. +- **Ninguna capacidad de dominio se pierde:** el enriquecimiento sigue (dentro de `chain`/`build`, + 0025), el restore sigue (`snapshot restore`, 0030), la resolución DOI sigue (`seed --resolve`, + 0035). Solo `thesaurus` se retira como **verbo** (su lugar como paso de normalización se resuelve en + la implementación — ver Consecuencias). + +## Consecuencias + +**Lo que se gana** + +- **El conteo del 0037 se vuelve verdad.** Lo que `--help` lista coincide con lo que el contrato + declara: 10 verbos del ciclo + `gui` (excepción documentada). Desaparece la brecha entre el ADR y la + superficie real que motivó este ADR. +- **Menos puertas paralelas.** `enrich`/`resolve` dejan de duplicar lo que `chain`/`build` y + `seed --resolve` ya hacen; `restore` se vuelve legible como parte de `snapshot`. +- **La ventana de deprecación tiene fin cierto** (P1): los aliases no quedan vivos + indefinidamente; 0.11.0 es el corte. + +**Lo que cuesta** + +- **`docs/API.md` a actualizar en la implementación** (trabajo del `coder`, no de este ADR): retirar + `enrich`/`thesaurus`/`resolve` del contrato, mover `restore`→`snapshot restore`, documentar `gui` + como excepción al set de 10, fijar el default `build --scope=all` (P2) y sumar el apéndice + `maturity` (P3). Recomendación: revisar las menciones a estos verbos en el contrato actual y en los + golden tests `--json`. +- **Tests de contrato y aliases a ajustar:** sumar el aviso de deprecación de `resolve` y del + entry-point `bib2graph`, y el corte programado para 0.11.0. + +**Tensiones que hay que mirar (drift honesto)** + +- **`thesaurus` vs ADR [0031](0031-preprocesamiento-automatico-en-ingesta.md).** El 0031 declaró + `thesaurus` como **"único paso explícito (18° subcomando, transversal)"** del preprocesamiento. + Retirarlo como verbo **revisa esa parte del 0031** (no la revierte entera: el 0031 ya volvió + *normalize + dedup* **automáticos en la ingesta**; lo que cae es el único paso que había quedado + explícito). **Pregunta abierta para el `coder`/PO:** ¿la normalización de keywords se **pliega a la + ingesta automática** (coherente con el espíritu del 0031) o **se elimina** junto con el verbo? La + issue [#149](https://github.com/complexluise/bib2graph/issues/149) (invalid) sugiere que la + implementación actual no era un tesauro real; conviene **confirmar el destino de la funcionalidad**, + no solo del verbo, en el hito de implementación. *Este ADR retira el verbo; el destino del paso de + normalización debe quedar explícito en `docs/API.md`.* Relacionada: ADR + [0011](0011-thesaurus-multilingue.md). +- **`restore` vs ADR [0030](0030-ecuacion-declarativa-corpus-ejemplo.md).** El 0030 (AS-BUILT) + nombró `restore` como verbo plano. `snapshot restore` es **renombre semántico**, no pérdida de + capacidad; aun así, es un cambio de superficie pública que `docs/API.md` y la guía de usuario deben + reflejar. + +## Alternativas + +- **Dejar los 5 huérfanos como verbos sueltos.** Rechazada: vuelve el "10" del 0037 una **ficción + contable** —el problema exacto que el 0037 quería cerrar (superficie ilegible, puertas paralelas) + reaparece por la puerta de atrás—. La higiene del contrato exige despacharlos. +- **Meter `gui` dentro del set de 10.** Rechazada: `gui` no es un paso del ciclo agents-first; es una + superficie distinta con su propio gobierno (0027/0028) y gate (#34). Forzarla al set mezcla dos + contratos. Excluirla **explícitamente** es más honesto que disimularla en el conteo. +- **Retirar `enrich` por completo (no absorberlo).** Rechazada: el enriquecimiento es capacidad de + dominio viva (ADR 0025); lo que sobra es el **verbo**, no la función. Se absorbe en `chain`/`build`. +- **Mantener `resolve` como verbo y deprecar `seed --resolve`.** Rechazada: contradice el ADR + [0035](0035-ingesta-multipuerta-resolucion-doi.md), que fijó la resolución como **servicio + compartido** con `seed --resolve` como ruta de ingesta. Se conserva la ruta del 0035; se retira el + verbo suelto. +- **Cerrar la ventana de deprecación por fecha calendario en vez de por versión.** Rechazada (P1): + el criterio por **versión (0.11.0)** es verificable, reproducible y no depende del calendario de + release; es coherente con cómo el proyecto versiona (release-please). + +## Enmienda 2026-06-27 (append-only) — corrige el gap de P1: `filter` también entra a la ventana (#155) + +> Anotación append-only (no revierte nada de arriba). Surge de la implementación del grupo `curate` +> noun-verb (sub-issue [#155](https://github.com/complexluise/bib2graph/issues/155)): al absorber +> `filter` en `curate filter` se constató que **P1 omitió `filter`** de la lista de aliases en +> deprecación. + +El parámetro **(P1)** enumeró los aliases que cierran en `0.11.0` como *"`networks`, `accept`, +`reject`, `inspect`, `monitor` —más `resolve` y el entry-point `bib2graph`"*. Pero el ADR +[0037](0037-superficie-cli-10-verbos-ciclo.md) (decisión (b)) absorbió **`accept`/`reject`/`filter`** +en el grupo `curate`: los tres verbos planos quedan como alias deprecados. P1 listó `accept` y +`reject` pero **omitió `filter`** —un gap, no una decisión—. + +**Corrección:** el verbo suelto **`filter` se suma a la ventana de deprecación**, con el mismo +criterio que `accept`/`reject`: sigue funcionando **con aviso** durante 0.10.x y **se elimina en +0.11.0**, como alias deprecado del nuevo **`curate filter`**. (`filter` y `curate filter` comparten la +lógica de servicio `filter_corpus`, fuente única; el suelto es un shim que delega.) El resto de P1 no +cambia. AS-BUILT del grupo `curate` en [`../API.md`](../API.md) §`curate`. + +## Enmienda 2026-06-27 (append-only) — fija el detalle de `restore`→`snapshot restore`: `snapshot` se vuelve grupo noun-verb y el plano → `snapshot create` [BREAKING] (#163) + +> Anotación append-only (gemela de las enmiendas D1 (#159) / #155; no revierte nada de arriba). Surge +> de la implementación del sub-issue [#163](https://github.com/complexluise/bib2graph/issues/163). La +> **Decisión** de arriba ya fijó que `restore` pasa a **`snapshot restore`** (tabla de huérfanos + +> Consecuencias §`restore` vs 0030, líneas ~85/154-157), pero **no explicitó** qué pasa con el verbo +> `snapshot` mismo. La implementación lo resuelve: para alojar `snapshot restore`, **`snapshot` deja +> de ser verbo plano y se vuelve grupo noun-verb** —y eso obliga a renombrar el `snapshot` plano—. + +El ADR decidió el **destino** de `restore` (→ `snapshot restore`) pero no el **detalle del grupo**. +Concretamente, al volverse `snapshot` un grupo: + +- **(a) `snapshot` es ahora grupo noun-verb `{create, restore}`** —el **3er grupo del CLI**, mismo + patrón que `read` (1°, #156/#157) y `curate` (2°, #155): `snapshot` **sin subcomando** imprime la + ayuda y sale **exit 0**; el `command` del envelope usa la **ruta completa** (`"snapshot create"` / + `"snapshot restore"`). +- **(b) El `snapshot` plano → `snapshot create`** —**BREAKING, sin alias**, mismo criterio que el + BREAKING de `curate` (decisión (b) del 0037, forma-flag eliminada sin alias). `snapshot create` es + el ex `snapshot` plano sin cambios de semántica: sella la foto reproducible (parquet + + `manifest.json`, ADR 0017), **NO transiciona** el `CycleState` y **lleva el bloque `maturity`** del + one-shot (AS-BUILT #160, coherente con `build`/`read top`). +- **(c) `snapshot restore` es el ex `restore`** (mergea+dedup, preserva la curación, **transiciona a + `FILTERED`** reusando la transición permisiva `filter`, ADR 0016/0030). El **verbo suelto `restore` + queda INTACTO como alias deprecado** (shim que delega; su envelope lleva `command="restore"` por + backward-compat). Su **retiro se agenda en #165** (junto con `inspect`), no en este hito. +- **(d) Fuente única en `service/snapshot.py`** (`run_snapshot`/`run_restore`, servicio neutral con + reloj `decided_at` inyectado en la frontera, ADR 0017): `snapshot create`, `snapshot restore` y el + shim `restore` suelto **delegan** en ella. `run_snapshot` lleva el bloque `maturity` (de #160). + +**Invariantes intactos:** envelope `schema="1"`, exit codes y la forma del FSM no cambian; `create` +**NO** transiciona, `restore`→`FILTERED` (igual que antes). Esta enmienda solo fija el detalle del +grupo y el BREAKING que la Decisión no explicitó. AS-BUILT en [`../API.md`](../API.md) §`snapshot`. + +> **Follow-up (BAJO, #175):** la implementación dejó `normalize_and_dedup` duplicado en +> `service/snapshot.py` respecto del helper de `cli/_ingest.py`. Es deuda de DRY, **no** afecta el +> contrato; se trata en su propio issue, no aquí. + +## Enmienda 2026-06-28 (append-only) — corrige el gap de P1: `enrich` también entra a la ventana, y consolida los 9 aliases (#162/#165) + +> Anotación append-only (gemela de la enmienda `filter` de 2026-06-27; no revierte nada de arriba). +> Surge de absorber `enrich` en `chain`/`build` ([#162](https://github.com/complexluise/bib2graph/issues/162)) +> y de la implementación de la capa de deprecación ([#165](https://github.com/complexluise/bib2graph/issues/165)). + +El parámetro **(P1)** enumeró los aliases que cierran en `0.11.0` como *"`networks`, `accept`, +`reject`, `inspect`, `monitor` —más `resolve` y el entry-point `bib2graph`"*. Pero la tabla de +huérfanos de la **Decisión** de arriba absorbe **`enrich`** en `chain`/`build`: el verbo plano queda +como alias deprecado. P1 **omitió `enrich`** —el **mismo gap** que ya se corrigió para `filter` (#155) +y que aplica también a `restore` (→ `snapshot restore`, #163)—; no es una decisión, es una omisión. + +**Corrección:** el verbo suelto **`enrich` se suma a la ventana de deprecación**, con el mismo criterio +que `accept`/`reject`/`filter`/`restore`: sigue funcionando **con aviso** durante 0.10.x y **se elimina +en 0.11.0**, como alias que delega en la misma lógica (`cli/_enrich.py::enrich_corpus`, fuente única; +ver nota append-only del ADR [0025](0025-enricher-cocitacion-openalex.md)). + +**Lista completa de los 9 aliases deprecados** (alias vivo con aviso a stderr hasta 0.11.0), tal como +los registra `cli/_deprecation.py` (#165): + +| Alias deprecado | Forma canónica | +|---|---| +| `b2g accept` | `b2g curate accept` | +| `b2g reject` | `b2g curate reject` | +| `b2g filter` | `b2g curate filter` | +| `b2g inspect` | `b2g read show` / `b2g status` | +| `b2g monitor` | `b2g chain --since` | +| `b2g networks` | `b2g build --spec` | +| `b2g enrich` | `b2g chain` (+ `b2g build`) | +| `b2g restore` | `b2g snapshot restore` | +| `b2g resolve` | `b2g seed --resolve` | + +**Más** el entry-point `bib2graph` → `b2g` y la opción **`build --corpus-scope` → `build --scope`** +(mismo corte 0.11.0). `thesaurus` **no** está en esta lista: se **retira por completo** (sin alias); +su capacidad vive como `build --thesaurus` (#164; nota append-only del ADR +[0031](0031-preprocesamiento-automatico-en-ingesta.md)). AS-BUILT de la capa de avisos en +[`../API.md`](../API.md) §Avisos de deprecación. diff --git a/docs/decisiones/0039-skill-comando-meta-distribucion.md b/docs/decisiones/0039-skill-comando-meta-distribucion.md new file mode 100644 index 0000000..f9be4d4 --- /dev/null +++ b/docs/decisiones/0039-skill-comando-meta-distribucion.md @@ -0,0 +1,181 @@ +# 0039 — Distribución de la skill de Claude Code: `b2g skill add` como 2.º comando meta (junto a `gui`), vendoreada en el wheel con version-lock skill==cli + +- **Estado:** Aceptada +- **Fecha:** 2026-06-28 +- **Decidido por:** **Product Owner humano** (decisión acordada 2026-06-28). El mecanismo de + distribución (vendoring en el wheel como fuente del paquete, comando `b2g skill add` con + `--user|--project|--force`, version-lock skill==cli) y el descarte del extra `[skill]` y del + camino plugin+marketplace como primario son **decisiones del PO**. El **encuadre** —*"la mejor + forma de usar bib2graph es pedirle a Claude que lo use; la skill es esa puerta, y es un comando + **meta/admin**, no un paso del ciclo"*— y la categorización como **2.ª excepción meta junto a + `gui`** son **síntesis de la IA (architect) validada por el PO**. +- **Enmienda a:** [0038](0038-destino-verbos-huerfanos-0037.md) (destino de los huérfanos del 0037). + El 0038 estableció la categoría *"verbo fuera del set de 10, gobernado por su propio ADR"* para + `gui` (excepción explícita, no un paso del ciclo). Este ADR **agrega una 2.ª excepción de la misma + clase**: `skill` es un comando **meta/distribución**, no un verbo del ciclo de investigación. **No + revierte** el 0037 ni el 0038: el conteo de **10 verbos del ciclo sigue siendo verdad**; la + superficie pasa a leerse como *"10 verbos del ciclo + `gui` (GUI) + `skill` (meta/distribución)"*. + El **envelope `schema="1"`, los exit codes y el FSM del lazo se preservan** (ADR + [0021](0021-cli-agente-native-contrato.md) §C/§D/§F). +- **Relacionada con:** [0037](0037-superficie-cli-10-verbos-ciclo.md) (la superficie ES el ciclo: + `skill` **no** compite con `status` ni con ningún verbo del ciclo; vive **al lado**, no dentro), + [0028](0028-arquitectura-gui-api-capa-servicios.md) (precedente del **vendoring de assets en el + wheel**; `gui/static` usa `force-include` por ser artefacto **gitignored** — la skill **no** lo + necesita, ver §Mecanismo), + [0010](0010-agente-native-columna.md)/[0021](0021-cli-agente-native-contrato.md) (CLI + agente-native: la skill **enseña los 10 verbos** del 0037 a un agente end-user), + [0029](0029-workspace-por-investigacion.md) (`skill add` es un comando **meta global** que + funciona **sin workspace**, igual que `init` cuando crea uno). +- **No introduce IA** (coherente con [0022](0022-producto-sin-ia-generativa.md)): la skill es + **markdown** (instrucciones + `reference/`) que un agente Claude Code lee; bib2graph no embebe ni + invoca ningún modelo. La IA está en el **cliente del usuario** (su Claude Code), no en el producto. +- **Origen:** epic [#188](https://github.com/complexluise/bib2graph/issues/188). Encuadre de + posicionamiento: *"el antídoto al sesgo del related work"* (#187) — la skill es la forma en que un + usuario hispano corre el ciclo one-shot pidiéndole a Claude que use bib2graph. + +## Contexto + +El 0037 dejó la superficie CLI consolidada en **10 verbos que mapean el ciclo de investigación**, +legibles por un agente sin manual. El 0038 cerró el conteo despachando los huérfanos y fijó la +categoría de la **excepción meta**: `gui` se mantiene **fuera del set de 10** porque es una +**superficie distinta** (lanzador de la GUI local), gobernada por su propio ADR (0027/0028), no un +paso del ciclo agents-first. + +Falta una pieza del posicionamiento *"la mejor forma de usar bib2graph es pedirle a Claude que lo +use"*: una **skill de Claude Code para el usuario final** que (1) **entreviste** al investigador +sobre su pregunta y sus fuentes, y (2) le **enseñe al agente la mejor forma de manejar bib2graph** +—es decir, los 10 verbos del 0037 y la historia one-shot `init→seed→chain→build→read`, leyendo +`status` como mapa—. La skill es **markdown sin dependencias Python**: un `SKILL.md` + una carpeta +`reference/`. + +El problema es de **distribución y de versionado**, no de diseño del ciclo: + +- **¿Dónde vive la skill?** Si se publica como un paquete/extra aparte, su versión **desacopla** de + la del CLI: una skill v0.10 podría quedar instruyendo a un agente que corre un CLI v0.11 con la + superficie ya cambiada (los 9 aliases del 0038 retirados en 0.11.0). La skill **enseña los 10 + verbos**; si enseña verbos que ya no existen, miente. +- **¿Cómo se instala?** Claude Code descubre skills en `~/.claude/skills//` (user) o + `.claude/skills//` (project). Un `pip install` **no escribe** en esas rutas; instalar una + skill es **copiar archivos** a un directorio del cliente, no resolver dependencias Python. + +La pregunta de este ADR es: **¿cómo se empaqueta y se instala la skill de modo que su versión quede +amarrada a la del CLI, sin agregar superficie al ciclo?** + +## Decisión + +**bib2graph distribuye una skill de Claude Code end-user, vendoreada dentro del wheel bajo +`src/bib2graph/skill/`, y la instala con un comando meta nuevo `b2g skill add` que la copia al +directorio de skills del cliente.** `skill` es la **2.ª excepción meta explícita** (junto a `gui`): +un comando **meta/distribución**, NO un paso del ciclo de investigación. El contrato de salida +(envelope `schema="1"`/exit/FSM) **no cambia**. + +### `skill` es comando meta, no un verbo del ciclo (2.ª excepción junto a `gui`) + +- **No mapea ningún paso del ciclo** INIT→SEED→CHAIN→CURATE→BUILD→READ→EXPORT/SNAPSHOT. No produce + ni transforma corpus ni redes. **No compite con `status`** (el mapa del ciclo) ni con ningún verbo + del 0037: vive **al lado** de la superficie, no dentro. +- Es de la **misma clase que `gui`**: una superficie distinta gobernada por su propio ADR (este). + El 0038 categorizó `gui` como *"se mantiene, fuera del set de 10"*; `skill` reusa esa categoría. +- **Reconciliación del conteo:** los **10 verbos del ciclo del 0037 siguen siendo verdad**. La + superficie pasa a leerse como **"10 verbos del ciclo + `gui` (GUI) + `skill` (meta/distribución)"**. + El conteo de 10 es **verificable contra `b2g --help`**; las dos excepciones meta están + **documentadas**, no disimuladas (mismo criterio de honestidad del 0038: excluir explícitamente es + más honesto que omitir). + +### Mecanismo: vendoring + version-lock skill==cli + +- **(M1) La skill vive vendoreada en el wheel bajo `src/bib2graph/skill/`** (`SKILL.md` + + `reference/`). Como es **fuente commiteada bajo el paquete**, la incluye `packages = + ["src/bib2graph"]` **sin** `force-include`. (El frontend `gui/static/` **sí** necesita + `force-include` por ser un artefacto **gitignored**, ADR 0028 G5; la skill no — y meterla ahí la + **duplicaría y rompería el build**.) *(El `pyproject.toml` es trabajo del `coder`; este ADR fija el + mecanismo, no la sintaxis.)* +- **(M2) Garantía central: skill-version == cli-version.** Como la skill viaja **en el mismo wheel** + que el CLI, instalar `bib2graph==X.Y.Z` trae una skill que **enseña exactamente los verbos de esa + versión**. El vendoring en el mismo artefacto **es** el version-lock; no hay forma de tener una + skill v0.10 sobre un CLI v0.11. Esto es lo que cierra el riesgo de "la skill miente". +- **(M3) `b2g skill add [--user|--project] [--force]`** copia la skill vendida al directorio de + skills del cliente: + - **`--user`** (default) → `~/.claude/skills/bib2graph/`. + - **`--project`** → `.claude/skills/bib2graph/` (relativo al cwd). + - **`--force`** → pisa una instalación existente; sin él, el comando es **idempotente** (si ya + está en la versión vendida, no hace nada y lo reporta). + - **Funciona sin workspace:** es un comando **meta global**, no requiere `workspace.json` ni + resolución de ambiente (como `init` al crear, o como `gui`/`resolve` que no transicionan). + - **Emite el envelope `--json` `schema="1"` SIN transición de FSM** (igual que `gui`/`resolve`): + es ortogonal al lazo. *(El `data` emitido es `{install_path, scope, installed, already_present, + skill_md, reference_dir, how_to}`, `schema="1"` intacto; documentado en `docs/API.md`.)* + - **Salida agéntica (AgenticExperience):** la salida —humana y `--json`— es **explícita sobre dónde + quedó el `SKILL.md` y pide leerlo**, con un resumen `how_to` de cómo opera bib2graph a grandes + rasgos. Esto vuelve la skill **auto-descubrible por un agente de cualquier proveedor** (no solo + Claude Code) sin depender del mecanismo de descubrimiento del cliente — el primer puente hacia la + distribución agnóstica del 0.11.0 ([#193](https://github.com/complexluise/bib2graph/issues/193)). + +### La historia de uso (cómo llega la skill al investigador) + +```text +$ pip install bib2graph # trae el CLI + la skill vendoreada (mismo wheel, misma versión) +$ b2g skill add # copia la skill a ~/.claude/skills/bib2graph/ (--user default) +# en Claude Code: "usá bib2graph para armar la red de citación de estos papers…" +# el agente lee la skill, entrevista al investigador y corre el ciclo one-shot del 0037 +``` + +La skill es la materialización del mensaje *"la mejor forma de usar bib2graph es pedirle a Claude +que lo use"*: enseña el ciclo (`init→seed→chain→build→read`, `status` como mapa) que el 0037 hizo +legible para un agente. + +## Consecuencias + +**Lo que se gana** + +- **Version-lock por construcción.** La skill y el CLI **no pueden** divergir: viajan en el mismo + wheel. La skill siempre enseña los verbos que el CLI realmente expone (incluido el corte de los 9 + aliases en 0.11.0, ADR 0038 P1). +- **Instalación de un paso, sin Node ni deps extra.** `pip install bib2graph && b2g skill add`: + markdown copiado a `~/.claude/skills/`, sin tocar `[project.dependencies]`. +- **El conteo del ciclo se mantiene honesto.** 10 verbos + 2 excepciones meta **documentadas** + (`gui`, `skill`). El 0037/0038 no se debilita: `skill` no entra al ciclo, lo **acompaña**. +- **Reusa un precedente probado, más simple.** El ADR 0028 (G5) ya vendorea assets en el wheel; la + skill se monta sobre la misma idea pero al ser **fuente commiteada** entra por `packages`, sin tocar + `force-include` ni infraestructura nueva. + +**Lo que cuesta** + +- **`docs/API.md` documenta una 2.ª excepción meta**: `skill` con `skill add` y sus flags, la + reconciliación del conteo ("10 + gui + skill") y la nota de empaquetado (la skill entra por + `packages`, **no** por `force-include`). El `data` del envelope es `{install_path, scope, installed, + already_present, skill_md, reference_dir, how_to}` (`schema="1"` intacto). +- **El wheel engorda con la skill** (markdown, peso menor); al ser fuente del paquete entra por + `packages` sin config extra. *(`pyproject.toml`/CI son del `coder`.)* +- **Mantener la skill al día con el ciclo.** Si el ciclo del 0037 cambia (futuro ADR), la skill + —que lo enseña— debe actualizarse en el mismo cambio. El version-lock lo hace **detectable** (van + juntas) pero no **automático**: editar el ciclo sin editar la skill es drift a vigilar. +- **Atado a Claude Code — limitación DELIBERADA de 0.10.0.** `skill add` instala solo en + `~/.claude/skills/` (ruta/formato de Claude Code). Atar la distribución a un proveedor **restringe + el acceso** y repite la lógica de gatekeeping que el producto quiere evitar (que solo quien tiene + Claude pueda pedirle a su agente que use bib2graph). La **distribución agnóstica al proveedor** —el + usuario/agente declara su cliente (Claude Code, OpenCode, …) y `skill add` instala en su formato— es + **prioridad de 0.11.0** ([#193](https://github.com/complexluise/bib2graph/issues/193)), probable + enmienda a este ADR (`skill add` ganaría `--provider`). El version-lock skill==cli se preserva en + todos los proveedores. + +## Alternativas + +- **El extra `pip install bib2graph[skill]`.** **Descartada.** Un extra de pip solo agrega + **dependencias Python**; **no escribe** en `~/.claude/skills/`. La skill es **markdown sin deps**: + no hay nada que un extra resuelva. El acto de instalar una skill es **copiar archivos** al + directorio del cliente —eso lo hace `b2g skill add`, no `pip`—. Un extra `[skill]` daría la falsa + impresión de que instalarlo deja la skill lista, cuando no toca el directorio de Claude Code. +- **Plugin + marketplace de Claude Code (`/plugin install`).** **Descartada como primaria** (dejada + como **ruta futura mencionable**). Un plugin distribuido por marketplace **desacopla el + versionado**: la skill viviría en un repo/marketplace con su propia cadencia de release, y volvería + el riesgo que M2 cierra (skill v0.10 sobre CLI v0.11). El vendoring en el wheel da el version-lock + gratis; el plugin lo rompería. Si en el futuro se quiere descubribilidad vía marketplace, puede + convivir **como espejo** de la skill vendoreada, no como su fuente de verdad. +- **Meter `skill` dentro del set de 10.** **Descartada** (mismo criterio que el 0038 rechazó para + `gui`): `skill add` no es un paso del ciclo de investigación; es distribución/admin. Forzarlo al + set mezcla dos contratos. Excluirlo **explícitamente** —2.ª excepción meta documentada— es más + honesto que disimularlo en el conteo. +- **Publicar la skill en un repo aparte / como descarga manual.** **Descartada:** rompe el + version-lock (M2) y agrega un paso de instalación que `b2g skill add` resuelve en uno. El usuario + que ya tiene el CLI ya tiene la skill correcta. diff --git a/docs/decisiones/0040-retiro-gui-local.md b/docs/decisiones/0040-retiro-gui-local.md new file mode 100644 index 0000000..dbb898d --- /dev/null +++ b/docs/decisiones/0040-retiro-gui-local.md @@ -0,0 +1,157 @@ +# 0040 — Retirar la GUI local de la librería: el core es CLI/agente-native sobre la biblioteca viva + +- **Estado:** Aceptada +- **Fecha:** 2026-06-28 +- **Decidido por:** **Product Owner humano** (decisión tomada 2026-06-28). El retiro de la GUI local + (subcomando `b2g gui`, API local FastAPI, SPA `frontend/`, extra `[gui]`, vendoreo del frontend en + el wheel) y su carácter **BREAKING** son **decisiones del PO**. El **encuadre** —*"la GUI no es el + foco; el core es CLI/agente-native sobre la biblioteca viva, y el camino de adopción es la skill + (ADR 0039), no una SPA local"*— es **síntesis de la IA (architect) validada por el PO**. +- **Supersede a:** [0027](0027-pivote-posicionamiento-gui-local.md) (pivote de posicionamiento: GUI + local opt-in) y [0028](0028-arquitectura-gui-api-capa-servicios.md) (arquitectura GUI/API/frontend + + empaquetado `[gui]`). Ambos quedan como **historia inmutable** (no se reescriben ni se borran): este + ADR revierte la **dirección** que fijaron, no su registro. El pivote del 0027 (GUI como 4º frontend) + y la arquitectura de tres frontends del 0028 dejan de ser el TARGET del proyecto. +- **Enmienda a:** [0038](0038-destino-verbos-huerfanos-0037.md) (destino de los verbos huérfanos del + 0037). El 0038 fijó que `gui` *"se mantiene, fuera del set de 10"* como **excepción meta gobernada + por su propio ADR** (0027/0028). Este ADR **retira** esa excepción: `gui` deja de existir como verbo. + El conteo del 0037 **no se debilita** —los 10 verbos del ciclo siguen siendo verdad—; la superficie + pasa a leerse como **"10 verbos del ciclo + `skill` (meta/distribución)"**, sin `gui`. +- **No revierte el contrato del lazo:** el **envelope `schema="1"`, los exit codes y el FSM** se + preservan (ADR [0021](0021-cli-agente-native-contrato.md) §C/§D/§F). La **capa de servicios neutral + `service/`** —que el 0028 introdujo y elevó al contrato (envelope/errores/exit-code)— **se conserva**: + es la fuente única que usa el CLI. Lo que se retira es el **adaptador de transporte HTTP** (la API) y + su frontend, no la capa neutral. +- **Relacionada con:** [0010](0010-agente-native-columna.md)/[0021](0021-cli-agente-native-contrato.md) + (CLI agente-native como columna primaria: queda como la **única** frontera), + [0037](0037-superficie-cli-10-verbos-ciclo.md) (la superficie ES el ciclo de 10 verbos), + [0039](0039-skill-comando-meta-distribucion.md) (la skill de Claude Code es el **camino de adopción** + que reemplaza el rol que el 0027 daba a la GUI; tras este ADR, `skill` queda como la **única** + excepción meta, no la "2.ª junto a `gui`"), + [0005](0005-dependencias-extras.md) (matriz de extras: el extra `[gui]` = `fastapi` + `uvicorn` se + elimina, como antes `[llm]`/`[dedup]`), + [0032](0032-capa-servicios-duena-del-flujo.md)/[0033](0033-producto-library-centric-grafo-proyeccion.md) + /[0034](0034-etiquetado-tabla-tags-lateral.md) (propuestas library-centric que colgaban de la GUI: + quedan **fuera del alcance de la librería**; viven en el producto separado, no en bib2graph). +- **No introduce IA** (coherente con [0022](0022-producto-sin-ia-generativa.md)): es retiro de + superficie; no toca el núcleo determinista. +- **Origen:** issue [#190](https://github.com/complexluise/bib2graph/issues/190) (*"retirar la GUI + local (BREAKING) — fuera del foco"*). Encuadre de frontera bib2graph (motor determinista) vs. + producto (Zotero/Mendeley + IA-asiste). La limpieza **profunda** de la documentación (los ~53 + marcadores AS-BUILT de la epic GUI) se trata aparte en + [#191](https://github.com/complexluise/bib2graph/issues/191). + +## Contexto + +El ADR [0027](0027-pivote-posicionamiento-gui-local.md) (firmado 2026-06-18) pivoteó el +posicionamiento: una **GUI local "tool for thought"** opt-in para el investigador semi-técnico, como +**4º frontend** sobre la biblioteca viva. El ADR [0028](0028-arquitectura-gui-api-capa-servicios.md) +bajó la arquitectura: una **capa de servicios neutral** (`service/`) con **tres frontends de +frontera** —CLI, **API local FastAPI** (`src/bib2graph/api/`) y **SPA** (`frontend/`)— más el +empaquetado del frontend en el wheel (extra `[gui]`, `force-include` de `gui/static/`). La epic +[#34](https://github.com/complexluise/bib2graph/issues/34) llegó a **AS-BUILT G1–G5** (API + SPA + +empaquetado), gateada por la validación de un tercero sobre el caso real. + +Esa validación no llegó, y el foco del proyecto se aclaró por otro lado. El posicionamiento de +0.10.0 es **agents-first**: la superficie CLI de 10 verbos (ADR [0037](0037-superficie-cli-10-verbos-ciclo.md)) +mapea el ciclo de investigación para que **un agente lo corra one-shot**, y la **skill de Claude +Code** (ADR [0039](0039-skill-comando-meta-distribucion.md)) es la puerta de adopción —*"la mejor +forma de usar bib2graph es pedirle a Claude que lo use"*—. En ese marco, la GUI local: + +- **Compite por foco con el core CLI/agente-native** sin un caso validado que la justifique. +- **Carga superficie pesada**: una API HTTP de larga vida (que tensiona el single-writer del ADR + [0019](0019-concurrencia-diferida.md)), un subárbol JS (`frontend/`, único Node del monorepo, con su + toolchain `pnpm`/Vite/CI propio) y un empaquetado especial (`force-include` del frontend gitignored, + jobs de build de Node en `ci.yml`/`publish-*.yml`). +- **Pertenece al otro lado de la frontera de producto.** bib2graph es el **motor determinista** sin + IA; la experiencia visual library-centric (las propuestas 0032/0033/0034 que colgaban de la GUI) es + del **producto separado** (Zotero/Mendeley + IA-asiste), no de la librería. + +La pregunta de este ADR no es de diseño: es de **alcance**. *¿La GUI local es parte de la librería +bib2graph?* La respuesta del PO es **no**. + +## Decisión + +**bib2graph retira la GUI local de la librería.** Se eliminan el subcomando `b2g gui`, la API local +FastAPI (`src/bib2graph/api/`), la SPA `frontend/`, el extra `[gui]` y el vendoreo del frontend en el +wheel. El **core de bib2graph es la CLI/agente-native `b2g`** (ADR 0010/0021/0037) sobre la +biblioteca viva DuckDB. Es retiro de **superficie**; el contrato de salida (envelope `schema="1"` / +exit codes / FSM) y la **capa de servicios neutral `service/`** **no cambian**. + +### Qué se retira (BREAKING, pre-1.0 → bump minor) + +1. **El subcomando `b2g gui`** (`cli/commands/gui.py`) — sin alias de retrocompat (no es un renombre, + es un retiro de capacidad). +2. **La API local FastAPI** (`src/bib2graph/api/**`: `app`, `deps`, `security`, `envelopes`, `routers/`). +3. **La SPA `frontend/**`** (subárbol JS) y el prototipo `app/**`. +4. **El extra `[gui]`** (`fastapi` + `uvicorn`) y todo su andamiaje de empaquetado: `force-include` de + `gui/static/`, el directorio `src/bib2graph/gui/`, los `per-file-ignores` de `api/` y el override + de mypy `uvicorn.*` en `pyproject.toml`. +5. **El job `frontend` de `ci.yml`** y los pasos de build de Node de `publish-pypi.yml` / + `publish-testpypi.yml` (el wheel pasa a ser **Python puro**, construible con `uv build` sin Node). + +### Qué se conserva (NO es GUI; lo usa el CLI) + +- **La capa de servicios neutral `service/`** completa: `envelope.py`, `errors.py`, `resolve.py`, + `curate.py`, `maturity.py`, `snapshot.py` y **`reads.py`**. El 0028 la introdujo como contrato + compartido; el CLI (`read`/`curate`/`snapshot`/…) **depende** de ella. **`service/reads.py` no es + exclusiva de la API** —el grupo `read` (#156/#157) la consume (`list_papers`/`corpus_stats`/ + `get_paper`/`get_top`)—, así que **se queda**. +- **El entry point `b2g`** y los 10 verbos del ciclo + `skill` intactos. + +### El conteo de superficie tras el retiro + +La superficie 0.10.0 pasa de *"10 verbos del ciclo + `gui` + `skill`"* (estado del ADR 0039) a +**"10 verbos del ciclo + `skill` (meta/distribución)"**. `skill` queda como la **única excepción +meta**, ya no "la 2.ª junto a `gui`". El conteo de 10 verbos del 0037 sigue siendo **verificable +contra `b2g --help`**. + +## Consecuencias + +**Lo que se gana** + +- **Foco.** El core vuelve a ser una sola frontera: la CLI/agente-native. Menos superficie que + documentar, testear y mantener; el camino de adopción es la skill (0039), no una SPA local. +- **Wheel Python puro.** Sin Node en el build: `uv build` produce el wheel sin `pnpm`, sin + `force-include`, sin jobs de frontend en CI/publish. Empaquetado más simple y reproducible. +- **Sin la tensión de concurrencia del 0019.** Desaparece el server HTTP de larga vida; el modelo + single-writer (1 archivo = 1 escritor) deja de estar tensionado por la API. +- **Frontera de producto nítida** (memoria del PO): bib2graph = motor determinista sin IA; la + experiencia visual library-centric vive en el producto separado, no en la librería. + +**Lo que cuesta** + +- **BREAKING para quien usaba `b2g gui` o instalaba `bib2graph[gui]`.** Pre-1.0, se absorbe como bump + **minor**; release-please lo corta desde el commit BREAKING (`feat!`/footer `BREAKING CHANGE`). No + hay ruta de migración: la capacidad se retira. +- **Documentación a depurar.** Este ADR limpia los docs **activos** mínimos para que el retiro sea + coherente (AGENTS, API §0.1/§0.2/`gui`, ARCHITECTURE §4.4, PRD, ROADMAP/05-gui banner). La limpieza + **profunda** —los ~53 marcadores AS-BUILT de la epic GUI repartidos por API/ARCHITECTURE/AGENTS— se + trata en [#191](https://github.com/complexluise/bib2graph/issues/191), no aquí. +- **Trabajo descartado.** G1–G5 estaban AS-BUILT. Se retira código que funcionaba. El registro + (Notas 07/08/10/12/16/17, ADR 0027/0028) queda como **historia**: el porqué de haberlo construido y + el porqué de retirarlo conviven. +- **Las propuestas 0032/0033/0034 quedan sin destino en la librería.** Colgaban de la GUI + (library-centric, tags, flujo canónico vía `service/`). No se revierten —siguen como **Propuesta** + histórica— pero su materialización ya no es de bib2graph: es del producto separado. + +## Alternativas + +- **Mantener la GUI como extra opt-in dormido** (código presente, sin promocionar). **Descartada:** el + código vivo carga mantenimiento (deps `fastapi`/`uvicorn`, toolchain Node, CI, empaquetado especial) + aunque nadie lo use, y mantiene la ambigüedad de alcance que este ADR quiere cerrar. Un extra + "dormido" sigue siendo superficie pública que el contrato declara. +- **Mover la GUI a un repo/paquete aparte ahora.** **Descartada como parte de este issue:** el foco es + **retirarla de la librería**, no relanzarla. Si el producto separado la necesita, partirá de cero con + su propio gobierno; la historia (0027/0028 + Notas) queda disponible como referencia. Extraer y + mantener un paquete espejo hoy es trabajo que nadie pidió. +- **Borrar también `service/reads.py`** (como sugería el inventario inicial, asumiéndola API-only). + **Descartada:** es **incorrecto** —el grupo `read` del CLI la usa (`list_papers`/`corpus_stats`/ + `get_paper`/`get_top`)—. Borrarla rompería `b2g read`. Las funciones de `reads.py` que **solo** usaba + la API (`get_workspace`/`list_rounds`/`get_scent`/`get_network`/`compare_rounds`) quedan como código + inerte; su poda es **opcional** y se difiere a la limpieza profunda (#191), no a este retiro. +- **No marcarlo BREAKING** (retiro silencioso). **Descartada:** retira capacidad pública + (`b2g gui`, extra `[gui]`); el contrato exige señalarlo. Pre-1.0 = minor, pero **BREAKING** explícito + en el commit para que release-please y el CHANGELOG lo registren. + + diff --git a/docs/decisiones/README.md b/docs/decisiones/README.md index 55970dd..c18535e 100644 --- a/docs/decisiones/README.md +++ b/docs/decisiones/README.md @@ -40,9 +40,12 @@ Qué se vuelve posible/fácil y qué se vuelve costoso/imposible. Trade-offs hon > Los ADR se numeran **por orden de creación**, no se reservan en general. **0027** (pivote de > posicionamiento GUI) y **0028** (arquitectura GUI/API + capa de servicios) existen ya como > *Aceptada* (firmados 2026-06-18), derivados de la [Nota 12](../Notas/12-arquitectura-gui-encuadre.md) -> (revisada 2026-06-18): **0027 gatea 0028** y enmienda el PRD §3/§5.2. Son **TARGET** — la GUI sigue -> gateada por [#34](https://github.com/complexluise/bib2graph/issues/34) (código tras el caso real -> validado por un tercero). (El workspace —prerequisito GUI— ya es **0029**, Aceptada/AS-BUILT.) +> (revisada 2026-06-18): **0027 gatea 0028** y enmienda el PRD §3/§5.2. +> **Supersedidos por [0040](0040-retiro-gui-local.md)** (2026-06-28): la GUI local se **retira de la +> librería** (fuera del foco; el core es CLI/agente-native). 0027/0028 y las Notas de diseño +> (07/08/10/12/16/17) quedan como **historia inmutable**; la capa de servicios `service/` que el 0028 +> introdujo **se conserva** (la usa el CLI). (El workspace —ex prerequisito GUI— sigue vigente como +> **0029**, Aceptada/AS-BUILT.) | ADR | Título | Estado | |-----|--------|--------| @@ -66,14 +69,14 @@ Qué se vuelve posible/fácil y qué se vuelve costoso/imposible. Trade-offs hon | [0018](0018-source-agnostico-calidad.md) | Contrato `Source` agnóstico (mínimo universal vs enriquecimiento) + reporte de calidad declarado | Aceptada | | [0019](0019-concurrencia-diferida.md) | Concurrencia diferida: limitación conocida, 1 archivo = 1 escritor | Aceptada | | [0020](0020-metodo-forrajeo-scent-filtros-reject.md) | Método de forrajeo: scent bibliométrico determinista, backward puro / forward red, filtros que marcan `rejected` | Aceptada · **enmendada** (2026-06-15): scent pasa de frecuencia de enlace a **proyectores** (acoplamiento/co-citación/centralidad); `explain_candidate` y `[llm]` **eliminados** | -| [0021](0021-cli-agente-native-contrato.md) | Contrato del CLI agente-native `b2g`: set de 12 subcomandos (incl. `accept`/`reject`/`monitor`), envelope JSON versionado, exit codes por tipo, `--store` global | Aceptada · **enmendada** (2026-06-15): `status` muestra curación como acción siempre-disponible; refleja `reseed`/`MONITORED`; fix UTF-8 · **cleanup pre-v0.3** (2026-06-16): 12° subcomando `monitor` (→ `MONITORED`), asimetría del pre-check `monitor`/`chain`; alias `LoopState` retirado | +| [0021](0021-cli-agente-native-contrato.md) | Contrato del CLI agente-native `b2g`: set de 12 subcomandos (incl. `accept`/`reject`/`monitor`), envelope JSON versionado, exit codes por tipo, `--store` global | Aceptada · **enmendada** (2026-06-15): `status` muestra curación como acción siempre-disponible; refleja `reseed`/`MONITORED`; fix UTF-8 · **cleanup pre-v0.3** (2026-06-16): 12° subcomando `monitor` (→ `MONITORED`), asimetría del pre-check `monitor`/`chain`; alias `LoopState` retirado · **enmendada por [0037](0037-superficie-cli-10-verbos-ciclo.md)** (2026-06-26): consolida la superficie 12→**10 verbos** (absorbe `monitor`/`inspect`/`networks`; `curate`/`read` noun-verb); envelope `schema="1"`/exit codes/FSM **intactos** · **enmienda `B2G_JSON`** ([#151](https://github.com/complexluise/bib2graph/issues/151), 2026-06-27): env var truthy (`1`/`true`/`yes`) activa el modo JSON en **todos** los comandos (precedencia `--json` > env; sin `--no-json`); **stdout puro enforced** en `--json`/`B2G_JSON` (envelope único en stdout, incl. error); `--json` sigue por-comando post-verbo; envelope/exit/FSM intactos | | [0022](0022-producto-sin-ia-generativa.md) | El producto no usa IA generativa; la "inteligencia" del forrajeo es estructura bibliométrica | Aceptada | | [0023](0023-capa-constants-modelos-schema.md) | Capa base de vocabulario + modelos: `constants`, `ProvenanceEvent`, schema única (`PaperRow` ⇄ `CORPUS_SCHEMA`) | Aceptada | | [0024](0024-orden-d3-columna-secuencia-duckdb.md) | Orden D3 en DuckDB vía columna de secuencia interna (`_seq`) | Aceptada · AS-BUILT (2026-06-16) | | [0025](0025-enricher-cocitacion-openalex.md) | `Enricher` opt-in sobre OpenAlex (núcleo): refs→DOI + co-citación; supersede el `[s2]` del DoD del Hito 8 | Aceptada · AS-BUILT COMPLETO (2026-06-16): 8a + 8b → Hito 8 completo | | [0026](0026-dedup-fuzzy-determinista.md) | Dedup fuzzy determinista con `rapidfuzz` (autores + keywords, función de librería); `splink` diferido a post-V1 | Aceptada · AS-BUILT (2026-06-16): Hito 7 · **supersedida en parte por [0031](0031-preprocesamiento-automatico-en-ingesta.md)** (2026-06-18): el dedup pasa de función de librería sin subcomando a **automático en la ingesta**; el algoritmo sigue vigente | -| [0027](0027-pivote-posicionamiento-gui-local.md) | Pivote de posicionamiento: GUI local opt-in para semi-técnicos (gatea 0028) | **Aceptada** (firmada 2026-06-18) — enmienda PRD §3/§5.2 (bloque fechado); GUI gateada por [#34](https://github.com/complexluise/bib2graph/issues/34) | -| [0028](0028-arquitectura-gui-api-capa-servicios.md) | Arquitectura GUI/API/frontend: capa de servicios neutral (`service/`) + CLI/API como adaptadores + empaquetado (`[gui]`, wheel con frontend) | **Aceptada** (firmada 2026-06-18) — **TARGET**, gateado por [0027](0027-pivote-posicionamiento-gui-local.md). Sube el contrato (envelope/errores/exit-code) de `cli/` a `service/`; enmienda 0021, relacionada 0010/0019/0029 | +| [0027](0027-pivote-posicionamiento-gui-local.md) | Pivote de posicionamiento: GUI local opt-in para semi-técnicos (gatea 0028) | Aceptada (firmada 2026-06-18) · **supersedida por [0040](0040-retiro-gui-local.md)** (2026-06-28): la GUI local se retira de la librería (fuera del foco) | +| [0028](0028-arquitectura-gui-api-capa-servicios.md) | Arquitectura GUI/API/frontend: capa de servicios neutral (`service/`) + CLI/API como adaptadores + empaquetado (`[gui]`, wheel con frontend) | Aceptada (firmada 2026-06-18) · **supersedida por [0040](0040-retiro-gui-local.md)** (2026-06-28): se retiran API/SPA/`[gui]`; **la capa `service/` se conserva** (la usa el CLI) | | [0029](0029-workspace-por-investigacion.md) | Workspace por investigación: carpeta autocontenida (`workspace.json` + db + redes/snapshots/exports) + resolución ambiente | **Aceptada — AS-BUILT** (2026-06-16; remanentes #32 cerrados 2026-06-17). **Enmienda BREAKING #75 (2026-06-17):** `--store` eliminado del CLI y fin del modo degenerado — la carpeta con `workspace.json` es la única unidad canónica; `.duckdb` legacy se adopta con `b2g init .`. Enmienda 0009/0019/0021; prerequisito GUI (#34) | | [0030](0030-ecuacion-declarativa-corpus-ejemplo.md) | Ecuación declarativa (`equation.yaml`, `seed --spec`) + `restore` de corpus curado (sin red) + corpus de ejemplo commiteado (`examples/valoraciones/`) + `seed --from-bib` (BibTeX) + filtro de año | **Aceptada — AS-BUILT** (9a + 9b + Ciclo 10, 2026-06-17): `restore`+`equation.yaml` cargable; `examples/valoraciones/` + gate R2; **`seed --from-bib` + `examples/bibtex/` + filtro de año real (#50 cerrado)**. Enmienda 0029; relacionada 0005/0006/0007/0016/0017/0018; prereq. Ciclo #33 → gate GUI (#34) | | [0031](0031-preprocesamiento-automatico-en-ingesta.md) | Preprocesamiento automático en la ingesta (normalize + dedup sobre el corpus completo mergeado, cross-biblioteca); `rapidfuzz` al núcleo (`[dedup]` eliminado); `thesaurus` = único paso explícito (18° subcomando, transversal); `persist_replace`/`overwrite_corpus` | **Aceptada — AS-BUILT** (2026-06-18, #88). **Supersede en parte [0026](0026-dedup-fuzzy-determinista.md)** (invocación del dedup) y la enmienda `[dedup]` de [0005](0005-dependencias-extras.md). Relacionada 0011/0017/0022/0024/0016; revisión asistida de clusters → epic GUI (#34) | @@ -81,3 +84,8 @@ Qué se vuelve posible/fácil y qué se vuelve costoso/imposible. Trade-offs hon | [0033](0033-producto-library-centric-grafo-proyeccion.md) | Producto library-centric: la vista de Biblioteca (buscar/navegar/etiquetar/curar) es la superficie primaria; el grafo es proyección downstream; BIBFRAME fuera | **Propuesta** (2026-06-18). **Refina [0027](0027-pivote-posicionamiento-gui-local.md)** (puerta de entrada de la GUI, no revierte). Relacionada 0009/0032/0034. Encuadre: Notas [16](../Notas/16-retroalimentacion-gui-mvp.md)/[18](../Notas/18-flujo-canonico-biblioteca.md) | | [0034](0034-etiquetado-tabla-tags-lateral.md) | Etiquetado en tabla LATERAL `paper_tags` (no toca `CORPUS_SCHEMA`, esquiva BUG-2); tags libres → taxonomía fase 2 (SKOS + OpenAlex topics, no BIBFRAME); fuera del `corpus_hash` | **Propuesta** (2026-06-18). Relacionada 0009/0023/0006/0033/0011/0031/0024/0017. **Deja abierta** la extensibilidad general del schema (BUG-2). Encuadre: Notas [16](../Notas/16-retroalimentacion-gui-mvp.md)/[17](../Notas/17-validacion-tercero-gate34.md) | | [0035](0035-ingesta-multipuerta-resolucion-doi.md) | Ingesta de doble puerta (online + archivo) misma cadena/corpus; resolución DOI→OpenAlex ID como servicio compartido (cierra GAP-1/GAP-2); import multi-formato (RIS/EndNote/CSV); BibTeX/archivo = primera clase | **Propuesta** (2026-06-18). **Revisa "BibTeX secundaria" de [0007](0007-openalex-backbone.md)** (en la ingesta). Relacionada 0018/0030/0032/0031. Encuadre: [Nota 17](../Notas/17-validacion-tercero-gate34.md) | +| [0036](0036-identidad-source-id-agnostica-doi-ancla.md) | Identidad de fuente agnóstica: DOI como ancla universal, `source_id` genérico, motor de extracción intercambiable (tabla lateral `external_ids` 1↔N) | **Aceptada — AS-BUILT** (0.8, 2026-06-22): rename `openalex_id`→`source_id`, inversión de `_compute_id` (`doi > source_id > tt`), desacople del núcleo, infra `external_ids`, migración de `examples/valoraciones` (#118/#119). **Enmienda 0007/0013**; refuerza 0018; relacionada 0035/0034/0015. Población de `external_ids` y selector `--source` diferidos al 2º motor (#120) | +| [0037](0037-superficie-cli-10-verbos-ciclo.md) | Superficie CLI de 0.10.0: **10 verbos agents-first** que mapean el ciclo (INIT→SEED→CHAIN→CURATE→BUILD→READ→EXPORT/SNAPSHOT, STATUS transversal); `status` como mapa (`next_best_action` + **preview por-red "qué se materializa" (e)** + **maturity-stamp del one-shot (f)**, campos aditivos); `curate`/`read` noun-verb; `monitor`→`chain --since`; aliases de retrocompat | **Aceptada** (2026-06-26). **Enmienda [0021](0021-cli-agente-native-contrato.md)** (consolida 12→10; envelope `schema="1"`/exit/FSM intactos). Relacionada 0010/0016/0029/0035/0036/0032/0033; no introduce IA (0022). Origen: Discussion [#127](https://github.com/complexluise/bib2graph/discussions/127); issues #76/#132 | +| [0038](0038-destino-verbos-huerfanos-0037.md) | Destino de los 5 verbos huérfanos que el 0037 no nombró: `gui` se mantiene fuera de los 10 (0027/0028); `enrich`→absorbido en `chain`/`build`; `restore`→`snapshot restore`; `thesaurus`→retirado; `resolve`→retirado (ruta única `seed --resolve`). Fija: ventana de deprecación cierra en **0.11.0**; `build --scope=all` default; forma de `maturity` en `docs/API.md` | **Aceptada** (2026-06-27). **Enmienda [0037](0037-superficie-cli-10-verbos-ciclo.md)** (cierra el conteo "10 verbos"); revisa el `thesaurus` de [0031](0031-preprocesamiento-automatico-en-ingesta.md); renombre de [0030](0030-ecuacion-declarativa-corpus-ejemplo.md); refuerza 0035; envelope `schema="1"`/exit/FSM intactos. Contexto: issues #149/#34 | +| [0039](0039-skill-comando-meta-distribucion.md) | Distribución de la skill de Claude Code end-user: `b2g skill add [--user\|--project] [--force]` (comando **meta**; **fuera** del set de 10, no compite con `status`); skill vendoreada en el wheel (`src/bib2graph/skill/`, fuente commiteada incluida por `packages` — **no** `force-include`) → **version-lock skill==cli**; envelope `--json` sin FSM, sin workspace. **Descarta** el extra `[skill]` (no escribe en `~/.claude/skills/`) y plugin+marketplace como primario (desacopla versionado; ruta futura) | **Aceptada** (2026-06-28). **Enmienda [0038](0038-destino-verbos-huerfanos-0037.md)** (excepción meta); relacionada 0037/0028/0010/0021/0029; no introduce IA (0022). Epic [#188](https://github.com/complexluise/bib2graph/issues/188). **Nota (2026-06-28):** el [0040](0040-retiro-gui-local.md) retira `gui`, así que `skill` queda como **única** excepción meta (conteo "10 + skill") | +| [0040](0040-retiro-gui-local.md) | Retirar la GUI local de la librería (BREAKING): se eliminan `b2g gui`, la API local FastAPI (`api/`), la SPA `frontend/`, el extra `[gui]` y el vendoreo del frontend en el wheel; el core es CLI/agente-native sobre la biblioteca viva. **La capa `service/` (incl. `reads.py`) se conserva** (la usa el CLI). Conteo: "10 verbos + `skill`" | **Aceptada** (2026-06-28). **Supersede [0027](0027-pivote-posicionamiento-gui-local.md)/[0028](0028-arquitectura-gui-api-capa-servicios.md)**; enmienda [0038](0038-destino-verbos-huerfanos-0037.md) (retira la excepción `gui`); relacionada 0010/0021/0037/0039/0005/0019/0032/0033/0034; no introduce IA (0022). Issue [#190](https://github.com/complexluise/bib2graph/issues/190); limpieza profunda de docs en [#191](https://github.com/complexluise/bib2graph/issues/191) | diff --git a/docs/features/C-curar.feature b/docs/features/C-curar.feature index 75ec024..5e0b67f 100644 --- a/docs/features/C-curar.feature +++ b/docs/features/C-curar.feature @@ -84,7 +84,7 @@ Característica: Curar el corpus con filtros PRISMA y biblioteca viva # --- C4 (escala) · Curación en lote vía CSV --- Escenario: C4 — Volcar candidatos a CSV para revisión offline - Cuando ejecuto "b2g curate --dump --json" + Cuando ejecuto "b2g curate dump --json" Entonces el exit code es 0 Y se escribe "exports/curacion.csv" con las 16 columnas estables Y "data.papers_exported" es un entero >= 0 @@ -92,13 +92,13 @@ Característica: Curar el corpus con filtros PRISMA y biblioteca viva # Solo "decision" y "note" son editables por el humano. Escenario: C4 — Volcar semillas o todo el corpus con --scope - Cuando ejecuto "b2g curate --dump --scope all --json" + Cuando ejecuto "b2g curate dump --scope all --json" Entonces el exit code es 0 Y "data.papers_exported" cubre todo el corpus (candidates + seeds + accepted + rejected) Escenario: C4 — Reimportar las decisiones del CSV en lote (idempotente) Dado un "exports/curacion.csv" con la columna "decision" editada - Cuando ejecuto "b2g curate --from-csv exports/curacion.csv --json" + Cuando ejecuto "b2g curate apply exports/curacion.csv --json" Entonces el exit code es 0 Y "data.accepted_count" cuenta papers efectivamente marcados como accepted Y "data.rejected_count" cuenta papers efectivamente marcados como rejected @@ -106,9 +106,9 @@ Característica: Curar el corpus con filtros PRISMA y biblioteca viva Y "data.not_found_count" cuenta IDs del CSV ausentes del corpus (huérfanos, sin abortar) # Idempotente: reimportar el mismo CSV deja el mismo corpus_hash (note se ignora al importar). - Escenario: C4 — from-csv con una decision inválida falla accionable + Escenario: C4 — apply con una decision inválida falla accionable Dado un CSV con un valor de "decision" fuera de {accepted, rejected, undecided} - Cuando ejecuto "b2g curate --from-csv exports/curacion.csv --json" + Cuando ejecuto "b2g curate apply exports/curacion.csv --json" Entonces el exit code es 2 Y "error.code" indica un error de datos (DataError) diff --git a/docs/features/README.md b/docs/features/README.md index 4787407..c713543 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -34,7 +34,7 @@ vuelven **ejecutables con `pytest-bdd`** apuntando a este mismo directorio, sin | C1 dedup/normalización autores+inst. | **`@pendiente`** | **No hay subcomando CLI**: `normalize`/`deduplicate_keywords` son API de librería (`preprocessors/`), no `b2g`. Instituciones diferidas (ROADMAP C1). El CLI no expone preprocesamiento | | C2 thesaurus multilingüe | **`@pendiente`** | **No hay subcomando CLI**: `apply_thesaurus` es API de librería (`preprocessors/thesaurus.py`), no `b2g`. Determinista, sin fallback LLM (ADR 0022/0011) | | C3 filtros incl/excl con conteo | verde | `filter` con `data.steps[].count_before/count_after/excluded` (flujo PRISMA) | -| C4 aceptar/rechazar + biblioteca viva | verde | `accept`/`reject --ids`, `curate --dump`/`--from-csv`; persiste y crece entre corridas | +| C4 aceptar/rechazar + biblioteca viva | verde | `curate {dump,apply,accept,reject,filter}` (grupo noun-verb #155); `accept`/`reject --ids` sueltos vivos (alias deprecados); persiste y crece entre corridas | | D1 cinco proyecciones | verde (parcial) | `build` da 4 redes; la 5ª (cocitación) requiere `enrich` previo (cited_by_id). Instituciones salen si hay `institutions_id` | | D2 métricas y comunidades | verde | `metrics.json` (density, etc.) + comunidades Louvain; `clusters.csv` en redes de paper | | D3 asortatividad + composición + proxy | **`@pendiente`** | **No hay camino CLI**: `assortativity()`/`community_composition()` existen como funciones puras en `networks/analyzer.py` y se re-exportan en `networks/__init__.py`, pero `facade._build_artifact` fija `assortativity=None` y **no consume** `NetworkSpec.assortativity_attribute`. Es API de librería, no de CLI | @@ -62,9 +62,9 @@ uv run b2g chain --direction both --depth 1 --max-citing 25 --json # C — curar uv run b2g filter --year-gte 2010 --language en --json -uv run b2g curate --dump --json # escribe exports/curacion.csv (solo candidatos) +uv run b2g curate dump --json # escribe exports/curacion.csv (solo candidatos) # (editar la columna decision en exports/curacion.csv) -uv run b2g curate --from-csv exports/curacion.csv --json +uv run b2g curate apply exports/curacion.csv --json uv run b2g accept --ids oa:abc123 --json # D — redes diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index fbf6376..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - bib2graph — Observatorio bibliométrico - - - - - - -
- - - diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 8e254a3..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "bib2graph-gui", - "version": "0.1.0", - "private": true, - "packageManager": "pnpm@9.15.9", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview", - "lint": "tsc --noEmit", - "test:run": "vitest run", - "test": "vitest" - }, - "dependencies": { - "@tanstack/react-query": "^5.62.0", - "cytoscape": "^3.30.2", - "cytoscape-fcose": "^2.2.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", - "@types/cytoscape": "^3.21.8", - "@types/node": "^22.10.2", - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "jsdom": "^25.0.1", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "typescript": "^5.7.2", - "vite": "^6.0.6", - "vitest": "^2.1.8" - } -} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index 6388fca..0000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,2966 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tanstack/react-query': - specifier: ^5.62.0 - version: 5.101.0(react@18.3.1) - cytoscape: - specifier: ^3.30.2 - version: 3.34.0 - cytoscape-fcose: - specifier: ^2.2.0 - version: 2.2.0(cytoscape@3.34.0) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - zustand: - specifier: ^5.0.2 - version: 5.0.14(@types/react@18.3.31)(react@18.3.1) - devDependencies: - '@testing-library/jest-dom': - specifier: ^6.6.3 - version: 6.9.1 - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/cytoscape': - specifier: ^3.21.8 - version: 3.31.0 - '@types/node': - specifier: ^22.10.2 - version: 22.19.21 - '@types/react': - specifier: ^18.3.17 - version: 18.3.31 - '@types/react-dom': - specifier: ^18.3.5 - version: 18.3.7(@types/react@18.3.31) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(@types/node@22.19.21)(jiti@1.21.7)) - autoprefixer: - specifier: ^10.4.20 - version: 10.5.0(postcss@8.5.15) - jsdom: - specifier: ^25.0.1 - version: 25.0.1 - postcss: - specifier: ^8.4.49 - version: 8.5.15 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.19 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vite: - specifier: ^6.0.6 - version: 6.4.3(@types/node@22.19.21)(jiti@1.21.7) - vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.21)(jsdom@25.0.1) - -packages: - - '@adobe/css-tools@4.5.0': - resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.62.0': - resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.0': - resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.0': - resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.0': - resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.0': - resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.0': - resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': - resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.62.0': - resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.62.0': - resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.62.0': - resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.62.0': - resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.62.0': - resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.62.0': - resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.62.0': - resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.62.0': - resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.62.0': - resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.62.0': - resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.62.0': - resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.62.0': - resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.62.0': - resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.0': - resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.0': - resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.0': - resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.0': - resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.0': - resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} - cpu: [x64] - os: [win32] - - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} - - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} - peerDependencies: - react: ^18 || ^19 - - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/cytoscape@3.31.0': - resolution: {integrity: sha512-EXHOHxqQjGxLDEh5cP4te6J0bi7LbCzmZkzsR6f703igUac8UGMdEohMyU3GHAayCTZrLQOMnaE/lqB2Ekh8Ww==} - deprecated: This is a stub types definition. cytoscape provides its own type definitions, so you do not need this installed. - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/node@22.19.21': - resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - - '@types/react@18.3.31': - resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - autoprefixer@10.5.0: - resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - baseline-browser-mapping@2.10.38: - resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} - engines: {node: '>=6.0.0'} - hasBin: true - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cose-base@2.2.0: - resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - cytoscape-fcose@2.2.0: - resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} - peerDependencies: - cytoscape: ^3.2.0 - - cytoscape@3.34.0: - resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} - engines: {node: '>=0.10'} - - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - electron-to-chromium@1.5.375: - resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsdom@25.0.1: - resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^2.11.2 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - layout-base@2.0.1: - resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.13: - resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.48: - resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} - engines: {node: '>=18'} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - nwsapi@2.2.24: - resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.4: - resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.62.0: - resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} - engines: {node: '>=14.0.0'} - - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} - hasBin: true - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} - engines: {node: '>=16'} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - zustand@5.0.14: - resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - -snapshots: - - '@adobe/css-tools@4.5.0': {} - - '@alloc/quick-lru@5.2.0': {} - - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/runtime@7.29.7': {} - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@csstools/color-helpers@5.1.0': {} - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-tokenizer@3.0.4': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.62.0': - optional: true - - '@rollup/rollup-android-arm64@4.62.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.0': - optional: true - - '@rollup/rollup-darwin-x64@4.62.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.0': - optional: true - - '@tanstack/query-core@5.101.0': {} - - '@tanstack/react-query@5.101.0(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.101.0 - react: 18.3.1 - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.5.0 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/cytoscape@3.31.0': - dependencies: - cytoscape: 3.34.0 - - '@types/estree@1.0.9': {} - - '@types/node@22.19.21': - dependencies: - undici-types: 6.21.0 - - '@types/prop-types@15.7.15': {} - - '@types/react-dom@18.3.7(@types/react@18.3.31)': - dependencies: - '@types/react': 18.3.31 - - '@types/react@18.3.31': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.21)(jiti@1.21.7))': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.19.21)(jiti@1.21.7) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 - - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.21))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.21) - - '@vitest/pretty-format@2.1.9': - dependencies: - tinyrainbow: 1.2.0 - - '@vitest/runner@2.1.9': - dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 - - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - - '@vitest/utils@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 - - agent-base@7.1.4: {} - - ansi-regex@5.0.1: {} - - ansi-styles@5.2.0: {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - arg@5.0.2: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - - assertion-error@2.0.1: {} - - asynckit@0.4.0: {} - - autoprefixer@10.5.0(postcss@8.5.15): - dependencies: - browserslist: 4.28.2 - caniuse-lite: 1.0.30001799 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.15 - postcss-value-parser: 4.2.0 - - baseline-browser-mapping@2.10.38: {} - - binary-extensions@2.3.0: {} - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.375 - node-releases: 2.0.48 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - camelcase-css@2.0.1: {} - - caniuse-lite@1.0.30001799: {} - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.3: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@4.1.1: {} - - convert-source-map@2.0.0: {} - - cose-base@2.2.0: - dependencies: - layout-base: 2.0.1 - - css.escape@1.5.1: {} - - cssesc@3.0.0: {} - - cssstyle@4.6.0: - dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 - - csstype@3.2.3: {} - - cytoscape-fcose@2.2.0(cytoscape@3.34.0): - dependencies: - cose-base: 2.2.0 - cytoscape: 3.34.0 - - cytoscape@3.34.0: {} - - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - deep-eql@5.0.2: {} - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - didyoumean@1.2.2: {} - - dlv@1.1.3: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - electron-to-chromium@1.5.375: {} - - entities@6.0.1: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - expect-type@1.3.0: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - - fraction.js@5.3.4: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - html-encoding-sniffer@4.0.0: - dependencies: - whatwg-encoding: 3.1.1 - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - indent-string@4.0.0: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-potential-custom-element-name@1.0.1: {} - - jiti@1.21.7: {} - - js-tokens@4.0.0: {} - - jsdom@25.0.1: - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - form-data: 4.0.6 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.24 - parse5: 7.3.0 - rrweb-cssom: 0.7.1 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.21.0 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - layout-base@2.0.1: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - loupe@3.2.1: {} - - lru-cache@10.4.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - min-indent@1.0.1: {} - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.13: {} - - node-releases@2.0.48: {} - - normalize-path@3.0.0: {} - - nwsapi@2.2.24: {} - - object-assign@4.1.1: {} - - object-hash@3.0.0: {} - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - path-parse@1.0.7: {} - - pathe@1.1.2: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pify@2.3.0: {} - - pirates@4.0.7: {} - - postcss-import@15.1.0(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.12 - - postcss-js@4.1.0(postcss@8.5.15): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.15 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.15 - - postcss-nested@6.2.0(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-selector-parser: 6.1.4 - - postcss-selector-parser@6.1.4: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.15: - dependencies: - nanoid: 3.3.13 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-is@17.0.2: {} - - react-refresh@0.17.0: {} - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - rollup@4.62.0: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.0 - '@rollup/rollup-android-arm64': 4.62.0 - '@rollup/rollup-darwin-arm64': 4.62.0 - '@rollup/rollup-darwin-x64': 4.62.0 - '@rollup/rollup-freebsd-arm64': 4.62.0 - '@rollup/rollup-freebsd-x64': 4.62.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 - '@rollup/rollup-linux-arm-musleabihf': 4.62.0 - '@rollup/rollup-linux-arm64-gnu': 4.62.0 - '@rollup/rollup-linux-arm64-musl': 4.62.0 - '@rollup/rollup-linux-loong64-gnu': 4.62.0 - '@rollup/rollup-linux-loong64-musl': 4.62.0 - '@rollup/rollup-linux-ppc64-gnu': 4.62.0 - '@rollup/rollup-linux-ppc64-musl': 4.62.0 - '@rollup/rollup-linux-riscv64-gnu': 4.62.0 - '@rollup/rollup-linux-riscv64-musl': 4.62.0 - '@rollup/rollup-linux-s390x-gnu': 4.62.0 - '@rollup/rollup-linux-x64-gnu': 4.62.0 - '@rollup/rollup-linux-x64-musl': 4.62.0 - '@rollup/rollup-openbsd-x64': 4.62.0 - '@rollup/rollup-openharmony-arm64': 4.62.0 - '@rollup/rollup-win32-arm64-msvc': 4.62.0 - '@rollup/rollup-win32-ia32-msvc': 4.62.0 - '@rollup/rollup-win32-x64-gnu': 4.62.0 - '@rollup/rollup-win32-x64-msvc': 4.62.0 - fsevents: 2.3.3 - - rrweb-cssom@0.7.1: {} - - rrweb-cssom@0.8.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safer-buffer@2.1.2: {} - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - semver@6.3.1: {} - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.17 - ts-interface-checker: 0.1.13 - - supports-preserve-symlinks-flag@1.0.0: {} - - symbol-tree@3.2.4: {} - - tailwindcss@3.4.19: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.15 - postcss-import: 15.1.0(postcss@8.5.15) - postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) - postcss-nested: 6.2.0(postcss@8.5.15) - postcss-selector-parser: 6.1.4 - resolve: 1.22.12 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} - - tldts-core@6.1.86: {} - - tldts@6.1.86: - dependencies: - tldts-core: 6.1.86 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tough-cookie@5.1.2: - dependencies: - tldts: 6.1.86 - - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - - ts-interface-checker@0.1.13: {} - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - util-deprecate@1.0.2: {} - - vite-node@2.1.9(@types/node@22.19.21): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@22.19.21) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@22.19.21): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.15 - rollup: 4.62.0 - optionalDependencies: - '@types/node': 22.19.21 - fsevents: 2.3.3 - - vite@6.4.3(@types/node@22.19.21)(jiti@1.21.7): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.62.0 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.19.21 - fsevents: 2.3.3 - jiti: 1.21.7 - - vitest@2.1.9(@types/node@22.19.21)(jsdom@25.0.1): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.21)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.19.21) - vite-node: 2.1.9(@types/node@22.19.21) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.21 - jsdom: 25.0.1 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@7.0.0: {} - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - ws@8.21.0: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - yallist@3.1.1: {} - - zustand@5.0.14(@types/react@18.3.31)(react@18.3.1): - optionalDependencies: - '@types/react': 18.3.31 - react: 18.3.1 diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2aa7205..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index d9e229c..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Header } from "@/components/workspace/Header"; -import { RoundsColumn } from "@/components/rounds/RoundsColumn"; -import { GraphCanvas } from "@/components/graph/GraphCanvas"; -import { CandidatePanel } from "@/components/candidate/CandidatePanel"; -import { RoundDiffPanel } from "@/components/diff/RoundDiffPanel"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - -/** - * App — Layout de 3 columnas: RONDAS · GRAFO · CANDIDATO. - * - * El RoundDiffPanel se superpone sobre el área del grafo cuando está activo. - * Una sola pantalla sin modales que rompan el flujo (ADR 0027 §diseño). - */ -export function App() { - return ( - -
- {/* Header: nombre del workspace, estado, staleness */} -
- - {/* Layout de 3 columnas */} -
- {/* Columna izquierda: RONDAS */} -
- -
- - {/* Centro: GRAFO (el héroe) */} - - - {/* Columna derecha: CANDIDATO */} -
- -
- - {/* Overlay del diff (sobre el grafo) */} - -
-
-
- ); -} diff --git a/frontend/src/__tests__/client.test.ts b/frontend/src/__tests__/client.test.ts deleted file mode 100644 index dade385..0000000 --- a/frontend/src/__tests__/client.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Tests del cliente HTTP — contrato costura cliente↔API. - * - * Cobertura (docs/ROADMAP/05-gui.md §Tests G4): - * 1. apiFetch des-envuelve data de un envelope schema="1" válido. - * 2. apiFetch lanza ApiError con code STRING ante error !== null. - * 3. apiFetch manda el header Authorization: Bearer . - * 4. apiFetch lanza AuthError ante 401. - */ - -import { describe, it, expect, vi, afterEach } from "vitest"; -import { ApiError, AuthError, apiFetch } from "@/client/http"; - -// --------------------------------------------------------------------------- -// Mock del token -// --------------------------------------------------------------------------- - -vi.mock("@/client/token", () => ({ - getToken: () => "test-token-12345", -})); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function mockFetch(response: Response) { - return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(response); -} - -function envelopeResponse(data: T, status = 200): Response { - return new Response( - JSON.stringify({ - schema: "1", - ok: true, - command: "test", - exit_code: 0, - data, - warnings: [], - error: null, - }), - { status, headers: { "Content-Type": "application/json" } } - ); -} - -function errorEnvelopeResponse( - code: string, - message: string, - exitCode = 2 -): Response { - return new Response( - JSON.stringify({ - schema: "1", - ok: false, - command: "test", - exit_code: exitCode, - data: {}, - warnings: [], - error: { code, message }, - }), - { status: 422, headers: { "Content-Type": "application/json" } } - ); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("apiFetch", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("des-envuelve data de un envelope schema='1' válido", async () => { - const payload = { name: "mi-workspace", total_papers: 42 }; - mockFetch(envelopeResponse(payload)); - - const result = await apiFetch("/api/workspace"); - - expect(result).toEqual(payload); - expect(result.total_papers).toBe(42); - }); - - it("lanza ApiError con code STRING cuando error !== null", async () => { - mockFetch(errorEnvelopeResponse("DATA_ERROR", "El paper no existe", 2)); - - await expect(apiFetch("/api/paper/no-existe")).rejects.toThrow(ApiError); - - mockFetch(errorEnvelopeResponse("INTERNAL_ERROR", "Bug interno", 0)); - let caught: unknown = null; - try { - await apiFetch("/api/test"); - } catch (e) { - caught = e; - } - expect(caught).toBeInstanceOf(ApiError); - const apiErr = caught as ApiError; - // code es STRING, no number - expect(typeof apiErr.code).toBe("string"); - expect(apiErr.code).toBe("INTERNAL_ERROR"); - }); - - it("manda el header Authorization: Bearer ", async () => { - const payload = { rounds: [] }; - const fetchSpy = mockFetch(envelopeResponse(payload)); - - await apiFetch("/api/rounds"); - - expect(fetchSpy).toHaveBeenCalledOnce(); - const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - const headers = init?.headers as Record; - expect(headers["Authorization"]).toBe("Bearer test-token-12345"); - }); - - it("lanza AuthError ante respuesta 401", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response(null, { status: 401 }) - ); - - await expect(apiFetch("/api/workspace")).rejects.toThrow(AuthError); - }); - - it("lanza Error si el envelope no es schema='1'", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( - new Response( - JSON.stringify({ schema: "2", ok: true, data: {}, error: null }), - { status: 200, headers: { "Content-Type": "application/json" } } - ) - ); - - await expect(apiFetch("/api/workspace")).rejects.toThrow( - /schema.*"2".*esperado "1"/ - ); - }); -}); - -describe("ApiError", () => { - it("preserva code como string y message", () => { - const err = new ApiError("DATA_ERROR", "no existe", 2); - expect(err.code).toBe("DATA_ERROR"); - expect(typeof err.code).toBe("string"); - expect(err.message).toBe("no existe"); - expect(err.exitCode).toBe(2); - expect(err).toBeInstanceOf(Error); - }); - - it("code nunca es number (regresión vs mock antiguo)", () => { - // El mock antiguo tipaba code: number — esto fue el drift identificado. - const err = new ApiError("USAGE_ERROR", "uso incorrecto", 1); - expect(typeof err.code).toBe("string"); - // @ts-expect-error — intento de asignar number a string: el tipo previene esto - const _coercion: number = err.code; - void _coercion; // silenciar unused - }); -}); diff --git a/frontend/src/__tests__/components.test.tsx b/frontend/src/__tests__/components.test.tsx deleted file mode 100644 index 25c78f0..0000000 --- a/frontend/src/__tests__/components.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Tests de smoke de render para los componentes clave. - * - * Cobertura (docs/ROADMAP/05-gui.md §Tests G4): - * 1. GraphCanvas monta con datos mínimos de NetworkData. - * 2. RoundDiffPanel renderiza added/removed de un RoundDiff conocido. - * - * No se testea pixel-perfect ni Cytoscape real (requeriría jsdom + canvas). - * GraphCanvas se testea sin el canvas de Cytoscape (stub del import dinámico). - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { NetworkData, RoundDiff } from "@/types/api"; - -// --------------------------------------------------------------------------- -// Mocks — deben ir antes de los imports de los componentes -// --------------------------------------------------------------------------- - -// Mock del store con los valores que necesitan los tests -const mockStoreState = { - activeKind: "bibliographic_coupling" as const, - selectPaper: vi.fn(), - diffVisible: true, - diffRoundA: "snap-2024-01" as string | null, - diffRoundB: "live" as string | null, - closeDiff: vi.fn(), -}; - -vi.mock("@/store", () => ({ - useAppStore: (selector: (s: typeof mockStoreState) => unknown) => - selector(mockStoreState), -})); - -// Mock del cliente API con vi.fn() para poder configurar resoluciones por test -const mockFetchNetwork = vi.fn<() => Promise>(); -const mockFetchCompare = vi.fn<() => Promise>(); - -vi.mock("@/client/api", () => ({ - fetchNetwork: () => mockFetchNetwork(), - fetchCompare: () => mockFetchCompare(), -})); - -// Stub de cytoscape — evita que intente crear un canvas real en jsdom -vi.mock("cytoscape", () => ({ - default: () => ({ - nodes: () => ({ forEach: vi.fn() }), - edges: () => ({ forEach: vi.fn() }), - on: vi.fn(), - destroy: vi.fn(), - }), -})); - -vi.mock("cytoscape-fcose", () => ({ default: vi.fn() })); - -// --------------------------------------------------------------------------- -// Datos mock -// --------------------------------------------------------------------------- - -const MOCK_NETWORK: NetworkData = { - nodes: [ - { - id: "P1", - label: "Paper de prueba", - degree_centrality: 0.8, - community: 0, - year: 2020, - is_seed: true, - curation_status: "accepted", - }, - { - id: "P2", - label: "Otro paper", - degree_centrality: 0.3, - community: 1, - year: 2021, - is_seed: false, - curation_status: "candidate", - }, - ], - edges: [{ source: "P1", target: "P2", weight: 2 }], - metrics: { - n_nodes: 2, - n_edges: 1, - density: 1.0, - num_components: 1, - avg_clustering: 0.0, - n_communities: 2, - }, -}; - -const MOCK_DIFF: RoundDiff = { - round_a: "snap-2024-01", - round_b: "live", - added_paper_ids: ["P3", "P4"], - removed_paper_ids: ["P0"], - mutated_hubs: [], - metrics_change: [{ metric: "n_papers", before: 10, after: 12 }], -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeQueryClient() { - return new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); -} - -function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -// --------------------------------------------------------------------------- -// Test 1: GraphCanvas monta con datos mock de NetworkData -// --------------------------------------------------------------------------- - -describe("GraphCanvas", () => { - beforeEach(() => { - mockFetchNetwork.mockResolvedValue(MOCK_NETWORK); - }); - - it("monta sin errores con datos mínimos de NetworkData", async () => { - const { GraphCanvas } = await import("@/components/graph/GraphCanvas"); - - const { container } = render( - - - - ); - - // El componente monta sin lanzar errores - expect(container).toBeDefined(); - // El main wrapper debe estar presente - const main = container.querySelector("main"); - expect(main).not.toBeNull(); - }); - - it("muestra el hint de interacción del grafo", async () => { - const { GraphCanvas } = await import("@/components/graph/GraphCanvas"); - - render( - - - - ); - - // El texto de ayuda del scroll/drag debe estar en el DOM - expect(screen.getByText(/scroll para zoom/i)).toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// Test 2: RoundDiffPanel renderiza added/removed de un RoundDiff conocido -// --------------------------------------------------------------------------- - -describe("RoundDiffPanel", () => { - beforeEach(() => { - mockFetchCompare.mockResolvedValue(MOCK_DIFF); - // Asegurar que diffVisible=true y los rounds están configurados - mockStoreState.diffVisible = true; - mockStoreState.diffRoundA = "snap-2024-01"; - mockStoreState.diffRoundB = "live"; - }); - - it("renderiza el panel cuando diffVisible=true con roundA y roundB", async () => { - const { RoundDiffPanel } = await import( - "@/components/diff/RoundDiffPanel" - ); - - render( - - - - ); - - // El panel debe estar visible - expect(screen.getByRole("complementary")).toBeDefined(); - expect(screen.getByText("Diff de rondas")).toBeDefined(); - }); - - it("muestra los ids de las rondas A y B en el header", async () => { - const { RoundDiffPanel } = await import( - "@/components/diff/RoundDiffPanel" - ); - - render( - - - - ); - - // Buscar los ids de rondas en el panel - expect(screen.getByText(/snap-2024-01/)).toBeDefined(); - expect(screen.getByText(/live/)).toBeDefined(); - }); - - it("muestra los added_paper_ids cuando la query resuelve", async () => { - const { RoundDiffPanel } = await import( - "@/components/diff/RoundDiffPanel" - ); - - const { findByText } = render( - - - - ); - - // Esperar que la query async resuelva - const p3El = await findByText(/P3/); - expect(p3El).toBeDefined(); - - const p4El = await findByText(/P4/); - expect(p4El).toBeDefined(); - }); - - it("muestra los removed_paper_ids cuando la query resuelve", async () => { - const { RoundDiffPanel } = await import( - "@/components/diff/RoundDiffPanel" - ); - - const { findByText } = render( - - - - ); - - const p0El = await findByText(/P0/); - expect(p0El).toBeDefined(); - }); - - it("no renderiza nada cuando diffVisible=false", async () => { - mockStoreState.diffVisible = false; - - const { RoundDiffPanel } = await import( - "@/components/diff/RoundDiffPanel" - ); - - const { container } = render( - - - - ); - - // Sin contenido visible - expect(container.firstChild).toBeNull(); - }); -}); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts deleted file mode 100644 index d0de870..0000000 --- a/frontend/src/__tests__/setup.ts +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom"; diff --git a/frontend/src/client/api.ts b/frontend/src/client/api.ts deleted file mode 100644 index ba79c40..0000000 --- a/frontend/src/client/api.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * client/api.ts — Las 7 llamadas tipadas (1:1 con los endpoints de G3). - * - * Cada función refleja un endpoint real de la API (API.md §0.2). - * Los tipos de retorno espejant los DTOs de src/types/api.ts. - */ - -import type { - CurateRequest, - CurateResult, - NetworkData, - NetworkKind, - Paper, - RoundDiff, - RoundsData, - Scent, - WorkspaceState, -} from "@/types/api"; -import { apiFetch } from "./http"; - -// --------------------------------------------------------------------------- -// Lecturas (GET) -// --------------------------------------------------------------------------- - -/** GET /api/workspace — estado del workspace activo. */ -export async function fetchWorkspace(): Promise { - return apiFetch("/api/workspace"); -} - -/** GET /api/rounds — snapshots sellados + entrada "live". */ -export async function fetchRounds(): Promise { - return apiFetch("/api/rounds"); -} - -/** GET /api/paper/{id} — fila del corpus por id. */ -export async function fetchPaper(paperId: string): Promise { - return apiFetch(`/api/paper/${encodeURIComponent(paperId)}`); -} - -/** GET /api/paper/{id}/scent — score de acoplamiento + vecinos. */ -export async function fetchScent(paperId: string): Promise { - return apiFetch(`/api/paper/${encodeURIComponent(paperId)}/scent`); -} - -/** GET /api/network/{kind} — red de la ronda viva por kind. */ -export async function fetchNetwork(kind: NetworkKind): Promise { - return apiFetch(`/api/network/${kind}`); -} - -/** - * GET /api/compare?a=&b= — diff entre dos snapshots. - * - * @param roundA - id del snapshot A (o "live"). - * @param roundB - id del snapshot B (o "live"). - */ -export async function fetchCompare( - roundA: string, - roundB: string -): Promise { - const params = new URLSearchParams({ a: roundA, b: roundB }); - return apiFetch(`/api/compare?${params.toString()}`); -} - -// --------------------------------------------------------------------------- -// Escritura (POST) -// --------------------------------------------------------------------------- - -/** - * POST /api/paper/{id}/curate — acepta o rechaza un paper. - * - * @param paperId - id del paper a curar. - * @param decision - "accepted" | "rejected". - */ -export async function curatePaper( - paperId: string, - decision: CurateRequest["decision"] -): Promise { - return apiFetch( - `/api/paper/${encodeURIComponent(paperId)}/curate`, - { - method: "POST", - body: JSON.stringify({ decision } satisfies CurateRequest), - } - ); -} diff --git a/frontend/src/client/http.ts b/frontend/src/client/http.ts deleted file mode 100644 index 3774af4..0000000 --- a/frontend/src/client/http.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * client/http.ts — Capa base de fetch para la API bib2graph. - * - * Responsabilidades: - * - Añade Authorization: Bearer a cada request. - * - Valida que el envelope sea schema="1". - * - Des-envuelve data del envelope en el camino feliz. - * - Lanza ApiError con code STRING ante error !== null. - * - Maneja 401 (sin token / token inválido) aparte del envelope estándar. - */ - -import type { Envelope } from "@/types/api"; -import { getToken } from "./token"; - -// --------------------------------------------------------------------------- -// ApiError — error.code es STRING (no number) -// --------------------------------------------------------------------------- - -export class ApiError extends Error { - /** Código string del contrato: "DATA_ERROR" | "USAGE_ERROR" | etc. */ - readonly code: string; - readonly exitCode: number; - - constructor(code: string, message: string, exitCode = 0) { - super(message); - this.name = "ApiError"; - this.code = code; - this.exitCode = exitCode; - } -} - -export class AuthError extends Error { - constructor() { - super("No autorizado: token faltante o inválido (401)"); - this.name = "AuthError"; - } -} - -// --------------------------------------------------------------------------- -// apiFetch — función base -// --------------------------------------------------------------------------- - -/** Base URL de la API — misma origen en producción, proxy en dev. */ -const BASE_URL = ""; - -/** - * Realiza un fetch a la API, añade el Bearer y des-envuelve el envelope. - * - * @param path - Ruta relativa (p. ej. "/api/workspace"). - * @param init - Opciones de fetch estándar (method, body, etc.). - * @returns El campo `data` del envelope tipado como T. - * @throws AuthError si el servidor responde 401. - * @throws ApiError si el envelope trae error !== null. - * @throws Error si el envelope no es schema="1" o hay error de red. - */ -export async function apiFetch( - path: string, - init: RequestInit = {} -): Promise { - const token = getToken(); - - const headers: Record = { - "Content-Type": "application/json", - ...(init.headers as Record | undefined), - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const response = await fetch(`${BASE_URL}${path}`, { - ...init, - headers, - }); - - // 401 es del adaptador HTTP (sin token / token inválido), NO del envelope - if (response.status === 401) { - throw new AuthError(); - } - - const envelope = (await response.json()) as Envelope; - - // Validar que sea el contrato esperado - if (envelope.schema !== "1") { - throw new Error( - `Envelope inesperado: schema="${envelope.schema}" (esperado "1")` - ); - } - - // error !== null → lanzar ApiError con code STRING - if (envelope.error !== null) { - throw new ApiError( - envelope.error.code, - envelope.error.message, - envelope.exit_code - ); - } - - return envelope.data; -} diff --git a/frontend/src/client/token.ts b/frontend/src/client/token.ts deleted file mode 100644 index f75b7f1..0000000 --- a/frontend/src/client/token.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * client/token.ts — Lee el token Bearer inyectado por b2g gui. - * - * Mecanismo (B-G4-3): b2g gui reemplaza el placeholder "__B2G_TOKEN__" en el - * index.html antes de servirlo, tanto en el script inline (window.__B2G_TOKEN__) - * como en la meta tag . - * - * En modo desarrollo Vite (pnpm dev), el token se pasa como variable de entorno - * VITE_B2G_TOKEN o se deja null (la API devuelve 401 y el banner lo indica). - */ - -declare global { - interface Window { - __B2G_TOKEN__?: string; - } -} - -/** El placeholder literal que b2g gui reemplaza en el HTML. */ -const PLACEHOLDER = "__B2G_TOKEN__"; - -/** - * Devuelve el token efímero generado por b2g gui, o null si no está disponible. - * - * Prioridad: - * 1. window.__B2G_TOKEN__ (inyectado en el script inline del index.html) - * 2. (fallback si la inyección fue por meta) - * 3. import.meta.env.VITE_B2G_TOKEN (desarrollo local con Vite) - * 4. null (sin token — la API devuelve 401) - */ -export function getToken(): string | null { - // 1. Script inline (mecanismo principal) - const windowToken = window.__B2G_TOKEN__; - if (windowToken && windowToken !== PLACEHOLDER) { - return windowToken; - } - - // 2. Meta tag - const meta = document.querySelector( - 'meta[name="b2g-token"]' - ); - if (meta?.content && meta.content !== PLACEHOLDER) { - return meta.content; - } - - // 3. Variable de entorno de Vite (solo en desarrollo) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const envToken = (import.meta as { env?: Record }).env?.["VITE_B2G_TOKEN"]; - if (envToken) { - return envToken; - } - - return null; -} diff --git a/frontend/src/components/candidate/CandidatePanel.tsx b/frontend/src/components/candidate/CandidatePanel.tsx deleted file mode 100644 index df50e12..0000000 --- a/frontend/src/components/candidate/CandidatePanel.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { fetchPaper, fetchScent, curatePaper } from "@/client/api"; -import { useAppStore } from "@/store"; -import { Spinner } from "@/components/ui/Spinner"; -import { ErrorBanner } from "@/components/ui/ErrorBanner"; -import { Button } from "@/components/ui/Button"; -import { CurationBadge } from "@/components/ui/Badge"; -import { truncate } from "@/lib/format"; -import type { Paper, Scent } from "@/types/api"; - -/** - * CandidatePanel — columna derecha. - * - * Muestra el paper seleccionado (del grafo o al hacer clic). - * Incluye: metadatos, scent (score + vecinos), botones de curación. - * Al curar, hace refetch de la red para reflejar el estado actualizado. - */ -export function CandidatePanel() { - const selectedPaperId = useAppStore((s) => s.selectedPaperId); - - if (!selectedPaperId) { - return ( - - ); - } - - return ( - - ); -} - -// --------------------------------------------------------------------------- -// PaperDetail -// --------------------------------------------------------------------------- - -function PaperDetail({ paperId }: { paperId: string }) { - const queryClient = useQueryClient(); - const activeKind = useAppStore((s) => s.activeKind); - - const { - data: paper, - isLoading: loadingPaper, - error: errorPaper, - } = useQuery({ - queryKey: ["paper", paperId], - queryFn: () => fetchPaper(paperId), - }); - - const { - data: scent, - isLoading: loadingScent, - error: errorScent, - } = useQuery({ - queryKey: ["scent", paperId], - queryFn: () => fetchScent(paperId), - enabled: !!paper, - }); - - const curateMutation = useMutation({ - mutationFn: (decision: "accepted" | "rejected") => - curatePaper(paperId, decision), - onSuccess: () => { - // Refetch del paper y de la red para reflejar el cambio - void queryClient.invalidateQueries({ queryKey: ["paper", paperId] }); - void queryClient.invalidateQueries({ queryKey: ["network", activeKind] }); - void queryClient.invalidateQueries({ queryKey: ["workspace"] }); - }, - }); - - return ( - <> - {/* Header del panel */} -
-

Candidato

-
- -
- {loadingPaper && ( -
- -
- )} - {errorPaper && ( -
- -
- )} - - {paper && ( - <> - - - {/* Botones de curación */} - {paper.curation_status !== "rejected" && ( -
-

Curar

- curateMutation.mutate("accepted")} - onReject={() => curateMutation.mutate("rejected")} - loading={curateMutation.isPending} - error={curateMutation.error} - /> -
- )} - - {/* Scent */} -
- {loadingScent && ( -
- -
- )} - {errorScent && ( -
- -
- )} - {scent && } -
- - )} -
- - ); -} - -// --------------------------------------------------------------------------- -// PaperMeta -// --------------------------------------------------------------------------- - -function PaperMeta({ paper }: { paper: Paper }) { - return ( -
- {/* Título */} -

- {paper.title} -

- - {/* Estado + year */} -
- - {paper.year && ( - {paper.year} - )} -
- - {/* Autores */} - {paper.authors_raw.length > 0 && ( -
-

Autores

-

- {paper.authors_raw.slice(0, 3).join("; ")} - {paper.authors_raw.length > 3 && ` +${paper.authors_raw.length - 3}`} -

-
- )} - - {/* Abstract */} - {paper.abstract && ( -
-

Resumen

-

- {truncate(paper.abstract, 240)} -

-
- )} - - {/* Keywords */} - {paper.keywords_id.length > 0 && ( -
-

Keywords

-
- {paper.keywords_id.slice(0, 6).map((kw) => ( - - {kw} - - ))} -
-
- )} - - {/* IDs */} -
- {paper.openalex_id && ( - - {paper.openalex_id} - - )} - {paper.doi && ( - - doi:{paper.doi} - - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// CurateActions -// --------------------------------------------------------------------------- - -interface CurateActionsProps { - paper: Paper; - onAccept: () => void; - onReject: () => void; - loading: boolean; - error: Error | null; -} - -function CurateActions({ - paper, - onAccept, - onReject, - loading, - error, -}: CurateActionsProps) { - return ( -
-
- - -
- {error != null && } -
- ); -} - -// --------------------------------------------------------------------------- -// ScentView — score + vecinos reales (no 4 paneles cosméticos del mock) -// --------------------------------------------------------------------------- - -function ScentView({ scent }: { scent: Scent }) { - return ( -
- {/* Score */} -
- Scent bibliométrico - - {scent.score} - -
- - {/* Coupling — vecinos con referencias compartidas */} - {scent.coupling.length > 0 && ( -
-

Acoplamiento

-
- {scent.coupling.slice(0, 5).map((n) => ( - - ))} -
-
- )} - - {/* References */} - {scent.references.length > 0 && ( -
-

- Referencias en corpus ({scent.references.length}) -

-
- {scent.references.slice(0, 4).map((n) => ( - - ))} -
-
- )} - - {/* Cited by */} - {scent.cited_by.length > 0 && ( -
-

- Citado por ({scent.cited_by.length}) -

-
- {scent.cited_by.slice(0, 4).map((n) => ( - - ))} -
-
- )} -
- ); -} - -function NeighborRow({ - title, - weight, -}: { - title: string | null; - weight?: number; -}) { - return ( -
-
-
- - {title ?? "(sin título)"} - - {weight != null && ( - w={weight} - )} -
-
- ); -} diff --git a/frontend/src/components/diff/RoundDiffPanel.tsx b/frontend/src/components/diff/RoundDiffPanel.tsx deleted file mode 100644 index 35afdce..0000000 --- a/frontend/src/components/diff/RoundDiffPanel.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchCompare } from "@/client/api"; -import { useAppStore } from "@/store"; -import { Spinner } from "@/components/ui/Spinner"; -import { ErrorBanner } from "@/components/ui/ErrorBanner"; -import { Button } from "@/components/ui/Button"; -import { truncate } from "@/lib/format"; -import type { RoundDiff } from "@/types/api"; - -/** - * RoundDiffPanel — panel de diff entre dos rondas (EL DIFERENCIADOR, ADR 0027). - * - * Muestra: papers añadidos, papers eliminados, cambios en métricas. - * Se activa desde la columna de RONDAS (selector A/B + botón "Ver diff"). - */ -export function RoundDiffPanel() { - const diffVisible = useAppStore((s) => s.diffVisible); - const diffRoundA = useAppStore((s) => s.diffRoundA); - const diffRoundB = useAppStore((s) => s.diffRoundB); - const closeDiff = useAppStore((s) => s.closeDiff); - - if (!diffVisible) return null; - - return ( -
- {/* Header */} -
-
-

- Diff de rondas -

-
- - {diffRoundA ? truncate(diffRoundA, 12) : "—"} - - - - {diffRoundB ? truncate(diffRoundB, 12) : "—"} - -
-
- -
- - {/* Contenido */} -
- {diffRoundA && diffRoundB ? ( - - ) : ( -
- - Seleccioná dos rondas (A y B) para ver el diff - -
- )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// DiffContent -// --------------------------------------------------------------------------- - -function DiffContent({ - roundA, - roundB, -}: { - roundA: string; - roundB: string; -}) { - const { data, isLoading, error } = useQuery({ - queryKey: ["compare", roundA, roundB], - queryFn: () => fetchCompare(roundA, roundB), - staleTime: 30_000, - }); - - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- -
- ); - } - - if (!data) return null; - - return ; -} - -// --------------------------------------------------------------------------- -// DiffView -// --------------------------------------------------------------------------- - -function DiffView({ diff }: { diff: RoundDiff }) { - const totalAdded = diff.added_paper_ids.length; - const totalRemoved = diff.removed_paper_ids.length; - const netChange = totalAdded - totalRemoved; - - return ( -
- {/* Resumen */} -
-

Resumen

-
-
- - +{totalAdded} - - añadidos -
-
- - -{totalRemoved} - - eliminados -
-
- 0 - ? "text-curation-accepted" - : netChange < 0 - ? "text-curation-rejected" - : "text-text-muted" - }`} - > - {netChange >= 0 ? "+" : ""} - {netChange} - - neto -
-
-
- - {/* Cambios en métricas */} - {diff.metrics_change.length > 0 && ( -
-

Métricas

-
- {diff.metrics_change.map((mc) => ( - - ))} -
-
- )} - - {/* Papers añadidos */} - {totalAdded > 0 && ( -
-
- -

Añadidos ({totalAdded})

-
-
- {diff.added_paper_ids.map((id) => ( - - ))} -
-
- )} - - {/* Papers eliminados */} - {totalRemoved > 0 && ( -
-
- -

Eliminados ({totalRemoved})

-
-
- {diff.removed_paper_ids.map((id) => ( - - ))} -
-
- )} - - {totalAdded === 0 && totalRemoved === 0 && ( -
- - Las rondas son idénticas - -
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Subcomponentes -// --------------------------------------------------------------------------- - -function MetricRow({ change }: { change: { metric: string; before: number; after: number } }) { - const delta = change.after - change.before; - const isPositive = delta > 0; - const isNeutral = delta === 0; - - return ( -
- {change.metric} -
- {change.before} - - - {change.after} - - {!isNeutral && ( - - ({isPositive ? "+" : ""} - {delta}) - - )} -
-
- ); -} - -function PaperIdRow({ - id, - variant, -}: { - id: string; - variant: "added" | "removed"; -}) { - return ( -
- {variant === "added" ? "+" : "-"} - {id} -
- ); -} diff --git a/frontend/src/components/graph/GraphCanvas.tsx b/frontend/src/components/graph/GraphCanvas.tsx deleted file mode 100644 index def1d25..0000000 --- a/frontend/src/components/graph/GraphCanvas.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { useEffect, useRef, useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { fetchNetwork } from "@/client/api"; -import { useAppStore } from "@/store"; -import { Spinner } from "@/components/ui/Spinner"; -import { ErrorBanner } from "@/components/ui/ErrorBanner"; -import { - graphStyles, - communityColor, - nodeSize, - CURATION_BORDER, -} from "@/lib/cytoscapeStyle"; -import type { NetworkData } from "@/types/api"; - -// Cytoscape se importa de forma dinámica para no bloquear el bundle inicial -// y porque fcose es un layout plugin que se registra en el prototipo global. - -/** - * GraphCanvas — componente central, el HÉROE de la interfaz. - * - * Renderiza la red usando Cytoscape.js + fcose. - * Color de nodo = comunidad, tamaño = degree_centrality. - * Hover muestra tooltip; clic fija el paper en el store. - */ -export function GraphCanvas() { - const activeKind = useAppStore((s) => s.activeKind); - const selectPaper = useAppStore((s) => s.selectPaper); - - const { data, isLoading, error } = useQuery({ - queryKey: ["network", activeKind], - queryFn: () => fetchNetwork(activeKind), - staleTime: 60_000, - }); - - return ( -
- {/* Controles del grafo */} - - - {/* Contenedor del canvas */} -
- {isLoading && ( -
-
- - - calculando red… - -
-
- )} - - {error && ( -
-
- -
-
- )} - - {data && !isLoading && ( - - )} - - {/* Leyenda de comunidades */} - {data && } - - {/* Métricas */} - {data && } -
-
- ); -} - -// --------------------------------------------------------------------------- -// GraphControls -// --------------------------------------------------------------------------- - -function GraphControls() { - return ( -
- {/* Los controles se manejan con las teclas de Cytoscape; aquí solo info. */} -
- - scroll para zoom · drag para mover - -
-
- ); -} - -// --------------------------------------------------------------------------- -// GraphLegend — comunidades del grafo como paleta protagonista -// --------------------------------------------------------------------------- - -function GraphLegend({ data }: { data: NetworkData }) { - const communities = new Set( - data.nodes - .map((n) => n.community) - .filter((c): c is number => c != null) - ); - - if (communities.size === 0) return null; - - return ( -
-

Comunidades

-
- {[...communities].sort((a, b) => a - b).map((c) => ( -
-
- - C{c} - -
- ))} -
-
- ); -} - -// --------------------------------------------------------------------------- -// GraphMetrics — métricas de la red -// --------------------------------------------------------------------------- - -function GraphMetrics({ data }: { data: NetworkData }) { - const m = data.metrics; - return ( -
-

Métricas

-
- {m.n_nodes} nodos · {m.n_edges} aristas - densidad {m.density.toFixed(4)} - {m.n_communities} comunidades - componentes {m.num_components} -
-
- ); -} - -// --------------------------------------------------------------------------- -// CytoscapeCanvas — el corazón del grafo -// --------------------------------------------------------------------------- - -interface CytoscapeCanvasProps { - data: NetworkData; - onNodeClick: (id: string) => void; -} - -function CytoscapeCanvas({ data, onNodeClick }: CytoscapeCanvasProps) { - const containerRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cyRef = useRef(null); - const tooltipRef = useRef(null); - - const handleNodeClick = useCallback( - (id: string) => { - onNodeClick(id); - }, - [onNodeClick] - ); - - useEffect(() => { - if (!containerRef.current) return; - - let destroyed = false; - - // Carga dinámica de Cytoscape + fcose - Promise.all([ - import("cytoscape"), - import("cytoscape-fcose"), - ]).then(([cytoscapeModule, fcoseModule]) => { - if (destroyed) return; - - const cytoscape = cytoscapeModule.default; - const fcose = fcoseModule.default; - - // Registrar el layout fcose solo una vez - try { - cytoscape.use(fcose); - } catch { - // Ya registrado, ignorar - } - - // Construir elementos desde los datos reales de la API - const elements = [ - ...data.nodes.map((n) => ({ - data: { - id: n.id, - label: n.label, - degree_centrality: n.degree_centrality, - community: n.community, - year: n.year, - is_seed: n.is_seed, - curation_status: n.curation_status, - }, - classes: [ - n.is_seed ? "is-seed" : "", - ].filter(Boolean).join(" "), - })), - ...data.edges.map((e) => ({ - data: { - id: `${e.source}->${e.target}`, - source: e.source, - target: e.target, - weight: e.weight, - }, - })), - ]; - - // Opciones del layout fcose (tipado como any porque las opciones propias - // de fcose no están en los tipos base de Cytoscape). - const fcoseLayout: Record = { - name: "fcose", - animate: true, - animationDuration: 600, - fit: true, - padding: 40, - idealEdgeLength: 80, - nodeRepulsion: 6000, - edgeElasticity: 0.45, - gravity: 0.2, - }; - - const cy = cytoscape({ - container: containerRef.current!, - elements, - style: graphStyles, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - layout: fcoseLayout as any, - wheelSensitivity: 0.2, - minZoom: 0.05, - maxZoom: 8, - }); - - // Decorar nodos: color por comunidad, tamaño por centralidad - cy.nodes().forEach((node) => { - const community = node.data("community") as number | undefined; - const dc = (node.data("degree_centrality") as number | undefined) ?? 0; - const status = node.data("curation_status") as string | undefined; - - const borderColor = - status != null && status in CURATION_BORDER - ? CURATION_BORDER[status as keyof typeof CURATION_BORDER] - : "#252934"; - - node.style({ - "background-color": communityColor(community), - "width": nodeSize(dc), - "height": nodeSize(dc), - "border-color": borderColor, - }); - }); - - // Decorar aristas: peso → opacidad - cy.edges().forEach((edge) => { - const weight = (edge.data("weight") as number | undefined) ?? 1; - const maxWeight = Math.max( - ...data.edges.map((e) => e.weight), - 1 - ); - const opacity = 0.15 + (weight / maxWeight) * 0.65; - edge.style({ opacity, "line-color": "#3a4058" }); - }); - - // Eventos: hover para tooltip, click para seleccionar - cy.on("mouseover", "node", (evt) => { - const node = evt.target; - node.addClass("hovered"); - - const tooltip = tooltipRef.current; - if (!tooltip) return; - - const label = node.data("label") as string; - const dc = ((node.data("degree_centrality") as number | undefined) ?? 0).toFixed(3); - const community = node.data("community") as number | undefined; - const year = node.data("year") as number | undefined; - const status = node.data("curation_status") as string | undefined; - - tooltip.innerHTML = [ - `${label}`, - `centralidad ${dc}`, - community != null - ? `comunidad ${community}` - : "", - year ? `${year}` : "", - status ? `${status}` : "", - ] - .filter(Boolean) - .join("\n"); - - tooltip.style.display = "flex"; - }); - - cy.on("mouseout", "node", (evt) => { - evt.target.removeClass("hovered"); - if (tooltipRef.current) { - tooltipRef.current.style.display = "none"; - } - }); - - cy.on("mousemove", (evt) => { - const tooltip = tooltipRef.current; - if (!tooltip || tooltip.style.display === "none") return; - const renderedPos = evt.renderedPosition; - tooltip.style.left = `${renderedPos.x + 14}px`; - tooltip.style.top = `${renderedPos.y - 10}px`; - }); - - cy.on("tap", "node", (evt) => { - const node = evt.target; - handleNodeClick(node.data("id") as string); - }); - - cyRef.current = cy; - }); - - return () => { - destroyed = true; - if (cyRef.current) { - cyRef.current.destroy(); - cyRef.current = null; - } - }; - }, [data, handleNodeClick]); - - return ( -
-
- - {/* Tooltip flotante */} -
-
- ); -} diff --git a/frontend/src/components/rounds/RoundsColumn.tsx b/frontend/src/components/rounds/RoundsColumn.tsx deleted file mode 100644 index b2161a9..0000000 --- a/frontend/src/components/rounds/RoundsColumn.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchRounds } from "@/client/api"; -import { useAppStore } from "@/store"; -import { Spinner } from "@/components/ui/Spinner"; -import { ErrorBanner } from "@/components/ui/ErrorBanner"; -import { Button } from "@/components/ui/Button"; -import type { NetworkKind, RoundEntry } from "@/types/api"; -import { KIND_LABELS, formatDate, truncate } from "@/lib/format"; - -const NETWORK_KINDS: NetworkKind[] = [ - "bibliographic_coupling", - "cocitation", - "author_collab", - "institution_collab", - "keyword_cooccurrence", -]; - -/** Columna izquierda: listado de rondas/snapshots + selector de NetworkKind + control A/B para diff. */ -export function RoundsColumn() { - const { data, isLoading, error } = useQuery({ - queryKey: ["rounds"], - queryFn: fetchRounds, - staleTime: 15_000, - }); - - const activeKind = useAppStore((s) => s.activeKind); - const setActiveKind = useAppStore((s) => s.setActiveKind); - const diffRoundA = useAppStore((s) => s.diffRoundA); - const diffRoundB = useAppStore((s) => s.diffRoundB); - const setDiffRoundA = useAppStore((s) => s.setDiffRoundA); - const setDiffRoundB = useAppStore((s) => s.setDiffRoundB); - const toggleDiff = useAppStore((s) => s.toggleDiff); - - const rounds = data?.rounds ?? []; - - return ( - - ); -} - -// --------------------------------------------------------------------------- -// RoundItem -// --------------------------------------------------------------------------- - -interface RoundItemProps { - round: RoundEntry; - isSelectedA: boolean; - isSelectedB: boolean; - onSelectA: () => void; - onSelectB: () => void; -} - -function RoundItem({ - round, - isSelectedA, - isSelectedB, - onSelectA, - onSelectB, -}: RoundItemProps) { - const isLive = round.id === "live"; - - return ( -
- {/* ID y tipo */} -
- {isLive ? ( - - live - - ) : ( - snap - )} - - {truncate(round.id, 14)} - -
- - {/* Metadata */} -
- {round.total_papers} papers - {round.created_at && {formatDate(round.created_at)}} - {isLive && round.loop_state && ( - {round.loop_state} - )} -
- - {/* Selectores A/B */} -
- - -
-
- ); -} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx deleted file mode 100644 index 395bbf8..0000000 --- a/frontend/src/components/ui/Badge.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { CurationStatus } from "@/types/api"; -import { STATUS_LABELS } from "@/lib/format"; - -interface Props { - status: CurationStatus; - isSeed?: boolean; - className?: string; -} - -/** Badge de estado de curación. isSeed tiene precedencia visual. */ -export function CurationBadge({ status, isSeed, className = "" }: Props) { - if (isSeed) { - return ( - - semilla - - ); - } - - const cls = - status === "accepted" - ? "badge-accepted" - : status === "rejected" - ? "badge-rejected" - : "badge-candidate"; - - return ( - - {STATUS_LABELS[status] ?? status} - - ); -} - -interface SimpleBadgeProps { - children: React.ReactNode; - variant?: "neutral" | "accent" | "warn"; - className?: string; -} - -export function Badge({ children, variant = "neutral", className = "" }: SimpleBadgeProps) { - const cls = - variant === "accent" - ? "bg-action-primary/20 text-action-hover border border-action-primary/30" - : variant === "warn" - ? "bg-curation-seed/20 text-curation-seed border border-curation-seed/30" - : "bg-obs-border/50 text-text-secondary border border-obs-border"; - - return ( - - {children} - - ); -} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx deleted file mode 100644 index 96aa00c..0000000 --- a/frontend/src/components/ui/Button.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ButtonHTMLAttributes } from "react"; - -interface Props extends ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "danger" | "ghost"; - size?: "sm" | "md"; - loading?: boolean; -} - -/** Botón base para el tema "Observatorio". */ -export function Button({ - variant = "primary", - size = "md", - loading = false, - children, - disabled, - className = "", - ...rest -}: Props) { - const base = - "inline-flex items-center justify-center gap-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-action-primary disabled:opacity-40 disabled:cursor-not-allowed rounded-obs"; - - const variantCls = - variant === "primary" - ? "bg-action-primary text-obs-bg hover:bg-action-hover active:bg-action-focus" - : variant === "secondary" - ? "bg-obs-overlay text-text-primary border border-obs-border hover:border-text-muted" - : variant === "danger" - ? "bg-curation-rejected/20 text-curation-rejected border border-curation-rejected/40 hover:bg-curation-rejected/30" - : /* ghost */ "text-text-secondary hover:text-text-primary hover:bg-obs-overlay"; - - const sizeCls = - size === "sm" - ? "text-xs px-2 py-1" - : "text-sm px-3 py-1.5"; - - return ( - - ); -} diff --git a/frontend/src/components/ui/ErrorBanner.tsx b/frontend/src/components/ui/ErrorBanner.tsx deleted file mode 100644 index be5067c..0000000 --- a/frontend/src/components/ui/ErrorBanner.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ApiError, AuthError } from "@/client/http"; - -interface Props { - error: unknown; - context?: string; -} - -/** - * Muestra errores de API con el code string del contrato. - * No hace reset — el parent decide cómo recuperarse. - */ -export function ErrorBanner({ error, context }: Props) { - if (!error) return null; - - let title = "Error inesperado"; - let detail = String(error); - let code: string | null = null; - - if (error instanceof AuthError) { - title = "Sin autorización"; - detail = "Token faltante o inválido. Reiniciá b2g gui para obtener un token nuevo."; - } else if (error instanceof ApiError) { - code = error.code; - title = `Error: ${error.code}`; - detail = error.message; - } else if (error instanceof Error) { - detail = error.message; - } - - return ( -
-
- {title} - {context && ( - [{context}] - )} -
-

{detail}

- {code && ( - code: {code} - )} -
- ); -} diff --git a/frontend/src/components/ui/Spinner.tsx b/frontend/src/components/ui/Spinner.tsx deleted file mode 100644 index df96655..0000000 --- a/frontend/src/components/ui/Spinner.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** Indicador de carga minimalista para el tema oscuro. */ -export function Spinner({ size = 16 }: { size?: number }) { - return ( - - - - - ); -} diff --git a/frontend/src/components/workspace/Header.tsx b/frontend/src/components/workspace/Header.tsx deleted file mode 100644 index d0dd85a..0000000 --- a/frontend/src/components/workspace/Header.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchWorkspace } from "@/client/api"; -import { Spinner } from "@/components/ui/Spinner"; -import { Badge } from "@/components/ui/Badge"; - -/** Header global — nombre del workspace, estado del lazo, aviso de staleness. */ -export function Header() { - const { data, isLoading, error } = useQuery({ - queryKey: ["workspace"], - queryFn: fetchWorkspace, - staleTime: 30_000, - }); - - return ( -
- {/* Logotipo / nombre del producto */} -
- - bib2graph - - observatorio -
- -
- - {isLoading && } - - {data && ( - <> - {/* Nombre del workspace */} - - {data.name} - - -
- - {/* Estado del lazo */} - {data.loop_state && ( - - {data.loop_state.toLowerCase()} - - )} - - {/* Ronda */} - - ronda {data.round} - - - {/* Total papers */} - - {data.total_papers.toLocaleString("es-AR")} papers - - - {/* Staleness: aviso si la cache de redes está desactualizada */} - {data.networks_cache_stale && ( - - redes desactualizadas — ejecutá b2g build - - )} - - )} - - {error && ( - - Error al cargar workspace - - )} -
- ); -} diff --git a/frontend/src/lib/cytoscapeStyle.ts b/frontend/src/lib/cytoscapeStyle.ts deleted file mode 100644 index f159e9b..0000000 --- a/frontend/src/lib/cytoscapeStyle.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * lib/cytoscapeStyle.ts — Estilos de Cytoscape para la dirección D-2 "Observatorio". - * - * Reglas de diseño: - * - Color del nodo = comunidad (paleta protagonist desde design tokens). - * - Tamaño del nodo = degree_centrality (hubs más grandes, visible). - * - Bordes/aristas: peso → opacidad, sin sobrecargar. - * - El fondo es oscuro; los nodos brillan, la UI alrededor es silenciosa. - */ - -import type { StylesheetStyle } from "cytoscape"; -import type { CurationStatus } from "@/types/api"; - -/** Paleta de comunidades: índice → color hex (espeja los design tokens). */ -export const COMMUNITY_COLORS: readonly string[] = [ - "#4fc3f7", // community-0: cyan - "#a5d6a7", // community-1: verde - "#ffb74d", // community-2: naranja - "#ce93d8", // community-3: violeta - "#ef9a9a", // community-4: rojo suave - "#80cbc4", // community-5: teal - "#fff176", // community-6: amarillo - "#b0bec5", // community-7: gris azulado -] as const; - -/** Colores de curación para anillo exterior (borde del nodo). - * Fuente única: deben coincidir con `theme.colors.curation` de tailwind.config.js. */ -export const CURATION_BORDER: Record = { - accepted: "#4caf50", - rejected: "#f44336", - candidate: "#607d8b", -}; - -/** Devuelve el color de la comunidad (modulo sobre la paleta). */ -export function communityColor(community: number | undefined): string { - if (community == null) return "#5c86e8"; // action.primary - const color = COMMUNITY_COLORS[community % COMMUNITY_COLORS.length]; - return color ?? "#5c86e8"; -} - -/** Escala el degree_centrality [0,1] a un rango de tamaño de nodo en px. */ -export function nodeSize(dc: number): number { - const MIN = 12; - const MAX = 48; - return MIN + dc * (MAX - MIN); -} - -/** Estilos Cytoscape para la dirección "Observatorio". */ -export const graphStyles: StylesheetStyle[] = [ - { - selector: "node", - style: { - "background-color": "#5c86e8", - "width": 20, - "height": 20, - "border-width": 2, - "border-color": "#252934", - "label": "data(label)", - "font-size": "9px", - "font-family": "Inter, system-ui, sans-serif", - "color": "#e8eaf0", - "text-valign": "bottom", - "text-halign": "center", - "text-margin-y": 3, - "text-max-width": "80px", - "text-wrap": "ellipsis", - "overlay-opacity": 0, - }, - }, - { - selector: "node:selected", - style: { - "border-width": 3, - "border-color": "#c5cfe8", - "overlay-color": "#c5cfe8", - "overlay-opacity": 0.1, - }, - }, - { - selector: "node.hovered", - style: { - "border-color": "#e8eaf0", - "overlay-opacity": 0.08, - }, - }, - { - selector: "node.is-seed", - style: { - "border-color": "#ffd54f", - "border-width": 2.5, - }, - }, - { - selector: "edge", - style: { - "line-color": "#252934", - "width": 1, - "opacity": 0.6, - "curve-style": "bezier", - "overlay-opacity": 0, - }, - }, - { - selector: "edge:selected", - style: { - "line-color": "#5c86e8", - "opacity": 1, - "width": 2, - }, - }, -]; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts deleted file mode 100644 index 095bd9e..0000000 --- a/frontend/src/lib/format.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * lib/format.ts — Funciones de formato para la UI. - */ - -/** Trunca un string a maxLen caracteres con ellipsis. */ -export function truncate(s: string, maxLen: number): string { - if (s.length <= maxLen) return s; - return s.slice(0, maxLen - 1) + "…"; -} - -/** Formatea una fecha ISO a "DD MMM YYYY". */ -export function formatDate(isoString: string): string { - try { - const d = new Date(isoString); - return d.toLocaleDateString("es-AR", { - day: "2-digit", - month: "short", - year: "numeric", - }); - } catch { - return isoString; - } -} - -/** Formatea un número con separador de miles. */ -export function formatCount(n: number): string { - return n.toLocaleString("es-AR"); -} - -/** Formatea un float a N decimales, con fallback "-". */ -export function formatFloat(n: number | null | undefined, decimals = 3): string { - if (n == null || isNaN(n)) return "-"; - return n.toFixed(decimals); -} - -/** Nombre legible del NetworkKind para la UI. */ -export const KIND_LABELS: Record = { - bibliographic_coupling: "Acoplamiento", - cocitation: "Co-citación", - author_collab: "Colaboración", - institution_collab: "Instituciones", - keyword_cooccurrence: "Keywords", -}; - -/** Etiqueta corta del CurationStatus para badges. */ -export const STATUS_LABELS: Record = { - candidate: "candidato", - accepted: "aceptado", - rejected: "rechazado", -}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index bb455b6..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./styles/globals.css"; -import { App } from "./App"; - -const rootEl = document.getElementById("root"); -if (!rootEl) throw new Error("No se encontró el elemento #root en el DOM"); - -createRoot(rootEl).render( - - - -); diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts deleted file mode 100644 index 2d7e126..0000000 --- a/frontend/src/store/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * store/index.ts — Estado global mínimo (Zustand). - * - * Solo lo que cruza columnas: - * - paper seleccionado (clic en el grafo → panel candidato) - * - kind de red activo (toggle en RONDAS → grafo) - * - par A/B para el diff de rondas - * - si el panel diff está visible - */ - -import { create } from "zustand"; -import type { NetworkKind } from "@/types/api"; - -interface AppState { - /** Id del paper seleccionado (clic en nodo del grafo). */ - selectedPaperId: string | null; - - /** Tipo de red activa. */ - activeKind: NetworkKind; - - /** Ronda A seleccionada para el diff. */ - diffRoundA: string | null; - - /** Ronda B seleccionada para el diff. */ - diffRoundB: string | null; - - /** Si el panel de diff está visible. */ - diffVisible: boolean; - - // Mutaciones - selectPaper: (id: string | null) => void; - setActiveKind: (kind: NetworkKind) => void; - setDiffRoundA: (id: string | null) => void; - setDiffRoundB: (id: string | null) => void; - toggleDiff: () => void; - closeDiff: () => void; -} - -export const useAppStore = create((set) => ({ - selectedPaperId: null, - activeKind: "bibliographic_coupling", - diffRoundA: null, - diffRoundB: null, - diffVisible: false, - - selectPaper: (id) => set({ selectedPaperId: id }), - setActiveKind: (kind) => set({ activeKind: kind }), - setDiffRoundA: (id) => set({ diffRoundA: id }), - setDiffRoundB: (id) => set({ diffRoundB: id }), - toggleDiff: () => set((s) => ({ diffVisible: !s.diffVisible })), - closeDiff: () => set({ diffVisible: false }), -})); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css deleted file mode 100644 index 2c3ff4c..0000000 --- a/frontend/src/styles/globals.css +++ /dev/null @@ -1,64 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Ajustes globales de base para el tema "Observatorio" */ -@layer base { - * { - box-sizing: border-box; - } - - html, body, #root { - height: 100%; - margin: 0; - padding: 0; - } - - /* Scrollbar personalizado: oscuro, delgado */ - ::-webkit-scrollbar { - width: 5px; - height: 5px; - } - ::-webkit-scrollbar-track { - background: theme('colors.obs.bg'); - } - ::-webkit-scrollbar-thumb { - background: theme('colors.obs.border'); - border-radius: 3px; - } - ::-webkit-scrollbar-thumb:hover { - background: theme('colors.text.muted'); - } - - /* Selección de texto */ - ::selection { - background-color: theme('colors.action.primary'); - color: theme('colors.obs.bg'); - } -} - -@layer components { - /* Panel reutilizable de columna */ - .obs-panel { - @apply bg-obs-surface border border-obs-border rounded-obs; - } - - /* Label de sección */ - .obs-section-label { - @apply text-2xs font-mono uppercase tracking-widest text-text-muted; - } - - /* Badge de estado */ - .badge-candidate { - @apply bg-curation-candidate/20 text-text-secondary border border-curation-candidate/30; - } - .badge-accepted { - @apply bg-curation-accepted/20 text-curation-accepted border border-curation-accepted/30; - } - .badge-rejected { - @apply bg-curation-rejected/20 text-curation-rejected border border-curation-rejected/30; - } - .badge-seed { - @apply bg-curation-seed/20 text-curation-seed border border-curation-seed/30; - } -} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts deleted file mode 100644 index 5f76369..0000000 --- a/frontend/src/types/api.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * tipos/api.ts — DTOs que espejan el contrato REAL de la API (API.md §0.1/§0.2). - * - * Reglas: - * - error.code es STRING ("DATA_ERROR", "INTERNAL_ERROR"...) NUNCA number. - * - NetworkKind usa los valores del núcleo (bibliographic_coupling, etc.), NO los del mock. - * - Paper expone listas crudas (authors_raw, keywords_id), NO objetos Author/ORCID. - * - NetworkData NO tiene modularity ni id de red persistido. - * - RoundDiff tiene mutated_hubs como unknown[] (diferido, hoy []). - */ - -// --------------------------------------------------------------------------- -// Envelope (API.md §0) -// --------------------------------------------------------------------------- - -export interface EnvelopeError { - /** code es STRING: "DATA_ERROR" | "INTERNAL_ERROR" | "USAGE_ERROR" | etc. */ - code: string; - message: string; -} - -export interface Envelope { - schema: "1"; - ok: boolean; - command: string; - exit_code: number; - data: T; - warnings: string[]; - error: EnvelopeError | null; -} - -// --------------------------------------------------------------------------- -// Enums del dominio -// --------------------------------------------------------------------------- - -export type CurationStatus = "candidate" | "accepted" | "rejected"; - -/** Valores del núcleo (NetworkKind del servidor). */ -export type NetworkKind = - | "bibliographic_coupling" - | "cocitation" - | "author_collab" - | "institution_collab" - | "keyword_cooccurrence"; - -// --------------------------------------------------------------------------- -// WorkspaceState — GET /api/workspace (API.md §0.1 get_workspace) -// --------------------------------------------------------------------------- - -export interface WorkspaceState { - name: string; - root: string; - created_at: string; - bib2graph_version: string; - source: string | null; - loop_state: string | null; - round: number; - total_papers: number; - counts_by_status: Record; - transitions_available: string[]; - curation_available: string[]; - networks_cache_stale: boolean; -} - -// --------------------------------------------------------------------------- -// RoundEntry — GET /api/rounds (API.md §0.1 list_rounds) -// --------------------------------------------------------------------------- - -export interface RoundEntry { - id: string; - corpus_hash?: string; - created_at?: string; - total_papers: number; - schema_version?: string; - /** Solo en id="live" */ - round?: number; - /** Solo en id="live" */ - loop_state?: string | null; -} - -export interface RoundsData { - rounds: RoundEntry[]; -} - -// --------------------------------------------------------------------------- -// Paper — GET /api/paper/{id} (API.md §0.1 get_paper) -// --------------------------------------------------------------------------- - -export interface Paper { - id: string; - openalex_id: string | null; - doi: string | null; - title: string; - year: number | null; - abstract: string | null; - is_seed: boolean; - curation_status: CurationStatus; - /** Listas crudas — NO objetos Author/ORCID */ - authors_raw: string[]; - authors_id: string[]; - keywords_id: string[]; - references_id: string[]; - cited_by_id: string[]; - provenance: unknown[]; -} - -// --------------------------------------------------------------------------- -// Scent — GET /api/paper/{id}/scent (API.md §0.1 get_scent) -// --------------------------------------------------------------------------- - -export interface ScentNeighbor { - paper_id: string; - /** Puede ser null si el server no resolvió el título del vecino (reads.py). */ - title: string | null; - weight?: number; -} - -export interface Scent { - paper_id: string; - /** Score escalar: nº de corpus-papers con >=1 referencia compartida */ - score: number; - coupling: ScentNeighbor[]; - references: ScentNeighbor[]; - cited_by: ScentNeighbor[]; -} - -// --------------------------------------------------------------------------- -// NetworkData — GET /api/network/{kind} (API.md §0.1 get_network) -// --------------------------------------------------------------------------- - -export interface NetworkNode { - id: string; - label: string; - degree_centrality: number; - community?: number; - year?: number; - is_seed?: boolean; - curation_status?: CurationStatus; -} - -export interface NetworkEdge { - source: string; - target: string; - weight: number; -} - -export interface NetworkMetrics { - n_nodes: number; - n_edges: number; - density: number; - num_components: number; - avg_clustering: number; - n_communities: number; -} - -export interface NetworkData { - nodes: NetworkNode[]; - edges: NetworkEdge[]; - metrics: NetworkMetrics; -} - -// --------------------------------------------------------------------------- -// RoundDiff — GET /api/compare?a=&b= (API.md §0.1 compare_rounds) -// --------------------------------------------------------------------------- - -export interface MetricChange { - metric: string; - before: number; - after: number; -} - -export interface RoundDiff { - round_a: string; - round_b: string; - added_paper_ids: string[]; - removed_paper_ids: string[]; - /** Diferido (B-G2-3): hoy siempre [] */ - mutated_hubs: unknown[]; - metrics_change: MetricChange[]; -} - -// --------------------------------------------------------------------------- -// CurateResult — POST /api/paper/{id}/curate (API.md §0.2) -// --------------------------------------------------------------------------- - -export interface CurateRequest { - decision: "accepted" | "rejected"; -} - -export interface CurateResult { - accepted_count?: number; - rejected_count?: number; - ids: string[]; -} diff --git a/frontend/src/types/cytoscape-fcose.d.ts b/frontend/src/types/cytoscape-fcose.d.ts deleted file mode 100644 index 9d25503..0000000 --- a/frontend/src/types/cytoscape-fcose.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Stub de tipos para cytoscape-fcose. - * El paquete no expone sus propios tipos; declaramos lo mínimo. - */ -declare module "cytoscape-fcose" { - import type cytoscape from "cytoscape"; - const fcose: cytoscape.Ext; - export default fcose; -} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index 61ad9f0..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,81 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: { - /* Design tokens — Dirección D-2 "Observatorio" */ - colors: { - /* Base: fondos y superficies */ - obs: { - bg: "#0d0f14", /* fondo principal: casi negro azulado */ - surface: "#151820", /* cards/paneles: ligeramente más claro */ - border: "#252934", /* bordes y separadores */ - overlay: "#1c2028", /* hover states, tooltips */ - }, - /* Texto: escala de luminosidad calibrada */ - text: { - primary: "#e8eaf0", /* texto principal: blanco cálido */ - secondary: "#8b92a8", /* labels, metadata */ - muted: "#4a5068", /* placeholders, disabled */ - accent: "#c5cfe8", /* énfasis suave */ - }, - /* Comunidades del grafo: paleta protagonista (saturada, distinguible) */ - community: { - 0: "#4fc3f7", /* cyan */ - 1: "#a5d6a7", /* verde */ - 2: "#ffb74d", /* naranja */ - 3: "#ce93d8", /* violeta */ - 4: "#ef9a9a", /* rojo suave */ - 5: "#80cbc4", /* teal */ - 6: "#fff176", /* amarillo */ - 7: "#b0bec5", /* gris azulado */ - }, - /* Estado de curación */ - curation: { - accepted: "#4caf50", - rejected: "#f44336", - candidate: "#607d8b", - seed: "#ffd54f", - }, - /* Acción / interacción */ - action: { - primary: "#5c86e8", /* azul brillante para el grafo */ - hover: "#7ba0f0", - focus: "#4169d4", - }, - }, - fontFamily: { - /* Tipografía "tool for thought": legible, técnica, cálida */ - sans: ["Inter", "system-ui", "sans-serif"], - mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], - }, - fontSize: { - "2xs": ["0.65rem", { lineHeight: "1rem" }], - }, - spacing: { - /* Sistema de espaciado de 4px base */ - "col-left": "240px", - "col-right": "280px", - }, - borderRadius: { - obs: "6px", - }, - boxShadow: { - "obs-sm": "0 1px 3px rgba(0,0,0,0.5)", - "obs-md": "0 4px 12px rgba(0,0,0,0.6)", - "obs-glow": "0 0 20px rgba(92,134,232,0.15)", - }, - animation: { - "fade-in": "fadeIn 0.15s ease-in-out", - pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", - }, - keyframes: { - fadeIn: { - "0%": { opacity: "0", transform: "translateY(2px)" }, - "100%": { opacity: "1", transform: "translateY(0)" }, - }, - }, - }, - }, - plugins: [], -}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index f069c4b..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Strict mode */ - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index 61a5f31..0000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true, - "noImplicitAny": true - }, - "include": ["vite.config.ts"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 27659c3..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import { resolve } from "path"; - -export default defineConfig({ - plugins: [react()], - base: "./", - build: { - outDir: "../src/bib2graph/gui/static", - emptyOutDir: true, - }, - resolve: { - alias: { - "@": resolve(__dirname, "./src"), - }, - }, - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./src/__tests__/setup.ts"], - }, -}); diff --git a/pyproject.toml b/pyproject.toml index d761b5b..f6f1146 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ # LOCAL estándar (ruff, mypy, pytest, pre-commit, commitizen) configurado desde # el día uno (ADR 0006/0010). CI (.github/workflows/ci.yml) y release-please # (.github/workflows/release-please.yml) YA están conectados; la publicación a -# PyPI queda fuera por ahora (solo GitHub Releases). Ver VERSIONING.md. +# PyPI está conectada desde 0.10.0 vía Trusted Publishing/OIDC +# (.github/workflows/publish-pypi.yml, invocado por release-please). Ver VERSIONING.md. # # Ver: # - docs/ARCHITECTURE.md @@ -74,10 +75,6 @@ viz = [] bibtex = [ "bibtexparser>=1.4.4", ] -gui = [ - "fastapi>=0.110", - "uvicorn>=0.29", -] [project.urls] Homepage = "https://github.com/complexluise/bib2graph" @@ -90,17 +87,15 @@ Issues = "https://github.com/complexluise/bib2graph/issues" # CLI entry point. Hito 6: implementación real del CLI agente-native. # El paquete bib2graph.cli (antes cli.py) expone main() en __init__.py. b2g = "bib2graph.cli:main" +# Alias deprecado del ejecutable legado 'bib2graph' (ADR 0038, #165). +# Emite aviso a stderr y delega en main(). Se retira en 0.11.0. +bib2graph = "bib2graph.cli:main_bib2graph_alias" # ----- Build (hatchling, src layout) -------------------------------------- [tool.hatch.build.targets.wheel] packages = ["src/bib2graph"] -# Build output del frontend (gitignored → hatchling lo omite por defecto). -# force-include vende el artefacto al wheel sin commitearlo al VCS (ADR 0028 §B.1, G5). -# Requiere que `pnpm build` haya corrido ANTES de `uv build`. -[tool.hatch.build.targets.wheel.force-include] -"src/bib2graph/gui/static" = "bib2graph/gui/static" # El sdist incluye fuente, tests y docs para reproducibilidad, pero NO el sandbox # de exploración (datos de investigación IED: CSV/parquet/graphml) ni los binarios @@ -161,8 +156,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["B011"] # asserts en tests están bien -"src/bib2graph/api/routers/*" = ["B008"] # Depends() en defaults es el patrón canónico de FastAPI -"src/bib2graph/api/deps.py" = ["B008"] # idem [tool.ruff.format] quote-style = "double" @@ -188,10 +181,6 @@ ignore_missing_imports = true module = "rapidfuzz.*" ignore_missing_imports = true -[[tool.mypy.overrides]] -module = "uvicorn.*" -ignore_missing_imports = true - [tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] diff --git a/src/bib2graph/api/__init__.py b/src/bib2graph/api/__init__.py deleted file mode 100644 index f50c7d6..0000000 --- a/src/bib2graph/api/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -"""api — Costura FastAPI local (Hito G3, ADR 0028). - -Import perezoso: FastAPI solo se importa dentro de ``create_app``; el núcleo -puede importarse sin fastapi instalado. Usar: - - from bib2graph.api import create_app - app = create_app(ws, token="...") - -o, más habitual, dejar que ``cli/commands/gui.py`` lo invoque. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from bib2graph.workspace import Workspace - - -def create_app( - ws: Workspace, - *, - token: str, - cors_origins: list[str] | None = None, -) -> Any: - """Fábrica de la app FastAPI (import perezoso de fastapi). - - Args: - ws: Workspace resuelto — inyectado una sola vez (singleton por proceso). - token: Token efímero generado por ``b2g gui``. - cors_origins: Lista de orígenes CORS permitidos. Por defecto: - ``["http://localhost:5173", "http://127.0.0.1:5173"]``. - - Returns: - Instancia de ``FastAPI`` configurada con routers, CORS y seguridad. - """ - from bib2graph.api.app import create_app as _create_app - - return _create_app(ws, token=token, cors_origins=cors_origins) diff --git a/src/bib2graph/api/app.py b/src/bib2graph/api/app.py deleted file mode 100644 index 7b41987..0000000 --- a/src/bib2graph/api/app.py +++ /dev/null @@ -1,86 +0,0 @@ -"""api.app — Fábrica de la aplicación FastAPI (Hito G3, ADR 0028). - -``create_app`` monta los routers, CORS, el handler global de excepciones y el -token de seguridad. Es el único lugar del paquete ``api/`` que importa -``fastapi`` directamente — y solo se llama cuando la GUI está instalada. - -El núcleo (``corpus``, ``service``, etc.) nunca importa este módulo. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from bib2graph.workspace import Workspace - -_DEFAULT_CORS = ["http://localhost:5173", "http://127.0.0.1:5173"] - - -def create_app( - ws: Workspace, - *, - token: str, - cors_origins: list[str] | None = None, -) -> Any: - """Construye y configura la aplicación FastAPI. - - Args: - ws: Workspace resuelto — singleton por proceso. - token: Token efímero generado en el arranque (``b2g gui``). - cors_origins: Orígenes CORS permitidos. Default: - ``["http://localhost:5173", "http://127.0.0.1:5173"]``. - - Returns: - Instancia de ``FastAPI`` lista para ``uvicorn.run``. - """ - from fastapi import FastAPI - from fastapi.middleware.cors import CORSMiddleware - from starlette.requests import Request - - from bib2graph.api.deps import make_deps - from bib2graph.api.routers.curate import make_curate_router - from bib2graph.api.routers.reads import make_reads_router - - app = FastAPI( - title="bib2graph API", - description="API local de bib2graph — Hito G3 (ADR 0028).", - ) - - # CORS: permite la SPA en desarrollo (Vite dev-server) - origins = cors_origins if cors_origins is not None else _DEFAULT_CORS - app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Dependencias — fábrica para evitar globales mutables - get_ws_dep, require_token_dep, write_lock_dep = make_deps(ws, token) - - # Routers - reads_router = make_reads_router(get_ws_dep, require_token_dep) - curate_router = make_curate_router(get_ws_dep, require_token_dep, write_lock_dep) - - app.include_router(reads_router) - app.include_router(curate_router) - - # Handler global de excepciones: B2GError + Exception → JSONResponse con envelope - from bib2graph.api.envelopes import make_error_response - from bib2graph.service.errors import B2GError - - @app.exception_handler(B2GError) - async def b2g_error_handler(request: Request, exc: B2GError) -> Any: - """Convierte ``B2GError`` en JSONResponse con el envelope canónico.""" - path = request.url.path.lstrip("/").replace("/", "_") - return make_error_response(path, exc) - - @app.exception_handler(Exception) - async def generic_error_handler(request: Request, exc: Exception) -> Any: - """Convierte cualquier excepción no capturada en JSONResponse 500.""" - path = request.url.path.lstrip("/").replace("/", "_") - return make_error_response(path, exc) - - return app diff --git a/src/bib2graph/api/deps.py b/src/bib2graph/api/deps.py deleted file mode 100644 index 324ed9f..0000000 --- a/src/bib2graph/api/deps.py +++ /dev/null @@ -1,61 +0,0 @@ -"""api.deps — Dependencias FastAPI (workspace singleton, token, write lock). - -Provee tres dependencias inyectables: - - - ``get_workspace`` — devuelve el singleton ``Workspace`` inyectado al crear - la app (no resuelve desde el entorno en cada request). - - ``require_token`` — ``Depends`` que lanza ``HTTPException(401)`` si el - token del header es inválido o está ausente. - - ``acquire_write_lock`` — ``Depends`` que adquiere el ``WriteLock`` global - serializado (una escritura a la vez, ADR 0028 §6 / ADR 0019). - -Uso en ``app.py``: - from bib2graph.api.deps import make_deps - get_ws, check_token, write_lock = make_deps(ws, token) -""" - -from __future__ import annotations - -import threading -from collections.abc import Generator -from typing import Any - - -def make_deps(ws: Any, token: str) -> tuple[Any, Any, Any]: - """Fábrica de las tres dependencias inyectables. - - Cierra sobre ``ws`` y ``token`` para que no sean globales mutables. - - Args: - ws: ``Workspace`` resuelto — singleton por proceso. - token: Token efímero generado en el arranque. - - Returns: - Tupla ``(get_workspace_dep, require_token_dep, acquire_write_lock_dep)`` - listas para usarse con ``Depends(...)``. - """ - from fastapi import Depends, HTTPException - from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - - from bib2graph.api.security import verify_token - - _lock = threading.Lock() - _bearer_scheme = HTTPBearer(auto_error=False) - - async def get_workspace_dep() -> Any: - """Devuelve el workspace singleton inyectado al crear la app.""" - return ws - - async def require_token_dep( - credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), - ) -> None: - """Verifica el Bearer token; lanza 401 si falta o es inválido.""" - if credentials is None or not verify_token(credentials.credentials, token): - raise HTTPException(status_code=401, detail="Token inválido o ausente.") - - def acquire_write_lock_dep() -> Generator[None, None, None]: - """Adquiere el lock global de escritura; libera al salir del scope.""" - with _lock: - yield - - return get_workspace_dep, require_token_dep, acquire_write_lock_dep diff --git a/src/bib2graph/api/envelopes.py b/src/bib2graph/api/envelopes.py deleted file mode 100644 index be7fb8e..0000000 --- a/src/bib2graph/api/envelopes.py +++ /dev/null @@ -1,141 +0,0 @@ -"""api.envelopes — Handler de excepciones y mapeo código→HTTP (ADR 0028 §7). - -Convierte ``B2GError`` / ``Exception`` en ``JSONResponse`` con el status HTTP -adecuado. El body siempre es el envelope ``schema="1"`` íntegro construido -con ``service.build_envelope``; la SPA lee ``error.code`` para la lógica, -NO el status HTTP. - -Mapeo código→HTTP (ADR 0028 §7): - 0 → 200 (éxito) - 1 → 400 (UsageError: parámetro faltante/inválido) - 2 → 422 (DataError: schema inválido / id inexistente) - 3 → 501 (DependencyError: dependencia faltante) - 4 → 502 (NetworkError: red no disponible) - 5 → 409 (StoreError: store bloqueado/corrupto — retry cross-process diferido) - -Excepción no mapeada → 500 (error interno). -""" - -from __future__ import annotations - -from typing import Any - -# Mapeo exit-code → HTTP status (ADR 0028 §7) -_CODE_TO_HTTP: dict[int, int] = { - 0: 200, - 1: 400, - 2: 422, - 3: 501, - 4: 502, - 5: 409, -} - -# Excepción inesperada (bug interno): NO es un error de contrato 0-5. Usamos un -# código fuera del rango (70 = EX_SOFTWARE) que http_status_for mapea a 500 por -# defecto. La SPA lee error.code ("INTERNAL_ERROR") y el HTTP status, no este número. -_INTERNAL_ERROR_EXIT_CODE = 70 - - -def http_status_for(code: int) -> int: - """Devuelve el HTTP status correspondiente al exit-code del contrato. - - Args: - code: Exit code 0-5 (ADR 0021). - - Returns: - HTTP status code según el mapeo de ADR 0028 §7. - """ - return _CODE_TO_HTTP.get(code, 500) - - -def make_error_response( - command: str, - exc: BaseException, -) -> Any: - """Construye una ``JSONResponse`` a partir de una excepción. - - Reusa ``service.build_envelope`` y ``service.code_for`` para no reimplementar - la política de errores. - - Args: - command: Nombre del endpoint / operación (para el envelope). - exc: La excepción capturada. - - Returns: - ``fastapi.responses.JSONResponse`` con el envelope íntegro en el body - y el status HTTP del mapeo. - """ - from fastapi.responses import JSONResponse - - from bib2graph.service.envelope import build_envelope - from bib2graph.service.errors import B2GError, code_for - - if isinstance(exc, B2GError): - exit_code = exc.exit_code - error_code = exc.code - message = exc.message - else: - # Excepción no mapeada por code_for → 500 - try: - exit_code = code_for(exc) - error_code = type(exc).__name__.upper() - message = str(exc) - except TypeError: - # Excepción no mapeada = bug interno → HTTP 500 (NO 409: no es un - # conflicto de store; mentirle a la SPA sugeriría reintentar). - exit_code = _INTERNAL_ERROR_EXIT_CODE - error_code = "INTERNAL_ERROR" - message = str(exc) - - status = http_status_for(exit_code) - envelope = build_envelope( - command=command, - ok=False, - data={}, - exit_code=exit_code, - error={"code": error_code, "message": message}, - ) - return JSONResponse(status_code=status, content=envelope) - - -def exception_handler_factory(command: str) -> Any: - """Fábrica del handler de excepciones para FastAPI. - - Devuelve un handler async compatible con - ``app.add_exception_handler(Exception, handler)``. - - Args: - command: Prefijo del endpoint usado en el envelope de error. - - Returns: - Handler async para ``app.add_exception_handler``. - """ - from starlette.requests import Request - - async def handler(request: Request, exc: Exception) -> Any: - return make_error_response(command, exc) - - return handler - - -def build_ok_response(command: str, data: dict[str, Any]) -> Any: - """Construye una ``JSONResponse`` de éxito con el envelope canónico. - - Args: - command: Nombre del endpoint / operación. - data: Payload del resultado (siempre dict). - - Returns: - ``fastapi.responses.JSONResponse`` con status 200 y el envelope íntegro. - """ - from fastapi.responses import JSONResponse - - from bib2graph.service.envelope import build_envelope - - envelope = build_envelope( - command=command, - ok=True, - data=data, - exit_code=0, - ) - return JSONResponse(status_code=200, content=envelope) diff --git a/src/bib2graph/api/routers/__init__.py b/src/bib2graph/api/routers/__init__.py deleted file mode 100644 index 4c1e096..0000000 --- a/src/bib2graph/api/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""api.routers — Sub-paquete de routers FastAPI.""" diff --git a/src/bib2graph/api/routers/curate.py b/src/bib2graph/api/routers/curate.py deleted file mode 100644 index b3c5b0e..0000000 --- a/src/bib2graph/api/routers/curate.py +++ /dev/null @@ -1,75 +0,0 @@ -"""api.routers.curate — POST /api/paper/{id}/curate (Hito G3, ADR 0028). - -Endpoint de escritura: aplica una decisión de curación a un paper individual. -Requiere el WriteLock para serializar escrituras (ADR 0028 §6 / ADR 0019). - -El reloj (``decided_at``) se inyecta aquí — frontera de la API — igual que -el CLI lo inyecta en su frontera (R2/ADR 0017). -""" - -from __future__ import annotations - -from collections.abc import Generator -from datetime import UTC, datetime -from typing import Any - -from fastapi import APIRouter, Depends -from pydantic import BaseModel - -from bib2graph.api.envelopes import build_ok_response - - -class CurateRequest(BaseModel): - """Body del endpoint de curación.""" - - decision: str - - -def make_curate_router( - get_workspace_dep: Any, - require_token_dep: Any, - acquire_write_lock_dep: Any, -) -> APIRouter: - """Fábrica del router de curación con las dependencias inyectadas. - - Crea un ``APIRouter`` nuevo por invocación para evitar que múltiples - llamadas (p. ej. en tests) acumulen handlers en el mismo router. - - Args: - get_workspace_dep: Dependencia que devuelve el workspace singleton. - require_token_dep: Dependencia de verificación de token. - acquire_write_lock_dep: Dependencia del lock de escritura serializado. - - Returns: - ``APIRouter`` con el endpoint POST montado. - """ - router = APIRouter() - - @router.post("/api/paper/{paper_id}/curate") - async def curate_paper_endpoint( - paper_id: str, - body: CurateRequest, - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - _lock: Generator[None, None, None] = Depends(acquire_write_lock_dep), - ) -> Any: - """Aplica ``decision`` (``accepted``/``rejected``) al paper indicado. - - Toma el WriteLock global: una escritura a la vez (ADR 0028 §6). - Inyecta ``decided_at`` en la frontera API (R2/ADR 0017). - """ - from bib2graph.service.curate import curate_paper - - # R2: el reloj se inyecta en la frontera (la API es una frontera igual - # que el CLI); el servicio no llama datetime.now(). - now = datetime.now(UTC) - data = curate_paper( - ws.library_path, - paper_id, - decision=body.decision, - by="gui", - decided_at=now, - ) - return build_ok_response("curate_paper", data) - - return router diff --git a/src/bib2graph/api/routers/reads.py b/src/bib2graph/api/routers/reads.py deleted file mode 100644 index 0757d3e..0000000 --- a/src/bib2graph/api/routers/reads.py +++ /dev/null @@ -1,118 +0,0 @@ -"""api.routers.reads — 6 GET endpoints de lectura (Hito G3, ADR 0028). - -Cada endpoint llama a ``service.reads.*``, envuelve el resultado con -``build_ok_response`` y deja que el handler global de excepciones convierta -los ``B2GError`` en respuestas de error. - -Endpoints: - GET /api/workspace - GET /api/rounds - GET /api/paper/{id} - GET /api/paper/{id}/scent - GET /api/network/{kind} - GET /api/compare?a=&b= - -``data`` siempre es un dict: las lecturas que devuelven lista se envuelven -en un dict con clave semántica (``rounds``, ``nodes``, etc.) para respetar -la firma de ``build_envelope(data: dict)``. -""" - -from __future__ import annotations - -from typing import Any - -from fastapi import APIRouter, Depends - -from bib2graph.api.envelopes import build_ok_response - - -def make_reads_router( - get_workspace_dep: Any, - require_token_dep: Any, -) -> APIRouter: - """Fábrica del router de lecturas con las dependencias inyectadas. - - Crea un ``APIRouter`` nuevo por invocación para evitar que múltiples - llamadas (p. ej. en tests) acumulen handlers en el mismo router. - - Args: - get_workspace_dep: Dependencia que devuelve el workspace singleton. - require_token_dep: Dependencia de verificación de token. - - Returns: - ``APIRouter`` con los 6 endpoints de lectura montados. - """ - router = APIRouter() - - @router.get("/api/workspace") - async def get_workspace_endpoint( - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Estado del workspace activo.""" - from bib2graph.service.reads import get_workspace - - data = get_workspace(ws) - return build_ok_response("get_workspace", data) - - @router.get("/api/rounds") - async def list_rounds_endpoint( - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Lista de snapshots + entrada viva.""" - from bib2graph.service.reads import list_rounds - - rounds = list_rounds(ws) - return build_ok_response("list_rounds", {"rounds": rounds}) - - @router.get("/api/paper/{paper_id}") - async def get_paper_endpoint( - paper_id: str, - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Fila completa del corpus para un paper.""" - from bib2graph.service.reads import get_paper - - data = get_paper(ws, paper_id) - return build_ok_response("get_paper", data) - - @router.get("/api/paper/{paper_id}/scent") - async def get_scent_endpoint( - paper_id: str, - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Score de acoplamiento + vecinos de un paper.""" - from bib2graph.service.reads import get_scent - - data = get_scent(ws, paper_id) - return build_ok_response("get_scent", data) - - @router.get("/api/network/{kind}") - async def get_network_endpoint( - kind: str, - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Red de la ronda viva para el kind dado.""" - from bib2graph.service.reads import get_network - - data = get_network(ws, kind) - return build_ok_response("get_network", data) - - @router.get("/api/compare") - async def compare_rounds_endpoint( - a: str, - b: str, - ws: Any = Depends(get_workspace_dep), - _token: None = Depends(require_token_dep), - ) -> Any: - """Diff entre dos snapshots (added/removed paper_ids + metricas).""" - from bib2graph.service.reads import compare_rounds - - data = compare_rounds(ws, a, b) - return build_ok_response("compare_rounds", data) - - return router diff --git a/src/bib2graph/api/security.py b/src/bib2graph/api/security.py deleted file mode 100644 index 89bbc6b..0000000 --- a/src/bib2graph/api/security.py +++ /dev/null @@ -1,34 +0,0 @@ -"""api.security — Token efímero para la API local (ADR 0028, Nota 12 C.3). - -El token se genera en ``b2g gui`` con ``secrets.token_urlsafe(32)`` y se -inyecta en ``create_app``. La verificación usa ``secrets.compare_digest`` -para evitar timing attacks (aunque el servidor solo escucha en 127.0.0.1). - -El token viaja en el header ``Authorization: Bearer ``. -""" - -from __future__ import annotations - -import secrets - - -def generate_token() -> str: - """Genera un token efímero de 32 bytes (URL-safe base64). - - Returns: - Token de 43 caracteres URL-safe. - """ - return secrets.token_urlsafe(32) - - -def verify_token(provided: str, expected: str) -> bool: - """Verifica el token con comparación de tiempo constante. - - Args: - provided: Token enviado por el cliente (del header ``Authorization``). - expected: Token canónico generado en el arranque. - - Returns: - ``True`` si los tokens coinciden. - """ - return secrets.compare_digest(provided, expected) diff --git a/src/bib2graph/backends/duckdb.py b/src/bib2graph/backends/duckdb.py index cf17a0d..4fe0583 100644 --- a/src/bib2graph/backends/duckdb.py +++ b/src/bib2graph/backends/duckdb.py @@ -28,8 +28,9 @@ from __future__ import annotations import contextlib +import json from pathlib import Path -from typing import Literal +from typing import Any, Literal import duckdb import pyarrow as pa @@ -154,6 +155,18 @@ class StoreLockedError(OSError): ) """ +# #141 — tabla de EnricherRef para trazabilidad del manifest. +# Cada ejecución de enriquecimiento appendea/reemplaza sus refs de enriquecedor. +# ``params_json`` almacena el dict ``params`` de ``EnricherRef`` serializado como JSON. +# ``recorded_at`` usa ``now()`` de DuckDB (mismo patrón que ``filter_log``). +_DDL_ENRICHER_LOG = """ +CREATE TABLE IF NOT EXISTS enricher_log ( + name VARCHAR NOT NULL, + params_json VARCHAR NOT NULL DEFAULT '{}', + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +""" + # ADR 0024: migración liviana para bases pre-existentes sin columna ``_seq``. # Se suprime el error si la columna ya existe (CatalogException o BinderException). # El backfill usa ``rowid`` como proxy de orden de inserción original; es inocuo @@ -356,6 +369,8 @@ def _setup(self) -> None: self._con.execute(_DDL_EXTERNAL_IDS) # #126: tabla de pasos de filtro PRISMA para trazabilidad del manifest. self._con.execute(_DDL_FILTER_LOG) + # #141: tabla de EnricherRef para trazabilidad del manifest. + self._con.execute(_DDL_ENRICHER_LOG) self._register_udfs() def _register_udfs(self) -> None: @@ -485,6 +500,17 @@ def _clone(self) -> DuckDBBackend: "VALUES (?, ?, ?, ?, ?)", [name_val, criteria_val, cb_val, ca_val, at_val], ) + # #141: copiar la tabla de EnricherRef + enricher_rows = self._con.execute( + "SELECT name, params_json, recorded_at " + "FROM enricher_log ORDER BY recorded_at" + ).fetchall() + for er_name, er_params, er_at in enricher_rows: + new_backend._con.execute( + "INSERT INTO enricher_log (name, params_json, recorded_at) " + "VALUES (?, ?, ?)", + [er_name, er_params, er_at], + ) return new_backend else: # Para archivo en disco: compartimos la ruta; nueva instancia abre nueva conexión @@ -901,6 +927,64 @@ def load_filter_steps(self) -> list[dict[str, object]]: for r in rows ] + # ------------------------------------------------------------------ + # Extensiones propias: enricher_log (#141) + # ------------------------------------------------------------------ + + def persist_enricher_refs( + self, + refs: list[object], + *, + replace: bool = True, + ) -> None: + """Persiste las referencias de enriquecedor en ``enricher_log``. + + #141 — trazabilidad de enriquecimiento: los ``EnricherRef`` aplicados + en ``b2g enrich`` / ``b2g chain`` / ``b2g build`` se guardan para que + ``manifest.enrichers`` sobreviva entre cargas del store. + + Por defecto (``replace=True``) limpia las entradas anteriores antes de + insertar las nuevas: cada invocación reemplaza el registro completo + (idempotencia; el ``EnricherRef`` se identifica por nombre, ADR 0025). + + Args: + refs: Lista de ``EnricherRef`` (se accede a sus atributos + ``name`` y ``params``). + replace: Si es ``True`` (default), trunca ``enricher_log`` antes + de insertar. Si es ``False``, appendea. + """ + if replace: + self._con.execute("DELETE FROM enricher_log") + for ref in refs: + name = getattr(ref, "name", None) + params = getattr(ref, "params", {}) + self._con.execute( + "INSERT INTO enricher_log (name, params_json) VALUES (?, ?)", + [name, json.dumps(params)], + ) + + def load_enricher_refs(self) -> list[dict[str, Any]]: + """Carga las referencias de enriquecedor desde ``enricher_log``. + + #141 — trazabilidad de enriquecimiento: permite que + ``DuckDBStore.load()`` reconstruya ``manifest.enrichers`` con los + EnricherRef persistidos. + + Returns: + Lista de dicts con las claves ``name`` y ``params`` (dict + ``str→str``) en el orden de inserción. + """ + rows = self._con.execute( + "SELECT name, params_json FROM enricher_log ORDER BY recorded_at" + ).fetchall() + return [ + { + "name": r[0], + "params": json.loads(r[1]), + } + for r in rows + ] + # ------------------------------------------------------------------ # Extensión: query SQL libre # ------------------------------------------------------------------ diff --git a/src/bib2graph/cli/__init__.py b/src/bib2graph/cli/__init__.py index c00846a..3c6ea98 100644 --- a/src/bib2graph/cli/__init__.py +++ b/src/bib2graph/cli/__init__.py @@ -1,15 +1,24 @@ -"""cli — CLI agente-native ``b2g`` (Hito 6 + ADR 0029 workspace). +"""cli — CLI agente-native ``b2g`` (Hito 6 + ADR 0029 workspace + #155 + ADR 0038). -Arma el grupo Click principal, registra los 20 subcomandos y expone -``main()`` como entry point del paquete. +Arma el grupo Click principal, registra los subcomandos planos y los grupos +noun-verb, y expone ``main()`` como entry point del paquete. -Entry point en ``pyproject.toml``: - b2g = "bib2graph.cli:main" +Entry points en ``pyproject.toml``: + b2g = "bib2graph.cli:main" + bib2graph = "bib2graph.cli:main_bib2graph_alias" # deprecado, #165 -Subcomandos: - init, seed, chain, filter, build, enrich, monitor, export, snapshot, - status, inspect, validate, accept, reject, curate, networks, restore, - thesaurus, gui, resolve. +Subcomandos planos (17): + init, seed, chain, filter, build, enrich, monitor, export, + status, validate, accept, reject, networks, restore (shim #163), + resolve. + + inspect: absorbido por ``read show`` (#156); permanece como alias. + +Grupos noun-verb (4): + read [list|stats|show|top] — lecturas read-only del corpus (#156/#157). + curate [dump|apply|accept|reject|filter] — curación en lote (#155). + snapshot [create|restore] — fotos selladas y rehidratación (#163, ADR 0038). + skill [add] — instala la skill de bib2graph para Claude (Epic #188). Cada subcomando lleva: - ``--json``: salida JSON estructurada (envelope versionado, §API.md). @@ -23,6 +32,10 @@ La opción ``--store`` fue eliminada (#75): pasarla produce el error estándar de Click ("No such option"). El modo degenerado (.duckdb suelto) ya no existe. +ADR 0038 — snapshot como grupo noun-verb: + ``snapshot`` es ahora un grupo ``{create, restore}`` (BREAKING). + ``b2g restore`` se mantiene como shim intacto (#165 retirará el alias). + R5 — UTF-8 en la frontera: ``main()`` fuerza ``sys.stdout``/``sys.stderr`` a UTF-8 antes de que Click lea cualquier argumento. Esto corrige la corrupción de acentos en Windows @@ -37,25 +50,26 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli.commands.accept import accept_cmd from bib2graph.cli.commands.build import build_cmd from bib2graph.cli.commands.chain import chain_cmd -from bib2graph.cli.commands.curate import curate_cmd +from bib2graph.cli.commands.curate import curate_grp from bib2graph.cli.commands.enrich import enrich_cmd from bib2graph.cli.commands.export import export_cmd from bib2graph.cli.commands.filter import filter_cmd -from bib2graph.cli.commands.gui import gui_cmd from bib2graph.cli.commands.init import init_cmd from bib2graph.cli.commands.inspect import inspect_cmd from bib2graph.cli.commands.monitor import monitor_cmd from bib2graph.cli.commands.networks import networks_cmd +from bib2graph.cli.commands.read import read_grp from bib2graph.cli.commands.reject import reject_cmd from bib2graph.cli.commands.resolve import resolve_cmd from bib2graph.cli.commands.restore import restore_cmd from bib2graph.cli.commands.seed import seed_cmd -from bib2graph.cli.commands.snapshot import snapshot_cmd +from bib2graph.cli.commands.skill import skill_grp +from bib2graph.cli.commands.snapshot import snapshot_grp from bib2graph.cli.commands.status import status_cmd -from bib2graph.cli.commands.thesaurus import thesaurus_cmd from bib2graph.cli.commands.validate import validate_cmd @@ -76,6 +90,7 @@ def _force_utf8() -> None: @click.group() +@click.version_option(package_name="bib2graph") @click.option( "--workspace", default=None, @@ -101,8 +116,11 @@ def b2g(ctx: click.Context, workspace: str | None) -> None: error accionable (exit 1) que sugiere 'b2g init' o '--workspace'. Subcomandos: init, seed, chain, filter, build, enrich, monitor, export, - snapshot, status, inspect, validate, accept, reject, curate, networks, - restore, thesaurus, gui, resolve. + status, validate, accept, reject, networks, restore (shim), + resolve, + read [list|stats|show|top], curate [dump|apply|accept|reject|filter], + snapshot [create|restore] (ADR 0038), + skill [add] (Epic #188). Ejemplo: b2g init mi-investigacion @@ -114,7 +132,8 @@ def b2g(ctx: click.Context, workspace: str | None) -> None: ctx.obj["workspace"] = workspace -# Registrar los 20 subcomandos +# Registrar subcomandos planos + grupos noun-verb read (#156), curate (#155), +# snapshot (#163, ADR 0038), skill (Epic #188) b2g.add_command(init_cmd) b2g.add_command(seed_cmd) b2g.add_command(chain_cmd) @@ -123,18 +142,18 @@ def b2g(ctx: click.Context, workspace: str | None) -> None: b2g.add_command(enrich_cmd) b2g.add_command(monitor_cmd) b2g.add_command(export_cmd) -b2g.add_command(snapshot_cmd) +b2g.add_command(snapshot_grp) b2g.add_command(status_cmd) b2g.add_command(inspect_cmd) b2g.add_command(validate_cmd) b2g.add_command(accept_cmd) b2g.add_command(reject_cmd) -b2g.add_command(curate_cmd) +b2g.add_command(curate_grp) b2g.add_command(networks_cmd) b2g.add_command(restore_cmd) -b2g.add_command(thesaurus_cmd) -b2g.add_command(gui_cmd) b2g.add_command(resolve_cmd) +b2g.add_command(read_grp) +b2g.add_command(skill_grp) def main() -> int: @@ -164,3 +183,23 @@ def main() -> int: return 1 except SystemExit as exc: return int(exc.code) if exc.code is not None else 0 + + +def main_bib2graph_alias() -> int: + """Entry point del ejecutable legado ``bib2graph`` (alias deprecado, #165). + + Emite el aviso de deprecación a stderr y delega en ``main()``. + + DEPRECADO: el ejecutable canónico es ``b2g``. Este alias se retira en 0.11.0 + (ADR 0038, #165). + + Returns: + Exit code del proceso (0 éxito, 1-5 error según ADR 0010). + """ + _force_utf8() + emit_deprecation( + "bib2graph", + "b2g", + removed_in="0.11.0", + ) + return main() diff --git a/src/bib2graph/cli/_deprecation.py b/src/bib2graph/cli/_deprecation.py new file mode 100644 index 0000000..2ce1311 --- /dev/null +++ b/src/bib2graph/cli/_deprecation.py @@ -0,0 +1,50 @@ +"""cli._deprecation — Helper de avisos de deprecación (ADR 0038, #165). + +Centraliza la emisión de avisos de deprecación para verbos sueltos y +ejecutables legados. El aviso va SIEMPRE a stderr (tanto en modo humano +como en modo ``--json``) para no contaminar stdout (#151). + +En modo ``--json`` el mensaje también se propaga al top-level ``warnings[]`` +del envelope vía ``build_envelope(..., warnings=[msg])``. + +Formato canónico:: + + AVISO: 'b2g networks' está deprecado y se eliminará en 0.11.0; usá 'b2g build --spec'. + +Retiro planificado: 0.11.0 (ADR 0038 P1). +""" + +from __future__ import annotations + +import sys + + +def emit_deprecation( + old: str, + new: str, + *, + removed_in: str = "0.11.0", +) -> str: + """Emite un aviso de deprecación a stderr y devuelve el mensaje. + + El mensaje se imprime **siempre a stderr** (nunca a stdout), lo que + garantiza que el stdout en modo ``--json`` permanezca limpio (#151). + El retorno permite al llamador enhebrarlo en el ``warnings[]`` del + envelope JSON (top-level, no ``data.deprecation``). + + Args: + old: Forma deprecada (p.ej. ``'b2g networks'``). + new: Forma canónica sugerida (p.ej. ``'b2g build --spec'``). + removed_in: Versión en la que se eliminará (default ``"0.11.0"``). + + Returns: + El mensaje de aviso emitido, listo para enhebrarlo en el envelope. + + Example:: + + msg = emit_deprecation("b2g networks", "b2g build --spec") + envelope = build_envelope(..., warnings=[msg]) + """ + msg = f"AVISO: '{old}' está deprecado y se eliminará en {removed_in}; usá '{new}'." + print(msg, file=sys.stderr) + return msg diff --git a/src/bib2graph/cli/_enrich.py b/src/bib2graph/cli/_enrich.py new file mode 100644 index 0000000..a8d14ea --- /dev/null +++ b/src/bib2graph/cli/_enrich.py @@ -0,0 +1,118 @@ +"""cli._enrich — Helper de enriquecimiento en memoria (sin I/O de store). + +Centraliza la lógica de enriquecimiento para que ``chain``, ``build`` y +``enrich`` compartan UNA sola implementación (fuente única, sin duplicar). + +El helper opera sobre un ``Corpus`` ya en memoria, dentro de la transacción +existente de quien lo llama. NO abre ni cierra el store: eso es +responsabilidad del llamante. + +Pasadas soportadas: + - ``"refs_doi"``: resuelve ``references_id`` → ``references_doi`` (Hito 8a). + Corre en ``chain`` (forrajeo puro), automática. + - ``"cited_by"``: puebla ``cited_by_id`` de las semillas aceptadas (Hito 8b). + Corre en ``build``, automática si hay seeds aceptadas; si no, no-op graceful. + - ``"both"``: ambas pasadas (se usa en ``enrich`` standalone). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from bib2graph.corpus import Corpus + from bib2graph.sources.openalex import OpenAlexSource + + +def enrich_corpus( + corpus: Corpus, + source: OpenAlexSource, + *, + max_citing: int | None = None, + pass_name: str = "both", +) -> tuple[Corpus, dict[str, Any]]: + """Enriquece el corpus en memoria con una o ambas pasadas de OpenAlex. + + Construye un ``OpenAlexEnricher`` con el ``source`` dado y ejecuta la(s) + pasada(s) indicadas. Extrae las métricas del Manifest del corpus resultado + y las devuelve junto al corpus enriquecido. + + No abre store ni hace I/O de persistencia — eso es responsabilidad + del llamante (chain/build/enrich). + + Args: + corpus: Corpus en memoria sobre el que operar. + source: ``OpenAlexSource`` ya instanciado y configurado. + El mismo source que el comando ya creó para chaining o build. + max_citing: Tope de citantes por semilla para la pasada ``cited_by``. + ``None`` = sin tope. Ignorado en ``refs_doi``. + pass_name: Pasada(s) a ejecutar. + ``"refs_doi"`` = solo Pasada 1 (resuelve DOIs de referencias). + ``"cited_by"`` = solo Pasada 2 (puebla citantes de seeds aceptadas). + ``"both"`` = ambas en orden (Pasada 1 → Pasada 2). Default. + + Returns: + Tupla ``(corpus_enriquecido, metricas)`` donde ``metricas`` es un + dict con un subconjunto de: + - ``refs_resolved`` (int): referencias resueltas a DOI. + - ``refs_total_unique`` (int): total de references_id únicos. + - ``citing_new`` (int): nuevos citantes añadidos a cited_by_id. + - ``citing_targets`` (int): seeds aceptadas procesadas en pasada 2. + Solo aparecen las claves de las pasadas que se ejecutaron. + + Raises: + httpx.HTTPError: Si falla la conexión a OpenAlex (propagado). + ValueError: Si ``pass_name`` no es ``"refs_doi"``, ``"cited_by"`` + ni ``"both"``. + """ + from bib2graph.enrichers.openalex import OpenAlexEnricher + + if pass_name not in ("refs_doi", "cited_by", "both"): + raise ValueError( + f"pass_name desconocido: {pass_name!r}. " + "Usá 'refs_doi', 'cited_by' o 'both'." + ) + + enricher = OpenAlexEnricher(source, max_citing_per_paper=max_citing) + + if pass_name == "refs_doi": + enriched = enricher.enrich_references_doi(corpus) + elif pass_name == "cited_by": + enriched = enricher.enrich_cited_by(corpus) + else: # "both" + enriched = enricher.enrich(corpus) + + metrics = _extract_metrics(enriched) + return enriched, metrics + + +def _extract_metrics(corpus: Corpus) -> dict[str, Any]: + """Extrae métricas de enriquecimiento del Manifest del corpus. + + Recorre ``corpus.manifest.enrichers`` buscando las entradas de las + pasadas 1 (``openalex_references_doi``) y 2 (``openalex_cited_by``). + Devuelve un dict con solo las claves presentes. + + Args: + corpus: Corpus enriquecido (con el Manifest actualizado por el Enricher). + + Returns: + Dict con claves ``refs_resolved``, ``refs_total_unique``, ``citing_new`` + y/o ``citing_targets`` según las pasadas ejecutadas. + """ + enricher_refs = corpus.manifest.enrichers + metrics: dict[str, Any] = {} + + doi_entry = next( + (e for e in enricher_refs if e.name == "openalex_references_doi"), None + ) + if doi_entry is not None: + metrics["refs_resolved"] = int(doi_entry.params.get("resolved", 0)) + metrics["refs_total_unique"] = int(doi_entry.params.get("total_unique_refs", 0)) + + cb_entry = next((e for e in enricher_refs if e.name == "openalex_cited_by"), None) + if cb_entry is not None: + metrics["citing_new"] = int(cb_entry.params.get("resolved", 0)) + metrics["citing_targets"] = int(cb_entry.params.get("total", 0)) + + return metrics diff --git a/src/bib2graph/cli/_errors.py b/src/bib2graph/cli/_errors.py index dea136b..2a5c36e 100644 --- a/src/bib2graph/cli/_errors.py +++ b/src/bib2graph/cli/_errors.py @@ -41,6 +41,7 @@ # Re-exportar la jerarquía desde la capa de servicios neutral (ADR 0028). # Los imports existentes de cli/ y tests resuelven a los mismos objetos. +from bib2graph.cli._options import json_mode as _resolve_json_mode from bib2graph.service.errors import ( B2GError, DataError, @@ -121,10 +122,9 @@ def handle_errors(command: str) -> Callable[[F], F]: def decorator(func: F) -> F: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: - # Determinar modo JSON desde kwargs o ctx - json_mode: bool = kwargs.get("json_output", False) or kwargs.get( - "json", False - ) + # Determinar modo JSON desde el flag local (json_output) o env var B2G_JSON. + _local_flag = bool(kwargs.get("json_output", False)) + json_mode: bool = _resolve_json_mode(_local_flag) try: return func(*args, **kwargs) diff --git a/src/bib2graph/cli/_ingest.py b/src/bib2graph/cli/_ingest.py index 4beec33..3886aca 100644 --- a/src/bib2graph/cli/_ingest.py +++ b/src/bib2graph/cli/_ingest.py @@ -3,7 +3,7 @@ Aplica la pipeline de preprocesamiento automático al corpus completo en el momento de la ingesta (seed / seed_from_bib / restore / chain), sobre el corpus YA MERGEADO con el existente. El thesaurus queda como paso explícito -(``b2g thesaurus``). +(``b2g build --thesaurus``). Orden determinista: 1. ``Preprocessor().normalize`` — canonicaliza ``authors_id`` y ``language``. @@ -48,7 +48,7 @@ def normalize_and_dedup( mismo resultado (las tres operaciones son idempotentes). El thesaurus NO se aplica aquí; es un paso explícito del usuario - (``b2g thesaurus``). + (``b2g build --thesaurus``). Args: corpus: Corpus de entrada (no muta; semántica de valor). diff --git a/src/bib2graph/cli/_options.py b/src/bib2graph/cli/_options.py new file mode 100644 index 0000000..78897ee --- /dev/null +++ b/src/bib2graph/cli/_options.py @@ -0,0 +1,156 @@ +"""cli._options — Opciones Click compartidas entre subcomandos. + +Define el decorador ``json_option`` (una sola declaración del flag ``--json``), +los helpers ``_env_truthy`` / ``json_mode`` para resolver el modo JSON desde +el flag local o la variable de entorno ``B2G_JSON``, y ``parse_since`` para +convertir el flag ``--since`` de ``chain`` en un ``date`` concreto. + +Precedencia para activar el modo JSON: + 1. ``--json`` explícito en el comando (flag local ``json_output=True``). + 2. Variable de entorno ``B2G_JSON`` truthy (``"1"``, ``"true"``, ``"yes"``, + case-insensitive). + +Caso de uso agente-native: un agente setea ``B2G_JSON=1`` una vez y no pasa +``--json`` nunca más; todos los comandos emiten el envelope sin flag explícito. + +No hay ``--no-json``: el modo humano es el default cuando ni el flag ni la env +var están presentes. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Callable +from datetime import date, timedelta +from typing import Any, TypeVar + +import click + +F = TypeVar("F", bound=Callable[..., Any]) + +# --------------------------------------------------------------------------- +# Helpers de modo JSON +# --------------------------------------------------------------------------- + + +def _env_truthy(name: str) -> bool: + """Devuelve True si la variable de entorno ``name`` está seteada y es truthy. + + Considera truthy: ``"1"``, ``"true"``, ``"yes"`` (case-insensitive). + Todo lo demás (ausente, ``"0"``, ``"false"``, ``"no"``, vacío) → False. + + Args: + name: Nombre de la variable de entorno. + + Returns: + True si la variable tiene un valor truthy. + """ + val = os.environ.get(name, "").strip().lower() + return val in ("1", "true", "yes") + + +def json_mode(local_flag: bool) -> bool: + """Resuelve si el modo JSON está activo (flag local O env var ``B2G_JSON``). + + Precedencia: + 1. ``--json`` explícito (``local_flag=True``) → modo JSON. + 2. ``B2G_JSON`` truthy en el entorno → modo JSON. + 3. Ninguno → modo humano. + + Usalo en cada subcomando en lugar de ``if json_output:`` → + ``if json_mode(json_output):``. + + Args: + local_flag: Valor del flag ``--json`` recibido por el comando Click. + + Returns: + True si el modo JSON está activo (por flag o por entorno). + """ + return bool(local_flag) or _env_truthy("B2G_JSON") + + +def json_option(func: F) -> F: + """Decorador que agrega ``--json`` / ``json_output`` a un subcomando Click. + + Declaración única del flag; aplicado con ``@json_option`` en cada + subcomando en lugar de repetir el bloque ``@click.option("--json", ...)``. + El parámetro inyectado en la firma del comando es ``json_output: bool``. + + Para activar el modo JSON sin pasar el flag, setear ``B2G_JSON=1`` + (o ``"true"`` / ``"yes"``) en el entorno; ver ``json_mode``. + + Args: + func: Función Click a decorar. + + Returns: + La función decorada con la opción ``--json``. + """ + return click.option( + "--json", + "json_output", + is_flag=True, + default=False, + help="Salida JSON estructurada (también activado por B2G_JSON=1).", + )(func) + + +# --------------------------------------------------------------------------- +# Helper de --since +# --------------------------------------------------------------------------- + +_RELATIVE_RE = re.compile(r"^(\d+)(d|m|y)$", re.IGNORECASE) + +_DAYS_PER_UNIT: dict[str, int] = { + "d": 1, + "m": 30, + "y": 365, +} + + +def parse_since(value: str, *, now: date | None = None) -> date: + """Parsea el valor de ``--since`` en un ``date`` concreto. + + Acepta dos formatos: + - Fecha ISO ``YYYY-MM-DD`` (p. ej. ``"2024-01-01"``). + - Atajo relativo ``Nd``, ``Nm``, ``Ny`` (p. ej. ``"90d"``, ``"6m"``, ``"1y"``). + El relativo se resuelve contra ``now`` o ``datetime.now(UTC).date()`` + inyectado en la frontera (R2/ADR 0017). + + La resolución del reloj se hace en la -frontera- (el comando Click), NO + dentro del núcleo ni del servicio. + + Args: + value: Cadena recibida del flag CLI. + now: Fecha base para relativos (inyectable en tests). Cuando es + ``None``, se usa ``date.today()`` (equivalente a ``datetime.now(UTC).date()``). + + Returns: + Fecha resuelta como ``date``. + + Raises: + UsageError: Si el formato no es reconocido. + """ + from bib2graph.service.errors import UsageError + + value = value.strip() + + # Intento ISO primero + try: + return date.fromisoformat(value) + except ValueError: + pass + + # Intento relativo + m = _RELATIVE_RE.match(value) + if m: + n = int(m.group(1)) + unit = m.group(2).lower() + base = now if now is not None else date.today() + delta_days = n * _DAYS_PER_UNIT[unit] + return base - timedelta(days=delta_days) + + raise UsageError( + f"Formato de --since no reconocido: {value!r}. " + "Usá una fecha ISO (YYYY-MM-DD) o un atajo relativo (90d, 6m, 1y)." + ) diff --git a/src/bib2graph/cli/commands/accept.py b/src/bib2graph/cli/commands/accept.py index e653f77..f8ed674 100644 --- a/src/bib2graph/cli/commands/accept.py +++ b/src/bib2graph/cli/commands/accept.py @@ -1,4 +1,4 @@ -"""cli.commands.accept — Subcomando ``b2g accept``. +"""cli.commands.accept — Subcomando ``b2g accept`` (alias deprecado, #165). Marca papers como accepted en el corpus. @@ -10,6 +10,8 @@ Shim delgado (ADR 0028 G3): la orquestación vive en ``service.curate``; este módulo inyecta el reloj (frontera CLI, R2/ADR 0017) y delega. + +DEPRECADO (ADR 0038, #165): usar ``b2g curate accept``. Se retira en 0.11.0. """ from __future__ import annotations @@ -20,8 +22,10 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import resolve_library_path # --------------------------------------------------------------------------- @@ -76,13 +80,7 @@ def run_accept( show_default=True, help="Identificador de quien decide.", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("accept") def accept_cmd( @@ -96,15 +94,17 @@ def accept_cmd( Curación TRANSVERSAL: no transiciona el CycleState. Disponible en cualquier estado del lazo (Nota 05 §4, ADR 0016 enmendado R3). """ + dep_msg = emit_deprecation("b2g accept", "b2g curate accept") store_path = resolve_library_path(ctx.obj) data = run_accept(store_path, list(ids), by=by) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="accept", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/build.py b/src/bib2graph/cli/commands/build.py index 5af13b1..ee4f81a 100644 --- a/src/bib2graph/cli/commands/build.py +++ b/src/bib2graph/cli/commands/build.py @@ -1,15 +1,29 @@ """cli.commands.build — Subcomando ``b2g build``. -Computa las redes bibliométricas con Networks.quick y escribe artefactos -a disco. Transiciona el CycleState a BUILT tras persistir con éxito. +Computa las redes bibliométricas y escribe artefactos a disco. +Transiciona el CycleState a BUILT tras persistir con éxito. + +Modos de operación (ADR 0038, #159): + - **Sin ``--spec``** (one-shot): usa ``Networks.quick``. Construye las 4-5 + redes principales. + - **Con ``--spec``** (declarativo): carga un YAML con ``load_specs`` y + construye cada red con ``Networks.build``. Absorbe la capacidad de + ``b2g networks``, pero con transición de FSM y sellado de hash (D1). + +En AMBOS modos: + - ``--scope`` pre-filtra el corpus (``all`` / ``accepted`` / ``seeds``). + - ``--min-weight`` filtra aristas con peso < N (default 1 = sin filtro). + - Se transiciona a BUILT y se sella ``networks/.corpus_hash``. + - Se diagnostican redes vacías en build-time, reusando ``predict_build_preview`` + (fuente única con ``status``, ADR 0037 §(e)). ADR 0029 — workspace: El directorio de artefactos es ``/networks/`` por defecto. Si se pasa ``--out-dir`` explícito, se usa ese. ADR 0029 — sellado por corpus_hash: - Escribe ``networks/.corpus_hash`` con el hash del corpus que produjo - las redes. Permite detectar staleness sin un build system completo. + Escribe ``networks/.corpus_hash`` con el hash del corpus **filtrado** que + produjo las redes. Permite detectar staleness sin un build system completo. Los artefactos se escriben en ``//``: - ``network.graphml``: el grafo en formato GraphML. @@ -22,13 +36,18 @@ import csv as _csv import json +import sys +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any import click +from bib2graph.cli._deprecation import emit_deprecation +from bib2graph.cli._enrich import enrich_corpus from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DependencyError, handle_errors +from bib2graph.cli._errors import DataError, DependencyError, handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_workspace if TYPE_CHECKING: @@ -36,6 +55,71 @@ from bib2graph.networks.spec import NetworkArtifact +# --------------------------------------------------------------------------- +# Helper: mapeo del vocabulario CLI de --scope al vocabulario interno de corpus.scoped() +# --------------------------------------------------------------------------- + + +def _map_scope(scope: str) -> str: + """Mapea el vocab de ``--scope`` (CLI) al vocab interno de ``corpus.scoped()``. + + ``--scope`` usa ``seeds`` (forma corta), mientras que ``corpus.scoped()`` + espera ``seeds_only``. Los demás valores son idénticos en ambos vocabs. + + Args: + scope: Valor del flag ``--scope`` (``all`` | ``accepted`` | ``seeds``). + + Returns: + Vocabulario interno: ``all`` | ``accepted`` | ``seeds_only``. + """ + if scope == "seeds": + return "seeds_only" + return scope + + +# --------------------------------------------------------------------------- +# Helper compartido: carga de specs YAML + construcción de artefactos +# --------------------------------------------------------------------------- + + +def _build_from_spec_file( + corpus: Corpus, + spec_path: str | Path, +) -> list[NetworkArtifact]: + """Carga specs YAML y construye artefactos con ``Networks.build``. + + Helper compartido entre ``run_build --spec`` y ``run_networks``. + Centraliza la carga YAML + proyección para que ambos comandos no diverjan + (frontera con #165: cuando ``networks`` se retire, no habrá reconciliación). + + Args: + corpus: Corpus ya filtrado (scope aplicado por quien llama). + spec_path: Ruta al archivo YAML con la definición de redes. + + Returns: + Lista de ``NetworkArtifact`` en el orden del YAML. + + Raises: + DataError: Si el YAML está malformado o alguna spec es inválida. + DependencyError: Si falta ``python-louvain`` al detectar comunidades. + """ + from bib2graph.networks.facade import Networks + from bib2graph.networks.spec import load_specs + + try: + specs = load_specs(spec_path) + except (ValueError, FileNotFoundError) as exc: + raise DataError(str(exc)) from exc + + try: + return [Networks.build(corpus, spec) for spec in specs] + except ImportError as exc: + raise DependencyError( + f"Dependencia faltante para detectar comunidades: {exc}. " + "Instalá python-louvain: uv add python-louvain." + ) from exc + + # --------------------------------------------------------------------------- # Helper compartido: escritura de artefactos por red # --------------------------------------------------------------------------- @@ -154,6 +238,73 @@ def _write_artifacts( return networks_info +# --------------------------------------------------------------------------- +# Helper: aplicar thesaurus al corpus y persistir (ADR 0011, #164) +# --------------------------------------------------------------------------- + + +def _apply_thesaurus_and_persist( + corpus: Corpus, + thesaurus_path: str | Path, + store: Any, +) -> tuple[Corpus, dict[str, Any]]: + """Aplica el thesaurus al corpus completo y persiste (ADR 0011, #164). + + Sobrescribe keywords_id con los conceptos canonicos del mapa curado + y persiste el resultado. El CycleState NO se modifica aqui. + Se aplica al corpus COMPLETO (pre-scoping) para que la consolidacion + impacte la co-ocurrencia de keywords sea cual sea el scope de la red. + + Args: + corpus: Corpus a procesar (no muta; semantica de valor). + thesaurus_path: Ruta al JSON del thesaurus (formato ADR 0011). + store: Store abierto (para persist_replace). + + Returns: + Tupla (corpus_actualizado, stats) con keywords_mapped, + keywords_total, aliases_loaded y applied_at. + + Raises: + DataError: Si el archivo no existe o tiene formato invalido. + """ + from bib2graph.preprocessors.preprocessor import Preprocessor + from bib2graph.preprocessors.thesaurus import load_thesaurus + + resolved = Path(thesaurus_path) + try: + lookup = load_thesaurus(resolved) + except (FileNotFoundError, KeyError, ValueError, TypeError) as exc: + raise DataError( + f"No se pudo cargar el thesaurus '{resolved}': {exc}. " + "Verificá el formato JSON ADR 0011." + ) from exc + + applied_at = datetime.now(UTC) + preprocessor = Preprocessor() + updated = preprocessor.apply_thesaurus(corpus, resolved, applied_at=applied_at) + + # persist_replace: evita acumulacion de canonicos viejos (ADR 0011). + store.persist_replace(updated) + + rows = updated.to_arrow().to_pylist() + total_kw = sum(len(r["keywords_id"]) for r in rows if r.get("keywords_id")) + canonical_set = set(lookup.values()) + mapped_kw = sum( + 1 + for r in rows + if r.get("keywords_id") + for kw in r["keywords_id"] + if kw in canonical_set + ) + stats: dict[str, Any] = { + "keywords_mapped": mapped_kw, + "keywords_total": total_kw, + "aliases_loaded": len(lookup), + "applied_at": applied_at.isoformat(), + } + return updated, stats + + # --------------------------------------------------------------------------- # Función núcleo (testeable, sin Click) # --------------------------------------------------------------------------- @@ -164,16 +315,34 @@ def run_build( *, out_dir: str | Path | None = None, corpus_scope: str = "all", + spec_path: str | Path | None = None, + min_weight: int = 1, + scope_cli_token: str | None = None, + email: str | None = None, + transport: Any = None, + max_citing: int | None = None, + thesaurus_path: str | Path | None = None, ) -> dict[str, Any]: """Computa redes bibliométricas y escribe artefactos a disco. - Usa ``Networks.quick`` para las 4 redes principales (coupling, co-autoría, - institución, co-word). Escribe GraphML + metrics.json por red. + Modos de operación (#159 — absorción de ``networks``): + - **Sin ``spec_path``**: usa ``Networks.quick(corpus, min_weight=min_weight)`` + para las 4-5 redes principales. + - **Con ``spec_path``**: carga YAML con ``load_specs`` y construye cada red + con ``Networks.build``. + + En AMBOS modos: + - ``corpus_scope`` pre-filtra el corpus antes de construir redes. + - Se transiciona a BUILT y se sella ``networks/.corpus_hash`` con el hash + del corpus **filtrado** (D1 — ADR 0038). + - Se diagnostican redes vacías usando ``predict_build_preview`` como fuente + única (ADR 0037 §(e) — no-divergencia con ``status``). + - Si hay seeds aceptadas, corre la pasada cited_by ANTES de proyectar las + redes (ADR 0038 §enrich), de modo que la co-citación salga poblada. + Si no hay seeds aceptadas, la pasada es no-op graceful (→ 4 redes). + Para redes de paper con comunidades detectadas escribe también clusters.csv (tabla de resumen de comunidades, issue #31). - Sella ``networks/.corpus_hash`` con el hash del corpus **filtrado** que las - produjo (no del corpus vivo completo). - Transiciona a BUILT tras escribir con éxito. Args: store_path: Ruta al archivo ``.duckdb``. @@ -181,24 +350,48 @@ def run_build( corpus_scope: Filtro de curación aplicado antes de construir las redes. ``'all'`` (default) = corpus completo; ``'accepted'`` = semillas + aceptados; ``'seeds_only'`` = solo semillas. + spec_path: Ruta al YAML de specs (opcional). Si se omite, usa quick. + min_weight: Peso mínimo de arista. Default 1 = sin filtro. + Solo aplica al modo quick (sin spec); en modo spec, ``NetworkSpec`` + del YAML lleva su propio ``min_weight``. + scope_cli_token: Token CLI tal como lo tipió el usuario + (``'seeds'``/``'accepted'``/``'all'``). Si se pasa, ``data["scope"]`` + expone este token en lugar del vocab interno. Permite que #160 + (maturity) y la CLI sean consistentes. Si se omite (llamadas directas + sin CLI), ``data["scope"]`` queda igual a ``corpus_scope``. + email: Email para el polite pool de OpenAlex (pasada cited_by). + transport: Transport inyectable para tests (``httpx.MockTransport``). + max_citing: Tope de citantes por seed en la pasada cited_by. + ``None`` = sin tope. Returns: Dict con ``networks_built``, ``artifacts_dir``, ``corpus_hash``, - ``corpus_scope`` y lista de redes. + ``corpus_scope`` (vocab interno), ``scope`` (token CLI o vocab interno), + lista de redes, ``warnings``, ``empty_networks``, ``maturity`` + y ``enrichment`` (bloque aditivo con métricas de la pasada cited_by). Raises: + DataError: Si ``spec_path`` está malformado (modo spec). DependencyError: Si falta ``python-louvain``. StoreError: Si el store está bloqueado. """ import warnings as _warnings from bib2graph.cycle import apply_transition - from bib2graph.networks.facade import Networks + from bib2graph.networks.facade import Networks, predict_build_preview store = open_store(store_path) + thesaurus_stats: dict[str, Any] | None = None try: corpus_full = store.load() + # Aplicar thesaurus ANTES del scoping y ANTES de proyectar redes (#164). + # Persiste el corpus con keywords_id canonicos para builds posteriores. + if thesaurus_path is not None: + corpus_full, thesaurus_stats = _apply_thesaurus_and_persist( + corpus_full, thesaurus_path, store + ) + # R3 — fuente única de verdad: el destino de la transición lo dicta cycle.py, # no un literal en el comando (ADR 0016 enmendado §1). current_state = store.backend.loop_state() @@ -211,6 +404,29 @@ def run_build( else: artifacts_dir = Path(out_dir) + # Pasada cited_by: enriquecer con citantes ANTES de proyectar redes + # (ADR 0038 §enrich). Solo si hay seeds aceptadas; si no, no-op graceful + # (→ 4 redes en vez de 5, sin error). Persiste el corpus enriquecido para + # que la co-citación quede disponible en runs posteriores (idempotente). + from bib2graph.constants import Col, CurationStatus + from bib2graph.sources.openalex import OpenAlexSource + + _rows = corpus_full.to_arrow().to_pylist() + _has_accepted_seeds = any( + row.get(Col.IS_SEED) + and row.get(Col.CURATION_STATUS) == CurationStatus.ACCEPTED + for row in _rows + ) + enrich_metrics: dict[str, Any] = {} + if _has_accepted_seeds: + _source = OpenAlexSource(email=email, transport=transport) + corpus_full, enrich_metrics = enrich_corpus( + corpus_full, _source, max_citing=max_citing, pass_name="cited_by" + ) + store.persist_replace(corpus_full) + # #141: persistir EnricherRef (cited_by) para que manifest.enrichers sobreviva. + store.backend.persist_enricher_refs(corpus_full.manifest.enrichers) + # Filtrar el corpus según el scope ANTES de construir redes (#56). # El corpus filtrado también es el que se pasa a _write_artifacts para que # clusters.csv cuadre con los nodos del grafo (sin drift). @@ -221,34 +437,112 @@ def run_build( if len(corpus) == 0: msg = ( f"scope='{corpus_scope}' dejó 0 papers; " - "corré `b2g curate` para aceptar papers o usá `--corpus-scope=all`." + "corré `b2g curate` para aceptar papers o usá `--scope=all`." ) _warnings.warn(msg, stacklevel=2) build_warnings.append(msg) artifacts_dir.mkdir(parents=True, exist_ok=True) (artifacts_dir / ".corpus_hash").write_text("", encoding="utf-8") store.backend.set_loop_state(new_state, cycle_round=new_round) + # scope_display para early-return (corpus vacío): mismo cálculo que el path normal. + _scope_display_empty = ( + scope_cli_token if scope_cli_token is not None else corpus_scope + ) + # Maturity en early-return: curated desde corpus_full (el filtrado está vacío). + from bib2graph.service.maturity import compute_maturity as _compute_maturity + + _maturity_empty = _compute_maturity( + corpus_full, + scope=_scope_display_empty, + empty_network_kinds=[], + ) return { "networks_built": 0, "artifacts_dir": str(artifacts_dir), "corpus_hash": "", "corpus_scope": corpus_scope, + "scope": _scope_display_empty, "networks": [], "warnings": build_warnings, + "empty_networks": [], + "maturity": _maturity_empty, + "enrichment": enrich_metrics, + "thesaurus": thesaurus_stats, } - try: - artifacts = Networks.quick(corpus) - except ImportError as exc: - raise DependencyError( - f"Dependencia faltante para detectar comunidades: {exc}. " - "Instalá python-louvain: uv add python-louvain." - ) from exc + # Diagnóstico pre-build (ADR 0037 §(e), fuente única con status-time). + # Indexado por kind para lookup O(1) en el loop post-build. + preview_by_kind = {str(e["kind"]): e for e in predict_build_preview(corpus)} + + # Construir artefactos según el modo. + if spec_path is not None: + # Modo declarativo: YAML → specs → Networks.build por red. + # _build_from_spec_file levanta DataError / DependencyError. + artifacts = _build_from_spec_file(corpus, spec_path) + else: + # Modo quick: Networks.quick con min_weight propagado a cada spec. + try: + artifacts = Networks.quick(corpus, min_weight=min_weight) + except ImportError as exc: + raise DependencyError( + f"Dependencia faltante para detectar comunidades: {exc}. " + "Instalá python-louvain: uv add python-louvain." + ) from exc networks_info = _write_artifacts(artifacts, corpus, artifacts_dir) + # Diagnóstico de redes vacías en build-time. + # Reusa los reason/fix_command del preview (fuente única — ADR 0037 §(e)). + # Si el preview predijo no-vacía pero salió vacía, sospechamos min_weight. + empty_networks: list[dict[str, object]] = [] + for art in artifacts: + kind_str = str(art.spec.kind) + is_empty = ( + art.graph.number_of_nodes() == 0 or art.graph.number_of_edges() == 0 + ) + if not is_empty: + continue + + preview_entry = preview_by_kind.get(kind_str) + if preview_entry and preview_entry["would_be_empty"]: + # Preview ya predijo vacía — usar su reason/fix_command (no-divergencia). + reason: str = ( + str(preview_entry["reason"]) + if preview_entry["reason"] + else "Red vacía" + ) + fix_cmd: str | None = ( + str(preview_entry["fix_command"]) + if preview_entry["fix_command"] + else None + ) + elif spec_path is None and min_weight > 1: + # Modo quick: el --min-weight del CLI filtró todas las aristas. + reason = f"0 aristas con peso ≥ {min_weight}; bajá --min-weight" + fix_cmd = f"b2g build --min-weight {min_weight - 1}" + elif spec_path is not None and art.spec.min_weight > 1: + # Modo spec: el min_weight del propio YAML filtró todas las aristas. + # El --min-weight de la CLI NO se usa en modo spec → no sugerir bajarlo. + reason = ( + f"0 aristas con peso ≥ {art.spec.min_weight} " + f"(min_weight del spec '{kind_str}'); ajustá el spec" + ) + fix_cmd = None + else: + # Caso inesperado: red vacía sin causa clara identificable. + reason = "Red vacía (sin datos suficientes)" + fix_cmd = None + + empty_networks.append( + {"kind": kind_str, "reason": reason, "fix_command": fix_cmd} + ) + # Los diagnósticos de red vacía van solo en data["empty_networks"]. + # data["warnings"] queda reservado para avisos de corpus-scope + # (p. ej. "0 papers"); esto mantiene la compat con tests pre-0.10.0 + # que verifican data["warnings"] == [] cuando el corpus no está vacío. + # ADR 0029 — sellar con corpus_hash del corpus FILTRADO (no del vivo completo). - # Esto garantiza que el hash refleja exactamente lo que produjo las redes. + # D1: en AMBOS modos (quick y spec) transicionar + sellar (ADR 0038). from bib2graph.backends.memory import compute_corpus_hash corpus_hash = compute_corpus_hash(corpus.to_arrow()) @@ -257,6 +551,25 @@ def run_build( hash_file.write_text(corpus_hash, encoding="utf-8") store.backend.set_loop_state(new_state, cycle_round=new_round) + + # FIX 2 — gancho para #160 (maturity): "scope" expone el token CLI tal como + # lo tipió el usuario ("seeds"/"accepted"/"all"), NO el vocab interno + # ("seeds_only"). Esto hace que la superficie de agents-first sea consistente. + # Cuando se llama sin CLI (tests unitarios directos), scope_cli_token=None + # y se usa corpus_scope como fallback (preserva compat pre-0.10.0). + scope_display = scope_cli_token if scope_cli_token is not None else corpus_scope + + # Maturity (#160): computar DENTRO del try para acceder a corpus_full + # antes del store.close() en finally. Extrae los kinds de empty_networks + # (lista de dicts {kind, reason, fix_command}) sin duplicar reason/fix_command. + from bib2graph.service.maturity import compute_maturity as _compute_maturity + + empty_network_kinds = [str(en["kind"]) for en in empty_networks] + maturity = _compute_maturity( + corpus_full, + scope=scope_display, + empty_network_kinds=empty_network_kinds, + ) finally: store.close() @@ -265,8 +578,13 @@ def run_build( "artifacts_dir": str(artifacts_dir), "corpus_hash": corpus_hash, "corpus_scope": corpus_scope, + "scope": scope_display, "networks": networks_info, "warnings": build_warnings, + "empty_networks": empty_networks, + "maturity": maturity, + "enrichment": enrich_metrics, + "thesaurus": thesaurus_stats, } @@ -285,38 +603,101 @@ def run_build( ), ) @click.option( - "--corpus-scope", - "corpus_scope", - type=click.Choice(["all", "accepted", "seeds_only"]), + "--scope", + "scope", + type=click.Choice(["all", "accepted", "seeds"]), default="all", show_default=True, help=( "Filtra el corpus antes de construir las redes. " - "'all' = corpus completo (default, sin cambio de comportamiento); " - "'accepted' = semillas (is_seed=True) + papers aceptados por curación; " - "'seeds_only' = solo semillas (is_seed=True). " + "'all' = corpus completo (default); " + "'accepted' = semillas (is_seed=True) + papers aceptados; " + "'seeds' = solo semillas (is_seed=True). " "Si el scope deja 0 papers, termina con exit 0 y un warning accionable." ), ) @click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", + "--corpus-scope", + "corpus_scope_deprecated", + type=click.Choice(["all", "accepted", "seeds_only"]), + default=None, + hidden=True, + help=( + "DEPRECATED (cierra en 0.11.0): usar --scope. " + "Acepta los valores antiguos all|accepted|seeds_only." + ), +) +@click.option( + "--spec", + "spec_path", + type=click.Path(exists=True, dir_okay=False), + default=None, + help=( + "Ruta al YAML con la especificación de redes. " + "Si se pasa, usa Networks.build por spec en lugar de Networks.quick. " + "Sigue transicionando a BUILT y sellando corpus_hash (D1)." + ), +) +@click.option( + "--min-weight", + "min_weight", + type=int, + default=1, + show_default=True, + help=( + "Peso mínimo de arista (default 1 = sin filtro). " + "Aristas con peso < N se descartan. " + "Solo aplica al modo quick (sin --spec); en modo spec el YAML lleva su propio min_weight." + ), +) +@click.option( + "--max-citing", + "max_citing", + default=None, + type=int, + help=( + "Tope de citantes por seed en la pasada cited_by automática. " + "Default: sin tope. Solo aplica cuando hay seeds aceptadas." + ), ) +@click.option( + "--email", + default=None, + help="Email para el polite pool de OpenAlex (pasada cited_by).", +) +@click.option( + "--thesaurus", + "thesaurus_path", + type=click.Path(exists=True, dir_okay=False), + default=None, + help=( + "Ruta al JSON del thesaurus multilingüe (formato ADR 0011). " + "Aplica consolidacion conceptual cross-lingue sobre keywords_id " + "ANTES de proyectar redes, y persiste el corpus actualizado. " + "Absorbe la capacidad del verbo retirado b2g thesaurus (#164)." + ), +) +@json_option @click.pass_context @handle_errors("build") def build_cmd( ctx: click.Context, out_dir: str | None, - corpus_scope: str, + scope: str, + corpus_scope_deprecated: str | None, + spec_path: str | None, + min_weight: int, + max_citing: int | None, + email: str | None, + thesaurus_path: str | None, json_output: bool, ) -> None: - """Computa las 4 redes con Networks.quick y escribe artefactos. + """Computa redes bibliométricas y escribe artefactos. + + Sin ``--spec``: usa Networks.quick (4-5 redes principales). + Con ``--spec``: carga el YAML y construye cada red con Networks.build. - Tras el build, el estado del lazo transiciona a BUILT. - El directorio networks/ queda sellado con .corpus_hash del corpus filtrado. + En ambos modos transiciona a BUILT y sella networks/.corpus_hash. """ # ADR 0029: usar networks_dir del workspace si no se especifica --out-dir ws = resolve_workspace(ctx.obj) @@ -324,22 +705,72 @@ def build_cmd( if effective_out_dir is None: effective_out_dir = ws.networks_dir + # Resolver scope: --corpus-scope deprecado tiene prioridad con aviso. + corpus_scope_dep_msg: str | None = None + if corpus_scope_deprecated is not None: + corpus_scope_dep_msg = emit_deprecation( + "b2g build --corpus-scope", "b2g build --scope" + ) + # El vocab deprecado (all|accepted|seeds_only) ya es el vocab interno. + # Preservamos el mismo token para scope_cli_token (compat pre-0.10.0). + internal_scope = corpus_scope_deprecated + cli_token: str = corpus_scope_deprecated + else: + # El vocab nuevo (all|accepted|seeds) necesita mapeo para seeds→seeds_only. + internal_scope = _map_scope(scope) + # scope es el token tal como lo tipió el usuario ("seeds"/"accepted"/"all"). + cli_token = scope + + # FIX 1a — footgun: --min-weight se ignora en modo spec. + # Avisar explícitamente para no perder la intención del usuario en silencio. + if spec_path is not None and min_weight > 1: + print( + "--min-weight se ignora con --spec; cada red usa el min_weight de su YAML.", + file=sys.stderr, + ) + data = run_build( - ws.library_path, out_dir=effective_out_dir, corpus_scope=corpus_scope + ws.library_path, + out_dir=effective_out_dir, + corpus_scope=internal_scope, + spec_path=spec_path, + min_weight=min_weight, + scope_cli_token=cli_token, + email=email, + max_citing=max_citing, + thesaurus_path=thesaurus_path, ) - if json_output: + if json_mode(json_output): + # Fusionar warnings de corpus con el aviso de deprecación de --corpus-scope. + all_warnings: list[str] = list(data.get("warnings") or []) + if corpus_scope_dep_msg is not None: + all_warnings.append(corpus_scope_dep_msg) envelope = build_envelope( command="build", ok=True, data=data, exit_code=0, - warnings=data.get("warnings"), + warnings=all_warnings or None, ) emit(envelope) else: + # Warnings van a stderr en modo humano (ADR 0021 §C; patrón de status.py). for w in data.get("warnings", []): - emit_human(f"ADVERTENCIA: {w}") + print(f"ADVERTENCIA: {w}", file=sys.stderr) + # Diagnósticos de redes vacías también a stderr (no son warnings de corpus). + for en in data.get("empty_networks", []): + fix = f" Sugerencia: {en['fix_command']}" if en.get("fix_command") else "" + print( + f"ADVERTENCIA: Red '{en['kind']}' vacía — {en['reason']}.{fix}", + file=sys.stderr, + ) + if data.get("thesaurus"): + th = data["thesaurus"] + emit_human( + f"Thesaurus aplicado: {th['keywords_mapped']} keywords mapeadas " + f"(de {th['keywords_total']} totales, {th['aliases_loaded']} aliases cargados)." + ) emit_human(f"Redes construidas: {data['networks_built']}") emit_human(f"Artefactos en: {data['artifacts_dir']}") emit_human(f"corpus_hash: {data['corpus_hash']}") diff --git a/src/bib2graph/cli/commands/chain.py b/src/bib2graph/cli/commands/chain.py index bfde30e..bbe04e8 100644 --- a/src/bib2graph/cli/commands/chain.py +++ b/src/bib2graph/cli/commands/chain.py @@ -6,15 +6,17 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from pathlib import Path from typing import Any, Literal import click +from bib2graph.cli._enrich import enrich_corpus from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DependencyError, handle_errors +from bib2graph.cli._errors import DataError, DependencyError, UsageError, handle_errors from bib2graph.cli._ingest import normalize_and_dedup +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_library_path # --------------------------------------------------------------------------- @@ -32,6 +34,8 @@ def run_chain( email: str | None = None, transport: Any = None, preview: bool = False, + since: date | None = None, + _fsm_action: str | None = None, ) -> dict[str, Any]: """Expande el corpus con candidatos rankeados por information scent. @@ -72,10 +76,31 @@ def run_chain( max_candidates=max_candidates, ) + # Validación de --since: backward + since no tiene sentido. + if since is not None and direction == "backward": + raise UsageError( + "--since no es compatible con --direction backward. " + "Usá --direction forward (o 'both', donde la ventana aplica solo al tramo forward)." + ) + + # Cuando since está activo con direction='both', la ventana aplica solo + # al tramo forward — opción más simple y clara (ADR 0037 §c). + effective_direction = direction + if since is not None and direction == "both": + effective_direction = "forward" + from bib2graph.cycle import apply_transition from bib2graph.foraging import Forager from bib2graph.sources.openalex import OpenAlexSource + # Selección de acción FSM: "monitor" si --since activo O si se fuerza + # desde run_monitor (_fsm_action="monitor"); sino "chain" → FORAGED. + fsm_action = ( + _fsm_action + if _fsm_action is not None + else ("monitor" if since is not None else "chain") + ) + merged_backend_close = None store = open_store(store_path) try: @@ -85,7 +110,23 @@ def run_chain( # no un literal en el comando (ADR 0016 enmendado §1). current_state = store.backend.loop_state() current_round = store.backend.loop_round() - new_state, new_round = apply_transition(current_state, "chain", current_round) + + # Guarda corpus vacío (portada de monitor, ADR 0037 §c). + if fsm_action == "monitor": + if current_state is None: + raise DataError( + "No hay corpus ni estado previo en el store. " + "Iniciá la investigación con 'b2g seed' antes de monitorear." + ) + if len(corpus) == 0: + raise DataError( + "El corpus está vacío. " + "Usá 'b2g seed' para sembrar papers antes de monitorear." + ) + + new_state, new_round = apply_transition( + current_state, fsm_action, current_round + ) source = OpenAlexSource(email=email, transport=transport) @@ -94,7 +135,7 @@ def run_chain( # antes de entrar al Forager — así un ``AttributeError`` genuino que surja # dentro de chain/merge/_fetch_forward no queda disfrazado de "source no # soporta forward" (exit 3). - if direction in ("forward", "both") and not ( + if effective_direction in ("forward", "both") and not ( hasattr(source, "fetch_citing_batch") or hasattr(source, "fetch_citing") ): raise DependencyError( @@ -111,7 +152,7 @@ def run_chain( max_candidates=max_candidates, max_citing_per_paper=max_citing_per_paper, ) - ranked = forager.chain(corpus, direction=direction) + ranked = forager.chain(corpus, direction=effective_direction, since=since) except NotImplementedError as exc: raise DependencyError( f"Profundidad {depth} no soportada aún: {exc}. Usá depth=1 (por defecto)." @@ -125,6 +166,13 @@ def run_chain( ranking_preview = [ {"id": id_, "scent": scent} for id_, scent in ranked.ranking[:10] ] + # Calcular genuinamente nuevos vs corpus (reusado de monitor, ADR 0037 §c). + existing_ids = set(corpus.to_arrow().column("id").to_pylist()) + new_candidates_count = sum( + 1 + for id_ in ranked.corpus.to_arrow().column("id").to_pylist() + if id_ not in existing_ids + ) # Merge primero, dedup después sobre el corpus COMPLETO (fix cross-biblioteca, #88). # Los IDs backward (ranked.observed_refs) NO van al corpus — se persisten en la # tabla auxiliar ``referenced_but_not_fetched`` (#54, opción B). @@ -132,9 +180,19 @@ def run_chain( ingest_at = datetime.now(UTC) merged = corpus.merge(ranked.corpus) merged_deduped = normalize_and_dedup(merged, applied_at=ingest_at) + + # Pasada refs→DOI: enriquecer el corpus mergeado+dedup con el mismo source + # ya instanciado (forrajeo puro, ADR 0038 §enrich). Automático, sin flag. + # El source reutiliza la misma conexión HTTP → sin overhead extra. + merged_deduped, enrich_metrics = enrich_corpus( + merged_deduped, source, pass_name="refs_doi" + ) + total_papers = len(merged_deduped) merged_backend_close = getattr(merged_deduped._backend, "close", None) store.persist_replace(merged_deduped) + # #141: persistir EnricherRef (refs_doi) para que manifest.enrichers sobreviva. + store.backend.persist_enricher_refs(merged_deduped.manifest.enrichers) # #54: persistir IDs backward observados en la tabla auxiliar. # El backend del store (DuckDBBackend) ya tiene add_referenced_refs; @@ -154,11 +212,15 @@ def run_chain( return { "candidates_found": candidates_found, + "new_candidates": new_candidates_count, "total_papers": total_papers, - "direction": direction, + "direction": effective_direction, "depth": depth, "ranking_preview": ranking_preview, "observed_refs_count": len(ranked.observed_refs), + "loop_state": new_state.value, + "round": new_round, + "enrichment": enrich_metrics, } @@ -284,12 +346,17 @@ def _run_chain_preview( ), ) @click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", + "--since", + "since_str", + default=None, + help=( + "Forrajeo incremental: solo trae citantes publicados desde esta fecha. " + "Acepta fecha ISO (YYYY-MM-DD) o atajo relativo (90d, 6m, 1y). " + "Fuerza direction=forward y transiciona a MONITORED (no a FORAGED). " + "Incompatible con --direction backward." + ), ) +@json_option @click.pass_context @handle_errors("chain") def chain_cmd( @@ -300,6 +367,7 @@ def chain_cmd( max_citing_per_paper: int, email: str | None, preview: bool, + since_str: str | None, json_output: bool, ) -> None: """Expande el corpus con candidatos rankeados por information scent. @@ -307,7 +375,15 @@ def chain_cmd( Con --preview (dry-run), solo muestra la estimación de crecimiento sin tocar la red ni el corpus. Sin --preview, transiciona el estado a FORAGED. """ + from bib2graph.cli._options import parse_since + store_path = resolve_library_path(ctx.obj) + + # Parsear --since en la frontera (R2/ADR 0017): el reloj se fija aquí. + since: date | None = None + if since_str is not None: + since = parse_since(since_str, now=datetime.now(UTC).date()) + data = run_chain( store_path, direction=direction, # type: ignore[arg-type] @@ -316,9 +392,10 @@ def chain_cmd( max_citing_per_paper=max_citing_per_paper, email=email, preview=preview, + since=since, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="chain", ok=True, diff --git a/src/bib2graph/cli/commands/curate.py b/src/bib2graph/cli/commands/curate.py index 5ce4f2f..566e9e6 100644 --- a/src/bib2graph/cli/commands/curate.py +++ b/src/bib2graph/cli/commands/curate.py @@ -1,718 +1,417 @@ -"""cli.commands.curate — Subcomando ``b2g curate``. +"""cli.commands.curate — Grupo noun-verb ``b2g curate`` (#155, superficie CLI 0.10.0). -Curación en lote (#22 dump + #26 import + #72 fix scope + #58 columnas): -permite revisar y marcar papers en una tabla externa (Excel/Calc) y -reimportar las decisiones al corpus. +Convierte ``curate`` de comando plano con flags en un **grupo noun-verb** con +cinco subcomandos: + + ``curate dump`` — exporta papers a CSV para revisión offline. + ``curate apply`` — reimporta decisiones desde el CSV editado. + ``curate accept`` — acepta papers por id (transversal, sin FSM). + ``curate reject`` — rechaza papers por id (transversal, sin FSM). + ``curate filter`` — aplica filtros PRISMA y transiciona a FILTERED. + +Molde: ``read.py`` (#156/#157) — grupo noun-verb con ``invoke_without_command=True`` +y check manual para exit 0 sin subcomando (Click 8.4 usa exit 2 con +``no_args_is_help=True`` en grupos). + +BREAKING (#155): + - ``curate --dump`` y ``curate --from-csv`` eliminados (sin alias). + - ``curate --all`` eliminado; usar ``curate dump --scope all``. + +TRANSVERSAL (ADR 0016 enmendado, R3): + ``dump``, ``apply``, ``accept`` y ``reject`` son transversales: no transicionan + el CycleState. ``filter`` SÍ transiciona a FILTERED (el verbo define la + transición, precedente D1 de #159). + +Capa de servicios (#155): + Toda la lógica vive en ``service.curate``; este módulo son shims delgados + que inyectan el reloj (frontera CLI, R2/ADR 0017) y emiten el envelope. + +Backward compat con tests existentes: + ``CSV_COLUMNS``, ``CURATE_CSV_FILENAME``, ``CSV_REQUIRED_COLUMNS``, + ``VALID_DECISIONS``, ``VALID_SCOPES``, ``run_curate_dump``, + ``run_curate_from_csv`` se re-exportan desde ``service.curate`` para que los + tests que importan de ``bib2graph.cli.commands.curate`` sigan funcionando. Flujo canónico: - b2g curate --dump # exporta candidatos forrajeados - b2g curate --dump --scope seeds # exporta semillas - b2g curate --dump --scope all # exporta todo el corpus - b2g curate --dump --all # alias de --scope all (deprecado) - b2g curate --dump --out mi.csv # override de ruta de salida - b2g curate --from-csv mi.csv # reimporta las decisiones del CSV - -El CSV que ``--dump`` produce tiene exactamente las columnas que ``--from-csv`` -lee, lo que garantiza el round-trip sin fricción. - -Columnas (readonly salvo decision/note): - id — identificador canónico del paper (readonly) - source_id — identificador del motor de extracción (readonly) - title — título (readonly) - year — año de publicación (readonly) - authors — lista de autores separada por \" | \" (readonly) - venue — nombre de la revista/fuente (readonly) - doi — DOI del paper (readonly) - keywords — lista de keywords separada por \" | \" (readonly) - cited_by_count — conteo de citaciones; vacío si no está en el corpus (readonly) - references_count— conteo de referencias; vacío si no está en el corpus (readonly) - is_seed — True si es semilla original (readonly) - openalex_url — URL directa a OpenAlex (derivada de source_id si es W…) (readonly) - scent_score — best-effort desde provenance del candidato; vacío si no hay - cluster — reservado para futura integración de redes; siempre vacío hoy - decision — editable: accepted | rejected | undecided - note — editable: texto libre, advisory (round-trip solo) - -Semántica de scope (#72 / #58): - candidates (default) — papers forrajeados a revisar: - curation_status == 'candidate' AND is_seed == False. - Excluye semillas (que nacen con is_seed=True y status=candidate). - seeds — papers con is_seed == True. - all — todo el corpus (candidates + seeds + accepted + rejected). - -``note`` es advisory: - ``ProvenanceEvent`` no tiene un campo genérico de anotación. Registrar la - nota en ``decided_by`` (el único campo libre en provenance) contaminaría - semántica. Por lo tanto la nota se preserva en el CSV (round-trip) pero - NO se persiste en el corpus. El docstring lo declara explícitamente. - -``scent_score`` best-effort: - Si hay eventos de chaining en el provenance del paper, se lee el campo - ``scent`` que el Forager puede haber guardado. Si no existe, queda vacío. - No se falla por ausencia. - -``cluster`` best-effort: - Reservado para cuando los grafos de red estén disponibles. Siempre vacío - en esta versión. No se falla por ausencia. - -``cited_by_count`` / ``references_count`` best-effort: - No están en el schema canónico de 23 columnas (no los almacena OpenAlex - pipeline). Se dejan vacíos; la columna existe para que el humano la llene - manualmente si quiere. - -``openalex_url`` derivada: - Si hay ``source_id`` (ej. ``W2741809807`` de OpenAlex), la URL es - ``https://openalex.org/``. Si no hay source_id o no es un ID - de OpenAlex (W…), queda vacío. - -Idempotencia: - Reimportar el mismo CSV produce el mismo estado. ``accept``/``reject`` del - Corpus son idempotentes (``apply_curation`` en el backend verifica el id). - -R2 (ADR 0017 enmendado): - ``decided_at`` se inyecta en la frontera CLI (aquí), no en el núcleo. - El hash del Corpus excluye timestamps de provenance. - -CURACIÓN TRANSVERSAL (ADR 0016 enmendado, R3): - ``curate`` es curación transversal: no transiciona el CycleState. - Disponible en cualquier estado del lazo. + b2g curate dump # exporta candidatos a curacion.csv + b2g curate dump --scope all # exporta todo el corpus + b2g curate dump --out mi.csv # override de ruta + b2g curate apply curacion.csv # aplica decisiones + b2g curate apply curacion.csv --by maria # con identificador de curador + b2g curate accept --ids W1 --ids W2 # acepta por id + b2g curate reject --ids W3 # rechaza por id + b2g curate filter --year-gte 2018 # filtra y transiciona a FILTERED """ from __future__ import annotations -import csv from datetime import UTC, datetime -from pathlib import Path -from typing import Any import click from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, UsageError, handle_errors -from bib2graph.cli._store import open_store, resolve_workspace -from bib2graph.constants import Col, CurationStatus - -# --------------------------------------------------------------------------- -# Constantes del CSV de curación -# --------------------------------------------------------------------------- +from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_library_path, resolve_workspace +from bib2graph.service.curate import ( + CSV_COLUMNS, + CSV_REQUIRED_COLUMNS, + CURATE_CSV_FILENAME, + VALID_DECISIONS, + VALID_SCOPES, + run_curate_dump, + run_curate_from_csv, +) -CURATE_CSV_FILENAME = "curacion.csv" - -# Columnas del CSV: orden estable y conocido por el humano y la reimportación. -# Las columnas readonly van primero; decision y note son las editables. -CSV_COLUMNS = [ - "id", - "source_id", - "title", - "year", - "authors", - "venue", - "doi", - "keywords", - "cited_by_count", - "references_count", - "is_seed", - "openalex_url", - "scent_score", - "cluster", - "decision", - "note", +__all__ = [ + "CSV_COLUMNS", + "CSV_REQUIRED_COLUMNS", + "CURATE_CSV_FILENAME", + "VALID_DECISIONS", + "VALID_SCOPES", + "curate_grp", + "run_curate_dump", + "run_curate_from_csv", ] -# Columnas requeridas para que ``--from-csv`` acepte el archivo. -CSV_REQUIRED_COLUMNS = {"id", "decision"} - -# Valores válidos para la columna ``decision``. -VALID_DECISIONS = { - CurationStatus.ACCEPTED, - CurationStatus.REJECTED, - "undecided", -} - -# Mapa de curation_status → decision en el dump -_STATUS_TO_DECISION: dict[str, str] = { - CurationStatus.CANDIDATE: "undecided", - CurationStatus.ACCEPTED: "accepted", - CurationStatus.REJECTED: "rejected", -} - -# Scope válidos para --dump -VALID_SCOPES = {"candidates", "seeds", "all"} - # --------------------------------------------------------------------------- -# Helpers internos +# Grupo raíz # --------------------------------------------------------------------------- -def _authors_display(row: dict[str, Any]) -> str: - """Extrae los autores de una fila del corpus y los une con ' | '. - - Usa ``authors_raw`` si disponible, ``authors_id`` como fallback. - Devuelve cadena vacía si no hay datos. - - Args: - row: Fila de la tabla Arrow convertida a dict. - - Returns: - Cadena de autores separada por \" | \" o cadena vacía. +@click.group("curate", invoke_without_command=True) +@click.pass_context +def curate_grp(ctx: click.Context) -> None: + """Curación del corpus: dump, apply, accept, reject, filter. + + Subcomandos: dump, apply, accept, reject, filter. + + Ejemplos: + b2g curate dump + b2g curate dump --scope all + b2g curate apply curacion.csv + b2g curate accept --ids W1 --ids W2 + b2g curate reject --ids W3 + b2g curate filter --year-gte 2018 --year-lte 2024 """ - raw = row.get(Col.AUTHORS_RAW) - if raw and isinstance(raw, list): - return " | ".join(str(a) for a in raw if a) - ids = row.get(Col.AUTHORS_ID) - if ids and isinstance(ids, list): - return " | ".join(str(a) for a in ids if a) - return "" - - -def _keywords_display(row: dict[str, Any]) -> str: - """Extrae las keywords de una fila del corpus y las une con ' | '. - - Usa ``keywords_id`` si disponible (identificadores OpenAlex), con - ``keywords_raw`` como fallback. Mismo patrón que ``_authors_display``. - Devuelve cadena vacía si no hay datos. + ctx.ensure_object(dict) + # Click 8.4: no_args_is_help=True en grupos termina con exit 2. + # Usamos invoke_without_command=True + check manual para exit 0 correcto. + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) - Args: - row: Fila de la tabla Arrow convertida a dict. - - Returns: - Cadena de keywords separada por \" | \" o cadena vacía. - """ - ids = row.get(Col.KEYWORDS_ID) - if ids and isinstance(ids, list): - return " | ".join(str(k) for k in ids if k) - raw = row.get(Col.KEYWORDS_RAW) - if raw and isinstance(raw, list): - return " | ".join(str(k) for k in raw if k) - return "" +# --------------------------------------------------------------------------- +# curate dump +# --------------------------------------------------------------------------- -def _openalex_url(row: dict[str, Any]) -> str: - """Deriva la URL de OpenAlex a partir del source_id de la fila. - Solo aplica cuando el source_id es un ID de OpenAlex (W…). - Formato: ``https://openalex.org/``. - Devuelve cadena vacía si no hay source_id o no parece un ID de OpenAlex. +@curate_grp.command("dump") +@click.option( + "--out", + "out_override", + default=None, + type=click.Path(), + help=( + "Ruta de salida del CSV. Override del default /exports/curacion.csv." + ), +) +@click.option( + "--scope", + default="candidates", + type=click.Choice(["candidates", "seeds", "all"]), + show_default=True, + help=( + "Qué papers exportar. " + "'candidates' (default) = forrajeados a revisar (is_seed=False, status=candidate). " + "'seeds' = semillas originales. " + "'all' = todo el corpus." + ), +) +@json_option +@click.pass_context +@handle_errors("curate dump") +def dump_cmd( + ctx: click.Context, + out_override: str | None, + scope: str, + json_output: bool, +) -> None: + """Exporta papers a CSV para revisión offline. - Args: - row: Fila de la tabla Arrow convertida a dict. + Por defecto exporta solo los candidatos forrajeados (is_seed=False, + status=candidate). Editá las columnas ``decision`` y ``note`` en + Excel/Calc y luego reimportá con ``b2g curate apply``. - Returns: - URL de OpenAlex o cadena vacía. + Curación TRANSVERSAL: no transiciona el CycleState. """ - src_id = row.get(Col.SOURCE_ID) - if src_id: - src_str = str(src_id) - # Solo construir URL para IDs de OpenAlex (W seguido de dígitos) - if src_str.startswith("W") and src_str[1:].isdigit(): - return f"https://openalex.org/{src_str}" - return "" - + from pathlib import Path -def _scent_score_display(row: dict[str, Any]) -> str: - """Extrae el scent_score del provenance si está disponible. + ws = resolve_workspace(ctx.obj) + store_path = ws.library_path - El Forager puede haber guardado un campo ``scent`` en provenance. - Esta función lo busca en el JSON de provenance. Si no existe, devuelve - cadena vacía (best-effort, no falla). + if out_override is not None: + out_path = Path(out_override) + else: + out_path = ws.exports_dir / CURATE_CSV_FILENAME - Args: - row: Fila de la tabla Arrow convertida a dict. + data = run_curate_dump(store_path, out_path=out_path, scope=scope) - Returns: - Valor de scent como string, o cadena vacía. - """ - import json - - provenance_raw = row.get(Col.PROVENANCE) - if not provenance_raw: - return "" - try: - events = json.loads(str(provenance_raw)) - if not isinstance(events, list): - return "" - # Buscar el campo 'scent' en cualquier evento - for event in events: - if isinstance(event, dict): - val = event.get("scent") - if val is not None: - return str(val) - except (json.JSONDecodeError, TypeError): - pass - return "" - - -def _row_to_csv_dict(row: dict[str, Any]) -> dict[str, str]: - """Convierte una fila del corpus al dict para el CSV de curación. - - Incluye las columnas readonly enriquecidas (venue, doi, keywords, - cited_by_count, references_count, is_seed, openalex_url) además de - las columnas base (id, source_id, title, year, authors, scent_score, - cluster) y las columnas editables (decision, note). - - ``cited_by_count`` y ``references_count`` no están en el schema canónico; - se dejan vacíos (best-effort). - - Args: - row: Fila de la tabla Arrow convertida a dict. - - Returns: - Dict con las columnas del CSV de curación, todas como strings. - """ - status = str(row.get(Col.CURATION_STATUS, CurationStatus.CANDIDATE)) - decision = _STATUS_TO_DECISION.get(status, "undecided") - is_seed_val = row.get(Col.IS_SEED) - is_seed_str = str(is_seed_val) if is_seed_val is not None else "" - - return { - "id": str(row.get(Col.ID) or ""), - "source_id": str(row.get(Col.SOURCE_ID) or ""), - "title": str(row.get(Col.TITLE) or ""), - "year": str(row.get(Col.YEAR) or ""), - "authors": _authors_display(row), - "venue": str(row.get(Col.SOURCE) or ""), - "doi": str(row.get(Col.DOI) or ""), - "keywords": _keywords_display(row), - "cited_by_count": "", # no está en el schema canónico; best-effort vacío - "references_count": "", # no está en el schema canónico; best-effort vacío - "is_seed": is_seed_str, - "openalex_url": _openalex_url(row), - "scent_score": _scent_score_display(row), - "cluster": "", # reservado para integración con redes - "decision": decision, - "note": "", - } + if json_mode(json_output): + envelope = build_envelope( + command="curate dump", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"Exportados {data['papers_exported']} papers a: {data['csv_path']}") # --------------------------------------------------------------------------- -# Helpers de filtrado por scope +# curate apply # --------------------------------------------------------------------------- -def _filter_table_by_scope(table: Any, scope: str) -> Any: - """Filtra una tabla Arrow según el scope de dump. +@curate_grp.command("apply") +@click.argument("csv_file", type=click.Path()) +@click.option( + "--by", + default="cli", + show_default=True, + help="Identificador de quien decide (curador).", +) +@json_option +@click.pass_context +@handle_errors("curate apply") +def apply_cmd( + ctx: click.Context, + csv_file: str, + by: str, + json_output: bool, +) -> None: + """Reimporta decisiones de curación desde CSV al corpus. - Scope 'candidates': curation_status == 'candidate' AND is_seed == False. - Scope 'seeds': is_seed == True. - Scope 'all': sin filtro. + CSV_FILE es el archivo CSV editado producido por ``b2g curate dump``. + Solo las columnas ``id`` y ``decision`` son requeridas; el resto se ignora + (garantiza round-trip dump→apply aunque el CSV tenga columnas extra). - Se importa pyarrow.compute localmente para mantener el módulo libre de - efectos de import globales (AGENTS.md §Imports). + Decisiones aceptadas: accepted, rejected, undecided (no-op). - Args: - table: Tabla Arrow del corpus. - scope: Uno de 'candidates', 'seeds', 'all'. + Idempotente: reimportar el mismo CSV produce el mismo estado final. - Returns: - Tabla Arrow filtrada. + Curación TRANSVERSAL: no transiciona el CycleState. """ - import pyarrow.compute as pc + ws = resolve_workspace(ctx.obj) + store_path = ws.library_path + + # R2: el reloj se inyecta en la frontera CLI (ADR 0017 enmendado). + now = datetime.now(UTC) + data = run_curate_from_csv(store_path, csv_file, by=by, decided_at=now) - if scope == "all": - return table - elif scope == "seeds": - mask = table.column(Col.IS_SEED) - return table.filter(mask) + if json_mode(json_output): + envelope = build_envelope( + command="curate apply", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) else: - # candidates: status == candidate AND NOT is_seed - status_col = table.column(Col.CURATION_STATUS) - is_candidate = pc.equal(status_col, CurationStatus.CANDIDATE) # type: ignore[attr-defined] - is_seed_col = table.column(Col.IS_SEED) - not_seed = pc.invert(is_seed_col) # type: ignore[attr-defined] - mask = pc.and_(is_candidate, not_seed) # type: ignore[attr-defined] - return table.filter(mask) + emit_human( + f"Importados: {data['accepted_count']} aceptados, " + f"{data['rejected_count']} rechazados, " + f"{data['skipped_count']} sin cambio (undecided)." + ) + if data["not_found_count"] > 0: + emit_human( + f"Advertencia: {data['not_found_count']} IDs del CSV no se " + "encontraron en el corpus (posibles typos). " + "Verificá con ``b2g inspect``." + ) # --------------------------------------------------------------------------- -# Función núcleo: dump +# curate accept # --------------------------------------------------------------------------- -def run_curate_dump( - store_path: str | Path, - *, - out_path: Path, - scope: str = "candidates", - include_all: bool = False, -) -> dict[str, Any]: - """Exporta el corpus a un CSV para revisión offline. - - Por defecto exporta solo los candidatos forrajeados (``scope='candidates'``: - ``curation_status == 'candidate'`` AND ``is_seed == False``), que excluye - semillas. Con ``scope='seeds'`` exporta las semillas; con ``scope='all'`` - exporta todo el corpus. - - El parámetro ``include_all`` es un alias deprecado de ``scope='all'``: - si es ``True``, equivale a forzar ``scope='all'``. - - Columnas del CSV: id, source_id, title, year, authors, venue, doi, - keywords, cited_by_count, references_count, is_seed, openalex_url, - scent_score, cluster, decision, note. - Las columnas ``decision`` y ``note`` son las editables por el humano. - - Args: - store_path: Ruta al archivo ``.duckdb``. - out_path: Ruta completa del archivo CSV de salida. - scope: 'candidates' (default), 'seeds' o 'all'. - 'candidates' = curation_status=='candidate' AND is_seed==False. - 'seeds' = is_seed==True. - 'all' = todo el corpus. - include_all: Alias deprecado de scope='all'. Si True, fuerza scope='all'. - - Returns: - Dict con ``csv_path``, ``papers_exported``, ``columns``. - - Raises: - DataError: Si el corpus está vacío o no hay papers en el scope dado - cuando el scope no es 'all'. - StoreError: Si el store está bloqueado. - """ - # include_all es alias deprecado de scope='all' - effective_scope = "all" if include_all else scope - - store = open_store(store_path) - corpus = store.load() +@curate_grp.command("accept") +@click.option( + "--ids", + required=True, + multiple=True, + help="IDs de papers a aceptar (repetible: --ids ID1 --ids ID2).", +) +@click.option( + "--by", + default="cli", + show_default=True, + help="Identificador de quien decide.", +) +@json_option +@click.pass_context +@handle_errors("curate accept") +def curate_accept_cmd( + ctx: click.Context, + ids: tuple[str, ...], + by: str, + json_output: bool, +) -> None: + """Marca papers como accepted en el corpus. - all_table = corpus.to_arrow() - table = _filter_table_by_scope(all_table, effective_scope) + Curación TRANSVERSAL: no transiciona el CycleState. Disponible en + cualquier estado del lazo (Nota 05 §4, ADR 0016 enmendado R3). + """ + from bib2graph.service.curate import accept_papers - rows = table.to_pylist() + store_path = resolve_library_path(ctx.obj) + now = datetime.now(UTC) + data = accept_papers(store_path, list(ids), by=by, decided_at=now) - if not rows and effective_scope != "all": - scope_label = ( - "candidatos forrajeados" if effective_scope == "candidates" else "semillas" - ) - raise DataError( - f"No hay {scope_label} para exportar. " - "Usá --scope all para exportar todo el corpus, o ejecutá " - "``b2g chain`` para agregar candidatos." + if json_mode(json_output): + envelope = build_envelope( + command="curate accept", + ok=True, + data=data, + exit_code=0, ) - - out_path.parent.mkdir(parents=True, exist_ok=True) - - with open(out_path, "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS) - writer.writeheader() - for row in rows: - writer.writerow(_row_to_csv_dict(row)) - - return { - "csv_path": str(out_path), - "papers_exported": len(rows), - "columns": CSV_COLUMNS, - } + emit(envelope) + else: + emit_human(f"Aceptados {data['accepted_count']} papers.") # --------------------------------------------------------------------------- -# Función núcleo: from-csv +# curate reject # --------------------------------------------------------------------------- -def run_curate_from_csv( - store_path: str | Path, - csv_path: str | Path, - *, - by: str = "cli", - decided_at: datetime | None = None, -) -> dict[str, Any]: - """Reimporta decisiones de curación desde un CSV al corpus. - - Lee el CSV producido por ``--dump`` (u otro CSV con columnas ``id`` y - ``decision``), aplica las decisiones en lote e persiste. - - Las columnas extra del CSV enriquecido (venue, doi, keywords, etc.) se - ignoran; solo se requieren ``id`` y ``decision``. Esto garantiza el - round-trip dump→from-csv aunque el CSV tenga más columnas que el mínimo. - - Mapeo de ``decision``: - - ``accepted`` → ``Corpus.accept`` - - ``rejected`` → ``Corpus.reject`` - - ``undecided`` → no-op (el paper conserva su estado actual) - - Idempotente: reimportar el mismo CSV produce el mismo estado final. +@curate_grp.command("reject") +@click.option( + "--ids", + required=True, + multiple=True, + help="IDs de papers a rechazar (repetible: --ids ID1 --ids ID2).", +) +@click.option( + "--by", + default="cli", + show_default=True, + help="Identificador de quien decide.", +) +@json_option +@click.pass_context +@handle_errors("curate reject") +def curate_reject_cmd( + ctx: click.Context, + ids: tuple[str, ...], + by: str, + json_output: bool, +) -> None: + """Marca papers como rejected en el corpus. - ``note``: advisory — no se persiste en el corpus (``ProvenanceEvent`` - no tiene campo de anotación genérico). La nota solo hace round-trip en - el CSV; se ignora silenciosamente al importar. - - R2 (ADR 0017 enmendado): ``decided_at`` se inyecta desde la frontera CLI - (la función que llama); el núcleo no llama al reloj. - - Args: - store_path: Ruta al archivo ``.duckdb``. - csv_path: Ruta al archivo CSV con las decisiones. - by: Identificador de quien decide (default: ``"cli"``). - decided_at: Timestamp de la decisión inyectado desde la frontera. - Si es ``None``, el backend usa ``datetime.now(UTC)`` como fallback. - - Returns: - Dict con ``accepted_count``, ``rejected_count``, ``skipped_count``, - ``not_found_count``, ``total_rows``. - - ``accepted_count`` y ``rejected_count`` reflejan papers **efectivamente - encontrados y marcados** en el corpus, no filas del CSV. - ``not_found_count`` cuenta IDs del CSV que no existían en el corpus - (huérfanos — se reportan sin abortar para preservar la idempotencia - del flujo batch). - - Raises: - DataError: Si el CSV no existe, le faltan columnas requeridas - (``id``, ``decision``), o contiene valores de ``decision`` inválidos. - StoreError: Si el store está bloqueado. + Curación TRANSVERSAL: no transiciona el CycleState. Disponible en + cualquier estado del lazo (Nota 05 §4, ADR 0016 enmendado R3). """ - csv_path = Path(csv_path) - if not csv_path.exists(): - raise DataError( - f"El archivo CSV '{csv_path}' no existe. " - "Generalo primero con ``b2g curate --dump``." - ) + from bib2graph.service.curate import reject_papers - # Leer y validar el CSV - rows: list[dict[str, str]] = [] - with open(csv_path, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - fieldnames = set(reader.fieldnames or []) - - missing = CSV_REQUIRED_COLUMNS - fieldnames - if missing: - raise DataError( - f"El CSV '{csv_path}' no tiene las columnas requeridas: " - f"{sorted(missing)}. " - "Asegurate de exportar con ``b2g curate --dump`` y no borrar " - "las columnas obligatorias (id, decision)." - ) + store_path = resolve_library_path(ctx.obj) + now = datetime.now(UTC) + data = reject_papers(store_path, list(ids), by=by, decided_at=now) - for i, row in enumerate(reader, start=2): # línea 1 = header - decision = row.get("decision", "").strip().lower() - if decision not in VALID_DECISIONS: - raise DataError( - f"Línea {i}: valor de 'decision' inválido: '{row.get('decision')}'. " - f"Valores válidos: {sorted(VALID_DECISIONS)}." - ) - rows.append(row) - - if not rows: - return { - "accepted_count": 0, - "rejected_count": 0, - "skipped_count": 0, - "not_found_count": 0, - "total_rows": 0, - } - - # Agrupar por decisión - to_accept = [r["id"] for r in rows if r["decision"].strip().lower() == "accepted"] - to_reject = [r["id"] for r in rows if r["decision"].strip().lower() == "rejected"] - # undecided → no-op - skipped = len(rows) - len(to_accept) - len(to_reject) - - curated_backend_close = None - store = open_store(store_path) - try: - corpus = store.load() - - # Detectar IDs huérfanos (no existen en el corpus). - # No se aborta: el flujo batch debe ser idempotente aunque el corpus haya - # cambiado entre el dump y el from-csv. Se reporta ``not_found_count`` - # para que el humano/agente detecte typos en el CSV. - existing_ids: set[str] = { - str(r.get(Col.ID, "")) for r in corpus.to_arrow().to_pylist() - } - to_accept_found = [id_ for id_ in to_accept if id_ in existing_ids] - to_reject_found = [id_ for id_ in to_reject if id_ in existing_ids] - not_found = [id_ for id_ in (to_accept + to_reject) if id_ not in existing_ids] - - # R2: decided_at ya viene inyectado desde la frontera CLI - if to_accept_found: - corpus = corpus.accept(to_accept_found, by=by, decided_at=decided_at) - if to_reject_found: - corpus = corpus.reject(to_reject_found, by=by, decided_at=decided_at) - - curated_backend_close = getattr(corpus._backend, "close", None) - store.persist(corpus) - finally: - if curated_backend_close is not None: - curated_backend_close() - store.close() - - return { - "accepted_count": len(to_accept_found), - "rejected_count": len(to_reject_found), - "skipped_count": skipped, - "not_found_count": len(not_found), - "total_rows": len(rows), - } + if json_mode(json_output): + envelope = build_envelope( + command="curate reject", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"Rechazados {data['rejected_count']} papers.") # --------------------------------------------------------------------------- -# Comando Click +# curate filter # --------------------------------------------------------------------------- -@click.command("curate") +@curate_grp.command("filter") +@click.option("--year-gte", type=int, default=None, help="Incluir años >= este valor.") +@click.option("--year-lte", type=int, default=None, help="Incluir años <= este valor.") @click.option( - "--dump", - "do_dump", - is_flag=True, - default=False, - help=( - "Exporta los candidatos a un CSV para revisión offline. " - "Salida default: /exports/curacion.csv." - ), + "--language", + multiple=True, + help="Códigos ISO 639-1 a incluir (repetible: --language en --language es).", ) @click.option( - "--from-csv", - "from_csv", - default=None, - type=click.Path(), - help="Reimporta decisiones de curación desde el CSV dado.", + "--type", + "type_in", + multiple=True, + help="Áreas de investigación a incluir (repetible).", ) @click.option( - "--out", - "out_override", + "--min-citations", + type=int, default=None, - type=click.Path(), - help=( - "Ruta de salida del CSV (solo con --dump). " - "Override del default /exports/curacion.csv." - ), -) -@click.option( - "--scope", - "scope", - default="candidates", - type=click.Choice(["candidates", "seeds", "all"]), - show_default=True, - help=( - "Con --dump: qué papers exportar. " - "'candidates' (default) = forrajeados a revisar (is_seed=False, status=candidate). " - "'seeds' = semillas originales. " - "'all' = todo el corpus." - ), -) -@click.option( - "--all", - "include_all", - is_flag=True, - default=False, - help=( - "Con --dump: incluye todos los papers del corpus. " - "Alias deprecado de --scope all; tiene precedencia sobre --scope si ambos se pasan." - ), -) -@click.option( - "--by", - default="cli", - show_default=True, - help="Identificador de quien decide (solo con --from-csv).", -) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", + help="Mínimo de citantes en cited_by_id.", ) +@json_option @click.pass_context -@handle_errors("curate") -def curate_cmd( +@handle_errors("curate filter") +def curate_filter_cmd( ctx: click.Context, - do_dump: bool, - from_csv: str | None, - out_override: str | None, - scope: str, - include_all: bool, - by: str, + year_gte: int | None, + year_lte: int | None, + language: tuple[str, ...], + type_in: tuple[str, ...], + min_citations: int | None, json_output: bool, ) -> None: - """Curación en lote: exporta papers a CSV y reimporta decisiones. - - Dos modos mutuamente excluyentes (exactamente uno requerido): - - \b - --dump exporta papers a CSV para revisión offline. - --from-csv CSV reimporta decisiones desde el CSV dado. + """Aplica filtros PRISMA al corpus (marca rejected, no borra). - Flujo típico: + Tras el filtro, el estado del lazo transiciona a FILTERED. - \b - b2g curate --dump # genera curacion.csv (solo candidatos forrajeados) - b2g curate --dump --scope seeds # genera curacion.csv con semillas - b2g curate --dump --scope all # genera curacion.csv con todo el corpus - # (editar curacion.csv en Excel: columnas decision y note) - b2g curate --from-csv curacion.csv # aplica decisiones - - Curación TRANSVERSAL: no transiciona el CycleState. - Disponible en cualquier estado del lazo (ADR 0016 enmendado R3). + Este es el único subcomando de ``curate`` que transiciona el CycleState + (el verbo define la transición — precedente D1 de #159). """ - # --- Validar exclusividad de modos --- - if do_dump and from_csv: - raise UsageError( - "--dump y --from-csv son mutuamente excluyentes. " - "Usá uno u otro por invocación." - ) - if not do_dump and from_csv is None: - raise UsageError("Debés especificar un modo: --dump o --from-csv .") + from bib2graph.service.curate import filter_corpus - ws = resolve_workspace(ctx.obj) - store_path = ws.library_path - - # --- Modo dump --- - if do_dump: - if out_override is not None: - out_path = Path(out_override) - else: - out_path = ws.exports_dir / CURATE_CSV_FILENAME - - data = run_curate_dump( - store_path, - out_path=out_path, - scope=scope, - include_all=include_all, - ) - - if json_output: - envelope = build_envelope( - command="curate", - ok=True, - data=data, - exit_code=0, - ) - emit(envelope) - else: - emit_human( - f"Exportados {data['papers_exported']} papers a: {data['csv_path']}" - ) - return - - # --- Modo from-csv --- - # R2: el reloj se inyecta en la frontera (ADR 0017 enmendado) + store_path = resolve_library_path(ctx.obj) + # R2: el reloj se inyecta en la frontera CLI (ADR 0017 enmendado). now = datetime.now(UTC) - data = run_curate_from_csv( + data = filter_corpus( store_path, - from_csv, # type: ignore[arg-type] - by=by, + year_gte=year_gte, + year_lte=year_lte, + language=list(language) if language else None, + type_in=list(type_in) if type_in else None, + min_citations=min_citations, decided_at=now, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( - command="curate", + command="curate filter", ok=True, data=data, exit_code=0, ) emit(envelope) else: - emit_human( - f"Importados: {data['accepted_count']} aceptados, " - f"{data['rejected_count']} rechazados, " - f"{data['skipped_count']} sin cambio (undecided)." - ) - if data["not_found_count"] > 0: + emit_human(f"Filtros aplicados: {data['criteria_applied']}") + for step in data["steps"]: emit_human( - f"Advertencia: {data['not_found_count']} IDs del CSV no se " - "encontraron en el corpus (posibles typos). " - "Verificá con ``b2g inspect``." + f" {step['name']}: {step['count_before']} → {step['count_after']} " + f"(-{step['excluded']})" ) + emit_human(f"Total en corpus: {data['total_papers']}") + + +# --------------------------------------------------------------------------- +# Alias curate_cmd → curate_grp (compat con registros existentes) +# --------------------------------------------------------------------------- + +curate_cmd = curate_grp diff --git a/src/bib2graph/cli/commands/enrich.py b/src/bib2graph/cli/commands/enrich.py index 70a64b8..77fec7f 100644 --- a/src/bib2graph/cli/commands/enrich.py +++ b/src/bib2graph/cli/commands/enrich.py @@ -1,4 +1,4 @@ -"""cli.commands.enrich — Subcomando ``b2g enrich``. +"""cli.commands.enrich — Subcomando ``b2g enrich`` (alias deprecado, #165). Enriquece el corpus en dos pasadas usando OpenAlex: - **Pasada 1 (Hito 8a):** resuelve ``references_id`` → ``references_doi``. @@ -9,6 +9,9 @@ es una operación ortogonal al FSM del lazo bibliométrico. Si en el futuro se decide transicionar (p. ej. a un estado ENRICHED), se agrega la llamada a ``apply_transition`` aquí, sin tocar el FSM en el núcleo. + +DEPRECADO (ADR 0038, #165): absorbido en ``b2g chain`` / ``b2g build``. +Se retira en 0.11.0. """ from __future__ import annotations @@ -18,8 +21,11 @@ import click +from bib2graph.cli._deprecation import emit_deprecation +from bib2graph.cli._enrich import enrich_corpus from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_library_path # --------------------------------------------------------------------------- @@ -65,45 +71,27 @@ def run_enrich( ``@handle_errors`` como exit 4). StoreError: Si el store está bloqueado (exit 5). """ - from bib2graph.enrichers.openalex import OpenAlexEnricher from bib2graph.sources.openalex import OpenAlexSource store = open_store(store_path) try: corpus = store.load() - source = OpenAlexSource(email=email, api_key=api_key, transport=transport) - enricher = OpenAlexEnricher(source, max_citing_per_paper=max_citing) - enriched = enricher.enrich(corpus) - - store.persist(enriched) - - # Extraer métricas de los EnricherRef registrados - enricher_refs = enriched.manifest.enrichers - - doi_entry = next( - (e for e in enricher_refs if e.name == "openalex_references_doi"), None - ) - refs_resolved = int(doi_entry.params.get("resolved", 0)) if doi_entry else 0 - refs_total = ( - int(doi_entry.params.get("total_unique_refs", 0)) if doi_entry else 0 + enriched, metrics = enrich_corpus( + corpus, source, max_citing=max_citing, pass_name="both" ) - - cb_entry = next( - (e for e in enricher_refs if e.name == "openalex_cited_by"), None - ) - citing_new = int(cb_entry.params.get("resolved", 0)) if cb_entry else 0 - citing_targets = int(cb_entry.params.get("total", 0)) if cb_entry else 0 - + store.persist(enriched) + # #141: persistir EnricherRef para que manifest.enrichers sobreviva al reload. + store.backend.persist_enricher_refs(enriched.manifest.enrichers) total_papers = len(enriched) finally: store.close() return { - "refs_resolved": refs_resolved, - "refs_total_unique": refs_total, - "citing_new": citing_new, - "citing_targets": citing_targets, + "refs_resolved": metrics.get("refs_resolved", 0), + "refs_total_unique": metrics.get("refs_total_unique", 0), + "citing_new": metrics.get("citing_new", 0), + "citing_targets": metrics.get("citing_targets", 0), "total_papers": total_papers, } @@ -134,13 +122,7 @@ def run_enrich( "Default: sin tope (todos los citantes encontrados)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("enrich") def enrich_cmd( @@ -158,6 +140,7 @@ def enrich_cmd( Si no hay referencias ni semillas aceptadas, termina sin error. """ + dep_msg = emit_deprecation("b2g enrich", "b2g chain") store_path = resolve_library_path(ctx.obj) data = run_enrich( store_path, @@ -166,12 +149,13 @@ def enrich_cmd( max_citing=max_citing, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="enrich", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/export.py b/src/bib2graph/cli/commands/export.py index a533722..d814aee 100644 --- a/src/bib2graph/cli/commands/export.py +++ b/src/bib2graph/cli/commands/export.py @@ -19,6 +19,7 @@ from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import DataError, handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import resolve_workspace # --------------------------------------------------------------------------- @@ -144,13 +145,7 @@ def run_export( "(default: /exports/ o /exports/)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("export") def export_cmd( @@ -177,7 +172,7 @@ def export_cmd( networks_dir=ws.networks_dir, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="export", ok=True, diff --git a/src/bib2graph/cli/commands/filter.py b/src/bib2graph/cli/commands/filter.py index 41752d5..a76999b 100644 --- a/src/bib2graph/cli/commands/filter.py +++ b/src/bib2graph/cli/commands/filter.py @@ -1,7 +1,16 @@ -"""cli.commands.filter — Subcomando ``b2g filter``. +"""cli.commands.filter — Subcomando ``b2g filter`` (alias deprecado, #165). Aplica filtros PRISMA deterministas al corpus: marca rejected (no borra). Transiciona el CycleState a FILTERED tras persistir con éxito. + +Shim delgado (#155): la orquestación vive en ``service.curate.filter_corpus``; +este módulo es un shim que delega. ``curate filter`` comparte la misma fuente +de lógica para garantizar comportamiento idéntico. + +``run_filter`` se mantiene aquí como función importable para tests existentes; +internamente llama a ``service.curate.filter_corpus``. + +DEPRECADO (ADR 0038, #165): usar ``b2g curate filter``. Se retira en 0.11.0. """ from __future__ import annotations @@ -12,12 +21,14 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, handle_errors -from bib2graph.cli._store import open_store, resolve_library_path +from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_library_path # --------------------------------------------------------------------------- -# Función núcleo (testeable, sin Click) +# Función núcleo (testeable, sin Click) — shim que delega en service.curate # --------------------------------------------------------------------------- @@ -29,19 +40,25 @@ def run_filter( language: list[str] | None = None, type_in: list[str] | None = None, min_citations: int | None = None, + decided_at: datetime | None = None, ) -> dict[str, Any]: - """Aplica filtros PRISMA al corpus marcando rejected los excluidos. + """Shim CLI: delega en ``service.curate.filter_corpus``. + + Mantiene la firma original de ``run_filter`` para que los tests existentes + no cambien. Toda la lógica vive en ``service.curate.filter_corpus``. - Los filtros se aplican en orden; cada uno ve el resultado del anterior. - El CycleState transiciona a FILTERED tras persistir con éxito. + R2 (ADR 0017 enmendado): ``decided_at`` es inyectado por el llamador. + ``filter_cmd`` pasa ``datetime.now(UTC)``; los tests pueden pasar un + timestamp fijo para determinismo. Args: store_path: Ruta al archivo ``.duckdb``. year_gte: Filtrar años >= este valor. year_lte: Filtrar años <= este valor. - language: Lista de códigos ISO 639-1 a incluir (ej. ``["en", "es"]``). + language: Lista de códigos ISO 639-1 a incluir. type_in: Lista de áreas de investigación a incluir. - min_citations: Mínimo de citantes (``len(cited_by_id) >= min_citations``). + min_citations: Mínimo de citantes en cited_by_id. + decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). Returns: Dict con ``steps`` (conteos PRISMA por paso) y ``total_papers``. @@ -50,74 +67,17 @@ def run_filter( DataError: Si ningún criterio es válido. StoreError: Si el store está bloqueado. """ - from bib2graph.cycle import apply_transition - from bib2graph.filters.prisma import FilterCriterion, apply_filters - - criteria: list[FilterCriterion] = [] - - if year_gte is not None: - criteria.append(FilterCriterion(field="year", op="gte", value=year_gte)) - if year_lte is not None: - criteria.append(FilterCriterion(field="year", op="lte", value=year_lte)) - if language: - criteria.append(FilterCriterion(field="language", op="in", value=language)) - if type_in: - criteria.append(FilterCriterion(field="type", op="in", value=type_in)) - if min_citations is not None: - criteria.append( - FilterCriterion(field="min_citations", op="gte", value=min_citations) - ) - - if not criteria: - raise DataError( - "Debés especificar al menos un criterio de filtro: " - "--year-gte, --year-lte, --language, --type, o --min-citations." - ) + from bib2graph.service.curate import filter_corpus - filtered_backend_close = None - store = open_store(store_path) - try: - corpus = store.load() - - # R3 — fuente única de verdad: el destino de la transición lo dicta cycle.py, - # no un literal en el comando (ADR 0016 enmendado §1). - current_state = store.backend.loop_state() - current_round = store.backend.loop_round() - new_state, new_round = apply_transition(current_state, "filter", current_round) - - # R2: el reloj se inyecta en la frontera (ADR 0017 enmendado); el núcleo - # no llama datetime.now(). Un único timestamp para todos los pasos de - # filtrado de esta invocación. - now = datetime.now(UTC) - filtered_corpus, steps = apply_filters(corpus, criteria, decided_at=now) - total_papers = len(filtered_corpus) - filtered_backend_close = getattr(filtered_corpus._backend, "close", None) - store.persist(filtered_corpus) - # #126 — trazabilidad PRISMA: persistir los pasos de filtro en filter_log - # para que manifest.filters sobreviva entre cargas del store. - store.backend.persist_filter_steps(steps) - store.backend.set_loop_state(new_state, cycle_round=new_round) - finally: - if filtered_backend_close is not None: - filtered_backend_close() - store.close() - - steps_data = [ - { - "name": s.name, - "criteria": s.criteria, - "count_before": s.count_before, - "count_after": s.count_after, - "excluded": s.count_before - s.count_after, - } - for s in steps - ] - - return { - "steps": steps_data, - "total_papers": total_papers, - "criteria_applied": len(criteria), - } + return filter_corpus( + store_path, + year_gte=year_gte, + year_lte=year_lte, + language=language, + type_in=type_in, + min_citations=min_citations, + decided_at=decided_at, + ) # --------------------------------------------------------------------------- @@ -145,13 +105,7 @@ def run_filter( default=None, help="Mínimo de citantes en cited_by_id.", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("filter") def filter_cmd( @@ -167,7 +121,10 @@ def filter_cmd( Tras el filtro, el estado del lazo transiciona a FILTERED. """ + dep_msg = emit_deprecation("b2g filter", "b2g curate filter") store_path = resolve_library_path(ctx.obj) + # R2: el reloj se inyecta en la frontera CLI (ADR 0017 enmendado). + now = datetime.now(UTC) data = run_filter( store_path, year_gte=year_gte, @@ -175,14 +132,16 @@ def filter_cmd( language=list(language) if language else None, type_in=list(type_in) if type_in else None, min_citations=min_citations, + decided_at=now, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="filter", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/gui.py b/src/bib2graph/cli/commands/gui.py deleted file mode 100644 index ac8020d..0000000 --- a/src/bib2graph/cli/commands/gui.py +++ /dev/null @@ -1,232 +0,0 @@ -"""cli.commands.gui — 19º subcomando ``b2g gui`` (Hito G3, ADR 0028). - -Levanta uvicorn sobre la API local FastAPI, sirve los assets pre-build del -frontend (si existen en ``src/bib2graph/gui/static/index.html``), e imprime la -URL con el token efímero. - -Wiring del token (G4, B-G4-3): al servir ``index.html``, reemplaza el -placeholder ``__B2G_TOKEN__`` con el token real. Los demás assets (JS, CSS, -etc.) se sirven con ``StaticFiles`` sin modificación. - -El import de ``fastapi`` y ``uvicorn`` es **perezoso**: se hace dentro de -``run_gui`` para que el núcleo no los importe al arrancar el CLI sin el extra -``[gui]``. Si faltan, lanza ``DependencyError`` (exit 3) con un mensaje -accionable. - -Flags: - --host TEXT Host de bind (default: 127.0.0.1). - --port INTEGER Puerto (default: 8765). - --no-browser No abrir el browser automáticamente. -""" - -from __future__ import annotations - -from typing import Any - -import click - -from bib2graph.cli._errors import handle_errors -from bib2graph.cli._store import resolve_workspace - -# Placeholder que el frontend espera en index.html y en window.__B2G_TOKEN__ -_TOKEN_PLACEHOLDER = "__B2G_TOKEN__" - - -def _make_index_response(static_dir: object, token: str) -> object: - """Construye una respuesta HTMLResponse con el token inyectado en index.html. - - Lee el ``index.html`` del directorio estático, reemplaza todas las - ocurrencias del placeholder ``__B2G_TOKEN__`` con el token efímero y - devuelve una ``HTMLResponse`` lista para ser usada como respuesta de - un endpoint de FastAPI. - - Args: - static_dir: ``pathlib.Path`` del directorio de assets. - token: Token efímero generado en el arranque. - - Returns: - ``HTMLResponse`` con el HTML modificado. - """ - from pathlib import Path - - from fastapi.responses import HTMLResponse - - index_path = Path(str(static_dir)) / "index.html" - html = index_path.read_text(encoding="utf-8") - html = html.replace(_TOKEN_PLACEHOLDER, token) - return HTMLResponse(content=html) - - -def build_gui_app(ws: Any, token: str, static_dir: object | None) -> Any: - """Construye la app FastAPI de la GUI: API + (si hay frontend) index + assets. - - Separada de ``run_gui`` para ser testeable sin uvicorn. Cuando ``static_dir`` - existe, monta la ruta raíz ``GET /`` que sirve ``index.html`` con el token - inyectado, más ``StaticFiles`` para los assets. - - Args: - ws: ``Workspace`` resuelto. - token: Token efímero. - static_dir: ``pathlib.Path`` del frontend buildeado, o ``None`` si no existe. - - Returns: - Instancia de ``FastAPI`` lista para ``uvicorn.run``. - """ - from bib2graph.api import create_app - - app = create_app(ws, token=token, cors_origins=None) - - if static_dir is not None: - from fastapi.staticfiles import StaticFiles - - # Ruta raíz: sirve index.html con el token inyectado (B-G4-3). - # serve_index NO declara parámetros a propósito: bajo - # `from __future__ import annotations` un `request: Request` quedaría como - # anotación-string que FastAPI resuelve contra los globals del módulo — - # donde `Request` NO está (es import local) — y lo tomaría como query param - # requerido → 422. serve_index no necesita el request, así que no lo pide. - @app.get("/", include_in_schema=False) # type: ignore[untyped-decorator] - async def serve_index() -> Any: - """Sirve index.html con el token Bearer inyectado.""" - return _make_index_response(static_dir, token) - - # Assets estáticos (JS, CSS, imágenes, fuentes) - app.mount( - "/", - StaticFiles(directory=str(static_dir), html=False), - name="static", - ) - - return app - - -def run_gui( - *, - workspace_ctx: dict[str, object], - host: str = "127.0.0.1", - port: int = 8765, - no_browser: bool = False, -) -> None: - """Levanta la API local FastAPI con uvicorn. - - Verifica que ``fastapi`` y ``uvicorn`` estén instalados **antes** de - importar ``bib2graph.api`` (import perezoso). Si faltan → ``DependencyError`` - (exit 3) con sugerencia accionable. - - Cuando el frontend está buildeado (``gui/static/index.html`` existe), - inyecta el token en el HTML servido reemplazando el placeholder - ``__B2G_TOKEN__`` (G4, B-G4-3). - - Args: - workspace_ctx: Dict ``ctx.obj`` del grupo Click (para ``resolve_workspace``). - host: Host de bind (default: ``"127.0.0.1"``). - port: Puerto (default: ``8765``). - no_browser: Si ``True``, no abre el browser. - - Raises: - DependencyError: Si ``fastapi`` o ``uvicorn`` no están instalados. - UsageError: Si no se puede resolver el workspace. - """ - # Import perezoso: verificar dependencias ANTES de importar bib2graph.api - try: - import fastapi # noqa: F401 - import uvicorn # noqa: F401 - except ImportError as exc: - from bib2graph.cli._errors import DependencyError - - raise DependencyError( - f"Dependencia GUI faltante: {exc}. " - "Instalá el extra con: uv sync --extra gui" - ) from exc - - from bib2graph.api.security import generate_token - - ws = resolve_workspace(workspace_ctx) - token = generate_token() - - # Resolver el static del frontend (G4/G5) — import perezoso - import importlib.resources as _res - from pathlib import Path - - static_dir: Path | None = None - try: - # Busca el static generado por G4/G5 en el paquete instalado - pkg_root = Path(__file__).parent.parent.parent / "bib2graph" / "gui" / "static" - if not pkg_root.exists(): - # Intentar vía importlib.resources (wheel instalado) - pkg_root = Path(str(_res.files("bib2graph"))) / "gui" / "static" - if pkg_root.exists() and (pkg_root / "index.html").exists(): - static_dir = pkg_root - except Exception: - static_dir = None - - app = build_gui_app(ws, token, static_dir) - - if static_dir is not None: - click.echo(f"Frontend servido desde: {static_dir}") - else: - click.echo( - "Advertencia: frontend no construido aún (G4). " - "Solo la API está disponible.", - err=True, - ) - - url = f"http://{host}:{port}" - click.echo(f"b2g GUI — API en {url}") - click.echo(f"Token: {token}") - click.echo("Usá este token como Bearer en el header Authorization.") - - if not no_browser: - import webbrowser - - webbrowser.open(url) - - import uvicorn as _uvicorn - - _uvicorn.run(app, host=host, port=port) - - -# --------------------------------------------------------------------------- -# Comando Click -# --------------------------------------------------------------------------- - - -@click.command("gui") -@click.option( - "--host", - default="127.0.0.1", - show_default=True, - help="Host de bind para la API.", -) -@click.option( - "--port", - default=8765, - show_default=True, - type=int, - help="Puerto para la API.", -) -@click.option( - "--no-browser", - is_flag=True, - default=False, - help="No abrir el browser automáticamente.", -) -@click.pass_context -@handle_errors("gui") -def gui_cmd( - ctx: click.Context, - host: str, - port: int, - no_browser: bool, -) -> None: - """Levanta la API local GUI (FastAPI + uvicorn). - - Requiere el extra [gui]: uv sync --extra gui. - Imprime la URL y el token efímero al arrancar. - """ - run_gui( - workspace_ctx=ctx.obj, - host=host, - port=port, - no_browser=no_browser, - ) diff --git a/src/bib2graph/cli/commands/init.py b/src/bib2graph/cli/commands/init.py index 6f0f0d2..56d7f0a 100644 --- a/src/bib2graph/cli/commands/init.py +++ b/src/bib2graph/cli/commands/init.py @@ -25,6 +25,7 @@ from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import UsageError, handle_errors +from bib2graph.cli._options import json_mode, json_option # --------------------------------------------------------------------------- # Función núcleo (testeable, sin Click) @@ -75,13 +76,7 @@ def run_init(path: Path, name: str) -> dict[str, Any]: "Nombre legible de la investigación (default: nombre del directorio destino)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @handle_errors("init") def init_cmd( target: str, @@ -113,7 +108,7 @@ def init_cmd( data = run_init(target_path, resolved_name) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="init", ok=True, diff --git a/src/bib2graph/cli/commands/inspect.py b/src/bib2graph/cli/commands/inspect.py index 573f999..a29a570 100644 --- a/src/bib2graph/cli/commands/inspect.py +++ b/src/bib2graph/cli/commands/inspect.py @@ -1,7 +1,10 @@ -"""cli.commands.inspect — Subcomando ``b2g inspect``. +"""cli.commands.inspect — Subcomando ``b2g inspect`` (alias deprecado, #165). Dump read-only del manifest y conteos. Con --id: datos de un paper. NO transiciona el CycleState. + +DEPRECADO (ADR 0038, #165): usar ``b2g read show`` (con ``--id``) o +``b2g status`` (sin ``--id``). Se retira en 0.11.0. """ from __future__ import annotations @@ -12,8 +15,10 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import DataError, handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_library_path from bib2graph.constants import Col @@ -119,13 +124,7 @@ def run_inspect( default=None, help="ID del paper a inspeccionar.", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("inspect") def inspect_cmd( @@ -138,15 +137,19 @@ def inspect_cmd( Sin --id: muestra el manifest y conteos. Con --id: muestra datos + provenance de ese paper. """ + # Forma canónica depende de si se pasa --id o no. + new_cmd = "b2g read show" if paper_id else "b2g status" + dep_msg = emit_deprecation("b2g inspect", new_cmd) store_path = resolve_library_path(ctx.obj) data = run_inspect(store_path, paper_id=paper_id) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="inspect", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/monitor.py b/src/bib2graph/cli/commands/monitor.py index 4eb71f4..2256737 100644 --- a/src/bib2graph/cli/commands/monitor.py +++ b/src/bib2graph/cli/commands/monitor.py @@ -1,4 +1,4 @@ -"""cli.commands.monitor — Subcomando ``b2g monitor``. +"""cli.commands.monitor — Subcomando ``b2g monitor`` (alias deprecado, #165). Re-chequea OpenAlex por nuevos citantes del corpus (forward chaining), mergea los candidatos nuevos a la biblioteca viva y transiciona el @@ -7,6 +7,13 @@ Requiere que el corpus tenga al menos una semilla con ``source_id`` conocido (de lo contrario el forward chaining no encuentra nada). Si no hay corpus ni estado previo, falla con un error accionable. + +**Implementación:** ``run_monitor`` es ahora un delegador fino sobre +``run_chain`` (con ``_fsm_action="monitor"``), garantizando fuente única +de la lógica de forrajeo (ADR 0037 §c). La retirada formal de este +subcomando es el issue #165. + +DEPRECADO (ADR 0038, #165): usar ``b2g chain --since``. Se retira en 0.11.0. """ from __future__ import annotations @@ -16,9 +23,11 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, handle_errors -from bib2graph.cli._store import open_store, resolve_library_path +from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_library_path # --------------------------------------------------------------------------- # Función núcleo (testeable, sin Click) @@ -33,11 +42,9 @@ def run_monitor( ) -> dict[str, Any]: """Re-chequea OpenAlex por nuevos citantes del corpus y transiciona a MONITORED. - Usa forward chaining (``Forager`` con ``direction="forward"``, que usa - ``fetch_citing_batch`` del source, batcheado y con cap por semilla) para - encontrar nuevos papers que citan al corpus. - Mergea los candidatos nuevos a la biblioteca viva y transiciona el estado a - MONITORED vía ``apply_transition(current_state, "monitor", current_round)``. + Delega en ``run_chain`` con ``direction="forward"`` y + ``_fsm_action="monitor"`` para mantener fuente única de la lógica de + forrajeo (ADR 0037 §c). Args: store_path: Ruta al archivo ``.duckdb``. @@ -52,63 +59,20 @@ def run_monitor( NetworkError: Si falla la conexión a OpenAlex. StoreError: Si el store está bloqueado. """ - from bib2graph.cycle import apply_transition - from bib2graph.foraging import Forager - from bib2graph.sources.openalex import OpenAlexSource - - merged_backend_close = None - store = open_store(store_path) - try: - current_state = store.backend.loop_state() - current_round = store.backend.loop_round() - - # Error accionable: monitor requiere un corpus previo sembrado. - if current_state is None: - raise DataError( - "No hay corpus ni estado previo en el store. " - "Iniciá la investigación con 'b2g seed' antes de monitorear." - ) - - corpus = store.load() - if len(corpus) == 0: - raise DataError( - "El corpus está vacío. " - "Usá 'b2g seed' para sembrar papers antes de monitorear." - ) - - new_state, new_round = apply_transition(current_state, "monitor", current_round) - - source = OpenAlexSource(email=email, transport=transport) - - # Forward chaining: nuevos citantes del corpus. - forager = Forager(source, depth=1) - ranked = forager.chain(corpus, direction="forward") - - # Calcular cuántos son genuinamente nuevos (no estaban en el corpus). - existing_ids = set(corpus.to_arrow().column("id").to_pylist()) - new_candidate_ids = [ - id_ - for id_ in ranked.corpus.to_arrow().column("id").to_pylist() - if id_ not in existing_ids - ] - new_candidates_count = len(new_candidate_ids) - - # Merge de candidatos nuevos y persistencia. - merged = corpus.merge(ranked.corpus) - total_papers = len(merged) - merged_backend_close = getattr(merged._backend, "close", None) - store.persist(merged) - store.backend.set_loop_state(new_state, cycle_round=new_round) - finally: - if merged_backend_close is not None: - merged_backend_close() - store.close() - + from bib2graph.cli.commands.chain import run_chain + + result = run_chain( + store_path, + direction="forward", + email=email, + transport=transport, + _fsm_action="monitor", + ) return { - "new_candidates": new_candidates_count, - "total_papers": total_papers, - "loop_state": new_state.value, - "round": new_round, + "new_candidates": result["new_candidates"], + "total_papers": result["total_papers"], + "loop_state": result["loop_state"], + "round": result["round"], } @@ -123,13 +87,7 @@ def run_monitor( default=None, help="Email para el polite pool de OpenAlex (recomendado).", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("monitor") def monitor_cmd( @@ -146,15 +104,17 @@ def monitor_cmd( Requiere un corpus previo (ejecutar 'b2g seed' primero). Requiere --email para el polite pool de OpenAlex. """ + dep_msg = emit_deprecation("b2g monitor", "b2g chain --since") store_path = resolve_library_path(ctx.obj) data = run_monitor(store_path, email=email) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="monitor", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/networks.py b/src/bib2graph/cli/commands/networks.py index 5af3be0..a1c51d0 100644 --- a/src/bib2graph/cli/commands/networks.py +++ b/src/bib2graph/cli/commands/networks.py @@ -1,4 +1,4 @@ -"""cli.commands.networks — Subcomando ``b2g networks``. +"""cli.commands.networks — Subcomando ``b2g networks`` (alias deprecado, #165). Carga una especificación declarativa de redes desde un archivo YAML y construye cada red, escribiendo artefactos a disco. @@ -18,6 +18,14 @@ Envelope ``--json`` (schema="1"): lista de redes en el mismo formato que ``b2g build``. + +DEPRECADO (ADR 0038, #165): usar ``b2g build --spec``. Se retira en 0.11.0. + +#159 — helper compartido: + ``run_networks`` delega la carga YAML y proyección a ``_build_from_spec_file`` + (definido en ``build.py``). Esto garantiza que ``build --spec`` y + ``networks --spec`` nunca diverjan, y que #165 pueda retirar ``networks`` + sin reconciliar dos implementaciones. """ from __future__ import annotations @@ -27,10 +35,12 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, DependencyError, handle_errors +from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_workspace -from bib2graph.cli.commands.build import _write_artifacts +from bib2graph.cli.commands.build import _build_from_spec_file, _write_artifacts # --------------------------------------------------------------------------- # Función núcleo (testeable, sin Click) @@ -45,9 +55,9 @@ def run_networks( ) -> dict[str, Any]: """Construye redes bibliométricas desde una especificación YAML. - Carga ``spec_path`` con ``load_specs``, construye cada red con - ``Networks.build`` y escribe artefactos con el helper ``_write_artifacts`` - (compartido con ``run_build``). + Carga ``spec_path`` con ``load_specs`` (vía ``_build_from_spec_file``), + construye cada red con ``Networks.build`` y escribe artefactos con el + helper ``_write_artifacts`` (compartido con ``run_build``). NO transiciona el ``CycleState`` ni sella ``.corpus_hash``: esta operación es transversal al lazo bibliométrico (igual que ``enrich`` @@ -67,9 +77,6 @@ def run_networks( DependencyError: Si falta ``python-louvain``. StoreError: Si el store está bloqueado. """ - from bib2graph.networks.facade import Networks - from bib2graph.networks.spec import load_specs - store = open_store(store_path) corpus = store.load() @@ -79,18 +86,9 @@ def run_networks( else: artifacts_dir = Path(out_dir) - try: - specs = load_specs(spec_path) - except (ValueError, FileNotFoundError) as exc: - raise DataError(str(exc)) from exc - - try: - artifacts = [Networks.build(corpus, spec) for spec in specs] - except ImportError as exc: - raise DependencyError( - f"Dependencia faltante para detectar comunidades: {exc}. " - "Instalá python-louvain: uv add python-louvain." - ) from exc + # Delegar carga YAML + proyección al helper compartido con build --spec. + # Levanta DataError (YAML inválido) o DependencyError (louvain faltante). + artifacts = _build_from_spec_file(corpus, spec_path) networks_info = _write_artifacts(artifacts, corpus, artifacts_dir) @@ -122,13 +120,7 @@ def run_networks( "(default: /networks/ o /networks/)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("networks") def networks_cmd( @@ -144,6 +136,7 @@ def networks_cmd( No transiciona el estado del lazo bibliométrico. """ + dep_msg = emit_deprecation("b2g networks", "b2g build --spec") ws = resolve_workspace(ctx.obj) effective_out_dir: str | Path | None = out_dir if effective_out_dir is None: @@ -151,12 +144,13 @@ def networks_cmd( data = run_networks(ws.library_path, spec_path, out_dir=effective_out_dir) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="networks", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/read.py b/src/bib2graph/cli/commands/read.py new file mode 100644 index 0000000..693db53 --- /dev/null +++ b/src/bib2graph/cli/commands/read.py @@ -0,0 +1,340 @@ +"""cli.commands.read — Grupo noun-verb ``b2g read`` (#156/#157, superficie CLI 0.10.0). + +Primer grupo noun-verb del repo. Agrupa lecturas read-only del corpus bajo +el verbo ``read``, con cuatro subcomandos: + + ``read list`` — lista papers con filtros opcionales (query/status/seeds/year). + ``read stats`` — estadísticas del corpus agrupadas por status, year o is_seed. + ``read show`` — fila completa de un paper, resolviendo por id, doi o source_id. + ``read top`` — nodos más centrales + pares de co-citación con título (#157). + +Cada subcomando delega la lógica en ``service.reads`` (capa neutral, ADR 0028): + - ``list_papers`` → ``read list`` + - ``corpus_stats`` → ``read stats`` + - ``get_paper`` → ``read show`` + - ``get_top`` → ``read top`` + +Decisiones de esta implementación (#156/#157): + - ``read`` sin subcomando → imprime ayuda y sale con exit 0 + (``invoke_without_command=True`` + check en el body; Click 8.4 usa exit 2 + con ``no_args_is_help=True`` en grupos — workaround deliberado). + - ``--json`` y ``B2G_JSON`` funcionan en los cuatro subcomandos (ADR 0021 #151). + - El ``command`` del envelope es la ruta completa (``"read list"``, + ``"read stats"``, ``"read show"``, ``"read top"``), no solo ``"read"``. + - ``--seeds`` y ``--candidates`` son mutuamente excluyentes (UsageError exit 1). + - ``inspect`` permanece intacto; su absorción es del sub-issue #165. + - ``read top``: red vacía → exit 0 + bloque vacío + reason/fix_command + (honest-empty). ``--kind`` inválido → exit 1 (UsageError de Click.Choice, + remapeado por ``main()``). + +Exit codes: 0 éxito, 1 uso (incl. ``--kind`` inválido), 2 datos (paper +inexistente; ``get_top`` con kind inválido llamado directo desde el servicio), 5 store. +""" + +from __future__ import annotations + +import click + +from bib2graph.cli._envelope import build_envelope, emit, emit_human +from bib2graph.cli._errors import UsageError, handle_errors +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_workspace +from bib2graph.constants import NetworkKind + +# --------------------------------------------------------------------------- +# Grupo raíz +# --------------------------------------------------------------------------- + + +@click.group("read", invoke_without_command=True) +@click.pass_context +def read_grp(ctx: click.Context) -> None: + """Lee papers del corpus (read-only). + + Subcomandos: list, stats, show, top. + + Ejemplos: + b2g read list --query "unequal exchange" + b2g read list --status accepted --json + b2g read stats --group-by year + b2g read show --id W2741809807 + b2g read show --id 10.1016/j.ecolecon.2019.01.001 + b2g read top --top 5 --json + b2g read top --kind cocitation --json + """ + ctx.ensure_object(dict) + # Click 8.4: no_args_is_help=True en grupos termina con exit 2 (Missing command). + # Usamos invoke_without_command=True + check manual para exit 0 correcto. + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# --------------------------------------------------------------------------- +# read list +# --------------------------------------------------------------------------- + + +@read_grp.command("list") +@click.option( + "--query", + default=None, + help="Texto a buscar en el título (substring, case-insensitive).", +) +@click.option( + "--status", + default=None, + type=click.Choice(["candidate", "accepted", "rejected"]), + help="Filtrar por curation_status exacto.", +) +@click.option( + "--seeds", + is_flag=True, + default=False, + help="Mostrar solo semillas (is_seed=True). Excluyente con --candidates.", +) +@click.option( + "--candidates", + is_flag=True, + default=False, + help="Mostrar solo no-semillas (is_seed=False). Excluyente con --seeds.", +) +@click.option( + "--year", + default=None, + type=int, + help="Filtrar por año exacto de publicación.", +) +@json_option +@click.pass_context +@handle_errors("read list") +def list_cmd( + ctx: click.Context, + query: str | None, + status: str | None, + seeds: bool, + candidates: bool, + year: int | None, + json_output: bool, +) -> None: + """Lista papers del corpus con filtros opcionales. + + Los filtros se combinan con AND lógico. + Sin filtros devuelve todos los papers. + + Campos devueltos por paper: id, title, year, curation_status, is_seed. + """ + if seeds and candidates: + raise UsageError("--seeds y --candidates son mutuamente excluyentes.") + + is_seed: bool | None = None + if seeds: + is_seed = True + elif candidates: + is_seed = False + + from bib2graph.service.reads import list_papers + + ws = resolve_workspace(ctx.obj) + data = list_papers(ws, query=query, status=status, is_seed=is_seed, year=year) + + if json_mode(json_output): + envelope = build_envelope( + command="read list", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + count = data["count"] + emit_human(f"Papers encontrados: {count}") + for paper in data["papers"]: + seed_marker = " [seed]" if paper.get("is_seed") else "" + emit_human( + f" {paper['id']} {paper.get('year') or '----'}" + f" [{paper.get('curation_status')}]{seed_marker}" + f" {paper.get('title') or ''}" + ) + + +# --------------------------------------------------------------------------- +# read stats +# --------------------------------------------------------------------------- + + +@read_grp.command("stats") +@click.option( + "--group-by", + "group_by", + default="status", + type=click.Choice(["status", "year", "is_seed"]), + show_default=True, + help="Dimensión de agrupación.", +) +@json_option +@click.pass_context +@handle_errors("read stats") +def stats_cmd( + ctx: click.Context, + group_by: str, + json_output: bool, +) -> None: + """Estadísticas del corpus agrupadas por una dimensión. + + Dimensiones válidas: status (default), year, is_seed. + """ + from bib2graph.service.reads import corpus_stats + + ws = resolve_workspace(ctx.obj) + data = corpus_stats(ws, group_by=group_by) + + if json_mode(json_output): + envelope = build_envelope( + command="read stats", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"Total papers: {data['total']}") + emit_human(f"Agrupado por: {data['group_by']}") + for group in data["groups"]: + emit_human(f" {group['key']}: {group['count']}") + + +# --------------------------------------------------------------------------- +# read show +# --------------------------------------------------------------------------- + + +@read_grp.command("show") +@click.option( + "--id", + "ident", + required=True, + help=( + "Identificador del paper: id interno, DOI o source_id. " + "Se prueba en ese orden (ADR 0036)." + ), +) +@json_option +@click.pass_context +@handle_errors("read show") +def show_cmd( + ctx: click.Context, + ident: str, + json_output: bool, +) -> None: + """Muestra la fila completa de un paper. + + Resuelve --id contra id, doi y source_id (en ese orden de prioridad). + Devuelve ~14 campos: id, source_id, doi, title, year, abstract, is_seed, + curation_status, authors_raw, authors_id, keywords_id, references_id, + cited_by_id, provenance. + """ + from bib2graph.service.reads import get_paper + + ws = resolve_workspace(ctx.obj) + data = get_paper(ws, ident) + + if json_mode(json_output): + envelope = build_envelope( + command="read show", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"id: {data.get('id')}") + emit_human(f"title: {data.get('title')}") + emit_human(f"year: {data.get('year')}") + emit_human(f"doi: {data.get('doi')}") + emit_human(f"source_id: {data.get('source_id')}") + emit_human(f"curation_status: {data.get('curation_status')}") + emit_human(f"is_seed: {data.get('is_seed')}") + abstract = data.get("abstract") or "" + emit_human( + f"abstract: {abstract[:120]}{'…' if len(abstract) > 120 else ''}" + ) + + +# --------------------------------------------------------------------------- +# read top +# --------------------------------------------------------------------------- + +_NETWORK_KIND_CHOICES: list[str] = [nk.value for nk in NetworkKind] + + +@read_grp.command("top") +@click.option( + "--top", + "-n", + "n", + default=10, + type=int, + show_default=True, + help="Número de nodos/pares a mostrar.", +) +@click.option( + "--kind", + default=NetworkKind.BIBLIOGRAPHIC_COUPLING.value, + show_default=True, + type=click.Choice(_NETWORK_KIND_CHOICES), + help="Tipo de red para el bloque de nodos centrales.", +) +@json_option +@click.pass_context +@handle_errors("read top") +def top_cmd( + ctx: click.Context, + n: int, + kind: str, + json_output: bool, +) -> None: + """Muestra los nodos más centrales y los pares de co-citación con título. + + Dos bloques de salida: + + \b + central — top N nodos de la red --kind, ordenados por degree_centrality + descendente. Default: bibliographic_coupling (robusto en + one-shot frío, no requiere enrich previo). + cocitation — top N pares de co-citación por peso, SIEMPRE desde la red + cocitation (requiere 'b2g enrich' previo para tener + cited_by_id). Si la red está vacía → bloque vacío con + reason/fix_command (honest-empty, exit 0). + + No requiere 'b2g build' previo: recomputa en tiempo de lectura. + """ + from bib2graph.service.reads import get_top + + ws = resolve_workspace(ctx.obj) + data = get_top(ws, n=n, kind=kind) + + if json_mode(json_output): + envelope = build_envelope( + command="read top", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"Nodos centrales ({kind}) — top {n}:") + for node in data["central"]: + dc = node["degree_centrality"] + title_str = node.get("title") or node["id"] + emit_human(f" {node['id']} dc={dc:.4f} {title_str}") + + emit_human(f"\nPares de co-citación — top {n}:") + for pair in data["cocitation"]: + src = pair.get("source_title") or pair["source"] + tgt = pair.get("target_title") or pair["target"] + emit_human(f" [{pair['weight']}] {src} ↔ {tgt}") + + if "reason" in data: + emit_human(f"\nCo-citación vacía: {data['reason']}") + if data.get("fix_command"): + emit_human(f"Solución: {data['fix_command']}") diff --git a/src/bib2graph/cli/commands/reject.py b/src/bib2graph/cli/commands/reject.py index 044df08..6aa84ed 100644 --- a/src/bib2graph/cli/commands/reject.py +++ b/src/bib2graph/cli/commands/reject.py @@ -1,4 +1,4 @@ -"""cli.commands.reject — Subcomando ``b2g reject``. +"""cli.commands.reject — Subcomando ``b2g reject`` (alias deprecado, #165). Marca papers como rejected en el corpus. @@ -10,6 +10,8 @@ Shim delgado (ADR 0028 G3): la orquestación vive en ``service.curate``; este módulo inyecta el reloj (frontera CLI, R2/ADR 0017) y delega. + +DEPRECADO (ADR 0038, #165): usar ``b2g curate reject``. Se retira en 0.11.0. """ from __future__ import annotations @@ -20,8 +22,10 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import resolve_library_path # --------------------------------------------------------------------------- @@ -76,13 +80,7 @@ def run_reject( show_default=True, help="Identificador de quien decide.", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("reject") def reject_cmd( @@ -96,15 +94,17 @@ def reject_cmd( Curación TRANSVERSAL: no transiciona el CycleState. Disponible en cualquier estado del lazo (Nota 05 §4, ADR 0016 enmendado R3). """ + dep_msg = emit_deprecation("b2g reject", "b2g curate reject") store_path = resolve_library_path(ctx.obj) data = run_reject(store_path, list(ids), by=by) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="reject", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/resolve.py b/src/bib2graph/cli/commands/resolve.py index 6c07dc6..1c32e24 100644 --- a/src/bib2graph/cli/commands/resolve.py +++ b/src/bib2graph/cli/commands/resolve.py @@ -1,4 +1,4 @@ -"""cli.commands.resolve — Subcomando ``b2g resolve``. +"""cli.commands.resolve — Subcomando ``b2g resolve`` (alias deprecado, #165). Resuelve los DOIs del workspace actual a IDs de OpenAlex (``source_id``), habilitando el enriquecimiento posterior con ``b2g enrich`` y el forrajeo @@ -13,6 +13,8 @@ --json Salida JSON estructurada (envelope versionado, ADR 0021). NO transiciona el CycleState (operación ortogonal al lazo, igual que enrich). + +DEPRECADO (ADR 0038, #165): usar ``b2g seed --resolve``. Se retira en 0.11.0. """ from __future__ import annotations @@ -22,8 +24,10 @@ import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import resolve_library_path # --------------------------------------------------------------------------- @@ -72,13 +76,7 @@ def run_resolve( default=None, help="Email para el polite pool de OpenAlex (recomendado).", ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("resolve") def resolve_cmd( @@ -103,15 +101,17 @@ def resolve_cmd( b2g resolve --email mi@email.com b2g enrich --email mi@email.com """ + dep_msg = emit_deprecation("b2g resolve", "b2g seed --resolve") store_path = resolve_library_path(ctx.obj) data = run_resolve(store_path, email=email) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="resolve", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/restore.py b/src/bib2graph/cli/commands/restore.py index 8c3aa3a..49227d0 100644 --- a/src/bib2graph/cli/commands/restore.py +++ b/src/bib2graph/cli/commands/restore.py @@ -1,11 +1,16 @@ -"""cli.commands.restore — Subcomando ``b2g restore``. +"""cli.commands.restore — Shim ``b2g restore`` (ADR 0038, #163, alias deprecado #165). -Rehidrata el corpus desde un parquet curado (snapshot) sin tocar la red. -Semánticamente: ``restore`` es a ``snapshot`` lo que ``load`` es a ``dump``. -El parquet es corpus *curado*, no semilla. +El subcomando ``restore`` suelto se mantiene intacto como shim para +compatibilidad con scripts y flujos existentes. Su retiro está planificado +en el sub-issue #165. -Modo único requerido: - --from-corpus carga el corpus desde un parquet con schema canónico. +DEPRECADO (ADR 0038, #165): usar ``b2g snapshot restore``. Se retira en 0.11.0. + +La lógica vive en ``service.snapshot.run_restore`` (fuente única). Este +módulo es un adaptador delgado que: + - Inyecta el reloj en la frontera CLI (``decided_at``, R2/ADR 0017). + - Emite el envelope JSON con ``command="restore"`` (compatibilidad). + - Re-exporta ``run_restore`` para tests que importan desde este módulo. Decisión de CycleState tras restore: El corpus restaurado viene de un snapshot curado — ya pasó el lazo completo @@ -24,129 +29,24 @@ from __future__ import annotations from datetime import UTC, datetime -from pathlib import Path -from typing import Any import click +from bib2graph.cli._deprecation import emit_deprecation from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, handle_errors -from bib2graph.cli._ingest import normalize_and_dedup -from bib2graph.cli._store import open_store, resolve_library_path - -# --------------------------------------------------------------------------- -# Función núcleo: rehidratación desde parquet (testeable, sin Click) -# --------------------------------------------------------------------------- - +from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_library_path -def run_restore( - store_path: str | Path, - corpus_path: str | Path, -) -> dict[str, Any]: - """Carga un corpus curado desde un parquet y lo persiste en el store sin red. +# Fuente única: service.snapshot.run_restore (ADR 0038). +# Re-exportado para backward compat con tests que importan desde este módulo. +from bib2graph.service.snapshot import run_restore - Lee el parquet con el schema canónico (``CORPUS_SCHEMA``), lo hidrata con - ``Corpus.from_arrow``, hace merge con el corpus existente y persiste. - Transiciona el ``CycleState`` a ``FILTERED`` (el corpus ya fue curado; - ver docstring del módulo para la justificación). - - Preserva las columnas de curación del parquet - (``decision`` / ``curation_status`` / ``is_seed``): el merge de ``Corpus`` - respeta el ``curation_status`` más reciente (D3 del merge). - - No instancia ``OpenAlexSource``, no hace requests. Es el camino offline - para rehidratar un corpus curado exportado con ``b2g snapshot``. - - Args: - store_path: Ruta al archivo ``.duckdb``. - corpus_path: Ruta al archivo ``.parquet`` con el corpus curado. - - Returns: - Dict con ``papers_loaded``, ``total_papers``, ``state``, ``round``. - - Raises: - DataError: Si el parquet no existe o no tiene el schema canónico. - StoreError: Si el store está bloqueado. - """ - import pyarrow.parquet as pq - - from bib2graph.corpus import Corpus - from bib2graph.cycle import CycleState, apply_transition - from bib2graph.schemas import CORPUS_SCHEMA - - resolved = Path(corpus_path) - if not resolved.exists(): - raise DataError( - f"El parquet '{resolved}' no existe. Verificá la ruta al corpus curado." - ) - - try: - table = pq.read_table(str(resolved), schema=CORPUS_SCHEMA) # type: ignore[no-untyped-call] - except Exception as exc: - raise DataError( - f"No se pudo leer el parquet '{resolved}': {exc}. " - "Verificá que el archivo tenga el schema canónico de bib2graph." - ) from exc - - try: - incoming = Corpus.from_arrow(table) - except Exception as exc: - raise DataError( - f"El parquet '{resolved}' no cumple el schema canónico: {exc}." - ) from exc - - merged_backend_close = None - store = open_store(store_path) - try: - existing = store.load() - - # Transición a FILTERED: el corpus restaurado ya pasó curación. - # apply_transition es permisiva — acepta "filter" desde cualquier estado - # actual del store (incluyendo None para un store vacío nuevo). - current_state = store.backend.loop_state() - # La ronda nunca debe ser < 1: loop_round() devuelve 0 para bases legacy - # (round=NULL, pre-R3) y para stores vacíos. max(..., 1) la normaliza en - # ambos branches para no persistir un estado con ronda 0 incoherente. - current_round = max(store.backend.loop_round(), 1) - # "filter" lleva a FILTERED; la ronda no cambia (no es reseed). - # Para un store vacío (current_state=None), arrancamos desde SEEDED ficticio. - if current_state is None: - new_state, new_round = apply_transition( - CycleState.SEEDED, "filter", current_round - ) - else: - new_state, new_round = apply_transition( - current_state, "filter", current_round - ) - - # Merge primero, dedup después sobre el corpus COMPLETO (fix bug cross-biblioteca). - # Orden: existing + incoming → merged completo → normalize_and_dedup → persist_replace. - # El reloj se fija UNA vez por invocación (R2). - ingest_at = datetime.now(UTC) - merged = existing.merge(incoming) - merged_deduped = normalize_and_dedup(merged, applied_at=ingest_at) - papers_loaded = len(incoming) - total_papers = len(merged_deduped) - merged_backend_close = getattr(merged_deduped._backend, "close", None) - store.persist_replace(merged_deduped) - store.backend.set_loop_state(new_state, cycle_round=new_round) - finally: - # Ver run_seed_from_bib: cierra explícitamente las conexiones DuckDB - # para evitar segfault en Linux ante llamadas consecutivas al mismo archivo. - if merged_backend_close is not None: - merged_backend_close() - store.close() - - return { - "papers_loaded": papers_loaded, - "total_papers": total_papers, - "state": str(new_state), - "round": new_round, - } +__all__ = ["restore_cmd", "run_restore"] # --------------------------------------------------------------------------- -# Comando Click (no se testea directamente) +# Comando Click (shim delgado — no se testea directamente) # --------------------------------------------------------------------------- @@ -158,16 +58,10 @@ def run_restore( type=click.Path(), help=( "Ruta al parquet con el corpus curado a importar sin red " - "(producido por b2g snapshot)." + "(producido por b2g snapshot create)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("restore") def restore_cmd( @@ -189,16 +83,23 @@ def restore_cmd( Ejemplos: b2g restore --from-corpus snapshots/corpus.parquet b2g restore --from-corpus corpus_curado.parquet --json + + \b + Alternativa canónica (ADR 0038): b2g snapshot restore --from-corpus ... """ + dep_msg = emit_deprecation("b2g restore", "b2g snapshot restore") store_path = resolve_library_path(ctx.obj) - data = run_restore(store_path, corpus_path) + # R2/ADR 0017: el reloj se inyecta en la frontera CLI. + decided_at = datetime.now(UTC) + data = run_restore(store_path, corpus_path, decided_at=decided_at) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="restore", ok=True, data=data, exit_code=0, + warnings=[dep_msg], ) emit(envelope) else: diff --git a/src/bib2graph/cli/commands/seed.py b/src/bib2graph/cli/commands/seed.py index 1f15405..029c863 100644 --- a/src/bib2graph/cli/commands/seed.py +++ b/src/bib2graph/cli/commands/seed.py @@ -37,6 +37,7 @@ handle_errors, ) from bib2graph.cli._ingest import normalize_and_dedup +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store, resolve_library_path # --------------------------------------------------------------------------- @@ -395,13 +396,7 @@ def run_seed_from_bib( "(solo con --from-bib; encadena b2g resolve automáticamente)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("seed") def seed_cmd( @@ -499,7 +494,7 @@ def seed_cmd( # --- Modo --from-bib (BibTeX local, sin red) --- if bib_path is not None: data = run_seed_from_bib(store_path, bib_path, resolve=do_resolve, email=email) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="seed", ok=True, @@ -537,7 +532,7 @@ def seed_cmd( min_year=spec.min_year, max_year=spec.max_year, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="seed", ok=True, @@ -568,7 +563,7 @@ def seed_cmd( max_year=max_year, ) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="seed", ok=True, diff --git a/src/bib2graph/cli/commands/skill.py b/src/bib2graph/cli/commands/skill.py new file mode 100644 index 0000000..f02207c --- /dev/null +++ b/src/bib2graph/cli/commands/skill.py @@ -0,0 +1,294 @@ +"""cli.commands.skill — Grupo noun-verb ``b2g skill`` (Epic #188). + +Gestión de la skill vendida de bib2graph para Claude. + + ``skill add`` — instala la skill en ~/.claude/skills/bib2graph/ (scope + ``--user``, default) o en /.claude/skills/bib2graph/ + (scope ``--project``). + +La skill reside en ``src/bib2graph/skill/`` y se localiza vía +``importlib.resources.files("bib2graph") / "skill"`` con fallback a la ruta +relativa a ``__file__`` (árbol de desarrollo). + +Flags de ``skill add``: + --user / --project Scope de instalación (mutuamente excluyentes vía + ``flag_value``; default ``--user``). + --force Pisar el destino si ya existe. + +Comportamiento (idempotente — contrato en ADR 0039 / API.md): + - Destino no existe → instala. + - Destino existe e **idéntico** a la versión vendida → **no-op**, exit 0, lo + reporta (``already_present=True``). Re-correr el comando es seguro. + - Destino existe y **difiere** (edición del usuario u otra versión), sin + ``--force`` → ``UsageError`` (exit 1) que sugiere ``--force``. + - Con ``--force`` → pisa (``shutil.rmtree`` + ``shutil.copytree``). + - **NO requiere workspace** (es comando meta global; no llama a + ``resolve_workspace``). + - Emite envelope ``--json`` ``schema="1"`` con ``install_path``, ``scope``, + ``installed`` y ``already_present``. + - Sin transición de FSM (operación transversal al lazo, como ``gui``). +""" + +from __future__ import annotations + +import filecmp +import shutil +from pathlib import Path +from typing import Any + +import click + +from bib2graph.cli._envelope import build_envelope, emit, emit_human +from bib2graph.cli._errors import UsageError, handle_errors +from bib2graph.cli._options import json_mode, json_option + +# --------------------------------------------------------------------------- +# Helpers internos +# --------------------------------------------------------------------------- + + +def _locate_skill_source() -> Path: + """Localiza el directorio de la skill vendida en el paquete instalado. + + Intenta primero con ``importlib.resources.files`` (funciona en editable + install y en wheel instalado). Si no existe o falla, usa un fallback + relativo a ``__file__`` (árbol de desarrollo). + + Returns: + Path al directorio ``skill/`` que contiene ``SKILL.md`` y + ``reference/``. + + Raises: + RuntimeError: Si no se puede localizar el directorio en ninguna de + las dos rutas intentadas. + """ + import importlib.resources as _res + + # Primario: importlib.resources — funciona tanto en editable install + # (apunta a src/bib2graph/) como en wheel instalado. + try: + skill_dir = Path(str(_res.files("bib2graph"))) / "skill" + if skill_dir.exists() and (skill_dir / "SKILL.md").exists(): + return skill_dir + except Exception: + pass + + # Fallback: árbol de fuentes relativo a este archivo. + # Ruta: cli/commands/skill.py → cli/ → bib2graph/ → skill/ + fallback = Path(__file__).parent.parent.parent / "skill" + if fallback.exists() and (fallback / "SKILL.md").exists(): + return fallback + + raise RuntimeError( + "No se puede localizar el directorio de la skill vendida. " + "Verificá que el paquete bib2graph esté instalado correctamente " + "(src/bib2graph/skill/ debe contener SKILL.md)." + ) + + +def _trees_identical(a: Path, b: Path) -> bool: + """True si ``a`` y ``b`` tienen los mismos archivos con idéntico contenido. + + Comparación **por contenido** (``shallow=False``), recursiva. Si difiere + cualquier archivo, falta o sobra alguno, devuelve ``False``. Es la base de + la idempotencia: ``skill add`` es no-op solo si lo instalado coincide + exactamente con la versión vendida. + """ + cmp = filecmp.dircmp(str(a), str(b)) + if cmp.left_only or cmp.right_only or cmp.funny_files: + return False + _, mismatch, errors = filecmp.cmpfiles( + str(a), str(b), cmp.common_files, shallow=False + ) + if mismatch or errors: + return False + return all(_trees_identical(a / sub, b / sub) for sub in cmp.common_dirs) + + +# Resumen "a grandes rasgos" de cómo opera bib2graph, para que un agente de +# CUALQUIER proveedor (no solo Claude Code) sepa qué es esto y vaya al SKILL.md. +# (La integración real depende del cliente; esto la hace auto-descubrible: la +# salida del CLI dice dónde está el artículo y pide leerlo. Ver #193 para la +# distribución agnóstica al proveedor de 0.11.0.) +_HOW_IT_WORKS = ( + "bib2graph entrevista al investigador y corre el ciclo de forrajeo " + "seed→chain→build→read (one-shot o profundizando), priorizando candidatos " + "por estructura de citación (determinista, sin IA). El SKILL.md trae la " + "entrevista y cómo operar cada paso." +) + + +def _build_result( + dest: Path, scope: str, *, installed: bool, already_present: bool +) -> dict[str, Any]: + """Arma el dict de resultado de ``skill add``. + + Incluye las rutas que un agente necesita para **leer** la skill —``skill_md`` + y ``reference_dir``— y el resumen ``how_to``, de modo que un agente agnóstico + al proveedor pueda auto-onboardearse desde la salida ``--json`` sin depender + del mecanismo de descubrimiento de Claude Code. + """ + return { + "install_path": str(dest), + "scope": scope, + "installed": installed, + "already_present": already_present, + "skill_md": str(dest / "SKILL.md"), + "reference_dir": str(dest / "reference"), + "how_to": _HOW_IT_WORKS, + } + + +def run_skill_add( + *, + scope: str, + force: bool, + cwd: Path | None = None, + home: Path | None = None, +) -> dict[str, Any]: + """Instala la skill de bib2graph en el directorio de Claude. + + Args: + scope: ``"user"`` (instala en ``~/.claude/skills/bib2graph/``) o + ``"project"`` (instala en ``/.claude/skills/bib2graph/``). + force: Si ``True``, pisa el destino si ya existe. + cwd: Directorio de trabajo para el scope ``"project"`` (inyectable + en tests; default: ``Path.cwd()``). + home: Directorio home para el scope ``"user"`` (inyectable en tests; + default: ``Path.home()``). + + Returns: + Dict con ``install_path``, ``scope``, ``installed`` (``True`` si + copió/pisó, ``False`` si fue no-op), ``already_present`` (``True`` si la + versión vendida ya estaba), y —para que un agente sepa dónde leerla— + ``skill_md`` (ruta al SKILL.md), ``reference_dir`` y ``how_to`` (resumen). + + Raises: + UsageError: Si el destino existe, **difiere** de la versión vendida y + ``force=False``. + RuntimeError: Si no se puede localizar el directorio de la skill. + """ + effective_home = home if home is not None else Path.home() + effective_cwd = cwd if cwd is not None else Path.cwd() + + if scope == "user": + dest = effective_home / ".claude" / "skills" / "bib2graph" + else: + dest = effective_cwd / ".claude" / "skills" / "bib2graph" + + source = _locate_skill_source() + + # Idempotencia: si ya está exactamente la versión vendida → no-op. + if dest.exists() and _trees_identical(source, dest): + return _build_result(dest, scope, installed=False, already_present=True) + + # Existe pero difiere (edición del usuario u otra versión): exige --force. + if dest.exists() and not force: + raise UsageError( + f"El destino ya existe y difiere de la versión vendida: {dest}. " + "Usá --force para pisar la instalación existente." + ) + + if dest.exists(): + shutil.rmtree(dest) + + shutil.copytree(source, dest) + + return _build_result(dest, scope, installed=True, already_present=False) + + +# --------------------------------------------------------------------------- +# Grupo raíz +# --------------------------------------------------------------------------- + + +@click.group("skill", invoke_without_command=True) +@click.pass_context +def skill_grp(ctx: click.Context) -> None: + """Gestión de la skill de bib2graph para Claude. + + Subcomandos: add. + + Ejemplos: + b2g skill add + b2g skill add --project + b2g skill add --force + """ + ctx.ensure_object(dict) + # Click 8.4: no_args_is_help=True en grupos termina con exit 2. + # Usamos invoke_without_command=True + check manual para exit 0 correcto. + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# --------------------------------------------------------------------------- +# skill add +# --------------------------------------------------------------------------- + + +@skill_grp.command("add") +@click.option( + "--user", + "scope", + flag_value="user", + default=True, + help="Instala la skill en ~/.claude/skills/bib2graph/ (default).", +) +@click.option( + "--project", + "scope", + flag_value="project", + help="Instala la skill en /.claude/skills/bib2graph/.", +) +@click.option( + "--force", + is_flag=True, + default=False, + help="Pisar el destino si ya existe.", +) +@json_option +@click.pass_context +@handle_errors("skill add") +def add_cmd( + ctx: click.Context, + scope: str, + force: bool, + json_output: bool, +) -> None: + """Instala la skill de bib2graph en el directorio de Claude. + + Por defecto instala en ~/.claude/skills/bib2graph/ (scope --user). + Con --project instala en /.claude/skills/bib2graph/. + + Es idempotente: si la versión vendida ya está instalada, no hace nada y lo + reporta. Si el destino existe pero difiere, use --force para pisarlo. + """ + data = run_skill_add(scope=scope, force=force) + + if json_mode(json_output): + envelope = build_envelope( + command="skill add", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + estado = ( + "Skill ya instalada (versión vendida)" + if data["already_present"] + else "Skill instalada" + ) + # Salida explícita y agnóstica al proveedor: dónde quedó el artículo, + # cómo opera bib2graph a grandes rasgos, y la instrucción de leerlo + # (cualquier agente, no solo Claude Code, puede auto-onboardearse así). + emit_human( + f"{estado} en: {data['install_path']}\n" + "\n" + "Cómo opera bib2graph (a grandes rasgos):\n" + f" {data['how_to']}\n" + "\n" + "Agente (cualquier proveedor): leé este archivo para operar bib2graph →\n" + f" {data['skill_md']}\n" + f" (marco teórico en {data['reference_dir']}/ciclo.md)" + ) diff --git a/src/bib2graph/cli/commands/snapshot.py b/src/bib2graph/cli/commands/snapshot.py index f90ed49..3647cdb 100644 --- a/src/bib2graph/cli/commands/snapshot.py +++ b/src/bib2graph/cli/commands/snapshot.py @@ -1,68 +1,90 @@ -"""cli.commands.snapshot — Subcomando ``b2g snapshot``. - -Exporta una foto sellada del corpus actual (parquet + manifest.json). -NO transiciona el CycleState. - -ADR 0029 — workspace: - El directorio de salida es ``/snapshots/`` por defecto. - Si se pasa ``--out-dir`` explícito, se usa ese (override opcional). +"""cli.commands.snapshot — Grupo noun-verb ``b2g snapshot`` (ADR 0038, #163). + +Convierte ``snapshot`` de comando plano a **grupo noun-verb** con dos +subcomandos: + + ``snapshot create`` — exporta una foto sellada del corpus actual + (= comportamiento anterior del comando plano). + ``snapshot restore`` — rehidrata el corpus desde un parquet curado sin red + (= lógica de ``b2g restore``, fuente única en service). + +Molde: ``curate.py`` (#155) — grupo ``invoke_without_command=True`` + check +manual para exit 0 sin subcomando (Click 8.4 usa exit 2 con +``no_args_is_help=True`` en grupos). + +BREAKING (ADR 0038): + - ``b2g snapshot`` plano pasa a ``b2g snapshot create``. + - ``b2g snapshot restore`` es el nuevo camino canónico para rehabilitar corpus. + - ``b2g restore`` suelto queda intacto como shim; su retiro es #165. + +Capa de servicios (ADR 0038): + Toda la lógica vive en ``service.snapshot``; este módulo son shims delgados + que inyectan el reloj en la frontera CLI (R2/ADR 0017) y emiten el envelope. + +Backward compat con tests existentes: + ``run_snapshot`` y ``run_restore`` se re-exportan desde ``service.snapshot`` + para que los tests que importan de ``bib2graph.cli.commands.snapshot`` sigan + funcionando sin cambios. + ``snapshot_cmd`` = ``snapshot_grp`` (alias para tests que usaban el nombre viejo). + +Flujo canónico: + b2g snapshot create # exporta corpus.parquet + manifest.json + b2g snapshot create --out-dir mis_snaps/ # directorio alternativo + b2g snapshot restore --from-corpus snap/corpus.parquet # rehidrata + b2g snapshot # imprime ayuda y sale con exit 0 """ from __future__ import annotations +from datetime import UTC, datetime from pathlib import Path -from typing import Any import click from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors -from bib2graph.cli._store import open_store, resolve_workspace +from bib2graph.cli._options import json_mode, json_option +from bib2graph.cli._store import resolve_workspace +from bib2graph.service.snapshot import run_restore, run_snapshot -# --------------------------------------------------------------------------- -# Función núcleo (testeable, sin Click) -# --------------------------------------------------------------------------- +__all__ = [ + "run_restore", + "run_snapshot", + "snapshot_cmd", + "snapshot_grp", +] -def run_snapshot( - store_path: str | Path, - *, - out_dir: str | Path, -) -> dict[str, Any]: - """Exporta una foto sellada del corpus actual. +# --------------------------------------------------------------------------- +# Grupo raíz +# --------------------------------------------------------------------------- - Carga el corpus del store y exporta un snapshot sellado (parquet + - manifest.json) al directorio indicado. No transiciona el CycleState. - Args: - store_path: Ruta al archivo ``.duckdb``. - out_dir: Directorio destino del snapshot. +@click.group("snapshot", invoke_without_command=True) +@click.pass_context +def snapshot_grp(ctx: click.Context) -> None: + """Gestión de snapshots del corpus: create y restore. - Returns: - Dict con ``snapshot_dir``, ``corpus_hash``, ``total_papers``. + Subcomandos: create, restore. - Raises: - StoreError: Si el store está bloqueado. + Ejemplos: + b2g snapshot create + b2g snapshot create --out-dir mis_snaps/ + b2g snapshot restore --from-corpus snaps/corpus.parquet """ - store = open_store(store_path) - corpus = store.load() - - snap = corpus.snapshot(Path(out_dir)) - - return { - "snapshot_dir": str(snap.path), - "corpus_hash": snap.manifest.corpus_hash, - "total_papers": len(corpus), - "schema_version": snap.manifest.schema_version, - } + ctx.ensure_object(dict) + # Click 8.4: no_args_is_help=True en grupos termina con exit 2. + # Usamos invoke_without_command=True + check manual para exit 0 correcto. + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) # --------------------------------------------------------------------------- -# Comando Click +# snapshot create # --------------------------------------------------------------------------- -@click.command("snapshot") +@snapshot_grp.command("create") @click.option( "--out-dir", default=None, @@ -71,16 +93,10 @@ def run_snapshot( "(default: /snapshots/ o /snapshots/)." ), ) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context -@handle_errors("snapshot") -def snapshot_cmd( +@handle_errors("snapshot create") +def create_cmd( ctx: click.Context, out_dir: str | None, json_output: bool, @@ -98,9 +114,9 @@ def snapshot_cmd( data = run_snapshot(ws.library_path, out_dir=effective_out_dir) - if json_output: + if json_mode(json_output): envelope = build_envelope( - command="snapshot", + command="snapshot create", ok=True, data=data, exit_code=0, @@ -110,3 +126,68 @@ def snapshot_cmd( emit_human(f"Snapshot exportado en: {data['snapshot_dir']}") emit_human(f"corpus_hash: {data['corpus_hash']}") emit_human(f"Total papers: {data['total_papers']}") + + +# --------------------------------------------------------------------------- +# snapshot restore +# --------------------------------------------------------------------------- + + +@snapshot_grp.command("restore") +@click.option( + "--from-corpus", + "corpus_path", + required=True, + type=click.Path(), + help=( + "Ruta al parquet con el corpus curado a importar sin red " + "(producido por b2g snapshot create)." + ), +) +@json_option +@click.pass_context +@handle_errors("snapshot restore") +def restore_sub_cmd( + ctx: click.Context, + corpus_path: str, + json_output: bool, +) -> None: + """Rehidrata el corpus desde un parquet curado sin tocar la red. + + \b + Carga el parquet con el schema canónico de bib2graph, hace merge con + el corpus existente y transiciona el lazo a FILTERED (el corpus ya + fue curado; build y networks pueden correr a continuación). + + \b + Preserva las columnas de curación del parquet (curation_status, is_seed). + + \b + Ejemplos: + b2g snapshot restore --from-corpus snapshots/corpus.parquet + b2g snapshot restore --from-corpus corpus_curado.parquet --json + """ + ws = resolve_workspace(ctx.obj) + # R2/ADR 0017: el reloj se inyecta en la frontera CLI. + decided_at = datetime.now(UTC) + data = run_restore(ws.library_path, corpus_path, decided_at=decided_at) + + if json_mode(json_output): + envelope = build_envelope( + command="snapshot restore", + ok=True, + data=data, + exit_code=0, + ) + emit(envelope) + else: + emit_human(f"Corpus restaurado: {data['papers_loaded']} papers importados.") + emit_human(f"Total en corpus: {data['total_papers']}") + emit_human(f"Estado del lazo: {data['state']}") + + +# --------------------------------------------------------------------------- +# Alias snapshot_cmd → snapshot_grp (compat con registros y tests existentes) +# --------------------------------------------------------------------------- + +snapshot_cmd = snapshot_grp diff --git a/src/bib2graph/cli/commands/status.py b/src/bib2graph/cli/commands/status.py index 56c0de5..3d4f8a5 100644 --- a/src/bib2graph/cli/commands/status.py +++ b/src/bib2graph/cli/commands/status.py @@ -26,8 +26,10 @@ from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store_readonly, resolve_workspace -from bib2graph.cycle import CURATION_ACTIONS, available_transitions +from bib2graph.cycle import CURATION_ACTIONS, available_transitions, next_best_action +from bib2graph.networks.facade import predict_build_preview # --------------------------------------------------------------------------- # Función núcleo (testeable, sin Click) @@ -48,12 +50,20 @@ def run_status(store_path: str | Path) -> dict[str, Any]: campo no existía y ``transitions_available`` nunca los listaba (bug). - ``round``: contador de ronda (0 = sin estado; 1 = primera ronda; 2+ = re-sembrados). + ADR 0037 §(e) — campos aditivos (schema="1" intacto): + - ``next_best_action``: único próximo comando recomendado (derivado del FSM). + - ``readiness``: si el próximo paso va a dar fruto (preparación, no solo + alcanzabilidad FSM). + - ``build_preview``: por cada red proyectable, predice vacío/no-vacío ANTES + de correr build (diagnóstico de red-vacía en status-time). + Args: store_path: Ruta al archivo ``.duckdb``. Returns: Dict con ``loop_state``, ``transitions_available``, ``curation_available``, - ``round``, ``counts_by_status``, ``total_papers``. + ``round``, ``counts_by_status``, ``total_papers``, ``next_best_action``, + ``readiness``, ``build_preview``. Raises: StoreError: Si el store está bloqueado. @@ -91,6 +101,59 @@ def run_status(store_path: str | Path) -> dict[str, Any]: # El campo es aditivo (schema="1" intacto; campos nuevos no rompen agentes). referenced_not_fetched = store.backend.referenced_refs_count() + # ADR 0037 §(e) — campos aditivos: next_best_action, readiness, build_preview. + # Cargar el corpus para los conteos de columnas (predicados compartidos con + # los proyectores → fuente única preview ↔ build). + corpus = store.load() + + # next_best_action: derivado puramente del FSM (cycle.py). + action = next_best_action(loop_state) + + # build_preview: predice vacío/no-vacío por red sin proyectar el grafo. + build_prev = predict_build_preview(corpus) + + # readiness: si el próximo paso va a DAR FRUTO (no solo si está permitido). + # Caso crítico "build": ready si al menos 1 red no sería vacía. + # Caso "chain": ready si al menos 1 seed tiene source_id (necesario para + # el forrajeo en OpenAlex). BibTeX sin --resolve → source_id=None en + # todos los seeds → chaining produce 0 papers nuevos (Nota 20, ADR 0037). + if action == "build": + all_empty = all(bool(item["would_be_empty"]) for item in build_prev) + if all_empty: + readiness: dict[str, Any] = { + "ready": False, + "reason": ( + "Todas las redes proyectables saldrían vacías. " + "Revisá build_preview.fix_command para cada red." + ), + } + else: + readiness = {"ready": True, "reason": None} + elif action == "chain": + from bib2graph.constants import Col + + table_for_chain = corpus.to_arrow() + rows_for_chain = table_for_chain.to_pylist() + total_seeds_count = sum(1 for r in rows_for_chain if r.get(Col.IS_SEED)) + n_seeds_with_source = sum( + 1 + for r in rows_for_chain + if r.get(Col.IS_SEED) and r.get(Col.SOURCE_ID) is not None + ) + if total_seeds_count > 0 and n_seeds_with_source == 0: + readiness = { + "ready": False, + "reason": ( + f"0/{total_seeds_count} seeds tienen source_id: " + "ejecutá 'b2g seed --resolve' para obtener IDs de OpenAlex " + "necesarios para el forrajeo." + ), + } + else: + readiness = {"ready": True, "reason": None} + else: + readiness = {"ready": True, "reason": None} + return { "loop_state": state_str, "transitions_available": transitions, @@ -99,6 +162,9 @@ def run_status(store_path: str | Path) -> dict[str, Any]: "counts_by_status": counts, "total_papers": total, "referenced_not_fetched": referenced_not_fetched, + "next_best_action": action, + "readiness": readiness, + "build_preview": build_prev, } @@ -108,13 +174,7 @@ def run_status(store_path: str | Path) -> dict[str, Any]: @click.command("status") -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("status") def status_cmd( @@ -164,7 +224,7 @@ def status_cmd( data["networks_cache_stale"] = stale - if json_output: + if json_mode(json_output): envelope = build_envelope( command="status", ok=True, @@ -194,5 +254,21 @@ def status_cmd( ref_count = data.get("referenced_not_fetched", 0) if ref_count > 0: emit_human(f"Referenciados sin materializar: {ref_count}") + # ADR 0037 §(e) — campos aditivos + emit_human(f"Próximo mejor paso: b2g {data['next_best_action']}") + readiness = data["readiness"] + ready_str = ( + "listo" if readiness["ready"] else f"NO listo — {readiness['reason']}" + ) + emit_human(f"Preparación: {ready_str}") + build_preview = data.get("build_preview", []) + if build_preview: + emit_human("Preview de redes (si corrieras build ahora):") + for entry in build_preview: + status_icon = "vacía" if entry["would_be_empty"] else "ok" + line = f" [{status_icon}] {entry['kind']}" + if entry["would_be_empty"] and entry["reason"]: + line += f" — {entry['reason']} → {entry['fix_command']}" + emit_human(line) for w in warnings: print(f"AVISO: {w}", file=sys.stderr) diff --git a/src/bib2graph/cli/commands/thesaurus.py b/src/bib2graph/cli/commands/thesaurus.py deleted file mode 100644 index df98f3b..0000000 --- a/src/bib2graph/cli/commands/thesaurus.py +++ /dev/null @@ -1,191 +0,0 @@ -"""cli.commands.thesaurus — Subcomando ``b2g thesaurus``. - -Aplica un thesaurus multilingüe curado al corpus, sobrescribiendo -``keywords_id`` con los conceptos canónicos definidos por el usuario. - -Transversal al FSM: NO transiciona el ``CycleState`` (mismo criterio que -``b2g enrich`` / ``b2g curate`` / ``b2g networks``). El thesaurus es un -paso explícito y voluntario del investigador; la deduplicación automática -(``normalize + dedup``) ocurre en la ingesta. - -Formato del thesaurus JSON (ADR 0011): - { - "concepts": { - "": { - "aliases_en": ["...", "..."], - "aliases_es": ["...", "..."], - "aliases_pt": ["...", "..."] - } - } - } - -Envelope ``--json`` (schema="1"): ``keywords_mapped``, ``keywords_total``, -``aliases_loaded``, ``applied_at``. -""" - -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -import click - -from bib2graph.cli._envelope import build_envelope, emit, emit_human -from bib2graph.cli._errors import DataError, handle_errors -from bib2graph.cli._store import open_store, resolve_library_path - -# --------------------------------------------------------------------------- -# Función núcleo (testeable, sin Click) -# --------------------------------------------------------------------------- - - -def run_thesaurus( - store_path: str | Path, - thesaurus_path: str | Path, -) -> dict[str, Any]: - """Aplica el thesaurus al corpus y persiste sin transicionar el CycleState. - - Lee el corpus del store, aplica ``Preprocessor.apply_thesaurus`` (que - sobrescribe ``keywords_id`` desde los aliases del thesaurus) y persiste - el corpus actualizado. El ``CycleState`` no cambia. - - Args: - store_path: Ruta al archivo ``.duckdb``. - thesaurus_path: Ruta al JSON del thesaurus (formato ADR 0011). - - Returns: - Dict con ``keywords_mapped``, ``keywords_total``, ``aliases_loaded``, - ``applied_at``. - - Raises: - DataError: Si el thesaurus no existe o tiene formato inválido. - StoreError: Si el store está bloqueado. - """ - from bib2graph.preprocessors.preprocessor import Preprocessor - from bib2graph.preprocessors.thesaurus import load_thesaurus - - resolved = Path(thesaurus_path) - if not resolved.exists(): - raise DataError( - f"El thesaurus '{resolved}' no existe. " - "Verificá la ruta al archivo JSON del thesaurus." - ) - - try: - lookup = load_thesaurus(resolved) - except Exception as exc: - raise DataError( - f"No se pudo cargar el thesaurus '{resolved}': {exc}. " - "Verificá que el archivo tenga el formato correcto " - '({"concepts": {"canonical": {"aliases_en": [...]}}})' - ) from exc - - applied_at = datetime.now(UTC) - preprocessor = Preprocessor() - - merged_backend_close = None - store = open_store(store_path) - try: - corpus = store.load() - - result = preprocessor.apply_thesaurus( - corpus, - resolved, - applied_at=applied_at, - ) - - # Contar keywords mapeadas por el thesaurus - rows = result.to_arrow().to_pylist() - total_kw = sum(len(r["keywords_id"]) for r in rows if r.get("keywords_id")) - # Contar cuántas son canónicas del thesaurus (keys del lookup invertido) - canonical_set = set(lookup.values()) - mapped_kw = sum( - 1 - for r in rows - if r.get("keywords_id") - for kw in r["keywords_id"] - if kw in canonical_set - ) - - merged_backend_close = getattr(result._backend, "close", None) - # persist_replace: el thesaurus reemplaza los keywords_id del corpus - # completo; el upsert-concat reintroduría los canónicos viejos junto a - # los nuevos si el mapeo cambió (mismo bug que el dedup cross-biblioteca). - store.persist_replace(result) - # NO transicionar el CycleState (transversal al lazo) - finally: - if merged_backend_close is not None: - merged_backend_close() - store.close() - - return { - "keywords_mapped": mapped_kw, - "keywords_total": total_kw, - "aliases_loaded": len(lookup), - "applied_at": applied_at.isoformat(), - } - - -# --------------------------------------------------------------------------- -# Comando Click (no se testea directamente) -# --------------------------------------------------------------------------- - - -@click.command("thesaurus") -@click.option( - "--from", - "thesaurus_path", - required=True, - type=click.Path(), - help=( - "Ruta al archivo JSON del thesaurus multilingüe (formato ADR 0011). " - "Sobrescribe keywords_id con los conceptos canónicos del mapa." - ), -) -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) -@click.pass_context -@handle_errors("thesaurus") -def thesaurus_cmd( - ctx: click.Context, - thesaurus_path: str, - json_output: bool, -) -> None: - """Aplica el thesaurus multilingüe al corpus (transversal al lazo). - - Sobrescribe ``keywords_id`` con los conceptos canónicos del mapa curado. - NO transiciona el CycleState: puede aplicarse en cualquier momento del - ciclo bibliométrico (igual que enrich, curate y networks). - - \\b - El thesaurus debe ser un JSON con la estructura: - {\"concepts\": {\"canonical\": {\"aliases_en\": [...], \"aliases_es\": [...]}}} - - \\b - Ejemplos: - b2g thesaurus --from thesaurus.json - b2g thesaurus --from thesaurus.json --json - """ - store_path = resolve_library_path(ctx.obj) - data = run_thesaurus(store_path, thesaurus_path) - - if json_output: - envelope = build_envelope( - command="thesaurus", - ok=True, - data=data, - exit_code=0, - ) - emit(envelope) - else: - emit_human( - f"Thesaurus aplicado: {data['keywords_mapped']} keywords mapeadas " - f"(de {data['keywords_total']} totales, " - f"{data['aliases_loaded']} aliases cargados)." - ) diff --git a/src/bib2graph/cli/commands/validate.py b/src/bib2graph/cli/commands/validate.py index e98efc6..90944ed 100644 --- a/src/bib2graph/cli/commands/validate.py +++ b/src/bib2graph/cli/commands/validate.py @@ -14,6 +14,7 @@ from bib2graph.cli._envelope import build_envelope, emit, emit_human from bib2graph.cli._errors import DataError, StoreError, handle_errors +from bib2graph.cli._options import json_mode, json_option from bib2graph.cli._store import open_store_readonly, resolve_library_path from bib2graph.constants import Col, CurationStatus @@ -111,13 +112,7 @@ def run_validate(store_path: str | Path) -> dict[str, Any]: @click.command("validate") -@click.option( - "--json", - "json_output", - is_flag=True, - default=False, - help="Salida JSON estructurada.", -) +@json_option @click.pass_context @handle_errors("validate") def validate_cmd( @@ -131,7 +126,7 @@ def validate_cmd( store_path = resolve_library_path(ctx.obj) data = run_validate(store_path) - if json_output: + if json_mode(json_output): envelope = build_envelope( command="validate", ok=True, diff --git a/src/bib2graph/cycle.py b/src/bib2graph/cycle.py index b8d331b..09558af 100644 --- a/src/bib2graph/cycle.py +++ b/src/bib2graph/cycle.py @@ -221,3 +221,43 @@ def available_transitions(state: CycleState | None) -> list[str]: Lista de nombres de acciones disponibles desde el estado dado. """ return list(_AVAILABLE_TRANSITIONS.get(state, ["seed"])) + + +# --------------------------------------------------------------------------- +# next_best_action — ADR 0037 §(e) +# --------------------------------------------------------------------------- + +# Mapa determinista estado → único próximo comando recomendado. +# Refleja el camino canónico del ciclo de investigación (Nota 05 §3): +# init → seed → chain → build → read → (monitorear → chain). +_NEXT_BEST_ACTION: dict[CycleState | None, str] = { + None: "seed", + CycleState.SEEDED: "chain", + CycleState.FORAGED: "build", + CycleState.FILTERED: "build", + CycleState.BUILT: "read", + CycleState.MONITORED: "chain", +} + + +def next_best_action(state: CycleState | None) -> str: + """Devuelve el único próximo comando recomendado desde el estado del FSM. + + Derivado de forma determinista del FSM (ADR 0037 §(e)). El agente lee + este campo para saber qué hacer sin adivinar ni recorrer + ``transitions_available``. + + Mapeo canónico: + - ``None`` (sin estado) → ``"seed"`` + - ``SEEDED`` → ``"chain"`` + - ``FORAGED`` / ``FILTERED`` → ``"build"`` + - ``BUILT`` → ``"read"`` + - ``MONITORED`` → ``"chain"`` (ciclo de monitoreo) + + Args: + state: Estado actual del lazo (``None`` = sin estado previo). + + Returns: + Nombre del próximo comando recomendado (string simple, sin ``b2g``). + """ + return _NEXT_BEST_ACTION.get(state, "seed") diff --git a/src/bib2graph/enrichers/openalex.py b/src/bib2graph/enrichers/openalex.py index 7b998ee..7794dda 100644 --- a/src/bib2graph/enrichers/openalex.py +++ b/src/bib2graph/enrichers/openalex.py @@ -100,20 +100,38 @@ def enrich(self, corpus: Corpus) -> Corpus: y dos ``EnricherRef`` registrados en el Manifest. """ # Pasada 1: references_id → references_doi - corpus = self._enrich_references_doi(corpus) + corpus = self.enrich_references_doi(corpus) # Pasada 2: cited_by_id para semillas aceptadas - corpus = self._enrich_cited_by(corpus) + corpus = self.enrich_cited_by(corpus) return corpus # ------------------------------------------------------------------ - # Pasada 1: references_id → references_doi + # Pasada 1: references_id → references_doi (pública para absorción en chain) # ------------------------------------------------------------------ - def _enrich_references_doi(self, corpus: Corpus) -> Corpus: + def enrich_references_doi(self, corpus: Corpus) -> Corpus: """Rellena ``references_doi`` alineado a ``references_id``. + Pasada 1 del enriquecimiento (Hito 8a). Expuesta como método público + para que ``chain`` pueda ejecutarla de forma aislada (sin la pasada + de co-citación), usando el mismo helper ``enrich_corpus`` que ``enrich`` + standalone y ``build``. + + Args: + corpus: Corpus de entrada. + + Returns: + Corpus con ``references_doi`` rellenado y ``EnricherRef`` registrado. + """ + return self._enrich_references_doi(corpus) + + def _enrich_references_doi(self, corpus: Corpus) -> Corpus: + """Implementación interna de la pasada refs→DOI. + + Ver ``enrich_references_doi`` para la documentación pública. + Args: corpus: Corpus de entrada. @@ -157,12 +175,30 @@ def _enrich_references_doi(self, corpus: Corpus) -> Corpus: ) # ------------------------------------------------------------------ - # Pasada 2: cited_by_id para semillas aceptadas (Hito 8b) + # Pasada 2: cited_by_id para semillas aceptadas (Hito 8b, pública para build) # ------------------------------------------------------------------ - def _enrich_cited_by(self, corpus: Corpus) -> Corpus: + def enrich_cited_by(self, corpus: Corpus) -> Corpus: """Puebla ``cited_by_id`` de las semillas aceptadas. + Pasada 2 del enriquecimiento (Hito 8b). Expuesta como método público + para que ``build`` pueda ejecutarla de forma aislada (sin la pasada + de refs→DOI), usando el mismo helper ``enrich_corpus`` que ``enrich`` + standalone y ``chain``. + + Args: + corpus: Corpus de entrada. + + Returns: + Corpus con ``cited_by_id`` rellenado y ``EnricherRef`` registrado. + """ + return self._enrich_cited_by(corpus) + + def _enrich_cited_by(self, corpus: Corpus) -> Corpus: + """Implementación interna de la pasada cited_by. + + Ver ``enrich_cited_by`` para la documentación pública. + Algoritmo: 1. Filtra las semillas con ``curation_status=accepted`` y ``source_id`` no nulo (solo esas tienen IDs válidos para consultar en OpenAlex). diff --git a/src/bib2graph/foraging/forager.py b/src/bib2graph/foraging/forager.py index 8a924ec..92b78b7 100644 --- a/src/bib2graph/foraging/forager.py +++ b/src/bib2graph/foraging/forager.py @@ -41,7 +41,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any import pyarrow as pa @@ -274,6 +274,7 @@ def chain( corpus: Corpus, *, direction: Direction = "both", + since: date | None = None, ) -> RankedCandidates: """Computa candidatos rankeados por *information scent*. @@ -315,7 +316,9 @@ def chain( bwd_observed.add(ref_id) if direction in ("forward", "both"): - fwd_scent, fwd_rows = self._fetch_forward(rows, fetched_at=fetched_at) + fwd_scent, fwd_rows = self._fetch_forward( + rows, fetched_at=fetched_at, since=since + ) for cand_id, scent_val in fwd_scent.items(): combined_scent[cand_id] = combined_scent.get(cand_id, 0.0) + scent_val if cand_id not in fwd_candidate_rows: @@ -379,6 +382,7 @@ def _fetch_forward( corpus_rows: list[dict[str, Any]], *, fetched_at: str, + since: date | None = None, ) -> tuple[dict[str, float], dict[str, dict[str, Any]]]: """Trae citantes via batcheo y calcula scent forward. @@ -453,24 +457,26 @@ def _fetch_forward( unit="lote", leave=False, ) as pbar: + _since_kw = {"since": since} if since is not None else {} if use_with_works: citing_dict, works_map = self._source.fetch_citing_batch_with_works( - seed_ids, max_per_paper=self._max_citing_per_paper + seed_ids, max_per_paper=self._max_citing_per_paper, **_since_kw ) else: citing_dict = self._source.fetch_citing_batch( - seed_ids, max_per_paper=self._max_citing_per_paper + seed_ids, max_per_paper=self._max_citing_per_paper, **_since_kw ) works_map = {} pbar.update(1) except ImportError: + _since_kw = {"since": since} if since is not None else {} if use_with_works: citing_dict, works_map = self._source.fetch_citing_batch_with_works( - seed_ids, max_per_paper=self._max_citing_per_paper + seed_ids, max_per_paper=self._max_citing_per_paper, **_since_kw ) else: citing_dict = self._source.fetch_citing_batch( - seed_ids, max_per_paper=self._max_citing_per_paper + seed_ids, max_per_paper=self._max_citing_per_paper, **_since_kw ) works_map = {} diff --git a/src/bib2graph/networks/__init__.py b/src/bib2graph/networks/__init__.py index 566e819..2864fd8 100644 --- a/src/bib2graph/networks/__init__.py +++ b/src/bib2graph/networks/__init__.py @@ -23,7 +23,7 @@ ) from bib2graph.networks.clusters import cluster_table from bib2graph.networks.decorate import decorate, decorate_graph -from bib2graph.networks.facade import Networks +from bib2graph.networks.facade import Networks, predict_build_preview from bib2graph.networks.projectors import ( MIN_WEIGHT_DEFAULT, AuthorCollaborationProjector, @@ -57,4 +57,5 @@ "detect_communities", "load_specs", "network_metrics", + "predict_build_preview", ] diff --git a/src/bib2graph/networks/facade.py b/src/bib2graph/networks/facade.py index fd9a45d..10286db 100644 --- a/src/bib2graph/networks/facade.py +++ b/src/bib2graph/networks/facade.py @@ -19,7 +19,7 @@ import networkx as nx import pyarrow as pa -from bib2graph.constants import NetworkKind +from bib2graph.constants import Col, NetworkKind from bib2graph.networks.analyzer import ( assortativity, community_composition, @@ -321,6 +321,282 @@ def _build_artifact(corpus: Corpus, spec: NetworkSpec) -> NetworkArtifact: return artifact +# --------------------------------------------------------------------------- +# Helpers de predicados para predict_build_preview (misma lógica que los +# proyectores — fuente única preview ↔ build, ADR 0037 §(e)) +# --------------------------------------------------------------------------- + + +def _count_rows_with_col( + rows: list[dict[str, object]], + col: str, + *, + seeds_only: bool = False, +) -> int: + """Cuenta filas donde la columna lista tiene al menos 1 elemento no-None. + + Usado SOLO para texto de reason ("N/M papers con col"), no para el + predicado de vaciedad. Ver ``_has_shared_item`` y + ``_has_paper_with_multi_distinct`` para los predicados correctos. + + Args: + rows: Filas del corpus como dicts (salida de ``table.to_pylist()``). + col: Nombre de la columna de tipo lista. + seeds_only: Si ``True``, solo cuenta filas donde ``is_seed == True``. + + Returns: + Número de filas con al menos 1 elemento no-None en la columna dada. + """ + count = 0 + for row in rows: + if seeds_only and not row.get(Col.IS_SEED): + continue + val = row.get(col) + if isinstance(val, list) and any(e is not None for e in val): + count += 1 + return count + + +def _has_shared_item( + rows: list[dict[str, object]], + col: str, + *, + seeds_only: bool = False, +) -> bool: + """True sii algún item en ``col`` aparece en ≥2 papers distintos. + + Espeja ``collect_item_to_papers`` + ``_build_shared_refs_graph`` + (projectors.py): una arista entre P1 y P2 existe sii comparten algún + ítem. El mínimo para ≥1 arista es que algún ítem sea compartido por ≥2 + papers distintos. + + Necesario y suficiente para grafos de tipo shared-refs: dos papers con + referencias disjuntas producen 0 aristas, aunque cada uno tenga muchas + referencias (caso trampa de la Nota 20). + + Args: + rows: Filas del corpus como dicts (salida de ``table.to_pylist()``). + col: Columna de tipo lista (``references_id``, ``cited_by_id``). + seeds_only: Si ``True``, solo considera filas ``is_seed == True`` + (para co-citación con scope ``seeds_only``). + + Returns: + ``True`` si algún ítem aparece en ≥2 papers distintos. + """ + # item → conjunto de paper_ids que lo contienen (dedup por paper) + item_papers: dict[str, set[str]] = {} + for row in rows: + if seeds_only and not row.get(Col.IS_SEED): + continue + val = row.get(col) + if not isinstance(val, list): + continue + paper_id = str(row.get(Col.ID) or "") + for item in val: + if item is not None: + key = str(item) + if key not in item_papers: + item_papers[key] = set() + item_papers[key].add(paper_id) + return any(len(papers) >= 2 for papers in item_papers.values()) + + +def _has_paper_with_multi_distinct( + rows: list[dict[str, object]], + col: str, +) -> bool: + """True sii algún paper tiene ≥2 entidades DISTINTAS no-None en ``col``. + + Espeja exactamente ``_build_cooccurrence_graph`` (projectors.py): el + criterio es ``sorted(set(str(e) for e in entities if e is not None))``, + luego ``combinations(unique_entities, 2)`` → ≥1 par sii ≥2 distintas. + + Corrige el bug de la Nota 20 P1: ``[A1, A1]`` (autor duplicado) tiene + 2 elementos pero tras dedup solo 1 distinto → la red sale vacía aunque + ``len(val) >= 2``. Esta función usa ``set`` para el dedup, igual que el + proyector. + + Args: + rows: Filas del corpus como dicts (salida de ``table.to_pylist()``). + col: Columna de tipo lista (``authors_id``, ``institutions_id``, + ``keywords_id``). + + Returns: + ``True`` si algún paper tiene ≥2 entidades distintas no-None. + """ + for row in rows: + val = row.get(col) + if not isinstance(val, list): + continue + distinct = {str(e) for e in val if e is not None} + if len(distinct) >= 2: + return True + return False + + +def predict_build_preview(corpus: Corpus) -> list[dict[str, object]]: + """Predice vacío/no-vacío para cada red posible sin proyectarlas. + + Fuente única con ``Networks.quick``: reusa los mismos predicados de columna + que los proyectores para decidir la inclusión de papers. El conteo es + barato (no proyecta ningún grafo). + + Incluye siempre las 5 redes posibles (incluida co-citación incluso si + ``cited_by_id`` está vacío), para que el diagnóstico sea completo: + el agente/humano ve qué falta para activar cada tipo de red ANTES del + ``build`` (ADR 0037 §(e), cura de la trampa de la Nota 20). + + Args: + corpus: Corpus a inspeccionar. + + Returns: + Lista de dicts, uno por tipo de red, con: + + - ``kind``: tipo de red (string de ``NetworkKind``). + - ``would_be_empty``: ``True`` si la red saldría vacía. + - ``reason``: causa determinista si saldría vacía, ``None`` si no. + - ``fix_command``: comando exacto para arreglarlo, ``None`` si no vacía. + """ + table = corpus.to_arrow() + rows: list[dict[str, object]] = table.to_pylist() + total = len(rows) + total_seeds = sum(1 for r in rows if r.get(Col.IS_SEED)) + + preview: list[dict[str, object]] = [] + + # --- bibliographic_coupling: references_id, scope=full --- + # No-vacía sii algún references_id aparece en ≥2 papers distintos. + # (dos papers con refs disjuntas producen 0 aristas → caso trampa Nota 20) + n_refs_any = _count_rows_with_col(rows, Col.REFERENCES_ID) + bc_empty = not _has_shared_item(rows, Col.REFERENCES_ID) + if bc_empty: + if n_refs_any == 0: + bc_reason: str | None = f"0/{total} papers con {Col.REFERENCES_ID}" + else: + bc_reason = ( + f"{n_refs_any}/{total} papers con {Col.REFERENCES_ID} " + f"pero ninguna referencia aparece en ≥2 papers" + ) + else: + bc_reason = None + preview.append( + { + "kind": str(NetworkKind.BIBLIOGRAPHIC_COUPLING), + "would_be_empty": bc_empty, + "reason": bc_reason, + "fix_command": "b2g seed --resolve" if bc_empty else None, + } + ) + + # --- author_collab: authors_id, scope=full --- + # No-vacía sii algún paper tiene ≥2 autores DISTINTOS no-None. + # ([A1, A1] → tras dedup 1 único → 0 aristas, aunque len=2) + n_authors_any = _count_rows_with_col(rows, Col.AUTHORS_ID) + ac_empty = not _has_paper_with_multi_distinct(rows, Col.AUTHORS_ID) + if ac_empty: + if n_authors_any == 0: + ac_reason: str | None = f"0/{total} papers con {Col.AUTHORS_ID}" + else: + ac_reason = ( + f"{n_authors_any}/{total} papers con {Col.AUTHORS_ID} " + f"pero ninguno con ≥2 autores distintos" + ) + else: + ac_reason = None + preview.append( + { + "kind": str(NetworkKind.AUTHOR_COLLAB), + "would_be_empty": ac_empty, + "reason": ac_reason, + "fix_command": "b2g seed --resolve" if ac_empty else None, + } + ) + + # --- institution_collab: institutions_id, scope=full --- + # No-vacía sii algún paper tiene ≥2 instituciones DISTINTAS no-None. + n_inst_any = _count_rows_with_col(rows, Col.INSTITUTIONS_ID) + ic_empty = not _has_paper_with_multi_distinct(rows, Col.INSTITUTIONS_ID) + if ic_empty: + if n_inst_any == 0: + ic_reason: str | None = f"0/{total} papers con {Col.INSTITUTIONS_ID}" + else: + ic_reason = ( + f"{n_inst_any}/{total} papers con {Col.INSTITUTIONS_ID} " + f"pero ninguno con ≥2 instituciones distintas" + ) + else: + ic_reason = None + preview.append( + { + "kind": str(NetworkKind.INSTITUTION_COLLAB), + "would_be_empty": ic_empty, + "reason": ic_reason, + "fix_command": "b2g seed --resolve" if ic_empty else None, + } + ) + + # --- keyword_cooccurrence: keywords_id, scope=full --- + # No-vacía sii algún paper tiene ≥2 keywords DISTINTAS no-None. + # Fix preferencial: si hay keywords_raw pero no keywords_id, thesaurus puede + # generar keywords_id sin red; si no hay keywords_raw, se necesita seed --resolve. + n_kw_any = _count_rows_with_col(rows, Col.KEYWORDS_ID) + n_kw_raw = _count_rows_with_col(rows, Col.KEYWORDS_RAW) + kw_empty = not _has_paper_with_multi_distinct(rows, Col.KEYWORDS_ID) + if kw_empty: + if n_kw_any == 0: + kw_reason: str | None = f"0/{total} papers con {Col.KEYWORDS_ID}" + else: + kw_reason = ( + f"{n_kw_any}/{total} papers con {Col.KEYWORDS_ID} " + f"pero ninguno con ≥2 keywords distintas" + ) + # Si hay keywords_raw pero no keywords_id, build --thesaurus puede generarlos + kw_fix: str | None = ( + "b2g build --thesaurus " + if n_kw_raw > 0 and n_kw_any == 0 + else "b2g seed --resolve" + ) + else: + kw_reason = None + kw_fix = None + preview.append( + { + "kind": str(NetworkKind.KEYWORD_COOCCURRENCE), + "would_be_empty": kw_empty, + "reason": kw_reason, + "fix_command": kw_fix, + } + ) + + # --- cocitation: cited_by_id, scope=seeds_only --- + # No-vacía sii algún cited_by_id aparece en ≥2 seeds distintas. + # (dos seeds con citantes disjuntos producen 0 aristas) + n_cited_any = _count_rows_with_col(rows, Col.CITED_BY_ID, seeds_only=True) + coc_empty = not _has_shared_item(rows, Col.CITED_BY_ID, seeds_only=True) + if coc_empty: + if n_cited_any == 0: + coc_reason: str | None = ( + f"{n_cited_any}/{total_seeds} seeds con {Col.CITED_BY_ID}" + ) + else: + coc_reason = ( + f"{n_cited_any}/{total_seeds} seeds con {Col.CITED_BY_ID} " + f"pero ningún citante aparece en ≥2 seeds" + ) + else: + coc_reason = None + preview.append( + { + "kind": str(NetworkKind.COCITATION), + "would_be_empty": coc_empty, + "reason": coc_reason, + "fix_command": "b2g enrich" if coc_empty else None, + } + ) + + return preview + + class Networks: """API de alto nivel para construir redes bibliométricas desde un Corpus. @@ -341,7 +617,7 @@ def build(corpus: Corpus, spec: NetworkSpec) -> NetworkArtifact: return _build_artifact(corpus, spec) @staticmethod - def quick(corpus: Corpus) -> list[NetworkArtifact]: + def quick(corpus: Corpus, *, min_weight: int = 1) -> list[NetworkArtifact]: """Construye las redes principales con configuración razonable. Arma specs para: acoplamiento bibliográfico (full), co-autoría, @@ -354,6 +630,9 @@ def quick(corpus: Corpus) -> list[NetworkArtifact]: Args: corpus: Corpus de origen. + min_weight: Peso mínimo de arista (#159 — ``build --min-weight``). + Default 1 = sin filtro (comportamiento anterior intacto). + Si es > 1, las aristas con peso < N se filtran en todas las redes. Returns: Lista de 4 o 5 ``NetworkArtifact`` (coupling, co-autoría, @@ -372,5 +651,5 @@ def quick(corpus: Corpus) -> list[NetworkArtifact]: "para proyectar con los citantes disponibles." ) - specs = [NetworkSpec(kind=k) for k in kinds] + specs = [NetworkSpec(kind=k, min_weight=min_weight) for k in kinds] return [_build_artifact(corpus, spec) for spec in specs] diff --git a/src/bib2graph/service/__init__.py b/src/bib2graph/service/__init__.py index 0c9e35e..5e3a981 100644 --- a/src/bib2graph/service/__init__.py +++ b/src/bib2graph/service/__init__.py @@ -10,9 +10,15 @@ - ``code_for`` — helper de mapeo error→exit code (sin I/O). - ``get_workspace``, ``list_rounds``, ``get_paper``, ``get_scent``, ``get_network``, ``compare_rounds`` — lecturas del Hito G2 (ADR 0028). + - ``list_papers``, ``corpus_stats`` — lecturas del grupo ``read`` + (#156, sub-issue de la superficie CLI 0.10.0). + - ``get_top`` — nodos centrales + pares de co-citación con título resuelto + (#157, sub-issue de la superficie CLI 0.10.0). - ``accept_papers``, ``reject_papers``, ``curate_paper`` — escrituras del Hito G3 (ADR 0028). - ``resolve_dois`` — resolución DOI→source_id (ADR 0035). + - ``compute_maturity`` — bloque ``maturity`` para build/snapshot/read top + (#160, sub-issue de la superficie CLI 0.10.0, ADR 0037 §f). """ from __future__ import annotations @@ -28,12 +34,16 @@ UsageError, code_for, ) +from bib2graph.service.maturity import compute_maturity from bib2graph.service.reads import ( compare_rounds, + corpus_stats, get_network, get_paper, get_scent, + get_top, get_workspace, + list_papers, list_rounds, ) from bib2graph.service.resolve import resolve_dois @@ -50,11 +60,15 @@ "build_envelope", "code_for", "compare_rounds", + "compute_maturity", + "corpus_stats", "curate_paper", "get_network", "get_paper", "get_scent", + "get_top", "get_workspace", + "list_papers", "list_rounds", "reject_papers", "resolve_dois", diff --git a/src/bib2graph/service/curate.py b/src/bib2graph/service/curate.py index 878616a..40fd2bb 100644 --- a/src/bib2graph/service/curate.py +++ b/src/bib2graph/service/curate.py @@ -1,27 +1,76 @@ -"""service.curate — Orquestación de curación paper-a-paper (ADR 0028 Hito G3). +"""service.curate — Orquestación de curación (ADR 0028 Hito G3 + #155). Capa de servicios neutral: sin ``print``, ``sys.exit``, Click ni FastAPI. -Las operaciones de curación toman ``store_path`` (no el workspace completo) -porque mutar el corpus solo necesita la ruta al archivo ``.duckdb``. ``decided_at`` es **inyectado por el llamador** (R2/ADR 0017): el servicio nunca llama ``datetime.now()``. La frontera (CLI o API) inyecta el reloj. -Operaciones: +Operaciones de curación paper-a-paper (G3 original): - ``accept_papers`` — marca ids como ``accepted``. - ``reject_papers`` — marca ids como ``rejected``. - - ``curate_paper`` — wrapper de un solo paper (``decision`` = ``"accepted"`` - o ``"rejected"``; otra cosa → ``DataError``). + - ``curate_paper`` — wrapper de un solo paper. + +Operaciones en lote (subidas desde cli/ en #155): + - ``run_curate_dump`` — exporta corpus a CSV para revisión offline. + - ``run_curate_from_csv`` — reimporta decisiones desde CSV. + +Operación de filtrado PRISMA (subida desde cli/ en #155): + - ``filter_corpus`` — aplica filtros PRISMA y transiciona a FILTERED. + +El CLI (`cli/commands/curate.py` grupo noun-verb, `cli/commands/filter.py`) +son shims delgados que inyectan el reloj e invocan estas funciones. """ from __future__ import annotations +import csv from datetime import datetime from pathlib import Path from typing import Any +from bib2graph.constants import Col, CurationStatus from bib2graph.service.errors import DataError, StoreError +# --------------------------------------------------------------------------- +# Constantes CSV — fuente canónica (exportadas desde cli/ para compat) +# --------------------------------------------------------------------------- + +CURATE_CSV_FILENAME = "curacion.csv" + +CSV_COLUMNS = [ + "id", + "source_id", + "title", + "year", + "authors", + "venue", + "doi", + "keywords", + "cited_by_count", + "references_count", + "is_seed", + "openalex_url", + "scent_score", + "cluster", + "decision", + "note", +] + +CSV_REQUIRED_COLUMNS: frozenset[str] = frozenset({"id", "decision"}) + +VALID_DECISIONS: frozenset[str] = frozenset( + {CurationStatus.ACCEPTED, CurationStatus.REJECTED, "undecided"} +) + +_STATUS_TO_DECISION: dict[str, str] = { + CurationStatus.CANDIDATE: "undecided", + CurationStatus.ACCEPTED: "accepted", + CurationStatus.REJECTED: "rejected", +} + +VALID_SCOPES: frozenset[str] = frozenset({"candidates", "seeds", "all"}) + + # --------------------------------------------------------------------------- # Helper interno — abrir store para escritura # --------------------------------------------------------------------------- @@ -30,10 +79,6 @@ def _open_writable(path: Path) -> Any: """Abre el store para escritura; falla accionable si está bloqueado. - Gemelo de ``_open_readonly`` de ``service/reads.py``, pero sin la - verificación de existencia (los comandos de escritura crean el archivo - si no existe). - Args: path: Ruta al archivo ``.duckdb``. @@ -57,6 +102,419 @@ def _open_writable(path: Path) -> Any: ) from exc +# --------------------------------------------------------------------------- +# Helpers internos — transformación de filas para el CSV +# --------------------------------------------------------------------------- + + +def _authors_display(row: dict[str, Any]) -> str: + """Extrae los autores de una fila del corpus y los une con ' | '.""" + raw = row.get(Col.AUTHORS_RAW) + if raw and isinstance(raw, list): + return " | ".join(str(a) for a in raw if a) + ids = row.get(Col.AUTHORS_ID) + if ids and isinstance(ids, list): + return " | ".join(str(a) for a in ids if a) + return "" + + +def _keywords_display(row: dict[str, Any]) -> str: + """Extrae las keywords de una fila del corpus y las une con ' | '.""" + ids = row.get(Col.KEYWORDS_ID) + if ids and isinstance(ids, list): + return " | ".join(str(k) for k in ids if k) + raw = row.get(Col.KEYWORDS_RAW) + if raw and isinstance(raw, list): + return " | ".join(str(k) for k in raw if k) + return "" + + +def _openalex_url(row: dict[str, Any]) -> str: + """Deriva la URL de OpenAlex a partir del source_id (solo IDs W…).""" + src_id = row.get(Col.SOURCE_ID) + if src_id: + src_str = str(src_id) + if src_str.startswith("W") and src_str[1:].isdigit(): + return f"https://openalex.org/{src_str}" + return "" + + +def _scent_score_display(row: dict[str, Any]) -> str: + """Extrae el scent_score del provenance si está disponible (best-effort).""" + import json as _json + + provenance_raw = row.get(Col.PROVENANCE) + if not provenance_raw: + return "" + try: + events = _json.loads(str(provenance_raw)) + if not isinstance(events, list): + return "" + for event in events: + if isinstance(event, dict): + val = event.get("scent") + if val is not None: + return str(val) + except (_json.JSONDecodeError, TypeError): + pass + return "" + + +def _row_to_csv_dict(row: dict[str, Any]) -> dict[str, str]: + """Convierte una fila del corpus al dict para el CSV de curación.""" + status = str(row.get(Col.CURATION_STATUS, CurationStatus.CANDIDATE)) + decision = _STATUS_TO_DECISION.get(status, "undecided") + is_seed_val = row.get(Col.IS_SEED) + is_seed_str = str(is_seed_val) if is_seed_val is not None else "" + + return { + "id": str(row.get(Col.ID) or ""), + "source_id": str(row.get(Col.SOURCE_ID) or ""), + "title": str(row.get(Col.TITLE) or ""), + "year": str(row.get(Col.YEAR) or ""), + "authors": _authors_display(row), + "venue": str(row.get(Col.SOURCE) or ""), + "doi": str(row.get(Col.DOI) or ""), + "keywords": _keywords_display(row), + "cited_by_count": "", # no en el schema canónico + "references_count": "", # no en el schema canónico + "is_seed": is_seed_str, + "openalex_url": _openalex_url(row), + "scent_score": _scent_score_display(row), + "cluster": "", # reservado para integración con redes + "decision": decision, + "note": "", + } + + +def _filter_table_by_scope(table: Any, scope: str) -> Any: + """Filtra una tabla Arrow según el scope de dump. + + Scope 'candidates': curation_status == 'candidate' AND is_seed == False. + Scope 'seeds': is_seed == True. + Scope 'all': sin filtro. + + Args: + table: Tabla Arrow del corpus. + scope: Uno de 'candidates', 'seeds', 'all'. + + Returns: + Tabla Arrow filtrada. + """ + import pyarrow.compute as pc + + if scope == "all": + return table + elif scope == "seeds": + mask = table.column(Col.IS_SEED) + return table.filter(mask) + else: + # candidates: status == candidate AND NOT is_seed + status_col = table.column(Col.CURATION_STATUS) + is_candidate = pc.equal(status_col, CurationStatus.CANDIDATE) # type: ignore[attr-defined] + is_seed_col = table.column(Col.IS_SEED) + not_seed = pc.invert(is_seed_col) # type: ignore[attr-defined] + mask = pc.and_(is_candidate, not_seed) # type: ignore[attr-defined] + return table.filter(mask) + + +# --------------------------------------------------------------------------- +# run_curate_dump +# --------------------------------------------------------------------------- + + +def run_curate_dump( + store_path: str | Path, + *, + out_path: Path, + scope: str = "candidates", + include_all: bool = False, +) -> dict[str, Any]: + """Exporta el corpus a un CSV para revisión offline. + + Por defecto exporta solo los candidatos forrajeados (``scope='candidates'``). + Con ``scope='seeds'`` exporta las semillas; con ``scope='all'`` exporta todo. + + ``include_all`` es un alias de ``scope='all'`` mantenido por compatibilidad + con tests existentes. Si ``True``, fuerza ``scope='all'``. + + Args: + store_path: Ruta al archivo ``.duckdb``. + out_path: Ruta completa del archivo CSV de salida. + scope: 'candidates' (default), 'seeds' o 'all'. + include_all: Alias de scope='all'. Si True, fuerza scope='all'. + + Returns: + Dict con ``csv_path``, ``papers_exported``, ``columns``. + + Raises: + DataError: Si el corpus está vacío o no hay papers en el scope dado. + StoreError: Si el store está bloqueado. + """ + effective_scope = "all" if include_all else scope + + path = Path(store_path) + store = _open_writable(path) + try: + corpus = store.load() + all_table = corpus.to_arrow() + table = _filter_table_by_scope(all_table, effective_scope) + rows = table.to_pylist() + finally: + store.close() + + if not rows and effective_scope != "all": + scope_label = ( + "candidatos forrajeados" if effective_scope == "candidates" else "semillas" + ) + raise DataError( + f"No hay {scope_label} para exportar. " + "Usá --scope all para exportar todo el corpus, o ejecutá " + "``b2g chain`` para agregar candidatos." + ) + + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + with open(out_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS) + writer.writeheader() + for row in rows: + writer.writerow(_row_to_csv_dict(row)) + + return { + "csv_path": str(out_path), + "papers_exported": len(rows), + "columns": CSV_COLUMNS, + } + + +# --------------------------------------------------------------------------- +# run_curate_from_csv +# --------------------------------------------------------------------------- + + +def run_curate_from_csv( + store_path: str | Path, + csv_path: str | Path, + *, + by: str = "cli", + decided_at: datetime | None = None, +) -> dict[str, Any]: + """Reimporta decisiones de curación desde un CSV al corpus. + + Lee el CSV producido por ``run_curate_dump`` y aplica las decisiones en + lote. Solo requiere columnas ``id`` y ``decision``; columnas extra se + ignoran (garantiza round-trip). + + Mapeo: + - ``accepted`` → ``Corpus.accept`` + - ``rejected`` → ``Corpus.reject`` + - ``undecided`` → no-op + + Idempotente: reimportar el mismo CSV produce el mismo estado final. + + R2: ``decided_at`` se inyecta desde la frontera; el servicio no llama + ``datetime.now()``. + + Args: + store_path: Ruta al archivo ``.duckdb``. + csv_path: Ruta al archivo CSV con las decisiones. + by: Identificador de quien decide (default: ``"cli"``). + decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). + + Returns: + Dict con ``accepted_count``, ``rejected_count``, ``skipped_count``, + ``not_found_count``, ``total_rows``. + + Raises: + DataError: Si el CSV no existe, faltan columnas o hay decisiones inválidas. + StoreError: Si el store está bloqueado. + """ + csv_path = Path(csv_path) + if not csv_path.exists(): + raise DataError( + f"El archivo CSV '{csv_path}' no existe. " + "Generalo primero con ``b2g curate dump``." + ) + + rows: list[dict[str, str]] = [] + with open(csv_path, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = set(reader.fieldnames or []) + + missing = CSV_REQUIRED_COLUMNS - fieldnames + if missing: + raise DataError( + f"El CSV '{csv_path}' no tiene las columnas requeridas: " + f"{sorted(missing)}. " + "Asegurate de exportar con ``b2g curate dump`` y no borrar " + "las columnas obligatorias (id, decision)." + ) + + for i, row in enumerate(reader, start=2): # línea 1 = header + decision = row.get("decision", "").strip().lower() + if decision not in VALID_DECISIONS: + raise DataError( + f"Línea {i}: valor de 'decision' inválido: '{row.get('decision')}'. " + f"Valores válidos: {sorted(VALID_DECISIONS)}." + ) + rows.append(row) + + if not rows: + return { + "accepted_count": 0, + "rejected_count": 0, + "skipped_count": 0, + "not_found_count": 0, + "total_rows": 0, + } + + to_accept = [r["id"] for r in rows if r["decision"].strip().lower() == "accepted"] + to_reject = [r["id"] for r in rows if r["decision"].strip().lower() == "rejected"] + skipped = len(rows) - len(to_accept) - len(to_reject) + + curated_backend_close = None + path = Path(store_path) + store = _open_writable(path) + try: + corpus = store.load() + + existing_ids: set[str] = { + str(r.get(Col.ID, "")) for r in corpus.to_arrow().to_pylist() + } + to_accept_found = [id_ for id_ in to_accept if id_ in existing_ids] + to_reject_found = [id_ for id_ in to_reject if id_ in existing_ids] + not_found = [id_ for id_ in (to_accept + to_reject) if id_ not in existing_ids] + + if to_accept_found: + corpus = corpus.accept(to_accept_found, by=by, decided_at=decided_at) + if to_reject_found: + corpus = corpus.reject(to_reject_found, by=by, decided_at=decided_at) + + curated_backend_close = getattr(corpus._backend, "close", None) + store.persist(corpus) + finally: + if curated_backend_close is not None: + curated_backend_close() + store.close() + + return { + "accepted_count": len(to_accept_found), + "rejected_count": len(to_reject_found), + "skipped_count": skipped, + "not_found_count": len(not_found), + "total_rows": len(rows), + } + + +# --------------------------------------------------------------------------- +# filter_corpus +# --------------------------------------------------------------------------- + + +def filter_corpus( + store_path: str | Path, + *, + year_gte: int | None = None, + year_lte: int | None = None, + language: list[str] | None = None, + type_in: list[str] | None = None, + min_citations: int | None = None, + decided_at: datetime | None = None, +) -> dict[str, Any]: + """Aplica filtros PRISMA al corpus marcando rejected los excluidos. + + Los filtros se aplican en orden; cada uno ve el resultado del anterior. + El CycleState transiciona a FILTERED tras persistir con éxito. + + R3 — fuente única de verdad: el destino de la transición lo dicta + ``cycle.py``, no un literal en este módulo (ADR 0016 enmendado §1). + R2 (ADR 0017 enmendado): ``decided_at`` es **inyectado por el llamador**; + este servicio nunca llama ``datetime.now()``. El CLI inyecta el reloj + en la frontera CLI→servicio (``curate filter`` y ``run_filter`` suelto). + + Args: + store_path: Ruta al archivo ``.duckdb``. + year_gte: Filtrar años >= este valor. + year_lte: Filtrar años <= este valor. + language: Lista de códigos ISO 639-1 a incluir. + type_in: Lista de áreas de investigación a incluir. + min_citations: Mínimo de citantes (``len(cited_by_id) >= min_citations``). + decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). + ``None`` → el núcleo (``apply_filters``) usará su propio timestamp, + pero el llamador DEBE inyectarlo en la frontera para determinismo. + + Returns: + Dict con ``steps`` (conteos PRISMA por paso) y ``total_papers``. + + Raises: + DataError: Si ningún criterio es válido. + StoreError: Si el store está bloqueado. + """ + from bib2graph.cycle import apply_transition + from bib2graph.filters.prisma import FilterCriterion, apply_filters + + criteria: list[FilterCriterion] = [] + + if year_gte is not None: + criteria.append(FilterCriterion(field="year", op="gte", value=year_gte)) + if year_lte is not None: + criteria.append(FilterCriterion(field="year", op="lte", value=year_lte)) + if language: + criteria.append(FilterCriterion(field="language", op="in", value=language)) + if type_in: + criteria.append(FilterCriterion(field="type", op="in", value=type_in)) + if min_citations is not None: + criteria.append( + FilterCriterion(field="min_citations", op="gte", value=min_citations) + ) + + if not criteria: + raise DataError( + "Debés especificar al menos un criterio de filtro: " + "--year-gte, --year-lte, --language, --type, o --min-citations." + ) + + path = Path(store_path) + filtered_backend_close = None + store = _open_writable(path) + try: + corpus = store.load() + + current_state = store.backend.loop_state() + current_round = store.backend.loop_round() + new_state, new_round = apply_transition(current_state, "filter", current_round) + + filtered_corpus, steps = apply_filters(corpus, criteria, decided_at=decided_at) + total_papers = len(filtered_corpus) + filtered_backend_close = getattr(filtered_corpus._backend, "close", None) + store.persist(filtered_corpus) + store.backend.persist_filter_steps(steps) + store.backend.set_loop_state(new_state, cycle_round=new_round) + finally: + if filtered_backend_close is not None: + filtered_backend_close() + store.close() + + steps_data = [ + { + "name": s.name, + "criteria": s.criteria, + "count_before": s.count_before, + "count_after": s.count_after, + "excluded": s.count_before - s.count_after, + } + for s in steps + ] + + return { + "steps": steps_data, + "total_papers": total_papers, + "criteria_applied": len(criteria), + } + + # --------------------------------------------------------------------------- # accept_papers # --------------------------------------------------------------------------- @@ -78,8 +536,6 @@ def accept_papers( ids: Lista de ids a aceptar. by: Identificador de quien decide (default: ``"api"``). decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). - ``None`` → el núcleo usará el timestamp que ya tiene (pero el - llamador DEBE inyectarlo en la frontera para determinismo). Returns: Dict con ``accepted_count``, ``ids``. @@ -133,8 +589,6 @@ def reject_papers( ) -> dict[str, Any]: """Marca los papers dados como ``rejected`` y persiste. - Verifica que todos los ids existan en el corpus antes de operar. - Args: store_path: Ruta al archivo ``.duckdb``. ids: Lista de ids a rechazar. @@ -183,7 +637,7 @@ def reject_papers( # curate_paper — wrapper de un solo paper # --------------------------------------------------------------------------- -_VALID_DECISIONS = frozenset({"accepted", "rejected"}) +_VALID_DECISIONS_STRICT = frozenset({"accepted", "rejected"}) def curate_paper( @@ -207,15 +661,14 @@ def curate_paper( decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). Returns: - Dict con los campos del resultado de la operación correspondiente - (``accepted_count``/``rejected_count`` + ``ids``). + Dict con los campos del resultado (``accepted_count``/``rejected_count`` + ``ids``). Raises: DataError: Si ``decision`` es inválida o el id no existe. StoreError: Si el store está bloqueado. """ - if decision not in _VALID_DECISIONS: - valid = ", ".join(sorted(_VALID_DECISIONS)) + if decision not in _VALID_DECISIONS_STRICT: + valid = ", ".join(sorted(_VALID_DECISIONS_STRICT)) raise DataError(f"Decisión '{decision}' inválida. Valores válidos: {valid}.") if decision == "accepted": diff --git a/src/bib2graph/service/maturity.py b/src/bib2graph/service/maturity.py new file mode 100644 index 0000000..94e06b3 --- /dev/null +++ b/src/bib2graph/service/maturity.py @@ -0,0 +1,91 @@ +"""service.maturity — Bloque ``maturity`` para el ``--json`` de build/snapshot/read top. + +Función pura sin I/O que calcula el bloque de madurez declarativo (ADR 0037 §f, +sub-issue #160). La honestidad por construcción requiere declarar explícitamente +el estado de curación, saturación y vaciedad de redes, **sin afirmar más de lo +que se sabe**. + +Contrato del bloque:: + + { + "curated": false, # bool + "scope": "all", # str | None + "saturated": false, # bool — SIEMPRE False en one-shot (PO) + "empty_networks": [] # list[str] — solo los kinds vacíos + } + +``saturated`` es siempre ``False`` en el camino one-shot: el PO decidió no +sobre-afirmar; la condición de saturación de referencias requeriría comparar +el conteo de referencias nuevas entre dos rondas consecutivas de ``enrich`` +(gancho futuro: ``referenced_refs_count()`` en el Enricher de OpenAlex). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from bib2graph.corpus import Corpus + + +def compute_maturity( + corpus: Corpus, + *, + scope: str | None, + empty_network_kinds: list[str], +) -> dict[str, Any]: + """Calcula el bloque ``maturity`` para el ``--json`` de build/snapshot/read top. + + Función pura: no produce I/O ni efectos de lado. Recibe el corpus ya + cargado (sin abrir stores), el token de scope y los kinds de redes vacías + determinados por quien llama. + + ``curated`` es ``True`` si el corpus contiene ≥1 paper con + ``curation_status`` ∈ {``accepted``, ``rejected``}. Refleja si hay + decisiones de curación aplicadas, independientemente del scope o del FSM. + + ``saturated`` es siempre ``False`` (decisión PO: never over-claim en one-shot). + Gancho futuro: comparar ``referenced_refs_count()`` entre rondas de ``enrich`` + para detectar convergencia de referencias. + + Args: + corpus: Corpus ya cargado. Se usa para derivar ``curated`` a partir + de los conteos de ``curation_status``. + scope: Token de scope tal como lo expone ``data["scope"]``. En build + se reutiliza ``data["scope"]``; en snapshot y read top es ``"all"``. + Puede ser ``None`` si no aplica. + empty_network_kinds: Lista de tokens ``kind`` de redes que resultaron + vacías. En build se extraen de ``data["empty_networks"]`` (lista + de dicts ``{kind, reason, fix_command}``). En read top es + ``["cocitation"]`` si la red está vacía; en snapshot es ``[]``. + Solo se incluyen los ``kind``, no los ``reason``/``fix_command`` + (esos siguen viviendo en ``data["empty_networks"]`` de build). + + Returns: + Dict con exactamente 4 claves: + - ``"curated"``: bool. + - ``"scope"``: str | None. + - ``"saturated"``: bool (siempre False). + - ``"empty_networks"``: list[str] (solo kinds). + """ + import pyarrow.compute as pc + + from bib2graph.constants import Col, CurationStatus + + table = corpus.to_arrow() + + # Derivar curated: ≥1 paper con curation_status ∈ {accepted, rejected} + curation_col = table.column(Col.CURATION_STATUS) + accepted_mask = pc.equal(curation_col, CurationStatus.ACCEPTED) # type: ignore[attr-defined] + rejected_mask = pc.equal(curation_col, CurationStatus.REJECTED) # type: ignore[attr-defined] + curated_mask = pc.or_(accepted_mask, rejected_mask) # type: ignore[attr-defined] + curated = bool(pc.any(curated_mask).as_py()) # type: ignore[attr-defined] + + return { + "curated": curated, + "scope": scope, + # saturated: SIEMPRE False en one-shot — PO decidió no sobre-afirmar. + # Gancho futuro: referenced_refs_count() entre rondas de enrich. + "saturated": False, + "empty_networks": list(empty_network_kinds), + } diff --git a/src/bib2graph/service/reads.py b/src/bib2graph/service/reads.py index ebbe742..aeb434e 100644 --- a/src/bib2graph/service/reads.py +++ b/src/bib2graph/service/reads.py @@ -1,4 +1,4 @@ -"""service.reads — Las 6 funciones de lectura del Hito G2 (ADR 0028). +"""service.reads — Funciones de lectura de la capa de servicios (ADR 0028). Capa de servicios neutral: sin ``print``, ``sys.exit``, Click ni FastAPI. Cada función recibe el ``store_path`` o el ``Workspace`` ya resuelto (la @@ -6,24 +6,35 @@ modo lectura, y devuelve un ``dict`` serializable o lanza un ``B2GError`` tipado. -Lecturas implementadas: +Lecturas del Hito G2 (6 originales): - ``get_workspace`` — estado del workspace activo (nombre, loop_state, ronda, conteos, staleness de cache de redes). - ``list_rounds`` — snapshots sellados en ``/snapshots/`` más una entrada sintética ``id="live"`` para el corpus vivo. - - ``get_paper`` — fila completa del corpus por id (o ``DataError``). + - ``get_paper`` — fila completa del corpus resolviendo por id, doi o source_id + (ADR 0036; antes solo por id). Prioridad: id > doi > source_id. - ``get_scent`` — score de acoplamiento + vecinos compartidos de un paper ya en el corpus. - ``get_network`` — red de la ronda viva (desde cache ``networks/`` o recomputo). - ``compare_rounds`` — diff entre dos snapshots (added/removed paper_ids + métricas básicas). +Lecturas del grupo ``read`` (sub-issues #156, #157): + - ``list_papers`` — lista papers con filtros opcionales: query (título + substring CI), status, is_seed, year. Payload mínimo para listados. + - ``corpus_stats`` — estadísticas agregadas del corpus, agrupadas por + ``status``, ``year`` o ``is_seed``. + - ``get_top`` — nodos más centrales de la red ``kind`` + pares de co-citación + con título resuelto (#157). + Decisiones de producto (Bifurcaciones B-G2-1/B-G2-2/B-G2-3): - Ronda = snapshot sellado (Opción A). ``list_rounds``/``compare_rounds`` se anclan a los snapshots de ``/snapshots/``. - ``get_scent`` expone el score de acoplamiento real (referencias compartidas + resoluciones de referencias e citantes en el corpus), NO 4 paneles cosméticos. - ``get_network`` sirve la red viva. ``kind`` inválido o sin red → ``DataError``. + - ``get_top``: red vacía → honest-empty (exit 0 + bloques vacíos + reason/fix_command); + NO ``DataError``. ``n <= 0`` o ``kind`` inválido → ``DataError``. """ from __future__ import annotations @@ -195,22 +206,28 @@ def list_rounds(ws: Workspace) -> list[dict[str, Any]]: # --------------------------------------------------------------------------- -def get_paper(ws: Workspace, paper_id: str) -> dict[str, Any]: +def get_paper(ws: Workspace, ident: str) -> dict[str, Any]: """Devuelve la fila completa del corpus para un paper. + Resuelve ``ident`` contra tres columnas en orden de prioridad: + ``Col.ID`` (exacto) → ``Col.DOI`` (exacto) → ``Col.SOURCE_ID`` (exacto). + Si varios matchean, gana el que coincide por ``id``. + Coherente con ADR 0036 (identidad source-agnóstica, DOI ancla). + Incluye todos los campos del ``CORPUS_SCHEMA``: id, source_id, doi, title, year, abstract, is_seed, curation_status, authors_raw, - keywords_id, references_id, cited_by_id, provenance. + authors_id, keywords_id, references_id, cited_by_id, provenance. Args: ws: Workspace resuelto. - paper_id: Valor de ``Col.ID`` del paper buscado. + ident: Valor a buscar; se prueba contra id, doi y source_id en ese + orden. Returns: Dict con todos los campos de la fila del corpus. Raises: - DataError: Si el ``paper_id`` no existe en el corpus. + DataError: Si ``ident`` no coincide con ningún paper. StoreError: Si el store no existe o está bloqueado. """ store = _open_readonly(ws.library_path) @@ -218,12 +235,31 @@ def get_paper(ws: Workspace, paper_id: str) -> dict[str, Any]: table = corpus.to_arrow() rows = table.to_pylist() - matching = [r for r in rows if str(r.get(Col.ID)) == paper_id] + + # Prioridad: id > doi > source_id (ADR 0036) + matching_by_id = [r for r in rows if str(r.get(Col.ID)) == ident] + if matching_by_id: + matching = matching_by_id + else: + matching_by_doi = [ + r + for r in rows + if r.get(Col.DOI) is not None and str(r.get(Col.DOI)) == ident + ] + if matching_by_doi: + matching = matching_by_doi + else: + matching = [ + r + for r in rows + if r.get(Col.SOURCE_ID) is not None + and str(r.get(Col.SOURCE_ID)) == ident + ] if not matching: raise DataError( - f"Paper '{paper_id}' no encontrado en el corpus. " - "Verificá el id con 'b2g status' o 'b2g inspect'." + f"Paper '{ident}' no encontrado en el corpus. " + "Verificá el id con 'b2g read list' o 'b2g status'." ) row = matching[0] @@ -607,3 +643,311 @@ def _read_network_metrics(snapshot_id: str, kind: str) -> dict[str, Any] | None: "mutated_hubs": [], # diferido: requiere redes construidas por snapshot "metrics_change": metrics_change, } + + +# --------------------------------------------------------------------------- +# 7. list_papers (sub-issue #156 — grupo read) +# --------------------------------------------------------------------------- + + +def list_papers( + ws: Workspace, + *, + query: str | None = None, + status: str | None = None, + is_seed: bool | None = None, + year: int | None = None, +) -> dict[str, Any]: + """Lista papers del corpus con filtros opcionales. + + Devuelve un payload mínimo por paper (id, title, year, curation_status, + is_seed) más el conteo total. Diseñado para listar en el CLI/SPA sin + cargar campos pesados (abstract, referencias, autores). + + Filtros combinables (todos opcionales, se aplican con AND lógico): + - ``query``: substring case-insensitive sobre el título. + - ``status``: valor exacto de ``curation_status`` + (``"candidate"``, ``"accepted"``, ``"rejected"``). + - ``is_seed``: ``True`` → solo semillas; ``False`` → solo no-semillas. + - ``year``: año exacto de publicación. + + Args: + ws: Workspace resuelto. + query: Texto a buscar en el título (substring, case-insensitive). + status: Valor exacto de ``curation_status``. + is_seed: Filtro por campo is_seed. + year: Año exacto de publicación. + + Returns: + Dict con ``papers`` (lista de dicts mínimos) y ``count`` (int). + + Raises: + StoreError: Si el store no existe o está bloqueado. + """ + store = _open_readonly(ws.library_path) + corpus = store.load() + table = corpus.to_arrow() + rows = table.to_pylist() + + result: list[dict[str, Any]] = [] + for row in rows: + # Filtro por query: substring CI sobre el título + if query is not None: + title_val = str(row.get(Col.TITLE) or "") + if query.lower() not in title_val.lower(): + continue + + # Filtro por status (curation_status exacto) + if status is not None and str(row.get(Col.CURATION_STATUS)) != status: + continue + + # Filtro por is_seed + if is_seed is not None and bool(row.get(Col.IS_SEED)) != is_seed: + continue + + # Filtro por año exacto + if year is not None: + row_year = row.get(Col.YEAR) + if row_year is None or int(row_year) != year: + continue + + result.append( + { + "id": row.get(Col.ID), + "title": row.get(Col.TITLE), + "year": row.get(Col.YEAR), + "curation_status": row.get(Col.CURATION_STATUS), + "is_seed": row.get(Col.IS_SEED), + } + ) + + return {"papers": result, "count": len(result)} + + +# --------------------------------------------------------------------------- +# 8. corpus_stats (sub-issue #156 — grupo read) +# --------------------------------------------------------------------------- + +_VALID_GROUP_BY: frozenset[str] = frozenset({"status", "year", "is_seed"}) + +# Mapeo de alias CLI → nombre de columna SQL +_GROUP_BY_COL: dict[str, str] = { + "status": Col.CURATION_STATUS.value, + "year": Col.YEAR.value, + "is_seed": Col.IS_SEED.value, +} + + +def corpus_stats( + ws: Workspace, + *, + group_by: str = "status", +) -> dict[str, Any]: + """Estadísticas del corpus agrupadas por ``status``, ``year`` o ``is_seed``. + + Para ``group_by="status"`` reutiliza la consulta GROUP BY de + ``get_workspace`` (``counts_by_status``); para ``year`` e ``is_seed`` + aplica la misma estrategia sobre la columna correspondiente. + + Args: + ws: Workspace resuelto. + group_by: Dimensión de agrupación. Valores válidos: + ``"status"`` (default), ``"year"``, ``"is_seed"``. + + Returns: + Dict con: + - ``group_by``: dimensión usada. + - ``total``: total de papers en el corpus. + - ``groups``: lista de ``{key, count}`` ordenada por clave. + + Raises: + DataError: Si ``group_by`` no es un valor válido. + StoreError: Si el store no existe o está bloqueado. + """ + if group_by not in _VALID_GROUP_BY: + valid = ", ".join(sorted(_VALID_GROUP_BY)) + raise DataError(f"group_by '{group_by}' no válido. Valores admitidos: {valid}.") + + store = _open_readonly(ws.library_path) + col = _GROUP_BY_COL[group_by] + + counts_table = store.backend.query( + f"SELECT {col}, COUNT(*) AS cnt FROM corpus GROUP BY 1 ORDER BY 1" + ) + + groups: list[dict[str, Any]] = [] + total = 0 + if len(counts_table) > 0: + for row in counts_table.to_pylist(): + key = row.get(col) + cnt = int(row["cnt"]) + groups.append({"key": key, "count": cnt}) + total += cnt + + return { + "group_by": group_by, + "total": total, + "groups": groups, + } + + +# --------------------------------------------------------------------------- +# 9. get_top (sub-issue #157 — read top) +# --------------------------------------------------------------------------- + + +def get_top( + ws: Workspace, + *, + n: int = 10, + kind: str = "bibliographic_coupling", +) -> dict[str, Any]: + """Devuelve los nodos más centrales de la red ``kind`` y los pares de co-citación. + + El bloque "central" se extrae de la red del ``kind`` solicitado, ordenando + los nodos por ``degree_centrality`` descendente y tomando los primeros ``n``. + El bloque "cocitation" es **siempre** la red ``cocitation`` (top N pares + por peso descendente), independientemente del ``kind`` elegido. + + La función es idempotente y no requiere ``build`` previo: recomputa las + redes en tiempo de lectura (mismo camino que ``get_network``). + + Para redes de paper (``bibliographic_coupling``, ``cocitation``), los ids + de nodo son ids del corpus y se resuelven al título completo. Para otras + redes (``author_collab``, ``institution_collab``, ``keyword_cooccurrence``), + los ids son ids de entidad y se usa el ``label`` decorado como título. + + Red vacía → honest-empty (exit 0 + bloque vacío + ``reason``/``fix_command``), + NO ``DataError``. + + Args: + ws: Workspace resuelto. + n: Número de nodos/pares a devolver (default 10). Debe ser > 0. + kind: Tipo de red para el bloque central. Uno de los 5 ``NetworkKind`` + (default ``bibliographic_coupling``). + + Returns: + Dict con: + + - ``kind``: tipo de red usado para el bloque central. + - ``top``: ``n`` pedido. + - ``central``: lista de hasta ``n`` nodos ``{id, title, + degree_centrality, ?community}`` ordenados por + ``degree_centrality`` descendente. + - ``cocitation``: lista de hasta ``n`` pares ``{source, + source_title, target, target_title, weight}`` ordenados + por ``weight`` descendente. + - ``reason`` / ``fix_command``: presentes solo cuando + ``cocitation`` está vacío (honest-empty; NOT un error). + + Raises: + DataError: Si ``kind`` no es un ``NetworkKind`` válido, si + ``n <= 0``, o si la construcción de alguna red falla + (error genuino, no vaciedad). + StoreError: Si el store no existe o está bloqueado. + """ + if kind not in _VALID_KINDS: + valid = ", ".join(sorted(_VALID_KINDS)) + raise DataError( + f"NetworkKind '{kind}' no reconocido. Valores válidos: {valid}." + ) + if n <= 0: + raise DataError(f"top N debe ser un entero positivo; se recibió {n}.") + + # Cargar corpus una vez para el índice id→título y para predict_build_preview + store = _open_readonly(ws.library_path) + corpus = store.load() + table = corpus.to_arrow() + id_to_title: dict[str, str | None] = { + str(r.get(Col.ID)): (str(r.get(Col.TITLE)) if r.get(Col.TITLE) else None) + for r in table.to_pylist() + if r.get(Col.ID) + } + + # ------------------------------------------------------------------- + # Bloque "central": top N nodos por degree_centrality en --kind + # ------------------------------------------------------------------- + central_net = get_network(ws, kind) + sorted_nodes = sorted( + central_net["nodes"], + key=lambda node: node["degree_centrality"], + reverse=True, + )[:n] + + central: list[dict[str, Any]] = [] + for node in sorted_nodes: + node_id = node["id"] + # Redes de paper → id es un Col.ID → resolvible al título del corpus. + # Otras redes → id es un id de entidad → usar label decorado. + title = id_to_title.get(node_id) or node.get("label") + entry: dict[str, Any] = { + "id": node_id, + "title": title, + "degree_centrality": node["degree_centrality"], + } + if "community" in node: + entry["community"] = node["community"] + central.append(entry) + + # ------------------------------------------------------------------- + # Bloque "cocitation": top N pares por peso (SIEMPRE red cocitation) + # ------------------------------------------------------------------- + coc_net = ( + central_net + if kind == NetworkKind.COCITATION + else get_network(ws, NetworkKind.COCITATION) + ) + + sorted_edges = sorted( + coc_net["edges"], + key=lambda e: e["weight"], + reverse=True, + )[:n] + + cocitation: list[dict[str, Any]] = [] + for edge in sorted_edges: + src_id = str(edge["source"]) + tgt_id = str(edge["target"]) + cocitation.append( + { + "source": src_id, + "source_title": id_to_title.get(src_id), + "target": tgt_id, + "target_title": id_to_title.get(tgt_id), + "weight": edge["weight"], + } + ) + + result: dict[str, Any] = { + "kind": kind, + "top": n, + "central": central, + "cocitation": cocitation, + } + + # Honest-empty: si co-citación vacía, agregar reason/fix_command. + # Igual que "build" con red vacía → exit 0, NO DataError. + coc_is_empty = not coc_net["edges"] + if coc_is_empty: + from bib2graph.networks.facade import predict_build_preview + + preview = predict_build_preview(corpus) + coc_entry = next( + (p for p in preview if p["kind"] == str(NetworkKind.COCITATION)), + None, + ) + if coc_entry is not None: + result["reason"] = coc_entry["reason"] + result["fix_command"] = coc_entry["fix_command"] + + # Maturity (#160): siempre presente en read top (scope="all", redes vacías inferidas). + from bib2graph.service.maturity import compute_maturity + + empty_network_kinds = ["cocitation"] if coc_is_empty else [] + result["maturity"] = compute_maturity( + corpus, + scope="all", + empty_network_kinds=empty_network_kinds, + ) + + return result diff --git a/src/bib2graph/service/snapshot.py b/src/bib2graph/service/snapshot.py new file mode 100644 index 0000000..d76892b --- /dev/null +++ b/src/bib2graph/service/snapshot.py @@ -0,0 +1,251 @@ +"""service.snapshot — Servicios de snapshot y restore (ADR 0038, #163). + +Capa neutral: sin ``print``, ``sys.exit``, Click ni FastAPI. + +``run_snapshot`` — exporta una foto sellada del corpus actual (parquet + +manifest.json). No transiciona el CycleState. + +``run_restore`` — rehidrata un corpus desde un parquet curado, lo mergea con +el existente, aplica normalize+dedup y transiciona el estado del lazo a +``FILTERED``. + +``decided_at`` se inyecta desde la frontera CLI (R2/ADR 0017): el servicio +no llama ``datetime.now()`` directamente. El CLI (``cli/commands/snapshot.py`` +y el shim ``cli/commands/restore.py``) son adaptadores delgados. + +El subcomando ``snapshot create`` y ``snapshot restore`` delegan acá; +el shim ``b2g restore`` también delega acá (fuente única — ADR 0038 §163). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from bib2graph.service.errors import DataError, StoreError + +# Umbrales fijos del auto-preproc (ADR 0031, espejo de cli/_ingest.py). +# Fuente canónica: cli/_ingest.py. Si se cambian allí, actualizar acá también. +_THRESHOLD_AUTHORS: float = 0.92 +_THRESHOLD_KEYWORDS: float = 0.90 + + +# --------------------------------------------------------------------------- +# Helper interno — abrir store para escritura +# --------------------------------------------------------------------------- + + +def _open_store(path: Path) -> Any: + """Abre (o crea) el DuckDBStore en ``path`` para escritura. + + Args: + path: Ruta al archivo ``.duckdb``. + + Returns: + ``DuckDBStore`` abierto y listo para leer/escribir. + + Raises: + StoreError: Si el store está bloqueado o no se puede abrir. + """ + from bib2graph.backends.duckdb import StoreLockedError + from bib2graph.stores.duckdb import DuckDBStore + + try: + return DuckDBStore(path) + except StoreLockedError as exc: + raise StoreError(str(exc)) from exc + except OSError as exc: + raise StoreError( + f"No se puede abrir el store '{path}': {exc}. " + "Verificá que el archivo no esté bloqueado por otro proceso." + ) from exc + + +# --------------------------------------------------------------------------- +# Helper interno — normalize + dedup (espejo neutral de cli._ingest) +# --------------------------------------------------------------------------- + + +def _normalize_and_dedup(corpus: Any, *, applied_at: datetime | None) -> Any: + """Aplica normalize + dedup_authors + dedup_keywords al corpus. + + Espejo neutral de ``cli._ingest.normalize_and_dedup``. No importa de + ``cli/`` para mantener la capa de servicios independiente del adaptador + Click. Los umbrales son los mismos que en ``cli/_ingest.py`` (ADR 0031). + + Args: + corpus: Corpus completo ya mergeado (existing + incoming). + applied_at: Timestamp de la operación (R2). ``None`` usa + ``datetime.now(UTC)`` internamente (para uso como librería). + + Returns: + Nuevo Corpus normalizado y deduplicado. + """ + from bib2graph.preprocessors.dedup import deduplicate_authors, deduplicate_keywords + from bib2graph.preprocessors.preprocessor import Preprocessor + + ts = applied_at if applied_at is not None else datetime.now(UTC) + preprocessor = Preprocessor() + result = preprocessor.normalize(corpus, applied_at=ts) + result = deduplicate_authors(result, threshold=_THRESHOLD_AUTHORS) + result = deduplicate_keywords(result, threshold=_THRESHOLD_KEYWORDS) + return result + + +# --------------------------------------------------------------------------- +# run_snapshot +# --------------------------------------------------------------------------- + + +def run_snapshot( + store_path: str | Path, + *, + out_dir: str | Path, +) -> dict[str, Any]: + """Exporta una foto sellada del corpus actual. + + Carga el corpus del store y exporta un snapshot sellado (parquet + + manifest.json) al directorio indicado. No transiciona el CycleState. + + Args: + store_path: Ruta al archivo ``.duckdb``. + out_dir: Directorio destino del snapshot. + + Returns: + Dict con ``snapshot_dir``, ``corpus_hash``, ``total_papers``, + ``schema_version``. + + Raises: + StoreError: Si el store está bloqueado o no se puede abrir. + """ + from bib2graph.service.maturity import compute_maturity + + store = _open_store(Path(store_path)) + corpus = store.load() + snap = corpus.snapshot(Path(out_dir)) + maturity = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + return { + "snapshot_dir": str(snap.path), + "corpus_hash": snap.manifest.corpus_hash, + "total_papers": len(corpus), + "schema_version": snap.manifest.schema_version, + "maturity": maturity, + } + + +# --------------------------------------------------------------------------- +# run_restore +# --------------------------------------------------------------------------- + + +def run_restore( + store_path: str | Path, + corpus_path: str | Path, + *, + decided_at: datetime | None = None, +) -> dict[str, Any]: + """Carga un corpus curado desde un parquet y lo persiste en el store sin red. + + Lee el parquet con el schema canónico (``CORPUS_SCHEMA``), lo hidrata con + ``Corpus.from_arrow``, hace merge con el corpus existente, aplica + normalize + dedup y persiste. Transiciona el ``CycleState`` a + ``FILTERED`` (el corpus ya fue curado; ver docstring de + ``cli/commands/restore.py`` para la justificación). + + Preserva las columnas de curación del parquet + (``decision`` / ``curation_status`` / ``is_seed``): el merge de ``Corpus`` + respeta el ``curation_status`` más reciente (D3 del merge). + + No instancia ``OpenAlexSource``, no hace requests. Es el camino offline + para rehidratar un corpus curado exportado con ``b2g snapshot create``. + + R2/ADR 0017: ``decided_at`` se inyecta desde la frontera CLI. Si + ``None``, el timestamp se genera internamente al pasar el control a los + preprocesadores (el servicio no llama ``datetime.now()`` directamente). + + Args: + store_path: Ruta al archivo ``.duckdb``. + corpus_path: Ruta al archivo ``.parquet`` con el corpus curado. + decided_at: Timestamp inyectado por el llamador (R2/ADR 0017). + + Returns: + Dict con ``papers_loaded``, ``total_papers``, ``state``, ``round``. + + Raises: + DataError: Si el parquet no existe o no tiene el schema canónico. + StoreError: Si el store está bloqueado. + """ + import pyarrow.parquet as pq + + from bib2graph.corpus import Corpus + from bib2graph.cycle import CycleState, apply_transition + from bib2graph.schemas import CORPUS_SCHEMA + + resolved = Path(corpus_path) + if not resolved.exists(): + raise DataError( + f"El parquet '{resolved}' no existe. Verificá la ruta al corpus curado." + ) + + try: + table = pq.read_table(str(resolved), schema=CORPUS_SCHEMA) # type: ignore[no-untyped-call] + except Exception as exc: + raise DataError( + f"No se pudo leer el parquet '{resolved}': {exc}. " + "Verificá que el archivo tenga el schema canónico de bib2graph." + ) from exc + + try: + incoming = Corpus.from_arrow(table) + except Exception as exc: + raise DataError( + f"El parquet '{resolved}' no cumple el schema canónico: {exc}." + ) from exc + + store = _open_store(Path(store_path)) + merged_backend_close = None + try: + existing = store.load() + + # Transición a FILTERED: el corpus restaurado ya pasó curación. + # apply_transition es permisiva — acepta "filter" desde cualquier estado + # actual del store (incluyendo None para un store vacío nuevo). + current_state = store.backend.loop_state() + # La ronda nunca debe ser < 1: loop_round() devuelve 0 para bases legacy + # (round=NULL, pre-R3) y para stores vacíos. max(..., 1) la normaliza en + # ambos branches para no persistir un estado con ronda 0 incoherente. + current_round = max(store.backend.loop_round(), 1) + # "filter" lleva a FILTERED; la ronda no cambia (no es reseed). + # Para un store vacío (current_state=None), arrancamos desde SEEDED ficticio. + if current_state is None: + new_state, new_round = apply_transition( + CycleState.SEEDED, "filter", current_round + ) + else: + new_state, new_round = apply_transition( + current_state, "filter", current_round + ) + + # Merge primero, dedup después sobre el corpus COMPLETO (fix bug cross-biblioteca). + # El reloj se fija UNA vez por invocación (R2): pasado via decided_at. + merged = existing.merge(incoming) + merged_deduped = _normalize_and_dedup(merged, applied_at=decided_at) + papers_loaded = len(incoming) + total_papers = len(merged_deduped) + merged_backend_close = getattr(merged_deduped._backend, "close", None) + store.persist_replace(merged_deduped) + store.backend.set_loop_state(new_state, cycle_round=new_round) + finally: + # Ver run_seed_from_bib: cierra explícitamente las conexiones DuckDB + # para evitar segfault en Linux ante llamadas consecutivas al mismo archivo. + if merged_backend_close is not None: + merged_backend_close() + store.close() + + return { + "papers_loaded": papers_loaded, + "total_papers": total_papers, + "state": str(new_state), + "round": new_round, + } diff --git a/src/bib2graph/skill/SKILL.md b/src/bib2graph/skill/SKILL.md new file mode 100644 index 0000000..c6706d8 --- /dev/null +++ b/src/bib2graph/skill/SKILL.md @@ -0,0 +1,177 @@ +--- +name: bib2graph +description: >- + Guía a un investigador para explorar literatura con bib2graph. Hace una + entrevista breve para elegir entre un barrido one-shot (rápido, ya útil) y un + refinamiento profundo (preciso, defendible), ayuda a elaborar la ecuación de + búsqueda, y corre el ciclo de forrajeo seed→chain→build→read. Usala cuando + alguien quiera hacer una revisión bibliográfica, mapear un campo o construir + redes de citación. Requiere bib2graph >= 0.10.0 (b2g). +--- + +# bib2graph — exploración bibliográfica con forrajeo asistido + +## Principio rector (leé esto primero) + +bib2graph asiste UN cuello de botella del ciclo de investigación: el +**forrajeo/chaining**, usando la **estructura bibliométrica como information +scent** (acoplamiento, co-citación, centralidad) — determinista, **sin IA +generativa**. El juicio sigue siendo del investigador: formular la pregunta, +dejar que la query mute, curar, interpretar las redes. + +Dos cosas que no se negocian en cómo asistís: + +1. **El one-shot es un entregable real.** Una sola pasada (seed→chain→build→read) + ya entrega valor: para un estudiante con una tarea, es más que suficiente y + mejor que nada. Ofrecelo de entrada y corrélo hasta un resultado. +2. **Mostrá que es el trabajo a la mitad.** Al terminar el one-shot, mostrá dónde + queda en el ciclo y que **profundizar con criterio humano eleva la calidad** + (curar, refinar la ecuación, leer las tensiones). No impongas: invitá. + +El eje teórico (el ciclo humano de exploración bibliográfica y dónde asiste la +herramienta) está en `reference/ciclo.md`. + +## La entrevista (híbrida: núcleo fijo + ramas) + +Preguntá SIEMPRE estas tres antes de proponer nada: + +1. **¿Qué querés lograr?** — una tarea/ensayo rápido · mapear un campo nuevo · + una revisión sistemática defendible · monitorear un tema en el tiempo. +2. **¿De qué partís?** — solo un tema (→ ecuación de búsqueda) · ya tenés papers + clave o un `.bib` (→ semillas con `seed --from-bib`). +3. **¿Precisión vs. esfuerzo?** — "algo bueno, ya" · "lo más exhaustivo y preciso + posible". + +Según la respuesta 1, ramificá: + +| Género | Estrategia por defecto | +|---|---| +| Tarea/ensayo rápido | **One-shot.** Una pasada amplia; mostrar el mapa al final. | +| Mapear un campo | **One-shot amplio** (`chain --depth 1`, `read top`). | +| Revisión sistemática | **Profundizar:** lazo seed→chain→curate→build→read + `snapshot`. | +| Monitoreo | One-shot ahora; más adelante `chain --since` (fuera de v1). | + +**Regla de decisión:** si pidió "ya / algo bueno" o la pregunta todavía es difusa +→ **one-shot**. Si pidió exhaustividad/precisión y el tema está acotado → +**profundizar**. Ante la duda, hacé el one-shot primero y ofrecé profundizar. + +## Camino A — One-shot (siempre disponible) + +``` +b2g init +b2g seed --equation "" # o: seed --from-bib refs.bib +b2g chain --depth 1 +b2g build +b2g read top +``` + +Qué entrega: un set de papers forrajeado por scent + las redes + los más +centrales. Al terminar, mostrá el **mapa "estás acá"** (sección abajo) y ofrecé +la retroalimentación. + +## Camino B — Profundizar y refinar + +El lazo no-lineal (la query muta al leer): + +``` +b2g seed --equation "..." # o --from-bib +b2g chain --depth N --direction backward # y/o forward +b2g curate filter --year-gte 2015 --language en --min-citations 5 +b2g curate reject --ids ... # decisión HUMANA +b2g curate accept --ids ... # decisión HUMANA +b2g build --scope accepted +b2g read top / read show +# ↳ el humano lee, muta la ecuación, vuelve a seed ⟲ +b2g snapshot create # congela el set reproducible (PRISMA) +``` + +Acá la skill **ejecuta** seed/chain/build/read y **prepara** las opciones de +curate, pero la decisión de aceptar/rechazar y de mutar la ecuación es del +investigador. Presentá el material; no decidas por él. + +## Elaborar la ecuación de búsqueda + +De idea difusa → `seed --equation "..."`: + +- **Conceptos núcleo** (2–4) + **sinónimos** por concepto. +- **Estructura booleana**: sinónimos con OR dentro del concepto, conceptos con + AND entre sí. Ej: + `("unequal exchange" OR "ecological debt") AND (trade OR commerce)`. +- **Scope**: `--min-year/--max-year`, `--exclude ""`. +- **Cuándo dejarla mutar (Bates):** tras `read top`, si los centrales revelan un + término mejor o un subcampo, **refiná la ecuación y re-`seed`**. Eso es señal de + buen forrajeo, no de error. + +## El mapa "estás acá" (mostralo tras el one-shot) + +Tras un one-shot, mostrá algo así: + +``` +Hiciste 1 de las ~3 vueltas del ciclo: + [✓] semillas → forrajeo → redes → centrales ← estás acá + [ ] curar (aceptar/rechazar con criterio) ............ +precisión + [ ] refinar la ecuación y volver a forrajear ......... +cobertura + [ ] congelar un set reproducible (snapshot) ......... +defendible + +Tenés un buen punto de partida. Si esto es para una tarea, ya alcanza. +Si necesitás algo defendible o más preciso, puedo ayudarte a profundizar. +``` + +## Retroalimentación (cerrá el lazo — hacelo fácil) + +Dar feedback es muy importante: es lo que mejora la skill con uso real. Al +terminar (one-shot o profundización), preguntá: + +> ¿Querés ayudar a mejorar bib2graph contando cómo te fue? (sí / no) + +Si responde **sí**: + +1. **Armá un resumen de la interacción sin datos personales.** Sacá nombre, + email, institución y cualquier dato identificable; si el tema de investigación + es sensible, generalizalo (p. ej. "un tema de economía ecológica" en vez de la + pregunta exacta). **Dejá** la interacción del usuario: género elegido, la + decisión one-shot vs profundizar, dónde dudó, qué funcionó, qué costó, qué faltó. +2. **Dejalo listo para copiar y pegar** en el bloque de abajo. +3. **Dale el link de una vez:** https://github.com/complexluise/bib2graph/issues/new + Si el cuerpo es corto, ofrecé además un link prellenado (ver más abajo). + +Bloque a entregar (rellenalo y mostralo en un fence para copiar): + +``` +Título: feedback(skill): +Label sugerida: enhancement (idea) | bug (algo falló) + +## Contexto +- Género: +- Estrategia: one-shot | profundización +- Versión: + +## Qué funcionó + + +## Fricción / qué costó + + +## Qué faltó / ideas + + +## (opcional) Pasos corridos +seed ... / chain ... / build / read ... +``` + +Link prellenado (solo si el cuerpo entra en la URL; si queda muy largo, usá el +bloque copy-paste de arriba): +`https://github.com/complexluise/bib2graph/issues/new?title=feedback(skill):%20<...>&labels=enhancement&body=` + +## Referencia de comandos (v1) + +- `seed` — `--equation` · `--from-bib` · `--exclude` · `--min-year/--max-year` · + `--max-results` · `--resolve` +- `chain` — `--depth` · `--direction backward|forward` · `--max-candidates` · + `--preview` +- `curate {dump,apply,accept,reject,filter}` — `--ids` · `--language` · + `--min-citations` · `--type` · `--year-gte/--year-lte` · `--scope` +- `build` — `--spec` · `--scope` · `--min-weight` +- `read {list,stats,show,top}` + +Para cualquier flag, `b2g --help` es la fuente autoritativa. diff --git a/src/bib2graph/skill/reference/ciclo.md b/src/bib2graph/skill/reference/ciclo.md new file mode 100644 index 0000000..6ef9fc4 --- /dev/null +++ b/src/bib2graph/skill/reference/ciclo.md @@ -0,0 +1,38 @@ +# El ciclo humano de exploración bibliográfica (eje teórico) + +Resumen operativo de la Nota 05 (`docs/Notas/05-ciclo-investigacion-humano.md`) para +la skill. Modela el ciclo **iterativo y no-lineal** de exploración bibliográfica y marca +**dónde asiste bib2graph** y dónde el juicio es **irreductiblemente humano**. + +## El ciclo (no lineal: lazo 2→3→4→1) + +| Paso | Qué pasa | Verbo bib2graph | Quién | +|---|---|---|---| +| 0 | Idea / pregunta difusa (Kuhlthau: alta incertidumbre) | — *(la entrevista asiste)* | **Humano** | +| 1 | Semillas (el "grano", pearl growing) | `seed --equation` / `seed --from-bib` | mixto | +| 2 | Chaining / forrajeo (snowballing back/forward) | `chain --depth --direction` | **Herramienta** | +| 3 | Browsing / diferenciar | `read top/show`, `build` | mixto | +| 4 | La query y la idea **mutan** (Bates: berrypicking) | refinar `--equation` / re-`seed` | **Humano** | +| 5 | Organizar en evidencia (concept matrix) | `build` + `read` | Herramienta | +| 6 | Sensemaking / tensiones | leer las redes | **Humano** | +| 7 | Curar la biblioteca (berry *growing*) | `curate accept/reject/filter`, `snapshot create` | **Humano** decide | +| 8 | Monitorear (alertas de lo nuevo) | `chain --since` | Herramienta | + +## El punto clave + +La **bibliometría es el information scent**: el candidato se prioriza por cuánto se +**acopla / co-cita / es central** respecto del corpus curado — **estructura, no IA**, y por +eso reproducible. Trade-off honesto: rankear por estructura presente sesga hacia lo +central/popular (efecto Mateo); el scent **prioriza**, la exhaustividad la sostienen los +filtros (PRISMA) en `curate`. + +**Irreductiblemente humanos:** pasos 0, 4, 6, 7. La herramienta **no automatiza el juicio**: +asiste el forrajeo con estructura y le da al humano el material para el sensemaking. + +## Tradiciones que lo fundamentan + +- **Information Seeking (LIS):** Kuhlthau (ISP, dimensión afectiva), Ellis (chaining), + Bates (berrypicking: la búsqueda no es lineal). +- **Foraging + Sensemaking (HCI):** Pirolli & Card (information scent, foraging/sensemaking loops). +- **Revisión sistemática:** Wohlin (snowballing), SALSA, Webster & Watson (concept matrix), + vom Brocke (rigor del proceso), PRISMA. diff --git a/src/bib2graph/sources/openalex.py b/src/bib2graph/sources/openalex.py index a708ac9..15133c7 100644 --- a/src/bib2graph/sources/openalex.py +++ b/src/bib2graph/sources/openalex.py @@ -26,7 +26,7 @@ import os import re import time -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any import httpx @@ -921,6 +921,7 @@ def _fetch_citing_pages( ids: list[str], *, max_per_paper: int | None = None, + since: date | None = None, ) -> tuple[dict[str, list[str]], dict[str, dict[str, Any]]]: """Núcleo compartido de paginación y atribución para los citantes en lote. @@ -934,6 +935,9 @@ def _fetch_citing_pages( ids: IDs cortos de OpenAlex ya normalizados (p. ej. ``["W111"]``). max_per_paper: Presupuesto máximo de citantes por semilla. ``None`` = sin tope. + since: Filtrar citantes publicados desde esta fecha + (``from_publication_date:YYYY-MM-DD`` en OpenAlex). ``None`` + = sin filtro de fecha. Returns: Tupla ``(attribution, works_map)`` donde: @@ -955,6 +959,8 @@ def _fetch_citing_pages( batch_acc: dict[str, set[str]] = {tid: set() for tid in lote} filter_str = "cites:" + "|".join(lote) + if since is not None: + filter_str += f",from_publication_date:{since.isoformat()}" cursor: str = "*" with self._client() as client: @@ -1006,7 +1012,11 @@ def _fetch_citing_pages( return result, works_map def fetch_citing_batch( - self, ids: list[str], *, max_per_paper: int | None = None + self, + ids: list[str], + *, + max_per_paper: int | None = None, + since: date | None = None, ) -> dict[str, list[str]]: """Trae en lote los citantes de varios papers usando ``cites:W1|W2|...``. @@ -1045,12 +1055,16 @@ def fetch_citing_batch( return {} normalized = [_oa_id_short(i) or i for i in ids] attribution, _ = self._fetch_citing_pages( - normalized, max_per_paper=max_per_paper + normalized, max_per_paper=max_per_paper, since=since ) return attribution def fetch_citing_batch_with_works( - self, ids: list[str], *, max_per_paper: int | None = None + self, + ids: list[str], + *, + max_per_paper: int | None = None, + since: date | None = None, ) -> tuple[dict[str, list[str]], dict[str, dict[str, Any]]]: """Como ``fetch_citing_batch`` pero conserva los objetos JSON completos. @@ -1080,7 +1094,9 @@ def fetch_citing_batch_with_works( if not ids: return {}, {} normalized = [_oa_id_short(i) or i for i in ids] - return self._fetch_citing_pages(normalized, max_per_paper=max_per_paper) + return self._fetch_citing_pages( + normalized, max_per_paper=max_per_paper, since=since + ) def _fetch_page_with_retry( self, diff --git a/src/bib2graph/stores/duckdb.py b/src/bib2graph/stores/duckdb.py index c5e2498..4535ed7 100644 --- a/src/bib2graph/stores/duckdb.py +++ b/src/bib2graph/stores/duckdb.py @@ -82,13 +82,15 @@ def load(self) -> Corpus: #126: reconstruye ``manifest.filters`` desde ``filter_log`` para que los pasos PRISMA persistan entre sesiones. + #141: reconstruye ``manifest.enrichers`` desde ``enricher_log`` para + que los ``EnricherRef`` persistan entre sesiones. Returns: El ``Corpus`` acumulado en el store. """ import pyarrow as pa - from bib2graph.corpus import FilterStep + from bib2graph.corpus import EnricherRef, FilterStep table = self._backend.to_arrow() if len(table) == 0: @@ -114,6 +116,21 @@ def load(self) -> Corpus: new_manifest = corpus.manifest.model_copy(update={"filters": filter_steps}) corpus = corpus.with_manifest(new_manifest) + # #141: reconstruir manifest.enrichers desde enricher_log + raw_refs = self._backend.load_enricher_refs() + if raw_refs: + enricher_refs = [ + EnricherRef( + name=str(r["name"]), + params={str(k): str(v) for k, v in r["params"].items()}, + ) + for r in raw_refs + ] + new_manifest = corpus.manifest.model_copy( + update={"enrichers": enricher_refs} + ) + corpus = corpus.with_manifest(new_manifest) + return corpus # ------------------------------------------------------------------ diff --git a/src/bib2graph/gui/static/.gitkeep b/tests/e2e/__init__.py similarity index 100% rename from src/bib2graph/gui/static/.gitkeep rename to tests/e2e/__init__.py diff --git a/tests/e2e/test_oneshot_readiness.py b/tests/e2e/test_oneshot_readiness.py new file mode 100644 index 0000000..7f0b13c --- /dev/null +++ b/tests/e2e/test_oneshot_readiness.py @@ -0,0 +1,438 @@ +"""E2E capstone: ciclo one-shot guiado por ``status --json`` (#76, epic #167). + +Verifica el flujo agents-first completo (ADR 0037): + + init → seed(bib) → [status: chain, not-ready] → + seed(bib+resolve) → [status: chain, ready] → + chain(both) → [status: build, preview-vacío] → + build → [status: read, ready] → + read top + +La "trampa de la Nota 20" (ADR 0037 §c): un agente que siembra desde BibTeX +sin ``--resolve`` ve ``readiness.ready=False`` con ``reason`` que menciona +``--resolve``. El test afirma que el agente debe resolver antes de poder +encadenar de forma productiva. + +Monkeypatch: ``bib2graph.sources.openalex.OpenAlexSource`` se reemplaza por +``_MockedOA`` que inyecta un ``httpx.MockTransport`` estático. Como +``chain.py`` y ``service/resolve.py`` importan ``OpenAlexSource`` dentro +del cuerpo de sus funciones (no a nivel de módulo), el patch del atributo +de módulo se ve cuando esas funciones ejecutan — sin necesidad de inyección +directa de transport desde el CLI. + +Marker: ``pytest.mark.integration`` (I/O local — DuckDB; sin red real). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.workspace import Workspace + +pytestmark = pytest.mark.integration + +# --------------------------------------------------------------------------- +# Fixture de datos +# --------------------------------------------------------------------------- + +_SAMPLE_BIB = Path(__file__).resolve().parents[2] / "examples" / "bibtex" / "sample.bib" + +# DOIs presentes en sample.bib con DOI → IDs sintéticos de OpenAlex +_DOI_TO_OA_ID: dict[str, str] = { + "10.1016/j.ecolecon.2010.02.003": "W9000001", + "10.1177/0020715209105141": "W9000002", + "10.1177/0020715209105144": "W9000003", + "10.1016/j.ecolecon.2020.106824": "W9000004", + "10.1016/j.ecolecon.2015.03.012": "W9000005", + "10.1016/j.ecolecon.2009.11.014": "W9000006", + "10.1177/1070496503260974": "W9000007", +} + +# Work citante sintético para el forward chain (cita W9000001 y W9000002) +_CITING_WORK: dict[str, Any] = { + "id": "https://openalex.org/W8888000001", + "doi": None, + "title": "E2E Test Citing Paper", + "display_name": "E2E Test Citing Paper", + "publication_year": 2024, + "language": "en", + "abstract_inverted_index": None, + "authorships": [], + "keywords": [], + "referenced_works": [ + "https://openalex.org/W9000001", + "https://openalex.org/W9000002", + ], + "primary_location": {"source": {"display_name": "E2E Test Journal"}}, + "type": "article", +} + + +# --------------------------------------------------------------------------- +# Mock transport para toda la sesión E2E +# --------------------------------------------------------------------------- + + +def _make_e2e_transport() -> httpx.MockTransport: + """Transport unificado que cubre todos los requests del ciclo E2E. + + Discrimina por el prefijo del parámetro ``filter``: + + - ``doi:...`` → resolución DOI→source_id (seed --resolve) + - ``cites:...`` → forward chain (chain --direction both) + - ``openalex_id:...`` → refs→DOI (enriquecimiento automático en chain) + - cualquier otro → resultado vacío (cero candidatos backward, etc.) + + El estado ``cites_calls`` limita el mock: solo devuelve + ``_CITING_WORK`` en la primera llamada y vacío en las siguientes, para + evitar paginación infinita (el cursor ``next_cursor=None`` ya lo previene, + pero el contador es explícito). + """ + cites_calls: list[int] = [0] + + def _handler(request: httpx.Request) -> httpx.Response: + params = dict(request.url.params) + filter_val: str = params.get("filter", "") + results: list[dict[str, Any]] = [] + + if filter_val.startswith("doi:"): + # Resolución DOI→source_id: devolver pares {id, doi} para los + # DOIs de sample.bib presentes en el filtro. + for doi, short_id in _DOI_TO_OA_ID.items(): + if doi in filter_val: + results.append( + { + "id": f"https://openalex.org/{short_id}", + "doi": f"https://doi.org/{doi}", + } + ) + + elif filter_val.startswith("cites:"): + # Forward chain: devolver _CITING_WORK en la primera página; + # next_cursor=None ya corta la paginación, pero el contador + # garantiza que un bug de paginación no cuelgue el test. + cites_calls[0] += 1 + if cites_calls[0] == 1: + results = [_CITING_WORK] + + elif filter_val.startswith("openalex_id:"): + # refs→DOI: resolver IDs de referencias a sus DOIs. + for doi, short_id in _DOI_TO_OA_ID.items(): + if short_id in filter_val: + results.append( + { + "id": f"https://openalex.org/{short_id}", + "doi": f"https://doi.org/{doi}", + } + ) + + # Para cualquier otro filtro (p.ej. backward scent) devuelve vacío. + return httpx.Response( + 200, + json={ + "results": results, + "meta": { + "count": len(results), + "next_cursor": None, + }, + }, + ) + + return httpx.MockTransport(_handler) + + +# --------------------------------------------------------------------------- +# Helpers de invocación +# --------------------------------------------------------------------------- + + +def _status(runner: CliRunner, ws: Workspace) -> dict[str, Any]: + """Invoca ``b2g status --json`` y devuelve ``envelope["data"]``. + + Valida que stdout sea exactamente 1 línea JSON (vía + ``_assert_single_json_line``) para que un mensaje espurio en stdout + también haga fallar al test de status. + """ + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "status", "--json"], + ) + assert result.exit_code == 0, ( + f"status --json falló (exit {result.exit_code}):\n{result.stdout}" + ) + envelope = _assert_single_json_line(result.stdout, "status") + assert envelope["ok"] is True, f"status devolvió ok=False: {envelope}" + return envelope["data"] # type: ignore[no-any-return] + + +def _assert_single_json_line(stdout: str, step: str) -> dict[str, Any]: + """Afirma que stdout es exactamente 1 línea JSON parseable y la devuelve.""" + non_empty = [line for line in stdout.splitlines() if line.strip()] + assert len(non_empty) == 1, ( + f"[{step}] stdout debe ser 1 línea JSON; got {len(non_empty)} líneas:\n{stdout!r}" + ) + parsed: dict[str, Any] = json.loads(non_empty[0]) + return parsed + + +# --------------------------------------------------------------------------- +# Test capstone +# --------------------------------------------------------------------------- + + +def test_oneshot_readiness_cycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ciclo one-shot guiado por status --json — capstone E2E del epic #167. + + Afirma la "trampa de la Nota 20": sin ``--resolve``, el agente ve + ``readiness.ready=False`` con un ``reason`` que menciona ``--resolve``. + Tras resolver, el ciclo avanza: chain → build → read top con salida + JSON limpia en cada paso. + """ + # --- Monkeypatch: reemplazar OpenAlexSource con versión de mock -------- + # + # chain.py y service/resolve.py importan OpenAlexSource DENTRO del cuerpo + # de sus funciones, no a nivel de módulo. Al patchear el atributo en el + # objeto módulo, el ``from bib2graph.sources.openalex import OpenAlexSource`` + # que ejecuta en tiempo de llamada obtiene la clase mockeada. + import bib2graph.sources.openalex as _oa_mod + + _mock_transport = _make_e2e_transport() + _real_cls = _oa_mod.OpenAlexSource + + class _MockedOA(_real_cls): # type: ignore[valid-type] + """Subclase que siempre inyecta el transport de mock, ignorando el recibido.""" + + def __init__( # type: ignore[override] + self, + *, + email: str | None = None, + api_key: str | None = None, + transport: Any = None, + base_url: str = "https://api.openalex.org", + max_results: int = 200, + ) -> None: + super().__init__( # type: ignore[misc] + email=email, + api_key=api_key, + transport=_mock_transport, + base_url=base_url, + max_results=max_results, + ) + + monkeypatch.setattr(_oa_mod, "OpenAlexSource", _MockedOA) + + # --- Paso 0: b2g init (primer verbo del ciclo agents-first) ------------ + # + # Usamos CLI pura para que el capstone ejerza init→seed→…→read-top + # completo. TARGET es la ruta absoluta al directorio destino; --name fija + # el nombre de la investigación; --json valida la salida. + ws_dir = tmp_path / "research" + runner = CliRunner() + + result = runner.invoke( + b2g, + ["init", str(ws_dir), "--name", "test-e2e", "--json"], + ) + envelope = _assert_single_json_line(result.stdout, "init") + assert result.exit_code == 0, ( + f"init falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + assert "workspace_dir" in envelope["data"], ( + f"init --json debe devolver 'workspace_dir'; got keys: {list(envelope['data'].keys())}" + ) + assert "library_path" in envelope["data"], ( + "init --json debe devolver 'library_path'" + ) + + # Abrir el workspace creado para usar ws.root en los pasos siguientes + ws = Workspace.open(ws_dir) + + # Status tras init: next_best_action="seed", siempre ready=True + data = _status(runner, ws) + assert data["next_best_action"] == "seed", ( + f"Tras init se esperaba 'seed', got {data['next_best_action']!r}" + ) + assert data["readiness"]["ready"] is True, ( + "Antes de la primera siembra, readiness.ready debe ser True" + ) + + # --- Paso 1: seed --from-bib SIN --resolve (BibTeX, sin red) ---------- + assert _SAMPLE_BIB.exists(), f"Fixture no encontrado: {_SAMPLE_BIB}" + + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "seed", "--from-bib", str(_SAMPLE_BIB), "--json"], + ) + envelope = _assert_single_json_line(result.stdout, "seed --from-bib") + assert result.exit_code == 0, ( + f"seed --from-bib falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + assert envelope["data"]["papers_added"] > 0, ( + "sample.bib debe contener al menos 1 paper" + ) + + # --- Status tras seed sin resolve: Nota 20 ---------------------------- + # + # Sin --resolve, source_id=None para todos los papers BibTeX. + # El agente debe ver readiness.ready=False con reason mencionando --resolve. + data = _status(runner, ws) + assert data["next_best_action"] == "chain", ( + f"Tras seed, se esperaba next_best_action='chain', got {data['next_best_action']!r}" + ) + readiness = data["readiness"] + assert readiness["ready"] is False, ( + "Sin --resolve, readiness.ready debe ser False (0 seeds con source_id)" + ) + assert readiness["reason"] is not None, ( + "readiness.reason no debe ser None cuando ready=False" + ) + assert "--resolve" in readiness["reason"], ( + f"readiness.reason debe mencionar '--resolve'; got: {readiness['reason']!r}" + ) + + # --- Paso 2: seed --from-bib --resolve (DOI→source_id con mock) ------- + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "seed", + "--from-bib", + str(_SAMPLE_BIB), + "--resolve", + "--json", + ], + ) + envelope = _assert_single_json_line(result.stdout, "seed --from-bib --resolve") + assert result.exit_code == 0, ( + f"seed --from-bib --resolve falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + resolve_sub = envelope["data"].get("resolve") + assert resolve_sub is not None, ( + "Con resolve=True, data debe contener sub-dict 'resolve'" + ) + assert resolve_sub["resolved"] > 0, ( + "El mock debe resolver al menos 1 DOI a source_id" + ) + + # --- Status tras seed --resolve: chain READY -------------------------- + data = _status(runner, ws) + assert data["next_best_action"] == "chain", ( + f"Tras seed --resolve, se esperaba 'chain', got {data['next_best_action']!r}" + ) + assert data["readiness"]["ready"] is True, ( + f"Tras --resolve, readiness.ready debe ser True; got: {data['readiness']}" + ) + + # --- Paso 3: chain --direction both (forward con mock) ---------------- + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "chain", "--direction", "both", "--json"], + ) + envelope = _assert_single_json_line(result.stdout, "chain --direction both") + assert result.exit_code == 0, ( + f"chain falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + + # Verificar que el forward-chain realmente encontró candidatos (W8888000001). + # Si la rama ``cites:`` del mock devolviera vacío, candidates_found sería 0 + # y esta aserción fallaría antes de que el error se propague silenciosamente. + chain_result = envelope["data"] + assert chain_result["candidates_found"] > 0, ( + "El mock forward-chain (cites:W9000001|…) debe haber encontrado ≥1 " + f"candidato; got candidates_found={chain_result['candidates_found']}" + ) + + # --- Status tras chain: build, preview tiene ≥1 red vacía con fix ---- + data = _status(runner, ws) + assert data["next_best_action"] == "build", ( + f"Tras chain, se esperaba 'build', got {data['next_best_action']!r}" + ) + build_preview = data["build_preview"] + assert len(build_preview) == 5, ( + f"build_preview debe tener 5 entradas (una por red); got {len(build_preview)}" + ) + empty_with_fix = [ + e + for e in build_preview + if e.get("would_be_empty") and e.get("fix_command") is not None + ] + assert len(empty_with_fix) >= 1, ( + "build_preview debe tener ≥1 red vacía con fix_command no nulo. " + f"Preview: {json.dumps(build_preview, ensure_ascii=False)}" + ) + + # --- Paso 4: build (sin seeds aceptadas; no hay llamada a OA) --------- + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "build", "--json"], + ) + envelope = _assert_single_json_line(result.stdout, "build") + assert result.exit_code == 0, ( + f"build falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + + build_data = envelope["data"] + maturity = build_data.get("maturity") + assert maturity is not None, "build --json debe incluir campo 'maturity'" + assert maturity["curated"] is False, ( + "Sin curación previa, maturity.curated debe ser False" + ) + assert maturity["scope"] == "all", ( + f"maturity.scope debe ser 'all'; got {maturity['scope']!r}" + ) + assert maturity["saturated"] is False, ( + "maturity.saturated es siempre False en el ciclo one-shot" + ) + assert isinstance(maturity["empty_networks"], list), ( + "maturity.empty_networks debe ser una lista" + ) + + # --- Status tras build: read, ready ----------------------------------- + data = _status(runner, ws) + assert data["next_best_action"] == "read", ( + f"Tras build, se esperaba 'read', got {data['next_best_action']!r}" + ) + assert data["readiness"]["ready"] is True, ( + f"Tras build, readiness.ready debe ser True; got: {data['readiness']}" + ) + + # --- Paso 5: read top (salida de investigación) ----------------------- + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "top", "--json"], + ) + envelope = _assert_single_json_line(result.stdout, "read top") + assert result.exit_code == 0, ( + f"read top falló (exit {result.exit_code}):\n{result.stdout}" + ) + assert envelope["ok"] is True + + read_data = envelope["data"] + assert "central" in read_data, ( + f"read top --json debe incluir 'central'; got keys: {list(read_data.keys())}" + ) + assert "cocitation" in read_data, ( + f"read top --json debe incluir 'cocitation'; got keys: {list(read_data.keys())}" + ) + assert isinstance(read_data["central"], list), ( + "read_data['central'] debe ser una lista" + ) + assert isinstance(read_data["cocitation"], list), ( + "read_data['cocitation'] debe ser una lista" + ) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py deleted file mode 100644 index e16b2b5..0000000 --- a/tests/unit/test_api.py +++ /dev/null @@ -1,524 +0,0 @@ -"""Tests del Hito G3 — API local FastAPI (ADR 0028). - -Cobertura priorizada (docs/ROADMAP/05-gui.md §Hito G3): - -1. ``b2g gui`` exit 3 sin extra (monkeypatch ImportError fastapi/uvicorn). -2. Mapeo código-HTTP: un caso por código 0-5. -3. Token: sin token → 401; con token → 200. -4. Happy-path forma del envelope (``schema=="1"``, ``ok``, ``data``) por endpoint. -5. Write: POST curate → 200 y get_paper refleja el cambio; id inexistente → 422; - decision inválida → 422. - -No se testea concurrencia del lock ni uvicorn/browser (plumbing). -Marcador: ``unit`` (DuckDB en tmp_path, TestClient en memoria, sin red real). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pyarrow as pa -import pytest - -from bib2graph.schemas import CORPUS_SCHEMA - -pytestmark = pytest.mark.unit - -# --------------------------------------------------------------------------- -# Helpers compartidos (mismo patrón que test_service_reads.py) -# --------------------------------------------------------------------------- - - -def _row( - *, - id: str, - title: str = "Test title", - year: int = 2020, - is_seed: bool = True, - curation_status: str = "candidate", - references_id: list[str] | None = None, - cited_by_id: list[str] | None = None, -) -> dict[str, Any]: - """Fila mínima con schema completo para tests.""" - return { - "id": id, - "openalex_id": None, - "doi": None, - "title": title, - "year": year, - "abstract": None, - "source": None, - "language": "en", - "publisher": None, - "research_areas": None, - "is_seed": is_seed, - "curation_status": curation_status, - "provenance": None, - "authors_raw": ["Autor A"], - "authors_id": ["oa:author1"], - "authors_affiliations": None, - "keywords_raw": ["keyword1"], - "keywords_id": ["kw1"], - "institutions_raw": None, - "institutions_id": None, - "references_id": references_id, - "references_doi": None, - "cited_by_id": cited_by_id, - } - - -def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: - """Crea y devuelve un Workspace inicializado en tmp_path.""" - from bib2graph.workspace import Workspace - - ws_dir = tmp_path / name - return Workspace.init(ws_dir, name) - - -def _seed_store(ws: Any, rows: list[dict[str, Any]]) -> None: - """Persiste filas en el store del workspace.""" - from bib2graph.corpus import Corpus - from bib2graph.stores.duckdb import DuckDBStore - - table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) - corpus = Corpus.from_arrow(table) - store = DuckDBStore(ws.library_path) - store.persist(corpus) - - -def _make_test_client(ws: Any, token: str) -> Any: - """Construye un TestClient con la app configurada.""" - from fastapi.testclient import TestClient - - from bib2graph.api.app import create_app - - app = create_app(ws, token=token, cors_origins=["http://localhost:3000"]) - return TestClient(app, raise_server_exceptions=False) - - -# Token de prueba fijo para los tests -_TEST_TOKEN = "token-de-prueba-para-tests-12345" -_AUTH_HEADER = {"Authorization": f"Bearer {_TEST_TOKEN}"} - - -# --------------------------------------------------------------------------- -# 1. b2g gui — exit 3 sin extra (monkeypatch ImportError) -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_b2g_gui_exit_3_sin_extra(tmp_path: Path) -> None: - """``b2g gui`` lanza DependencyError (exit 3) si fastapi/uvicorn no están instalados.""" - from bib2graph.cli._errors import DependencyError - from bib2graph.cli.commands.gui import run_gui - - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - ctx_obj: dict[str, object] = {"workspace": str(ws.root)} - - with ( - patch("bib2graph.cli.commands.gui.resolve_workspace", return_value=ws), - patch.dict( - "sys.modules", - {"fastapi": None, "uvicorn": None}, # type: ignore[dict-item] - ), - pytest.raises(DependencyError, match="gui"), - ): - run_gui(workspace_ctx=ctx_obj) - - -# --------------------------------------------------------------------------- -# 2. Mapeo código-HTTP - un caso por código 0-5 -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_http_status_for_mapeo_completo() -> None: - """``http_status_for`` devuelve el status correcto para cada código 0-5.""" - from bib2graph.api.envelopes import http_status_for - - assert http_status_for(0) == 200 - assert http_status_for(1) == 400 - assert http_status_for(2) == 422 - assert http_status_for(3) == 501 - assert http_status_for(4) == 502 - assert http_status_for(5) == 409 - # No mapeado → 500 - assert http_status_for(99) == 500 - - -@pytest.mark.unit -def test_make_error_response_b2gerror_codigo_y_status(tmp_path: Path) -> None: - """``make_error_response`` construye el envelope correcto con el status esperado.""" - from bib2graph.api.envelopes import make_error_response - from bib2graph.service.errors import DataError, StoreError, UsageError - - # UsageError → code 1 → HTTP 400 - resp = make_error_response("test_cmd", UsageError("uso incorrecto")) - assert resp.status_code == 400 - body = resp.body - import json - - data = json.loads(body) - assert data["ok"] is False - assert data["exit_code"] == 1 - assert data["error"]["code"] == "USAGE_ERROR" - - # StoreError → code 5 → HTTP 409 - resp5 = make_error_response("test_cmd", StoreError("bloqueado")) - assert resp5.status_code == 409 - data5 = json.loads(resp5.body) - assert data5["exit_code"] == 5 - - # DataError → code 2 → HTTP 422 - resp2 = make_error_response("test_cmd", DataError("no existe")) - assert resp2.status_code == 422 - data2 = json.loads(resp2.body) - assert data2["exit_code"] == 2 - - -@pytest.mark.unit -def test_make_error_response_excepcion_inesperada_es_500() -> None: - """Una excepción NO de contrato (bug interno) → HTTP 500, no 409. - - Regresión: el fallback enmascaraba errores internos como 409 (store - bloqueado), mintiéndole a la SPA. Un bug inesperado debe ser 500. - """ - import json - - from bib2graph.api.envelopes import make_error_response - - resp = make_error_response("test_cmd", ValueError("boom inesperado")) - assert resp.status_code == 500 - data = json.loads(resp.body) - assert data["ok"] is False - assert data["error"]["code"] == "INTERNAL_ERROR" - - -# --------------------------------------------------------------------------- -# 3. Token — sin token → 401; con token → 200 -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_api_sin_token_rechaza_401(tmp_path: Path) -> None: - """GET /api/workspace sin token → 401.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/workspace") - assert resp.status_code == 401 - - -@pytest.mark.unit -def test_api_token_invalido_rechaza_401(tmp_path: Path) -> None: - """GET /api/workspace con token incorrecto → 401.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get( - "/api/workspace", headers={"Authorization": "Bearer token-incorrecto"} - ) - assert resp.status_code == 401 - - -@pytest.mark.unit -def test_api_con_token_valido_acepta_200(tmp_path: Path) -> None: - """GET /api/workspace con token correcto → 200.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/workspace", headers=_AUTH_HEADER) - assert resp.status_code == 200 - - -# --------------------------------------------------------------------------- -# 4. Happy-path — forma del envelope por endpoint -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_get_workspace_forma_envelope(tmp_path: Path) -> None: - """GET /api/workspace devuelve envelope canónico con clave 'name'.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1"), _row(id="P2")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/workspace", headers=_AUTH_HEADER) - - assert resp.status_code == 200 - body = resp.json() - assert body["schema"] == "1" - assert body["ok"] is True - assert body["error"] is None - assert "name" in body["data"] - assert body["data"]["total_papers"] == 2 - - -@pytest.mark.unit -def test_list_rounds_forma_envelope(tmp_path: Path) -> None: - """GET /api/rounds devuelve envelope con clave 'rounds' (lista).""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/rounds", headers=_AUTH_HEADER) - - assert resp.status_code == 200 - body = resp.json() - assert body["schema"] == "1" - assert body["ok"] is True - assert isinstance(body["data"]["rounds"], list) - # Debe incluir la entrada "live" - live_entries = [r for r in body["data"]["rounds"] if r["id"] == "live"] - assert len(live_entries) == 1 - - -@pytest.mark.unit -def test_get_paper_forma_envelope(tmp_path: Path) -> None: - """GET /api/paper/{id} devuelve envelope con los campos del paper.""" - ws = _init_workspace(tmp_path) - _seed_store( - ws, - [ - _row( - id="P1", - title="Paper de prueba", - year=2023, - curation_status="candidate", - ) - ], - ) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/paper/P1", headers=_AUTH_HEADER) - - assert resp.status_code == 200 - body = resp.json() - assert body["ok"] is True - assert body["data"]["id"] == "P1" - assert body["data"]["title"] == "Paper de prueba" - - -@pytest.mark.unit -def test_get_paper_inexistente_422(tmp_path: Path) -> None: - """GET /api/paper/{id} con id inexistente → 422 (DataError).""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/paper/no-existe", headers=_AUTH_HEADER) - - assert resp.status_code == 422 - body = resp.json() - assert body["ok"] is False - assert body["exit_code"] == 2 - - -@pytest.mark.unit -def test_get_network_kind_invalido_422(tmp_path: Path) -> None: - """GET /api/network/{kind} con kind inválido → 422 (DataError).""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/network/red_inventada", headers=_AUTH_HEADER) - - assert resp.status_code == 422 - body = resp.json() - assert body["ok"] is False - assert body["exit_code"] == 2 - - -@pytest.mark.unit -def test_get_scent_forma_envelope(tmp_path: Path) -> None: - """GET /api/paper/{id}/scent devuelve envelope con claves de scent.""" - ws = _init_workspace(tmp_path) - _seed_store( - ws, - [ - _row(id="P1", references_id=["R1", "R2"]), - _row(id="P2", references_id=["R1", "R3"]), - ], - ) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.get("/api/paper/P1/scent", headers=_AUTH_HEADER) - - assert resp.status_code == 200 - body = resp.json() - assert body["ok"] is True - data = body["data"] - assert "paper_id" in data - assert "score" in data - assert "coupling" in data - - -# --------------------------------------------------------------------------- -# 5. Write — POST curate -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_post_curate_accepted_200(tmp_path: Path) -> None: - """POST /api/paper/{id}/curate {decision:'accepted'} → 200.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1", curation_status="candidate")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.post( - "/api/paper/P1/curate", - json={"decision": "accepted"}, - headers=_AUTH_HEADER, - ) - - assert resp.status_code == 200 - body = resp.json() - assert body["ok"] is True - assert body["data"]["accepted_count"] == 1 - - -@pytest.mark.unit -def test_post_curate_y_get_paper_refleja_cambio(tmp_path: Path) -> None: - """POST curate accepted + GET paper → curation_status == 'accepted'.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1", curation_status="candidate")]) - - client = _make_test_client(ws, _TEST_TOKEN) - client.post( - "/api/paper/P1/curate", - json={"decision": "accepted"}, - headers=_AUTH_HEADER, - ) - - resp = client.get("/api/paper/P1", headers=_AUTH_HEADER) - assert resp.status_code == 200 - assert resp.json()["data"]["curation_status"] == "accepted" - - -@pytest.mark.unit -def test_post_curate_id_inexistente_422(tmp_path: Path) -> None: - """POST curate con id inexistente → 422.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.post( - "/api/paper/id-que-no-existe/curate", - json={"decision": "accepted"}, - headers=_AUTH_HEADER, - ) - - assert resp.status_code == 422 - body = resp.json() - assert body["ok"] is False - assert body["exit_code"] == 2 - - -@pytest.mark.unit -def test_post_curate_decision_invalida_422(tmp_path: Path) -> None: - """POST curate con decision inválida → 422.""" - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = _make_test_client(ws, _TEST_TOKEN) - resp = client.post( - "/api/paper/P1/curate", - json={"decision": "decision_invalida"}, - headers=_AUTH_HEADER, - ) - - assert resp.status_code == 422 - body = resp.json() - assert body["ok"] is False - assert body["exit_code"] == 2 - - -# --------------------------------------------------------------------------- -# 6. Neutralidad — el núcleo no importa fastapi -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_nucleo_no_importa_fastapi() -> None: - """Importar bib2graph.service no requiere fastapi instalado.""" - import ast - import importlib - import pathlib - - # Verificar que service/__init__.py no importa fastapi en top-level - mod = importlib.import_module("bib2graph.service") - source_file = mod.__file__ - assert source_file is not None - tree = ast.parse(pathlib.Path(source_file).read_text(encoding="utf-8")) - - forbidden = {"fastapi"} - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - assert alias.name.split(".")[0] not in forbidden, ( - f"service importa '{alias.name}' — viola neutralidad" - ) - elif isinstance(node, ast.ImportFrom): - assert (node.module or "").split(".")[0] not in forbidden, ( - f"service importa de '{node.module}' — viola neutralidad" - ) - - -# --------------------------------------------------------------------------- -# 6. Wiring del frontend (build_gui_app) — REGRESIÓN GET / 422 -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_build_gui_app_sirve_index_con_token(tmp_path: Path) -> None: - """REGRESIÓN: GET / sirve index.html con el token inyectado (no 422). - - Bug real (caught en runtime, no por el verifier que reconstruía la app): - ``serve_index(_request: Request)`` bajo ``from __future__ import annotations`` - hacía que FastAPI tratara ``_request`` como query param requerido → 422. - Este test ejercita el wiring REAL ``build_gui_app``, no una app reconstruida. - """ - from fastapi.testclient import TestClient - - from bib2graph.cli.commands.gui import build_gui_app - - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - static_dir = tmp_path / "static" - static_dir.mkdir() - (static_dir / "index.html").write_text( - '', encoding="utf-8" - ) - - client = TestClient(build_gui_app(ws, "TESTTOK", static_dir)) - - resp = client.get("/") - assert resp.status_code == 200 - assert "TESTTOK" in resp.text - assert "__B2G_TOKEN__" not in resp.text # placeholder reemplazado - # El wiring del static NO rompe la auth de los routers /api/* - assert client.get("/api/workspace").status_code == 401 - - -@pytest.mark.unit -def test_build_gui_app_sin_static_solo_api(tmp_path: Path) -> None: - """Sin frontend buildeado (static_dir=None): GET / → 404; la API sigue montada.""" - from fastapi.testclient import TestClient - - from bib2graph.cli.commands.gui import build_gui_app - - ws = _init_workspace(tmp_path) - _seed_store(ws, [_row(id="P1")]) - - client = TestClient(build_gui_app(ws, "TESTTOK", None)) - - assert client.get("/").status_code == 404 - assert client.get("/api/workspace").status_code == 401 diff --git a/tests/unit/test_backward_no_placeholders.py b/tests/unit/test_backward_no_placeholders.py index b8341b1..4a641be 100644 --- a/tests/unit/test_backward_no_placeholders.py +++ b/tests/unit/test_backward_no_placeholders.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Any +import httpx import pyarrow as pa import pytest @@ -31,6 +32,17 @@ from bib2graph.foraging.forager import Forager from bib2graph.schemas import CORPUS_SCHEMA +# Transport nulo para tests de backward chain: chain.py ejecuta la pasada +# refs→DOI automáticamente (ADR 0038); los tests que sólo verifican la lógica +# backward necesitan un transport que devuelva resultados vacíos sin red real. +_NOOP_TRANSPORT = httpx.MockTransport( + lambda _req: httpx.Response( + 200, + json={"results": [], "meta": {"count": 0, "next_cursor": None}}, + headers={"x-openalex-api-version": "2026-05-01"}, + ) +) + pytestmark = pytest.mark.unit # --------------------------------------------------------------------------- @@ -401,7 +413,7 @@ def test_corpus_no_crece_con_backward(self, tmp_path: Path) -> None: n_before = len(store_before.load()) store_before.close() - run_chain(store_path, direction="backward") + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) store_after = DuckDBStore(store_path) corpus_after = store_after.load() @@ -422,7 +434,7 @@ def test_sin_titulo_placeholder_tras_chain_backward(self, tmp_path: Path) -> Non store_path = tmp_path / "lib.duckdb" self._seed_store(store_path, ["REF_A"]) - run_chain(store_path, direction="backward") + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) store = DuckDBStore(store_path) rows = store.load().to_arrow().to_pylist() @@ -442,7 +454,7 @@ def test_referenced_but_not_fetched_tiene_ids(self, tmp_path: Path) -> None: store_path = tmp_path / "lib.duckdb" self._seed_store(store_path, ["REF_A", "REF_B"]) - run_chain(store_path, direction="backward") + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) backend = DuckDBBackend(path=store_path) refs = set(backend.referenced_refs()) @@ -457,7 +469,7 @@ def test_observed_refs_count_en_resultado(self, tmp_path: Path) -> None: store_path = tmp_path / "lib.duckdb" self._seed_store(store_path, ["REF_A", "REF_B"]) - result = run_chain(store_path, direction="backward") + result = run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) assert "observed_refs_count" in result assert result["observed_refs_count"] == 2 @@ -470,8 +482,8 @@ def test_chain_idempotente_referenced(self, tmp_path: Path) -> None: store_path = tmp_path / "lib.duckdb" self._seed_store(store_path, ["REF_A", "REF_B"]) - run_chain(store_path, direction="backward") - run_chain(store_path, direction="backward") + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) backend = DuckDBBackend(path=store_path) count = backend.referenced_refs_count() @@ -507,7 +519,7 @@ def test_status_campo_referenced_not_fetched(self, tmp_path: Path) -> None: store.backend.set_loop_state(CycleState.SEEDED, cycle_round=1) store.close() - run_chain(store_path, direction="backward") + run_chain(store_path, direction="backward", transport=_NOOP_TRANSPORT) data = run_status(store_path) diff --git a/tests/unit/test_build_absorber_networks.py b/tests/unit/test_build_absorber_networks.py new file mode 100644 index 0000000..a7a818f --- /dev/null +++ b/tests/unit/test_build_absorber_networks.py @@ -0,0 +1,1099 @@ +"""Tests TDD — ``b2g build`` absorbe capacidad de ``b2g networks`` (#159). + +Cubre las decisiones y contratos del sub-issue #159 (ADR 0038): + +1. **Paridad**: ``build --spec YAML`` produce los mismos artefactos que + ``networks --spec`` para el mismo YAML. +2. **D1**: ``build --spec`` transiciona FSM a BUILT y sella ``.corpus_hash`` + (comportamiento deliberadamente distinto a ``networks``). +3. **Scopes**: ``--scope all|accepted|seeds`` filtran el corpus y sellan el hash + del corpus filtrado. +4. **Alias deprecado**: ``--corpus-scope seeds_only`` sigue funcionando y avisa + deprecación a stderr; no contamina stdout en ``--json``. +5. **min-weight**: aristas con peso < N se filtran; red vacía → warning específico. +6. **No-divergencia** (ADR 0037 §(e)): corpus sin ``keywords_id`` → ``build`` + exit 0, warning ``reason``/``fix_command`` coincide con ``predict_build_preview``. +7. **--json**: warning solo en envelope, stdout exactamente una línea JSON, + ``data.empty_networks`` presente cuando aplica, ``schema="1"``. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest + +from bib2graph.constants import NetworkKind +from bib2graph.corpus import Corpus +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers de fixture +# --------------------------------------------------------------------------- + + +def _row( + id: str, + *, + is_seed: bool = True, + curation_status: str = "candidate", + references_id: list[str] | None = None, + authors_id: list[str] | None = None, + institutions_id: list[str] | None = None, + keywords_id: list[str] | None = None, + keywords_raw: list[str] | None = None, + cited_by_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima con schema canónico completo.""" + return { + "id": id, + "source_id": None, + "doi": None, + "title": f"Paper {id}", + "year": 2020, + "abstract": None, + "source": None, + "language": None, + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": authors_id, + "authors_affiliations": None, + "keywords_raw": keywords_raw, + "keywords_id": keywords_id, + "institutions_raw": None, + "institutions_id": institutions_id, + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _make_corpus(*rows: dict[str, Any]) -> Corpus: + """Construye un Corpus en memoria desde filas dict.""" + table = pa.Table.from_pylist(list(rows), schema=CORPUS_SCHEMA) + return Corpus.from_arrow(table) + + +def _seed_store(store_path: Path, rows: list[dict[str, Any]]) -> None: + """Persiste un conjunto de filas en un DuckDB temporal.""" + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + store.close() + + +def _rows_con_referencias() -> list[dict[str, Any]]: + """3 papers con referencias compartidas → produce aristas de coupling.""" + return [ + _row( + f"P{i}", + references_id=[f"REF_{i}", "REF_SHARED"], + authors_id=[f"AUTH_{i}", "AUTH_0"], + keywords_id=[f"KW_{i}", "KW_SHARED"], + institutions_id=[f"INST_{i}"], + ) + for i in range(3) + ] + + +def _write_spec(path: Path, kinds: list[str]) -> None: + """Escribe un YAML de specs para los kinds dados.""" + lines = ["networks:"] + for k in kinds: + lines.append(f" - kind: {k}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# 1. Paridad: build --spec ≡ networks --spec +# --------------------------------------------------------------------------- + + +class TestParidadBuildVsNetworks: + """build --spec y networks --spec deben producir los mismos artefactos.""" + + def test_paridad_artefactos_mismos_nodos_aristas(self, tmp_path: Path) -> None: + """build --spec y networks --spec con el mismo YAML → mismos nodos/aristas.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.cli.commands.networks import run_networks + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + out_build = tmp_path / "out_build" + out_nets = tmp_path / "out_nets" + + data_build = run_build(store_path, out_dir=out_build, spec_path=spec_file) + data_nets = run_networks(store_path, spec_file, out_dir=out_nets) + + # Misma cantidad de redes + assert data_build["networks_built"] == data_nets["networks_built"] + + # Mismos nodos y aristas por red (mismo kind) + build_by_kind = {n["kind"]: n for n in data_build["networks"]} + nets_by_kind = {n["kind"]: n for n in data_nets["networks"]} + + for kind in build_by_kind: + assert kind in nets_by_kind, f"kind '{kind}' falta en networks" + assert build_by_kind[kind]["nodes"] == nets_by_kind[kind]["nodes"], ( + f"kind={kind}: nodos divergen" + ) + assert build_by_kind[kind]["edges"] == nets_by_kind[kind]["edges"], ( + f"kind={kind}: aristas divergen" + ) + + def test_paridad_multiples_redes(self, tmp_path: Path) -> None: + """Con 2 redes en el YAML, build --spec y networks --spec coinciden.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.cli.commands.networks import run_networks + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling", "keyword_cooccurrence"]) + + data_build = run_build(store_path, out_dir=tmp_path / "b", spec_path=spec_file) + data_nets = run_networks(store_path, spec_file, out_dir=tmp_path / "n") + + assert data_build["networks_built"] == data_nets["networks_built"] == 2 + build_kinds = {n["kind"] for n in data_build["networks"]} + nets_kinds = {n["kind"] for n in data_nets["networks"]} + assert build_kinds == nets_kinds + + +# --------------------------------------------------------------------------- +# 2. D1: build --spec transiciona FSM a BUILT y sella corpus_hash +# --------------------------------------------------------------------------- + + +class TestD1BuildSpecTransiciona: + """D1 (ADR 0038): build --spec DEBE transicionar a BUILT y sellar hash.""" + + def test_build_spec_transiciona_a_built(self, tmp_path: Path) -> None: + """build --spec cambia el CycleState a BUILT.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + # Estado antes: None (store recién creado) + store_before = DuckDBStore(store_path) + state_before = store_before.backend.loop_state() + store_before.close() + assert state_before is None + + run_build(store_path, out_dir=tmp_path / "nets", spec_path=spec_file) + + # Estado después: BUILT + store_after = DuckDBStore(store_path) + state_after = store_after.backend.loop_state() + store_after.close() + assert state_after == CycleState.BUILT + + def test_build_spec_sella_corpus_hash(self, tmp_path: Path) -> None: + """build --spec escribe .corpus_hash en el directorio de redes.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + out_dir = tmp_path / "nets" + data = run_build(store_path, out_dir=out_dir, spec_path=spec_file) + + hash_file = out_dir / ".corpus_hash" + assert hash_file.exists(), ".corpus_hash no fue creado" + assert hash_file.read_text(encoding="utf-8") == data["corpus_hash"] + assert len(data["corpus_hash"]) > 0 + + def test_networks_no_transiciona_fsm(self, tmp_path: Path) -> None: + """networks --spec NO transiciona el FSM (contraste con D1).""" + from bib2graph.cli.commands.networks import run_networks + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + store_before = DuckDBStore(store_path) + state_before = store_before.backend.loop_state() + store_before.close() + + run_networks(store_path, spec_file, out_dir=tmp_path / "nets") + + store_after = DuckDBStore(store_path) + state_after = store_after.backend.loop_state() + store_after.close() + + # networks NO debe cambiar el estado + assert state_after == state_before + + +# --------------------------------------------------------------------------- +# 3. Scopes --scope all|accepted|seeds +# --------------------------------------------------------------------------- + + +class TestScopes: + """Los 3 scopes filtran el corpus y sellan el hash del filtrado.""" + + def _setup_corpus(self, store_path: Path) -> None: + """P1=seed, P2=accepted no-seed, P3=candidate no-seed.""" + _seed_store( + store_path, + [ + _row( + "P1", + is_seed=True, + curation_status="candidate", + references_id=["R1", "R2"], + keywords_id=["k1", "k2"], + ), + _row( + "P2", + is_seed=False, + curation_status="accepted", + references_id=["R1", "R3"], + keywords_id=["k1", "k3"], + ), + _row( + "P3", + is_seed=False, + curation_status="candidate", + references_id=["R2", "R3"], + keywords_id=["k2", "k3"], + ), + ], + ) + + def test_scope_all_usa_corpus_completo(self, tmp_path: Path) -> None: + """--scope all (default) construye sobre los 3 papers.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + self._setup_corpus(store_path) + + data = run_build(store_path, out_dir=tmp_path / "nets", corpus_scope="all") + + assert data["corpus_scope"] == "all" + assert data["scope"] == "all" + # Hay 3 papers con keywords_id → kw_cooccurrence debería tener nodos + kw_net = next( + (n for n in data["networks"] if n["kind"] == "keyword_cooccurrence"), None + ) + assert kw_net is not None + assert kw_net["nodes"] == 3, "scope=all debe incluir los 3 papers" + + def test_scope_accepted_filtra_seeds_y_aceptados(self, tmp_path: Path) -> None: + """--scope accepted excluye candidatos no-seed (P3).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + self._setup_corpus(store_path) + + data = run_build(store_path, out_dir=tmp_path / "nets", corpus_scope="accepted") + + assert data["corpus_scope"] == "accepted" + # Keyword net: nodos son keywords (k1,k2,k3 únicos en P1+P2+P3). + # Con scope=accepted solo P1(k1,k2) y P2(k1,k3) → se excluye P3(k2,k3). + # La diferencia observable es en ARISTAS: P3 aporta la arista (k2,k3); + # sin P3, quedan solo 2 aristas: (k1,k2) de P1 y (k1,k3) de P2. + kw_net = next( + (n for n in data["networks"] if n["kind"] == "keyword_cooccurrence"), None + ) + assert kw_net is not None + assert kw_net["edges"] == 2, ( + "scope=accepted excluye P3 (candidate): debe tener 2 aristas, no 3" + ) + + def test_scope_seeds_filtra_solo_semillas(self, tmp_path: Path) -> None: + """--scope seeds deja solo P1 (is_seed=True).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + self._setup_corpus(store_path) + + data = run_build( + store_path, out_dir=tmp_path / "nets", corpus_scope="seeds_only" + ) + + assert data["corpus_scope"] == "seeds_only" + # Keyword net: solo P1 → 2 nodos de keywords (k1, k2 en combinaciones) + kw_net = next( + (n for n in data["networks"] if n["kind"] == "keyword_cooccurrence"), None + ) + assert kw_net is not None + # P1 tiene kw_id=[k1, k2] → 2 nodos de keyword con 1 arista + assert kw_net["nodes"] == 2 + + def test_scope_sella_hash_del_corpus_filtrado(self, tmp_path: Path) -> None: + """El .corpus_hash sellado es el hash del subset filtrado, no del completo.""" + from bib2graph.backends.memory import compute_corpus_hash + from bib2graph.cli.commands.build import run_build + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + self._setup_corpus(store_path) + + out_dir = tmp_path / "nets" + data = run_build(store_path, out_dir=out_dir, corpus_scope="accepted") + + # Calcular el hash esperado del subset accepted + store = DuckDBStore(store_path) + corpus_full = store.load() + subset = corpus_full.scoped("accepted") + store.close() + + expected_hash = compute_corpus_hash(subset.to_arrow()) + assert data["corpus_hash"] == expected_hash + + hash_file = out_dir / ".corpus_hash" + assert hash_file.read_text(encoding="utf-8") == expected_hash + + def test_map_scope_seeds_a_seeds_only(self) -> None: + """_map_scope mapea 'seeds' → 'seeds_only'; otros valores pasan sin cambio.""" + from bib2graph.cli.commands.build import _map_scope + + assert _map_scope("seeds") == "seeds_only" + assert _map_scope("all") == "all" + assert _map_scope("accepted") == "accepted" + + +# --------------------------------------------------------------------------- +# 4. Alias deprecado: --corpus-scope +# --------------------------------------------------------------------------- + + +class TestAliasDeprecado: + """--corpus-scope funciona con aviso de deprecación; no contamina stdout en --json.""" + + def test_alias_deprecado_cli_avisa_a_stderr(self, tmp_path: Path) -> None: + """--corpus-scope emite aviso de deprecación a stderr.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--corpus-scope", + "seeds_only", + ], + ) + + assert result.exit_code == 0, f"Salida inesperada: {result.output}" + assert "deprecad" in result.stderr.lower(), "Debe avisar deprecación en stderr" + + def test_alias_deprecado_no_contamina_stdout_json(self, tmp_path: Path) -> None: + """--corpus-scope con --json: el aviso va a stderr, stdout es JSON puro.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--corpus-scope", + "seeds_only", + "--json", + ], + ) + + assert result.exit_code == 0, f"Error: {result.output}" + + # stdout (result.stdout) debe ser exactamente 1 línea JSON válida. + # Click 8.4.1: result.stdout = stdout puro; result.stderr = stderr puro. + stdout_lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(stdout_lines) == 1, ( + f"stdout debe tener exactamente 1 línea JSON, tiene {len(stdout_lines)}: " + f"{result.stdout!r}" + ) + envelope = json.loads(stdout_lines[0]) + assert envelope["schema"] == "1" + + # El aviso de deprecación va a stderr, no a stdout + assert "deprecad" in result.stderr.lower() + + def test_alias_deprecado_seeds_only_funciona(self, tmp_path: Path) -> None: + """--corpus-scope seeds_only filtra correctamente (backward compat).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # 2 seeds + 1 non-seed + _seed_store( + store_path, + [ + _row("S1", is_seed=True, keywords_id=["k1", "k2"]), + _row("S2", is_seed=True, keywords_id=["k1", "k3"]), + _row("C1", is_seed=False, keywords_id=["k2", "k4"]), + ], + ) + + data = run_build( + store_path, out_dir=tmp_path / "nets", corpus_scope="seeds_only" + ) + + assert data["corpus_scope"] == "seeds_only" + kw_net = next( + (n for n in data["networks"] if n["kind"] == "keyword_cooccurrence"), None + ) + # Solo seeds (S1, S2) → keyword nodes de k1, k2, k3 (3 nodos, no k4) + assert kw_net is not None + assert kw_net["nodes"] <= 3, "Solo keywords de seeds deben aparecer" + + +# --------------------------------------------------------------------------- +# 5. --min-weight: filtrado de aristas + warning específico +# --------------------------------------------------------------------------- + + +class TestMinWeight: + """--min-weight N filtra aristas; red vacía → warning específico de min_weight.""" + + def test_min_weight_1_default_sin_filtro(self, tmp_path: Path) -> None: + """min_weight=1 (default) no filtra nada.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + data_mw1 = run_build(store_path, out_dir=tmp_path / "nets1", min_weight=1) + data_default = run_build(store_path, out_dir=tmp_path / "nets2") + + # Con min_weight=1 debe ser idéntico al default + assert data_mw1["networks_built"] == data_default["networks_built"] + + def test_min_weight_alto_produce_redes_vacias(self, tmp_path: Path) -> None: + """min_weight=999 filtra todas las aristas → redes vacías con warning.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # Corpus con referencias compartidas (peso 1 por par) + _seed_store(store_path, _rows_con_referencias()) + + data = run_build(store_path, out_dir=tmp_path / "nets", min_weight=999) + + # Debe haber redes vacías + assert any( + en.get("reason") and "999" in str(en["reason"]) + for en in data["empty_networks"] + ), ( + f"Esperaba warning de min_weight=999, empty_networks={data['empty_networks']}" + ) + + def test_min_weight_warning_reason_contiene_umbral(self, tmp_path: Path) -> None: + """El reason del warning de min_weight menciona el umbral N.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + data = run_build(store_path, out_dir=tmp_path / "nets", min_weight=50) + + min_weight_warnings = [ + en for en in data["empty_networks"] if "50" in str(en.get("reason", "")) + ] + # Debe haber al menos 1 red vacía con el umbral mencionado + assert len(min_weight_warnings) >= 1, ( + f"Esperaba warning de min_weight=50, " + f"empty_networks={data['empty_networks']}" + ) + + def test_min_weight_quick_extiende_networkspec(self, tmp_path: Path) -> None: + """Networks.quick con min_weight pasa el valor a NetworkSpec.""" + from bib2graph.networks.facade import Networks + + corpus = _make_corpus(*_rows_con_referencias()) + # min_weight=999 → todas las aristas filtradas + artifacts_999 = Networks.quick(corpus, min_weight=999) + # Verificar que el spec tiene min_weight=999 + for art in artifacts_999: + assert art.spec.min_weight == 999 + + def test_min_weight_fix_command_baja_umbral(self, tmp_path: Path) -> None: + """El fix_command sugiere un umbral menor (min_weight - 1).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + data = run_build(store_path, out_dir=tmp_path / "nets", min_weight=10) + + for en in data["empty_networks"]: + if "10" in str(en.get("reason", "")): + assert en["fix_command"] is not None + assert "9" in str(en["fix_command"]), ( + f"fix_command debe sugerir min-weight=9, got: {en['fix_command']}" + ) + break + + +# --------------------------------------------------------------------------- +# 6. No-divergencia: build-time ≡ status-time (ADR 0037 §(e)) +# --------------------------------------------------------------------------- + + +class TestNoDivergencia: + """La razón/fix de redes vacías en build-time coincide con predict_build_preview. + + NOTA sobre alcance de la no-divergencia (opcional, ADR 0037 §(e)): + La paridad build-vs-status es *por-corpus*. Cuando se usa ``--scope != all``, + ``run_build`` computa ``predict_build_preview`` sobre el corpus YA FILTRADO. + Esto es correcto: si status se llama sobre el corpus completo y build se llama + sobre un subset (accepted/seeds), los conteos en los ``reason`` pueden diferir + legítimamente. La invariante garantizada es que para el MISMO corpus, build y + status reportan el mismo reason/fix_command — no que coincidan cross-scope. + """ + + def test_keyword_warning_coincide_con_preview(self, tmp_path: Path) -> None: + """Corpus sin keywords_id → warning reason/fix_command == predict_build_preview.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.networks.facade import predict_build_preview + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + # BibTeX sin --resolve: keywords_raw poblado, keywords_id vacío + rows = [ + _row(f"P{i}", keywords_raw=["ecology", "diversity"]) for i in range(1, 5) + ] + _seed_store(store_path, rows) + + data = run_build(store_path, out_dir=tmp_path / "nets") + + # Obtener preview (como lo haría status) + store2 = DuckDBStore(store_path) + corpus2 = store2.load() + preview = predict_build_preview(corpus2) + store2.close() + + kw_preview = next( + e for e in preview if e["kind"] == str(NetworkKind.KEYWORD_COOCCURRENCE) + ) + + # Encontrar la entrada de empty_networks para keyword_cooccurrence + kw_empty = next( + ( + e + for e in data["empty_networks"] + if e["kind"] == str(NetworkKind.KEYWORD_COOCCURRENCE) + ), + None, + ) + + assert kw_empty is not None, ( + "keyword_cooccurrence debe estar en empty_networks " + f"(corpus sin keywords_id); empty_networks={data['empty_networks']}" + ) + assert kw_empty["reason"] == kw_preview["reason"], ( + f"build-time reason != status-time reason: " + f"'{kw_empty['reason']}' != '{kw_preview['reason']}'" + ) + assert kw_empty["fix_command"] == kw_preview["fix_command"], ( + f"build-time fix_command != status-time fix_command: " + f"'{kw_empty['fix_command']}' != '{kw_preview['fix_command']}'" + ) + + def test_build_exit_0_con_redes_vacias(self, tmp_path: Path) -> None: + """build con redes vacías termina con exit 0 (no error), warnings en data.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # Sin ningún _id: todas las redes vacías + rows = [_row(f"P{i}") for i in range(1, 4)] + _seed_store(store_path, rows) + + # No debe lanzar excepción + data = run_build(store_path, out_dir=tmp_path / "nets") + + # networks_built > 0 (quick sí construye redes, aunque vacías) + assert data["networks_built"] >= 1 + # Pero hay empty_networks + assert len(data["empty_networks"]) > 0 + + +# --------------------------------------------------------------------------- +# 7. --json: stdout puro, envelope, data.empty_networks +# --------------------------------------------------------------------------- + + +class TestJsonOutput: + """--json: stdout una línea JSON, warnings en envelope, data.empty_networks.""" + + # stdout de 1 línea JSON (camino de éxito) lo cubre + # test_json_warnings_en_envelope_no_en_stdout (asserta len==1 + colocación de + # warnings); el camino de error lo cubre el guard de test_cli_json_option + # (build en _CMDS_NO_WORKSPACE). Epic #184, sub-tarea 2. + + def test_json_schema_1(self, tmp_path: Path) -> None: + """--json: envelope tiene schema='1'.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + ) + + assert result.exit_code == 0 + envelope = json.loads(result.output) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "build" + + def test_json_empty_networks_en_data(self, tmp_path: Path) -> None: + """Cuando hay redes vacías, data.empty_networks está en el envelope JSON.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + # Sin _id: redes vacías + rows = [_row(f"P{i}") for i in range(1, 4)] + _seed_store(ws.library_path, rows) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + ) + + assert result.exit_code == 0 + envelope = json.loads(result.output) + + # empty_networks debe estar en data + assert "empty_networks" in envelope["data"], ( + "data.empty_networks debe estar presente cuando hay redes vacías" + ) + assert len(envelope["data"]["empty_networks"]) > 0 + + # Cada entrada de empty_networks tiene kind, reason, fix_command + for en in envelope["data"]["empty_networks"]: + assert "kind" in en + assert "reason" in en + assert "fix_command" in en + + def test_json_warnings_en_envelope_no_en_stdout(self, tmp_path: Path) -> None: + """Warnings van solo en envelope.warnings, NUNCA como línea extra en stdout.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + rows = [_row(f"P{i}") for i in range(1, 4)] + _seed_store(ws.library_path, rows) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + ) + + assert result.exit_code == 0 + + # stdout puro (result.stdout) debe ser 1 línea JSON. + # Warnings van en envelope.warnings (no prints sueltos a stdout en JSON mode). + stdout_lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(stdout_lines) == 1 + + envelope = json.loads(stdout_lines[0]) + # Los warnings están en el envelope, no sueltos en stdout + assert isinstance(envelope.get("warnings"), list) + + def test_json_data_tiene_scope_y_corpus_scope(self, tmp_path: Path) -> None: + """data incluye 'scope' (nuevo) y 'corpus_scope' (backward compat).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + ) + + assert result.exit_code == 0 + envelope = json.loads(result.output) + assert "scope" in envelope["data"], "data debe tener clave 'scope'" + assert "corpus_scope" in envelope["data"], ( + "data debe tener clave 'corpus_scope' (compat)" + ) + + def test_build_spec_json_envelope_correcto(self, tmp_path: Path) -> None: + """build --spec --json emite envelope con schema='1' y claves de build.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--spec", + str(spec_file), + "--json", + ], + ) + + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.output) + + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "build" + assert envelope["exit_code"] == 0 + assert "networks_built" in envelope["data"] + assert "corpus_hash" in envelope["data"] + assert envelope["data"]["networks_built"] == 1 + + def test_scope_seeds_cli_json(self, tmp_path: Path) -> None: + """--scope seeds via CLI en modo --json produce scope correcto en data.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + rows = [ + _row("S1", is_seed=True, keywords_id=["k1", "k2"]), + _row("C1", is_seed=False, keywords_id=["k3", "k4"]), + ] + _seed_store(ws.library_path, rows) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--scope", "seeds", "--json"], + ) + + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.output) + # corpus_scope = vocab interno (backward compat) + assert envelope["data"]["corpus_scope"] == "seeds_only" + # scope = token CLI tal como lo tipió el usuario (FIX 2 — gancho #160) + assert envelope["data"]["scope"] == "seeds" + + +# --------------------------------------------------------------------------- +# 8. FIX 1: --min-weight + --spec footgun; FIX 2: data["scope"] = token CLI +# --------------------------------------------------------------------------- + + +class TestFix1MinWeightSpec: + """FIX 1: --min-weight se ignora silenciosamente en modo --spec → avisar + diagnosticar bien.""" + + def test_spec_mas_min_weight_avisa_a_stderr(self, tmp_path: Path) -> None: + """--spec + --min-weight N>1 emite aviso a stderr; stdout sigue siendo JSON puro.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + spec_file = tmp_path / "redes.yaml" + _write_spec(spec_file, ["bibliographic_coupling"]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--spec", + str(spec_file), + "--min-weight", + "5", + "--json", + ], + ) + + assert result.exit_code == 0, f"Error: {result.output}" + + # stderr debe contener el aviso de que --min-weight se ignora con --spec + assert "ignora" in result.stderr.lower(), ( + f"Esperaba aviso 'ignora' en stderr; stderr={result.stderr!r}" + ) + assert "spec" in result.stderr.lower() + + # stdout debe ser exactamente 1 línea JSON válida (sin contaminar) + stdout_lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(stdout_lines) == 1, ( + f"stdout debe ser 1 línea JSON, tiene {len(stdout_lines)}: {result.stdout!r}" + ) + json.loads(stdout_lines[0]) + + def test_spec_red_vacia_por_min_weight_del_yaml_reason_honesto( + self, tmp_path: Path + ) -> None: + """Red vacía en modo spec por min_weight del YAML → reason del spec, no del CLI. + + El reason debe mencionar el min_weight del propio spec, NO sugerir + `--min-weight` de la CLI (que en modo spec no hace nada). + """ + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # Corpus con referencias compartidas → preview dice "no vacía" + _seed_store(store_path, _rows_con_referencias()) + + # Spec con min_weight muy alto para forzar red vacía + spec_lines = [ + "networks:", + " - kind: bibliographic_coupling", + " min_weight: 999", + ] + spec_file = tmp_path / "alto_umbral.yaml" + spec_file.write_text("\n".join(spec_lines) + "\n", encoding="utf-8") + + data = run_build(store_path, out_dir=tmp_path / "nets", spec_path=spec_file) + + bc_empty = next( + ( + en + for en in data["empty_networks"] + if en["kind"] == str(NetworkKind.BIBLIOGRAPHIC_COUPLING) + ), + None, + ) + + assert bc_empty is not None, ( + f"bibliographic_coupling debe estar en empty_networks; " + f"empty_networks={data['empty_networks']}" + ) + + # El reason debe mencionar el min_weight del spec (999), no el --min-weight CLI + assert "999" in str(bc_empty["reason"]), ( + f"reason debe mencionar 999 (min_weight del spec); reason={bc_empty['reason']!r}" + ) + assert "spec" in str(bc_empty["reason"]).lower(), ( + f"reason debe mencionar 'spec'; reason={bc_empty['reason']!r}" + ) + + # fix_command DEBE ser None: --min-weight de la CLI no aplica en modo spec + assert bc_empty["fix_command"] is None, ( + f"fix_command debe ser None en modo spec (CLI --min-weight no aplica); " + f"fix_command={bc_empty['fix_command']!r}" + ) + + # Verificación negativa: el reason NO debe sugerir `--min-weight` del CLI + assert "--min-weight" not in str(bc_empty["reason"]), ( + f"reason no debe mencionar '--min-weight' del CLI en modo spec; " + f"reason={bc_empty['reason']!r}" + ) + + def test_spec_red_vacia_fix_command_no_es_min_weight_cli( + self, tmp_path: Path + ) -> None: + """En modo spec, el fix_command de una red vacía NUNCA es 'b2g build --min-weight N'.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + spec_lines = [ + "networks:", + " - kind: bibliographic_coupling", + " min_weight: 999", + ] + spec_file = tmp_path / "alto.yaml" + spec_file.write_text("\n".join(spec_lines) + "\n", encoding="utf-8") + + # Incluso si el usuario pasó --min-weight (ignorado en spec mode), + # el fix_command no debe sugerir bajarlo + data = run_build( + store_path, + out_dir=tmp_path / "nets", + spec_path=spec_file, + min_weight=5, # ignorado en spec mode, pero presente en la firma + ) + + for en in data["empty_networks"]: + fix = en.get("fix_command") + if fix is not None: + assert "b2g build --min-weight" not in str(fix), ( + f"fix_command en modo spec no debe sugerir --min-weight CLI: {fix!r}" + ) + + +class TestFix2ScopeCliToken: + """FIX 2: data['scope'] expone el token CLI, no el vocab interno.""" + + def test_scope_seeds_cli_token_en_data(self, tmp_path: Path) -> None: + """--scope seeds → data['scope']='seeds' (token CLI), corpus_scope='seeds_only' (interno).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store( + ws.library_path, + [ + _row("S1", is_seed=True, references_id=["R1", "R2"]), + _row("C1", is_seed=False, references_id=["R1", "R3"]), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--scope", "seeds", "--json"], + ) + + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.stdout) + + # scope = token CLI + assert envelope["data"]["scope"] == "seeds", ( + f"data['scope'] debe ser 'seeds' (token CLI), no 'seeds_only'; " + f"got {envelope['data']['scope']!r}" + ) + # corpus_scope = vocab interno (backward compat) + assert envelope["data"]["corpus_scope"] == "seeds_only" + + def test_scope_accepted_cli_token_en_data(self, tmp_path: Path) -> None: + """--scope accepted → data['scope']='accepted' (idéntico en ambos vocabs).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--scope", "accepted", "--json"], + ) + + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + assert envelope["data"]["scope"] == "accepted" + assert envelope["data"]["corpus_scope"] == "accepted" + + def test_scope_directo_run_build_sin_cli_token_backward_compat( + self, tmp_path: Path + ) -> None: + """run_build() directamente sin scope_cli_token → data['scope'] = corpus_scope (compat).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_con_referencias()) + + # Llamada directa sin scope_cli_token (tests pre-0.10.0) + data = run_build( + store_path, out_dir=tmp_path / "nets", corpus_scope="seeds_only" + ) + + # Backward compat: sin token CLI, scope == corpus_scope + assert data["corpus_scope"] == "seeds_only" + assert data["scope"] == "seeds_only" + + def test_scope_alias_deprecado_preserva_vocab_interno(self, tmp_path: Path) -> None: + """--corpus-scope seeds_only (deprecado) → data['scope']='seeds_only' (mismo vocab).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path, _rows_con_referencias()) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--corpus-scope", + "seeds_only", + "--json", + ], + ) + + assert result.exit_code == 0 + envelope = json.loads(result.stdout) + # El alias deprecado usa el vocab interno tal cual (no hay nuevo token) + assert envelope["data"]["scope"] == "seeds_only" + assert envelope["data"]["corpus_scope"] == "seeds_only" diff --git a/tests/unit/test_build_thesaurus_flag.py b/tests/unit/test_build_thesaurus_flag.py new file mode 100644 index 0000000..680cdef --- /dev/null +++ b/tests/unit/test_build_thesaurus_flag.py @@ -0,0 +1,287 @@ +"""Tests para el flag build --thesaurus (#164). + +El verbo `b2g thesaurus` fue retirado. La capacidad se movio al flag +`b2g build --thesaurus ` (ADR 0038). + +Secciones: +1. build --thesaurus aplica consolidacion de keywords_id (unitario). +2. Sin --thesaurus el build funciona igual que antes. +3. El verbo b2g thesaurus ya no existe en el CLI. +4. El envelope JSON contiene stats del thesaurus cuando se pasa --thesaurus. +5. Thesaurus inexistente emite DataError. +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any + +import pyarrow as pa +import pytest + +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers locales (mismo contrato que test_ingest._make_corpus_row / _make_parquet) +# --------------------------------------------------------------------------- + + +def _row( + *, + id: str, + title: str = "Test", + keywords_raw: list[str] | None = None, + keywords_id: list[str] | None = None, + year: int = 2020, +) -> dict[str, Any]: + return { + "id": id, + "openalex_id": None, + "doi": None, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": True, + "curation_status": "candidate", + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": keywords_raw, + "keywords_id": keywords_id, + "institutions_raw": None, + "institutions_id": None, + "references_id": None, + "references_doi": None, + "cited_by_id": None, + } + + +def _write_parquet(tmp_path: pathlib.Path, rows: list[dict[str, Any]]) -> pathlib.Path: + import pyarrow.parquet as pq + + p = tmp_path / "corpus.parquet" + pq.write_table(pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA), str(p)) + return p + + +def _make_minimal_thesaurus(canonical: str, aliases: list[str]) -> dict[str, Any]: + return {"concepts": {canonical: {"aliases_en": aliases}}} + + +def _setup_store(tmp_path: pathlib.Path) -> pathlib.Path: + """Crea un store con corpus basico (deep learning + machine learning).""" + from bib2graph.cli.commands.restore import run_restore + + store_path = tmp_path / "lib.duckdb" + rows = [ + _row(id="P1", title="Paper on deep learning", keywords_raw=["deep learning"]), + _row(id="P2", title="Paper on ml", keywords_raw=["machine learning"]), + ] + run_restore(store_path, _write_parquet(tmp_path, rows)) + return store_path + + +# --------------------------------------------------------------------------- +# 1. build --thesaurus aplica consolidacion +# --------------------------------------------------------------------------- + + +def test_build_thesaurus_consolida_keywords_id(tmp_path: pathlib.Path) -> None: + """build --thesaurus unifica aliases bajo el termino canonico en keywords_id.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.constants import Col + from bib2graph.stores.duckdb import DuckDBStore + + th = _make_minimal_thesaurus("ml", ["deep learning", "machine learning", "ml"]) + th_path = tmp_path / "th.json" + th_path.write_text(json.dumps(th), encoding="utf-8") + + store_path = _setup_store(tmp_path) + run_build(store_path, thesaurus_path=th_path) + + corpus = DuckDBStore(store_path).load() + rows = corpus.to_arrow().to_pylist() + all_kw_ids = [kw for row in rows for kw in (row.get(Col.KEYWORDS_ID) or [])] + + assert "ml" in all_kw_ids, ( + f"El canonical 'ml' debe estar en keywords_id. Got: {all_kw_ids}" + ) + assert "deep learning" not in all_kw_ids, ( + "El alias 'deep learning' no debe estar en keywords_id (solo el canonical)." + ) + assert "machine learning" not in all_kw_ids, ( + "El alias 'machine learning' no debe estar en keywords_id (solo el canonical)." + ) + + +def test_build_thesaurus_retorna_stats(tmp_path: pathlib.Path) -> None: + """run_build con thesaurus_path retorna stats en data thesaurus.""" + from bib2graph.cli.commands.build import run_build + + th = _make_minimal_thesaurus("ml", ["deep learning", "machine learning"]) + th_path = tmp_path / "th.json" + th_path.write_text(json.dumps(th), encoding="utf-8") + + store_path = _setup_store(tmp_path) + data = run_build(store_path, thesaurus_path=th_path) + + th_stats = data.get("thesaurus") + assert th_stats is not None, "run_build debe retornar stats en data['thesaurus']" + assert "keywords_mapped" in th_stats + assert "keywords_total" in th_stats + assert "aliases_loaded" in th_stats + assert "applied_at" in th_stats + assert ( + th_stats["aliases_loaded"] == 3 + ) # 2 aliases + canonical self-map (ADR 0011 idempotence) + + +# --------------------------------------------------------------------------- +# 2. Sin --thesaurus el build funciona como antes +# --------------------------------------------------------------------------- + + +def test_build_sin_thesaurus_funciona_normal(tmp_path: pathlib.Path) -> None: + """Sin --thesaurus, run_build no aplica thesaurus y retorna data thesaurus = None.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.cli.commands.restore import run_restore + + store_path = tmp_path / "lib.duckdb" + rows = [_row(id="P1", title="Paper A", keywords_raw=["ecology"])] + run_restore(store_path, _write_parquet(tmp_path, rows)) + + data = run_build(store_path) + + assert data.get("thesaurus") is None, ( + "Sin thesaurus_path, data['thesaurus'] debe ser None" + ) + assert "networks_built" in data + + +# --------------------------------------------------------------------------- +# 3. El verbo b2g thesaurus ya no existe +# --------------------------------------------------------------------------- + + +def test_b2g_thesaurus_verb_no_existe() -> None: + """El subcomando thesaurus fue eliminado del CLI (#164).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + + runner = CliRunner() + result = runner.invoke(b2g, ["thesaurus", "--from", "nada.json"]) + + assert result.exit_code != 0, "El verbo 'thesaurus' no debe existir" + output = result.output or "" + assert "No such command" in output or "Error" in output + + +# --------------------------------------------------------------------------- +# 4. Envelope JSON con thesaurus stats +# --------------------------------------------------------------------------- + + +def test_build_thesaurus_json_envelope(tmp_path: pathlib.Path) -> None: + """build --thesaurus --json produce envelope schema=1 con thesaurus stats.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.cli.commands.restore import run_restore + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test_th") + rows = [_row(id="P1", title="Paper A", keywords_raw=["neural net"])] + run_restore(ws.library_path, _write_parquet(tmp_path, rows)) + + th = _make_minimal_thesaurus("ml", ["neural net", "deep learning"]) + th_path = tmp_path / "th.json" + th_path.write_text(json.dumps(th), encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--thesaurus", + str(th_path), + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"exit != 0: {result.output}" + envelope = json.loads(result.output) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "build" + data = envelope["data"] + assert data.get("thesaurus") is not None + assert "keywords_mapped" in data["thesaurus"] + assert "aliases_loaded" in data["thesaurus"] + + +# --------------------------------------------------------------------------- +# 5. Thesaurus inexistente emite DataError +# --------------------------------------------------------------------------- + + +def test_build_thesaurus_inexistente_error(tmp_path: pathlib.Path) -> None: + """build --thesaurus con ruta inexistente emite DataError (exit code 2).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.cli.commands.restore import run_restore + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test_err") + rows = [_row(id="P1", title="Paper A")] + run_restore(ws.library_path, _write_parquet(tmp_path, rows)) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "build", + "--thesaurus", + str(tmp_path / "no_existe.json"), + "--json", + ], + ) + + assert result.exit_code != 0, "Debe fallar con thesaurus inexistente" + + +@pytest.mark.unit +def test_build_thesaurus_json_malformado_dataerror(tmp_path: pathlib.Path) -> None: + """build --thesaurus con JSON malformado (existente) -> DataError limpio (#164). + + Regresion: el curador edita el JSON a mano y lo rompe; debe dar un DataError + accionable (exit 2), no un traceback no controlado. + """ + import pytest as _pytest + + from bib2graph.cli._errors import DataError + from bib2graph.cli.commands.build import run_build + + th_path = tmp_path / "th_malformado.json" + th_path.write_text("{ esto no es json valido ", encoding="utf-8") + + store_path = _setup_store(tmp_path) + with _pytest.raises(DataError): + run_build(store_path, thesaurus_path=th_path) diff --git a/tests/unit/test_chain_since.py b/tests/unit/test_chain_since.py new file mode 100644 index 0000000..bd514f0 --- /dev/null +++ b/tests/unit/test_chain_since.py @@ -0,0 +1,688 @@ +"""Tests TDD — issue #158: chain --since (forrajeo incremental). + +Verifica: +1. parse_since: ISO date y atajos relativos (90d, 6m, 1y). +2. chain --since -> MONITORED; chain sin --since -> FORAGED. +3. --since + direction=backward -> UsageError. +4. --since + direction=both -> forzado a forward (sin error). +5. new_candidates reportado en el envelope (campo aditivo). +6. Guarda corpus vacio cuando fsm_action="monitor". +7. run_monitor suelto sigue funcionando igual (delega en run_chain). +8. stdout puro: envelope una sola linea JSON (schema="1"). + +Marcador: unit (DuckDB en tmp_path, red mockeada con httpx.MockTransport). +""" + +from __future__ import annotations + +import json +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +import httpx +import pyarrow as pa +import pytest + +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + +_FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" +_SAMPLE_WORKS: list[dict[str, Any]] = json.loads( + (_FIXTURES_DIR / "sample_works.json").read_text(encoding="utf-8") +) + +# Citante genuinamente nuevo: cita al seed W2741809807, no esta en el corpus. +_CITING_NEW_WORK: dict[str, Any] = { + "id": "https://openalex.org/W8888888888", + "doi": None, + "title": "New paper citing the corpus seed", + "display_name": "New paper citing the corpus seed", + "publication_year": 2025, + "language": "en", + "abstract_inverted_index": None, + "authorships": [], + "keywords": [], + "referenced_works": [ + "https://openalex.org/W2741809807", + ], + "primary_location": {"source": {"display_name": "Test Journal"}}, + "type": "article", +} + + +def _make_row( + *, + id: str, + source_id: str | None = None, + is_seed: bool = True, + curation_status: str = "candidate", +) -> dict[str, Any]: + """Fila minima con schema canonico completo.""" + return { + "id": id, + "source_id": source_id, + "doi": None, + "title": f"Paper {id}", + "year": 2020, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": None, + "references_doi": None, + "cited_by_id": None, + } + + +def _seed_store( + store_path: Path, + rows: list[dict[str, Any]] | None = None, + *, + state_action: str = "seed", +) -> None: + """Puebla un store DuckDB con filas y fija el estado del lazo.""" + from bib2graph.corpus import Corpus + from bib2graph.cycle import apply_transition + from bib2graph.stores.duckdb import DuckDBStore + + if rows is None: + rows = [ + _make_row(id="P1", source_id="W2741809807"), + _make_row(id="P2", source_id="W9999999999"), + ] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + new_state, new_round = apply_transition(None, state_action, 0) + store.backend.set_loop_state(new_state, cycle_round=new_round) + store.close() + + +def _make_citing_transport( + works: list[dict[str, Any]] | None = None, +) -> httpx.MockTransport: + """MockTransport que devuelve los works dados como citantes en la primera llamada.""" + if works is None: + works = [_CITING_NEW_WORK] + + calls: list[int] = [0] + + def handler(request: httpx.Request) -> httpx.Response: + calls[0] += 1 + body = ( + { + "results": works, + "meta": {"count": len(works), "next_cursor": None}, + } + if calls[0] == 1 + else {"results": [], "meta": {"count": 0, "next_cursor": None}} + ) + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +def _make_empty_transport() -> httpx.MockTransport: + """MockTransport que siempre devuelve sin resultados.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"results": [], "meta": {"count": 0, "next_cursor": None}}, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- +# 1. parse_since: ISO date y atajos relativos +# --------------------------------------------------------------------------- + + +class TestParseSince: + """parse_since convierte string -> date para el flag --since.""" + + def test_iso_date(self) -> None: + """Fecha ISO YYYY-MM-DD -> date exacta.""" + from bib2graph.cli._options import parse_since + + result = parse_since("2024-01-15") + assert result == date(2024, 1, 15) + + def test_relativo_dias(self) -> None: + """90d resta 90 dias desde la fecha base inyectada.""" + from bib2graph.cli._options import parse_since + + now = date(2024, 6, 1) + result = parse_since("90d", now=now) + assert result == now - timedelta(days=90) + + def test_relativo_meses(self) -> None: + """6m resta 6*30 dias desde la fecha base.""" + from bib2graph.cli._options import parse_since + + now = date(2024, 6, 1) + result = parse_since("6m", now=now) + assert result == now - timedelta(days=180) + + def test_relativo_anios(self) -> None: + """1y resta 365 dias desde la fecha base.""" + from bib2graph.cli._options import parse_since + + now = date(2024, 6, 1) + result = parse_since("1y", now=now) + assert result == now - timedelta(days=365) + + def test_mayusculas_relativo(self) -> None: + """El atajo relativo es case-insensitive (90D, 6M, 1Y).""" + from bib2graph.cli._options import parse_since + + now = date(2024, 6, 1) + assert parse_since("90D", now=now) == parse_since("90d", now=now) + assert parse_since("6M", now=now) == parse_since("6m", now=now) + assert parse_since("1Y", now=now) == parse_since("1y", now=now) + + def test_formato_invalido_lanza_usage_error(self) -> None: + """Formato no reconocido lanza UsageError con mensaje accionable.""" + from bib2graph.cli._options import parse_since + from bib2graph.service.errors import UsageError + + with pytest.raises(UsageError, match="Formato de --since"): + parse_since("invalid-value") + + def test_formato_invalido_parcial(self) -> None: + """Fecha incompleta tambien lanza UsageError.""" + from bib2graph.cli._options import parse_since + from bib2graph.service.errors import UsageError + + with pytest.raises(UsageError): + parse_since("2024-01") # no es YYYY-MM-DD + + def test_iso_minimo_format(self) -> None: + """Fecha ISO de principio de anio.""" + from bib2graph.cli._options import parse_since + + result = parse_since("2024-01-01") + assert result == date(2024, 1, 1) + + +# --------------------------------------------------------------------------- +# 2. chain --since -> MONITORED; chain normal -> FORAGED +# --------------------------------------------------------------------------- + + +class TestChainSinceTransicion: + """chain --since transiciona a MONITORED; chain normal transiciona a FORAGED.""" + + def test_chain_normal_transiciona_a_foraged(self, tmp_path: Path) -> None: + """run_chain sin since -> FORAGED.""" + from bib2graph.cli.commands.chain import run_chain + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + run_chain( + store_path, + direction="forward", + transport=_make_empty_transport(), + ) + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.FORAGED + store.close() + + def test_chain_since_transiciona_a_monitored(self, tmp_path: Path) -> None: + """run_chain con since -> MONITORED.""" + from bib2graph.cli.commands.chain import run_chain + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.MONITORED + store.close() + + def test_chain_since_devuelve_loop_state_monitored(self, tmp_path: Path) -> None: + """El resultado de run_chain con since incluye loop_state=MONITORED.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + assert result["loop_state"] == "MONITORED" + assert "round" in result + + def test_chain_normal_devuelve_loop_state_foraged(self, tmp_path: Path) -> None: + """El resultado de run_chain normal incluye loop_state=FORAGED.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + transport=_make_empty_transport(), + ) + + assert result["loop_state"] == "FORAGED" + + +# --------------------------------------------------------------------------- +# 3. --since + direction=backward -> UsageError +# --------------------------------------------------------------------------- + + +class TestChainSinceBackwardError: + """--since + direction=backward -> UsageError accionable.""" + + def test_since_backward_lanza_usage_error(self, tmp_path: Path) -> None: + """run_chain con since y direction=backward lanza UsageError.""" + from bib2graph.cli._errors import UsageError + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + with pytest.raises(UsageError, match="backward"): + run_chain( + store_path, + direction="backward", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + +# --------------------------------------------------------------------------- +# 4. --since + direction=both -> forzado a forward (sin error) +# --------------------------------------------------------------------------- + + +class TestChainSinceBothForzaForward: + """--since + direction=both fuerza effective_direction=forward.""" + + def test_since_both_acepta_sin_error(self, tmp_path: Path) -> None: + """run_chain con since y direction=both no lanza error y transiciona MONITORED.""" + from bib2graph.cli.commands.chain import run_chain + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="both", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + # No debe lanzar; debe ir a MONITORED + assert result["loop_state"] == "MONITORED" + # La direccion efectiva debe ser forward + assert result["direction"] == "forward" + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.MONITORED + store.close() + + +# --------------------------------------------------------------------------- +# 5. new_candidates reportado en el envelope +# --------------------------------------------------------------------------- + + +class TestChainNewCandidates: + """new_candidates refleja los papers genuinamente nuevos vs el corpus.""" + + def test_new_candidates_se_reporta(self, tmp_path: Path) -> None: + """run_chain incluye new_candidates en el resultado.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + transport=_make_empty_transport(), + ) + + assert "new_candidates" in result + assert isinstance(result["new_candidates"], int) + + def test_new_candidates_cuenta_nuevos(self, tmp_path: Path) -> None: + """new_candidates = 1 cuando se agrega un citante genuinamente nuevo.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + transport=_make_citing_transport([_CITING_NEW_WORK]), + ) + + assert result["new_candidates"] == 1 + + def test_new_candidates_cero_sin_nuevos(self, tmp_path: Path) -> None: + """new_candidates = 0 cuando no hay citantes nuevos.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + transport=_make_empty_transport(), + ) + + assert result["new_candidates"] == 0 + + def test_new_candidates_en_chain_since(self, tmp_path: Path) -> None: + """new_candidates tambien esta presente cuando se usa chain --since.""" + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + result = run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=_make_citing_transport([_CITING_NEW_WORK]), + ) + + assert "new_candidates" in result + assert result["new_candidates"] == 1 + + def test_envelope_json_incluye_new_candidates(self, tmp_path: Path) -> None: + """El envelope JSON de chain incluye new_candidates.""" + from bib2graph.cli._envelope import build_envelope + from bib2graph.cli.commands.chain import run_chain + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + data = run_chain( + store_path, + direction="forward", + transport=_make_empty_transport(), + ) + envelope = build_envelope(command="chain", ok=True, data=data, exit_code=0) + + assert envelope["schema"] == "1" + assert "new_candidates" in envelope["data"] + + # JSON-serializable y una sola linea (sin newlines internos) + serialized = json.dumps(envelope) + assert "\n" not in serialized, "El envelope debe ser una sola linea JSON" + + +# --------------------------------------------------------------------------- +# 6. Guarda corpus vacio cuando since activo +# --------------------------------------------------------------------------- + + +class TestChainSinceGuardaCorpusVacio: + """chain --since falla con DataError si no hay corpus previo.""" + + def test_since_sin_estado_previo_lanza_data_error(self, tmp_path: Path) -> None: + """run_chain con since sin estado previo -> DataError accionable.""" + from bib2graph.cli._errors import DataError + from bib2graph.cli.commands.chain import run_chain + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "empty.duckdb" + DuckDBStore(store_path) # inicializa tablas, sin estado + + with pytest.raises(DataError, match="b2g seed"): + run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + def test_since_corpus_vacio_lanza_data_error(self, tmp_path: Path) -> None: + """run_chain con since y corpus vacio -> DataError accionable.""" + from bib2graph.cli._errors import DataError + from bib2graph.cli.commands.chain import run_chain + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "no_papers.duckdb" + store = DuckDBStore(store_path) + store.backend.set_loop_state(CycleState.SEEDED, cycle_round=1) + store.close() + + with pytest.raises(DataError, match="b2g seed"): + run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=_make_empty_transport(), + ) + + +# --------------------------------------------------------------------------- +# 7. run_monitor suelto delega en run_chain y sigue funcionando +# --------------------------------------------------------------------------- + + +class TestMonitorDelegaEnChain: + """run_monitor suelto sigue funcionando igual (delega en run_chain).""" + + def test_monitor_encuentra_nuevos_y_transiciona_a_monitored( + self, tmp_path: Path + ) -> None: + """run_monitor mergea 1 citante nuevo y transiciona a MONITORED.""" + from bib2graph.cli.commands.monitor import run_monitor + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "monitor.duckdb" + _seed_store(store_path) + + data = run_monitor( + store_path, transport=_make_citing_transport([_CITING_NEW_WORK]) + ) + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.MONITORED + store.close() + + assert data["new_candidates"] == 1 + assert data["loop_state"] == "MONITORED" + assert data["round"] == 1 + assert data["total_papers"] == 3 + + def test_monitor_sin_nuevos_transiciona_a_monitored(self, tmp_path: Path) -> None: + """run_monitor con 0 citantes igual transiciona a MONITORED.""" + from bib2graph.cli.commands.monitor import run_monitor + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "monitor_empty.duckdb" + _seed_store(store_path) + + data = run_monitor(store_path, transport=_make_empty_transport()) + + assert data["new_candidates"] == 0 + assert data["loop_state"] == "MONITORED" + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.MONITORED + store.close() + + def test_monitor_sin_estado_previo_lanza_data_error(self, tmp_path: Path) -> None: + """run_monitor sin estado previo -> DataError accionable.""" + from bib2graph.cli._errors import DataError + from bib2graph.cli.commands.monitor import run_monitor + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "empty.duckdb" + DuckDBStore(store_path) + + with pytest.raises(DataError, match="b2g seed"): + run_monitor(store_path, transport=_make_empty_transport()) + + def test_monitor_corpus_vacio_lanza_data_error(self, tmp_path: Path) -> None: + """run_monitor con corpus vacio -> DataError accionable.""" + from bib2graph.cli._errors import DataError + from bib2graph.cli.commands.monitor import run_monitor + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "no_papers.duckdb" + store = DuckDBStore(store_path) + store.backend.set_loop_state(CycleState.SEEDED, cycle_round=1) + store.close() + + with pytest.raises(DataError, match="b2g seed"): + run_monitor(store_path, transport=_make_empty_transport()) + + def test_monitor_envelope_json_schema_1(self, tmp_path: Path) -> None: + """El envelope de monitor incluye schema='1' y los campos canonicos.""" + from bib2graph.cli._envelope import build_envelope + from bib2graph.cli.commands.monitor import run_monitor + + store_path = tmp_path / "env.duckdb" + _seed_store(store_path) + + data = run_monitor(store_path, transport=_make_empty_transport()) + envelope = build_envelope( + command="monitor", + ok=True, + data=data, + exit_code=0, + ) + + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "monitor" + assert envelope["exit_code"] == 0 + assert "new_candidates" in envelope["data"] + assert "total_papers" in envelope["data"] + assert "loop_state" in envelope["data"] + assert "round" in envelope["data"] + + serialized = json.dumps(envelope) + parsed = json.loads(serialized) + assert parsed["data"]["loop_state"] == "MONITORED" + + +# --------------------------------------------------------------------------- +# 8. since filtra la URL enviada a OpenAlex (cableado de la ventana) +# --------------------------------------------------------------------------- + + +class TestChainSinceFiltroURL: + """Cuando se pasa since, el filtro from_publication_date aparece en la URL.""" + + def test_since_agrega_filtro_fecha_en_url(self, tmp_path: Path) -> None: + """La URL enviada a OpenAlex incluye from_publication_date cuando since activo.""" + urls_llamadas: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + urls_llamadas.append(str(request.url)) + return httpx.Response( + 200, + json={"results": [], "meta": {"count": 0, "next_cursor": None}}, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + transport = httpx.MockTransport(handler) + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + from bib2graph.cli.commands.chain import run_chain + + run_chain( + store_path, + direction="forward", + since=date(2024, 1, 1), + transport=transport, + ) + + # Al menos una URL debe contener el filtro de fecha + filtros_fecha = [u for u in urls_llamadas if "from_publication_date" in u] + assert len(filtros_fecha) >= 1, ( + f"Ninguna URL contiene from_publication_date. URLs: {urls_llamadas}" + ) + assert "2024-01-01" in filtros_fecha[0] + + def test_sin_since_no_agrega_filtro_fecha(self, tmp_path: Path) -> None: + """Sin --since, la URL no contiene from_publication_date.""" + urls_llamadas: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + urls_llamadas.append(str(request.url)) + return httpx.Response( + 200, + json={"results": [], "meta": {"count": 0, "next_cursor": None}}, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + transport = httpx.MockTransport(handler) + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path) + + from bib2graph.cli.commands.chain import run_chain + + run_chain( + store_path, + direction="forward", + since=None, + transport=transport, + ) + + filtros_fecha = [u for u in urls_llamadas if "from_publication_date" in u] + assert len(filtros_fecha) == 0, ( + f"Sin --since no debe haber filtro de fecha. URLs: {urls_llamadas}" + ) diff --git a/tests/unit/test_cli_json_option.py b/tests/unit/test_cli_json_option.py new file mode 100644 index 0000000..6ffbc67 --- /dev/null +++ b/tests/unit/test_cli_json_option.py @@ -0,0 +1,343 @@ +"""Tests del decorador compartido ``json_option`` y helper ``json_mode``. + +Cubre (DoD sub-issue #151): + 1. Helpers ``_env_truthy`` y ``json_mode`` — lógica pura. + 2. Flag ``--json`` (post-verbo): stdout exactamente una línea JSON válida, + ``schema == "1"``. + 3. Env var ``B2G_JSON=1`` sin flag: activa el mismo comportamiento. + 4. Precedencia: flag gana; ``B2G_JSON`` activa sin flag. + 5. Camino de ERROR con ``--json``: stdout = una línea envelope ``ok=False``. + 6. Anti-regresión: en modo JSON, stdout NUNCA más de una línea. + 7. Borde SIN envelope: error de parseo Click → stderr, stdout vacío. + +Estrategia: la mayoría de comandos se llaman sin workspace activo (no hay +``workspace.json`` en el cwd del runner aislado) → ``UsageError`` → +``handle_errors`` emite el envelope de error → exactamente una línea en stdout. +Para ``init`` se prueba el camino exitoso porque crea su propio workspace. + +Nota: Click 8.4+ provee ``result.stdout`` y ``result.stderr`` como streams +separados; ``result.stdout`` mezcla ambos. Todos los asserts sobre stdout +usan ``result.stdout``. + +Marcador: ``unit`` (sin red ni DuckDB persistente; el runner usa filesystem +aislado temporal). +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.cli._options import _env_truthy, json_mode + +# --------------------------------------------------------------------------- +# 1. Helpers puros — _env_truthy y json_mode +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEnvTruthy: + """Verifica valores truthy/falsy reconocidos por ``_env_truthy``.""" + + def test_valor_1_es_truthy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": "1"}): + assert _env_truthy("_B2G_TEST_VAR") is True + + def test_valor_true_es_truthy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": "true"}): + assert _env_truthy("_B2G_TEST_VAR") is True + + def test_valor_yes_es_truthy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": "yes"}): + assert _env_truthy("_B2G_TEST_VAR") is True + + def test_mayusculas_son_truthy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": "TRUE"}): + assert _env_truthy("_B2G_TEST_VAR") is True + + def test_valor_0_es_falsy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": "0"}): + assert _env_truthy("_B2G_TEST_VAR") is False + + def test_variable_ausente_es_falsy(self) -> None: + # Asegura que la variable no existe en el entorno + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("_B2G_TEST_VAR", None) + assert _env_truthy("_B2G_TEST_VAR") is False + + def test_valor_vacio_es_falsy(self) -> None: + with patch.dict(os.environ, {"_B2G_TEST_VAR": ""}): + assert _env_truthy("_B2G_TEST_VAR") is False + + +@pytest.mark.unit +class TestJsonMode: + """Verifica la lógica de precedencia de ``json_mode``.""" + + def test_flag_local_activa_json(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("B2G_JSON", None) + assert json_mode(True) is True + + def test_env_var_activa_json_sin_flag(self) -> None: + with patch.dict(os.environ, {"B2G_JSON": "1"}): + assert json_mode(False) is True + + def test_ninguno_retorna_false(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("B2G_JSON", None) + assert json_mode(False) is False + + def test_flag_y_env_var_retornan_true(self) -> None: + """Con ambos activos, el resultado sigue siendo True.""" + with patch.dict(os.environ, {"B2G_JSON": "1"}): + assert json_mode(True) is True + + def test_env_var_false_sin_flag_retorna_false(self) -> None: + with patch.dict(os.environ, {"B2G_JSON": "0"}): + assert json_mode(False) is False + + def test_env_var_true_string_activa_json(self) -> None: + with patch.dict(os.environ, {"B2G_JSON": "true"}): + assert json_mode(False) is True + + def test_env_var_yes_activa_json(self) -> None: + with patch.dict(os.environ, {"B2G_JSON": "yes"}): + assert json_mode(False) is True + + +# --------------------------------------------------------------------------- +# 2. Helpers de test para CliRunner +# --------------------------------------------------------------------------- + + +def _assert_one_json_line(stdout: str, *, schema: str = "1") -> dict: + """Aserta que stdout es exactamente una línea JSON con ``schema`` correcto. + + Returns: + El dict parseado para asserts adicionales. + """ + lines = [ln for ln in stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"Se esperaba exactamente 1 línea en stdout, se obtuvieron {len(lines)}:\n" + f"{stdout!r}" + ) + data = json.loads(lines[0]) + assert data.get("schema") == schema, ( + f"schema esperado '{schema}', obtenido {data.get('schema')!r}" + ) + return data + + +# --------------------------------------------------------------------------- +# 3. Parametrizado sobre comandos que emiten envelope +# (camino de error: sin workspace → UsageError → error envelope) +# --------------------------------------------------------------------------- + +# Comandos y sus argumentos mínimos para pasar la validación de Click. +# Todos fallarán en el interior (no hay workspace) → handle_errors → +# envelope de error (ok=False) → una línea en stdout. +_CMDS_NO_WORKSPACE: list[list[str]] = [ + ["status", "--json"], + ["validate", "--json"], + ["inspect", "--json"], + ["build", "--json"], + ["export", "--json"], + ["snapshot", "create", "--json"], + ["chain", "--json"], + ["filter", "--json"], + ["enrich", "--json"], + ["monitor", "--json"], + ["resolve", "--json"], + ["accept", "--ids", "DUMMY_ID", "--json"], + ["reject", "--ids", "DUMMY_ID", "--json"], + ["curate", "dump", "--json"], + ["restore", "--from-corpus", "nonexistent.parquet", "--json"], + ["seed", "--equation", "test query", "--json"], +] + + +@pytest.mark.unit +@pytest.mark.parametrize("cmd_args", _CMDS_NO_WORKSPACE) +def test_json_flag_stdout_una_linea_envelope(cmd_args: list[str]) -> None: + """Con ``--json``, stdout es exactamente una línea de envelope JSON válido.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, cmd_args, catch_exceptions=False) + stdout = result.stdout + _assert_one_json_line(stdout) + + +@pytest.mark.unit +@pytest.mark.parametrize("cmd_args", _CMDS_NO_WORKSPACE) +def test_b2g_json_env_var_stdout_una_linea_envelope(cmd_args: list[str]) -> None: + """Con ``B2G_JSON=1`` sin flag ``--json``, stdout es una línea de envelope.""" + # args sin --json (quitarlo si está presente) + args_sin_json = [a for a in cmd_args if a != "--json"] + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + b2g, args_sin_json, env={"B2G_JSON": "1"}, catch_exceptions=False + ) + stdout = result.stdout + _assert_one_json_line(stdout) + + +# --------------------------------------------------------------------------- +# 4. Comando init — camino exitoso +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_init_json_happy_path_stdout_una_linea() -> None: + """``b2g init --json`` exitoso: stdout = una línea envelope ok=True.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["init", "mi-ws", "--json"], catch_exceptions=False) + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "init" + assert data["exit_code"] == 0 + + +@pytest.mark.unit +def test_init_b2g_json_env_var_happy_path() -> None: + """``b2g init `` con ``B2G_JSON=1``: stdout = una línea envelope ok=True.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + b2g, ["init", "mi-ws"], env={"B2G_JSON": "1"}, catch_exceptions=False + ) + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + + +# --------------------------------------------------------------------------- +# 5. Camino de ERROR — error envelope en stdout (ok=False) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_error_envelope_stdout_ok_false_sin_workspace() -> None: + """Comando sin workspace → UsageError → envelope ok=False en stdout.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["status", "--json"], catch_exceptions=False) + data = _assert_one_json_line(result.stdout) + assert data["ok"] is False + assert "error" in data + assert data["error"] is not None + + +@pytest.mark.unit +def test_error_modo_humano_stderr_no_stdout() -> None: + """Modo humano: error va a stderr, NO a stdout.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["status"], catch_exceptions=False) + # stdout debe estar vacío (el mensaje de error fue a stderr) + assert result.stdout.strip() == "", ( + f"stdout debería estar vacío en modo humano-error, obtuvo: {result.stdout!r}" + ) + + +# --------------------------------------------------------------------------- +# 6. Precedencia +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_precedencia_flag_sin_env_var() -> None: + """``--json`` explícito activa JSON aunque ``B2G_JSON`` no esté seteada.""" + runner = CliRunner() + with runner.isolated_filesystem(): + # B2G_JSON ausente del env inyectado + result = runner.invoke( + b2g, + ["status", "--json"], + env={"B2G_JSON": "0"}, # explícitamente falsy + catch_exceptions=False, + ) + # El flag --json gana sobre B2G_JSON=0 + _assert_one_json_line(result.stdout) + + +@pytest.mark.unit +def test_precedencia_env_var_sin_flag() -> None: + """``B2G_JSON=1`` activa JSON sin pasar ``--json``.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + b2g, ["status"], env={"B2G_JSON": "1"}, catch_exceptions=False + ) + _assert_one_json_line(result.stdout) + + +@pytest.mark.unit +def test_sin_flag_ni_env_var_modo_humano() -> None: + """Sin ``--json`` ni ``B2G_JSON``: stderr tiene el error (modo humano).""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + b2g, ["status"], env={"B2G_JSON": "0"}, catch_exceptions=False + ) + # stdout vacío en modo humano-error + assert result.stdout.strip() == "" + + +# --------------------------------------------------------------------------- +# 7. Anti-regresión: stdout NUNCA más de una línea en modo JSON +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.parametrize("cmd_args", _CMDS_NO_WORKSPACE) +def test_anti_regresion_stdout_max_una_linea_con_json(cmd_args: list[str]) -> None: + """Guard: stdout NUNCA supera una línea no-vacía en modo JSON.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, cmd_args, catch_exceptions=False) + lineas = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lineas) <= 1, ( + f"stdout tiene {len(lineas)} líneas en modo JSON ({cmd_args[0]}):\n" + f"{result.stdout!r}" + ) + + +# --------------------------------------------------------------------------- +# 8. Borde SIN envelope: errores de parseo de Click +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_opcion_desconocida_no_emite_envelope() -> None: + """Opción desconocida → Click stderr, stdout vacío, exit 2 (sin envelope).""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["status", "--opcion-inexistente"]) + # stdout debe estar vacío (no hay envelope para errores de parseo de Click) + assert result.stdout.strip() == "", ( + f"stdout debería estar vacío ante opción desconocida, obtuvo: {result.stdout!r}" + ) + # Click retorna exit code 2 para errores de uso + assert result.exit_code == 2 + + +@pytest.mark.unit +def test_b2g_json_env_var_valores_truthy_alternativos() -> None: + """``B2G_JSON=true`` y ``B2G_JSON=yes`` también activan el modo JSON.""" + for val in ("true", "yes", "TRUE", "True", "YES"): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + b2g, ["status"], env={"B2G_JSON": val}, catch_exceptions=False + ) + ( + _assert_one_json_line(result.stdout), + f"B2G_JSON={val!r} debería activar JSON mode", + ) diff --git a/tests/unit/test_cli_read.py b/tests/unit/test_cli_read.py new file mode 100644 index 0000000..ae8d097 --- /dev/null +++ b/tests/unit/test_cli_read.py @@ -0,0 +1,967 @@ +"""Tests del grupo noun-verb ``b2g read`` (sub-issue #156). + +Cubre: +1. Servicios nuevos (unit puro, sin Click): + - ``list_papers``: sin filtros, con --query (CI), --status, is_seed, year. + - ``corpus_stats``: group_by status/year/is_seed; group_by inválido → DataError. + - ``get_paper`` extendido: resuelve por id, doi, source_id; inexistente → DataError. + +2. CLI ``read list --json``: envelope schema="1", exit 0, data.papers + count. +3. CLI ``read stats --json --group-by ``: forma del envelope. +4. CLI ``read show --id --json``: ~14 campos; inexistente → DataError exit 2. +5. ``b2g read`` sin subcomando → ayuda (exit 0). +6. stdout puro: una sola línea JSON, sin ruido. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + *, + id: str, + title: str = "Título de prueba", + year: int | None = 2020, + is_seed: bool = True, + curation_status: str = "candidate", + doi: str | None = None, + source_id: str | None = None, + references_id: list[str] | None = None, + cited_by_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima con schema completo para tests de read. + + Usa los nombres canónicos del CORPUS_SCHEMA (source_id, no openalex_id). + """ + return { + "id": id, + "source_id": source_id, + "doi": doi, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": ["Autor A"], + "authors_id": ["oa:author1"], + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: + """Crea y devuelve un Workspace inicializado en tmp_path.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / name + return Workspace.init(ws_dir, name) + + +def _seed_store(ws: Any, rows: list[dict[str, Any]]) -> None: + """Persiste filas en el store del workspace.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + + +def _assert_one_json_line(stdout: str, *, schema: str = "1") -> dict[str, Any]: + """Aserta exactamente una línea JSON con schema correcto; devuelve el dict.""" + lines = [ln for ln in stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"Se esperaba exactamente 1 línea en stdout, se obtuvieron {len(lines)}:\n" + f"{stdout!r}" + ) + data = json.loads(lines[0]) + assert data.get("schema") == schema + return data + + +# --------------------------------------------------------------------------- +# 1. Servicios — list_papers +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_list_papers_sin_filtros_devuelve_todos(tmp_path: Path) -> None: + """list_papers sin filtros devuelve todos los papers con campos mínimos.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", title="Paper uno", year=2021), + _row(id="P2", title="Paper dos", year=2022), + _row(id="P3", title="Paper tres", year=2023), + ] + _seed_store(ws, rows) + + result = list_papers(ws) + + assert result["count"] == 3 + assert len(result["papers"]) == 3 + ids = {p["id"] for p in result["papers"]} + assert ids == {"P1", "P2", "P3"} + # Campos mínimos presentes + for paper in result["papers"]: + assert "id" in paper + assert "title" in paper + assert "year" in paper + assert "curation_status" in paper + assert "is_seed" in paper + + +@pytest.mark.unit +def test_list_papers_query_filtra_por_titulo_ci(tmp_path: Path) -> None: + """list_papers --query filtra substring case-insensitive en el título.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", title="Unequal Exchange in World Trade"), + _row(id="P2", title="Capital Accumulation and Growth"), + _row(id="P3", title="UNEQUAL DISTRIBUTION OF WEALTH"), + ] + _seed_store(ws, rows) + + result = list_papers(ws, query="unequal") + + assert result["count"] == 2 + ids = {p["id"] for p in result["papers"]} + assert ids == {"P1", "P3"} + + +@pytest.mark.unit +def test_list_papers_status_filtra_por_curation_status(tmp_path: Path) -> None: + """list_papers filtra por curation_status exacto.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", curation_status="candidate"), + _row(id="P2", curation_status="accepted"), + _row(id="P3", curation_status="rejected"), + _row(id="P4", curation_status="accepted"), + ] + _seed_store(ws, rows) + + result = list_papers(ws, status="accepted") + + assert result["count"] == 2 + ids = {p["id"] for p in result["papers"]} + assert ids == {"P2", "P4"} + + +@pytest.mark.unit +def test_list_papers_seeds_filtra_is_seed_true(tmp_path: Path) -> None: + """list_papers con is_seed=True devuelve solo semillas.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + _row(id="P3", is_seed=True), + ] + _seed_store(ws, rows) + + result = list_papers(ws, is_seed=True) + + assert result["count"] == 2 + assert all(p["is_seed"] is True for p in result["papers"]) + + +@pytest.mark.unit +def test_list_papers_candidates_filtra_is_seed_false(tmp_path: Path) -> None: + """list_papers con is_seed=False devuelve solo no-semillas.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + _row(id="P3", is_seed=False), + ] + _seed_store(ws, rows) + + result = list_papers(ws, is_seed=False) + + assert result["count"] == 2 + assert all(p["is_seed"] is False for p in result["papers"]) + + +@pytest.mark.unit +def test_list_papers_year_filtra_por_anio(tmp_path: Path) -> None: + """list_papers filtra por año exacto.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", year=2019), + _row(id="P2", year=2021), + _row(id="P3", year=2021), + _row(id="P4", year=2023), + ] + _seed_store(ws, rows) + + result = list_papers(ws, year=2021) + + assert result["count"] == 2 + ids = {p["id"] for p in result["papers"]} + assert ids == {"P2", "P3"} + + +@pytest.mark.unit +def test_list_papers_filtros_combinados(tmp_path: Path) -> None: + """list_papers combina filtros con AND lógico.""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", title="Unequal Exchange", year=2020, curation_status="accepted"), + _row(id="P2", title="Unequal Trade", year=2021, curation_status="accepted"), + _row(id="P3", title="Unequal Income", year=2020, curation_status="candidate"), + ] + _seed_store(ws, rows) + + result = list_papers(ws, query="unequal", status="accepted", year=2020) + + assert result["count"] == 1 + assert result["papers"][0]["id"] == "P1" + + +# --------------------------------------------------------------------------- +# 2. Servicios — corpus_stats +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_corpus_stats_group_by_status(tmp_path: Path) -> None: + """corpus_stats agrupa por curation_status y devuelve conteos correctos.""" + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", curation_status="candidate"), + _row(id="P2", curation_status="accepted"), + _row(id="P3", curation_status="candidate"), + _row(id="P4", curation_status="rejected"), + ] + _seed_store(ws, rows) + + result = corpus_stats(ws, group_by="status") + + assert result["group_by"] == "status" + assert result["total"] == 4 + groups_map = {g["key"]: g["count"] for g in result["groups"]} + assert groups_map["candidate"] == 2 + assert groups_map["accepted"] == 1 + assert groups_map["rejected"] == 1 + + +@pytest.mark.unit +def test_corpus_stats_group_by_year(tmp_path: Path) -> None: + """corpus_stats agrupa por año.""" + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", year=2020), + _row(id="P2", year=2021), + _row(id="P3", year=2020), + ] + _seed_store(ws, rows) + + result = corpus_stats(ws, group_by="year") + + assert result["group_by"] == "year" + assert result["total"] == 3 + groups_map = {str(g["key"]): g["count"] for g in result["groups"]} + assert groups_map["2020"] == 2 + assert groups_map["2021"] == 1 + + +@pytest.mark.unit +def test_corpus_stats_group_by_is_seed(tmp_path: Path) -> None: + """corpus_stats agrupa por is_seed.""" + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + rows = [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + _row(id="P3", is_seed=True), + ] + _seed_store(ws, rows) + + result = corpus_stats(ws, group_by="is_seed") + + assert result["group_by"] == "is_seed" + assert result["total"] == 3 + # Cada grupo tiene key=True o key=False y count correspondiente + assert len(result["groups"]) == 2 + + +@pytest.mark.unit +def test_corpus_stats_group_by_invalido_lanza_dataerror(tmp_path: Path) -> None: + """corpus_stats con group_by inválido lanza DataError.""" + from bib2graph.service.errors import DataError + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + with pytest.raises(DataError, match="no válido"): + corpus_stats(ws, group_by="autor") + + +@pytest.mark.unit +def test_corpus_stats_default_es_status(tmp_path: Path) -> None: + """corpus_stats sin parámetro usa group_by='status' por defecto.""" + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", curation_status="candidate")]) + + result = corpus_stats(ws) + + assert result["group_by"] == "status" + assert "groups" in result + + +# --------------------------------------------------------------------------- +# 3. Servicios — get_paper extendido (id | doi | source_id) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_paper_resuelve_por_id(tmp_path: Path) -> None: + """get_paper resuelve por id interno (comportamiento original).""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + rows = [_row(id="P1", title="Paper uno", doi="10.1/p1", source_id="W1234")] + _seed_store(ws, rows) + + result = get_paper(ws, "P1") + + assert result["id"] == "P1" + assert result["title"] == "Paper uno" + + +@pytest.mark.unit +def test_get_paper_resuelve_por_doi(tmp_path: Path) -> None: + """get_paper resuelve por DOI cuando no hay match por id.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + rows = [_row(id="W123", title="Paper con DOI", doi="10.1016/j.ecolecon.2019")] + _seed_store(ws, rows) + + result = get_paper(ws, "10.1016/j.ecolecon.2019") + + assert result["id"] == "W123" + assert result["doi"] == "10.1016/j.ecolecon.2019" + + +@pytest.mark.unit +def test_get_paper_resuelve_por_source_id(tmp_path: Path) -> None: + """get_paper resuelve por source_id cuando no hay match por id ni doi.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + rows = [_row(id="P1", title="Paper con source_id", source_id="W2741809807")] + _seed_store(ws, rows) + + result = get_paper(ws, "W2741809807") + + assert result["id"] == "P1" + assert result["source_id"] == "W2741809807" + + +@pytest.mark.unit +def test_get_paper_prioriza_id_sobre_doi(tmp_path: Path) -> None: + """Cuando ident coincide con id de un paper y doi de otro, gana el id.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + # P-DOI tiene id="VALOR" y P2 tiene doi="VALOR" + rows = [ + _row(id="VALOR", title="Coincide por id", doi="10.1/otro"), + _row(id="P2", title="Coincide por doi", doi="VALOR"), + ] + _seed_store(ws, rows) + + result = get_paper(ws, "VALOR") + + # id gana sobre doi + assert result["id"] == "VALOR" + assert result["title"] == "Coincide por id" + + +@pytest.mark.unit +def test_get_paper_mismo_paper_tres_ids(tmp_path: Path) -> None: + """El mismo paper es encontrable por id, doi y source_id.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + rows = [ + _row( + id="W999", + title="Paper multi-identificador", + doi="10.1234/test", + source_id="W999_openalex", + ) + ] + _seed_store(ws, rows) + + by_id = get_paper(ws, "W999") + by_doi = get_paper(ws, "10.1234/test") + by_source = get_paper(ws, "W999_openalex") + + assert by_id["id"] == by_doi["id"] == by_source["id"] == "W999" + assert by_id["title"] == by_doi["title"] == by_source["title"] + + +@pytest.mark.unit +def test_get_paper_inexistente_lanza_dataerror(tmp_path: Path) -> None: + """get_paper con ident que no existe lanza DataError.""" + from bib2graph.service.errors import DataError + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + with pytest.raises(DataError, match="no encontrado"): + get_paper(ws, "id-que-no-existe") + + +@pytest.mark.unit +def test_get_paper_devuelve_catorce_campos(tmp_path: Path) -> None: + """get_paper devuelve los ~14 campos documentados.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + rows = [ + _row( + id="P1", + title="Paper completo", + year=2022, + doi="10.1/p1", + source_id="W001", + is_seed=True, + curation_status="accepted", + references_id=["R1", "R2"], + ) + ] + _seed_store(ws, rows) + + result = get_paper(ws, "P1") + + expected_keys = { + "id", + "source_id", + "doi", + "title", + "year", + "abstract", + "is_seed", + "curation_status", + "authors_raw", + "authors_id", + "keywords_id", + "references_id", + "cited_by_id", + "provenance", + } + for key in expected_keys: + assert key in result, f"Falta clave: {key}" + + +# Neutralidad de transporte de service.reads: consolidada en +# test_service.py::test_service_modulo_neutral_de_transporte (epic #184). + + +# --------------------------------------------------------------------------- +# 5. CLI — read list --json +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_list_json_envelope_forma(tmp_path: Path) -> None: + """read list --json devuelve envelope schema='1', ok=True, data.papers + count.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", title="Alpha", curation_status="candidate"), + _row(id="P2", title="Beta", curation_status="accepted"), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "list", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["command"] == "read list" + assert data["exit_code"] == 0 + assert "papers" in data["data"] + assert "count" in data["data"] + assert data["data"]["count"] == 2 + + +@pytest.mark.unit +def test_cli_read_list_query_filtra(tmp_path: Path) -> None: + """read list --query filtra por título en el CLI.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", title="Ecología Política"), + _row(id="P2", title="Economía Ambiental"), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "list", "--query", "ecolog", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["data"]["count"] == 1 + assert data["data"]["papers"][0]["id"] == "P1" + + +@pytest.mark.unit +def test_cli_read_list_status_filtra(tmp_path: Path) -> None: + """read list --status accepted filtra por curation_status.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", curation_status="accepted"), + _row(id="P2", curation_status="candidate"), + _row(id="P3", curation_status="accepted"), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "list", + "--status", + "accepted", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["data"]["count"] == 2 + + +@pytest.mark.unit +def test_cli_read_list_seeds_filtra(tmp_path: Path) -> None: + """read list --seeds filtra a is_seed=True.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "list", "--seeds", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["data"]["count"] == 1 + assert data["data"]["papers"][0]["id"] == "P1" + + +@pytest.mark.unit +def test_cli_read_list_candidates_filtra(tmp_path: Path) -> None: + """read list --candidates filtra a is_seed=False.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + _row(id="P3", is_seed=False), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "list", "--candidates", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["data"]["count"] == 2 + + +@pytest.mark.unit +def test_cli_read_list_year_filtra(tmp_path: Path) -> None: + """read list --year filtra por año exacto.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", year=2020), + _row(id="P2", year=2021), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "list", "--year", "2021", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["data"]["count"] == 1 + assert data["data"]["papers"][0]["id"] == "P2" + + +@pytest.mark.unit +def test_cli_read_list_seeds_candidates_mutuamente_excluyentes(tmp_path: Path) -> None: + """read list --seeds --candidates → UsageError (exit 1).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "list", + "--seeds", + "--candidates", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is False + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# 6. CLI — read stats --json +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_stats_json_envelope_forma(tmp_path: Path) -> None: + """read stats --json devuelve envelope con group_by, total y groups.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", curation_status="candidate"), + _row(id="P2", curation_status="accepted"), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "stats", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["command"] == "read stats" + payload = data["data"] + assert "group_by" in payload + assert "total" in payload + assert "groups" in payload + assert payload["total"] == 2 + + +@pytest.mark.unit +def test_cli_read_stats_group_by_year(tmp_path: Path) -> None: + """read stats --group-by year agrupa por año.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", year=2020), + _row(id="P2", year=2021), + _row(id="P3", year=2020), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "stats", "--group-by", "year", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + payload = data["data"] + assert payload["group_by"] == "year" + assert payload["total"] == 3 + groups_map = {str(g["key"]): g["count"] for g in payload["groups"]} + assert groups_map["2020"] == 2 + assert groups_map["2021"] == 1 + + +@pytest.mark.unit +def test_cli_read_stats_group_by_is_seed(tmp_path: Path) -> None: + """read stats --group-by is_seed agrupa por semilla.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="P1", is_seed=True), + _row(id="P2", is_seed=False), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "stats", + "--group-by", + "is_seed", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["group_by"] == "is_seed" + assert data["data"]["total"] == 2 + + +# --------------------------------------------------------------------------- +# 7. CLI — read show --json +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_show_por_id_json(tmp_path: Path) -> None: + """read show --id devuelve ~14 campos en el envelope.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row( + id="P1", + title="Paper de prueba", + year=2023, + doi="10.1/p1", + source_id="W001", + is_seed=True, + curation_status="accepted", + references_id=["R1"], + ) + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "show", "--id", "P1", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["command"] == "read show" + assert data["exit_code"] == 0 + payload = data["data"] + assert payload["id"] == "P1" + assert payload["title"] == "Paper de prueba" + assert payload["year"] == 2023 + assert payload["doi"] == "10.1/p1" + assert payload["source_id"] == "W001" + assert payload["is_seed"] is True + assert payload["curation_status"] == "accepted" + # provenance es lista + assert isinstance(payload["provenance"], list) + + +@pytest.mark.unit +def test_cli_read_show_por_doi_json(tmp_path: Path) -> None: + """read show --id resuelve por DOI.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [_row(id="W999", title="Paper DOI", doi="10.9999/test")], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "show", + "--id", + "10.9999/test", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["id"] == "W999" + + +@pytest.mark.unit +def test_cli_read_show_por_source_id_json(tmp_path: Path) -> None: + """read show --id resuelve por source_id.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [_row(id="P1", title="Paper source_id", source_id="W2741809807")], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "show", + "--id", + "W2741809807", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["id"] == "P1" + + +@pytest.mark.unit +def test_cli_read_show_inexistente_dataerror_exit2(tmp_path: Path) -> None: + """read show --id INEXISTENTE → DataError, ok=False, exit_code=2.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "show", + "--id", + "INEXISTENTE", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is False + assert data["exit_code"] == 2 + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# 8. read sin subcomando → imprime ayuda (exit 0) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_sin_subcomando_imprime_ayuda() -> None: + """b2g read sin subcomando → no_args_is_help=True → exit 0 con ayuda.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["read"]) + # no_args_is_help=True hace exit con código 0 y escribe la ayuda + assert result.exit_code == 0 + assert "list" in result.output + assert "stats" in result.output + assert "show" in result.output + + +# stdout de 1 línea JSON (list/show/stats): ya garantizado por _assert_one_json_line +# en los tests *_envelope_forma de cada comando (epic #184, sub-tarea 2). + + +# --------------------------------------------------------------------------- +# 10. read sin workspace → UsageError → envelope de error (exit 1) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_list_sin_workspace_exit1_json() -> None: + """read list --json sin workspace → UsageError → envelope error, exit 1.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["read", "list", "--json"], catch_exceptions=False) + data = _assert_one_json_line(result.stdout) + assert data["ok"] is False + assert result.exit_code == 1 diff --git a/tests/unit/test_cli_read_top.py b/tests/unit/test_cli_read_top.py new file mode 100644 index 0000000..830cf27 --- /dev/null +++ b/tests/unit/test_cli_read_top.py @@ -0,0 +1,600 @@ +"""Tests de ``b2g read top`` (sub-issue #157). + +Cubre: +1. Servicio ``get_top`` — unit puro, sin Click: + a. ``central`` ordenado por degree_centrality descendente, top N. + b. ``cocitation`` ordenado por peso descendente con títulos resueltos. + c. Kind default = ``bibliographic_coupling``. + d. Kind inválido → DataError. + e. n <= 0 → DataError. + f. Co-citación vacía (sin cited_by_id) → exit 0 + bloque vacío + + reason/fix_command (honest-empty, NO DataError). + +2. CLI ``read top --json``: + a. Envelope schema="1", command="read top", ok=True, exit_code=0. + b. Forma de ``central`` y ``cocitation`` en data. + c. ``--kind`` inválido → exit 2 (Click.Choice). + d. stdout puro: exactamente una línea JSON (#151). + +3. Invariante de neutralidad: ``get_top`` no viola la neutralidad de + transporte de ``service.reads``. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + *, + id: str, + title: str = "Título de prueba", + year: int | None = 2020, + is_seed: bool = True, + curation_status: str = "candidate", + doi: str | None = None, + source_id: str | None = None, + references_id: list[str] | None = None, + cited_by_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima con schema completo para tests de read top.""" + return { + "id": id, + "source_id": source_id, + "doi": doi, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": ["Autor A"], + "authors_id": ["oa:author1"], + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: + """Crea y devuelve un Workspace inicializado en tmp_path.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / name + return Workspace.init(ws_dir, name) + + +def _seed_store(ws: Any, rows: list[dict[str, Any]]) -> None: + """Persiste filas en el store del workspace.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + + +def _assert_one_json_line(stdout: str, *, schema: str = "1") -> dict[str, Any]: + """Aserta exactamente una línea JSON con schema correcto; devuelve el dict.""" + lines = [ln for ln in stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"Se esperaba exactamente 1 línea en stdout, se obtuvieron {len(lines)}:\n" + f"{stdout!r}" + ) + data = json.loads(lines[0]) + assert data.get("schema") == schema + return data + + +# --------------------------------------------------------------------------- +# Corpus con bibliographic coupling (3 papers con referencias compartidas) +# --------------------------------------------------------------------------- +# +# P1: refs [R1, R2, R3] → comparte con P2 (R1, R3) y P3 (R2) +# P2: refs [R1, R3] → comparte con P1 (R1, R3) +# P3: refs [R2] → comparte con P1 (R2) +# +# Red bibliographic_coupling: +# P1-P2 weight=2, P1-P3 weight=1 +# grados: P1=2, P2=1, P3=1 +# degree_centrality (n=3): P1=2/2=1.0, P2=1/2=0.5, P3=1/2=0.5 + + +def _bib_coupling_rows() -> list[dict[str, Any]]: + return [ + _row( + id="P1", + title="Paper Uno — título completo largo", + references_id=["R1", "R2", "R3"], + ), + _row( + id="P2", + title="Paper Dos", + references_id=["R1", "R3"], + ), + _row( + id="P3", + title="Paper Tres", + references_id=["R2"], + ), + ] + + +# --------------------------------------------------------------------------- +# Corpus con co-citación +# --------------------------------------------------------------------------- +# +# P1 (seed): cited_by=[C1, C2] +# P2 (seed): cited_by=[C1, C3] +# P3 (seed): cited_by=[C4] +# +# Red cocitation (seeds_only): +# P1-P2 weight=1 (comparten C1) +# P1 y P3 no comparten → sin arista +# P2 y P3 no comparten → sin arista + + +def _cocitation_rows() -> list[dict[str, Any]]: + return [ + _row(id="P1", title="Paper Uno Seed", is_seed=True, cited_by_id=["C1", "C2"]), + _row(id="P2", title="Paper Dos Seed", is_seed=True, cited_by_id=["C1", "C3"]), + _row(id="P3", title="Paper Tres Seed", is_seed=True, cited_by_id=["C4"]), + ] + + +# --------------------------------------------------------------------------- +# 1. Servicio get_top — central ordenado por degree_centrality desc +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_central_ordenado_por_degree_desc(tmp_path: Path) -> None: + """get_top devuelve central ordenado por degree_centrality descendente.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + result = get_top(ws, n=3, kind="bibliographic_coupling") + + central = result["central"] + assert len(central) >= 1 + # P1 tiene degree_centrality más alta (conectado a P2 y P3) + assert central[0]["id"] == "P1" + assert central[0]["degree_centrality"] >= central[-1]["degree_centrality"] + + # Verificar que el orden es descendente + dc_values = [node["degree_centrality"] for node in central] + assert dc_values == sorted(dc_values, reverse=True) + + +@pytest.mark.unit +def test_get_top_central_top_n_limita(tmp_path: Path) -> None: + """get_top con n=1 devuelve solo el nodo más central.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + result = get_top(ws, n=1, kind="bibliographic_coupling") + + assert len(result["central"]) == 1 + assert result["central"][0]["id"] == "P1" + assert result["top"] == 1 + + +@pytest.mark.unit +def test_get_top_central_tiene_titulo_completo(tmp_path: Path) -> None: + """get_top resuelve el título completo del corpus (no el label truncado).""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + result = get_top(ws, n=3, kind="bibliographic_coupling") + + node_map = {n["id"]: n for n in result["central"]} + # El título de P1 debe ser el completo del corpus + assert node_map["P1"]["title"] == "Paper Uno — título completo largo" + assert "id" in node_map["P1"] + assert "degree_centrality" in node_map["P1"] + + +# --------------------------------------------------------------------------- +# 2. Servicio get_top — cocitación por peso desc con títulos resueltos +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_cocitacion_con_titulos_resueltos(tmp_path: Path) -> None: + """get_top resuelve source_title y target_title desde el corpus.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _cocitation_rows()) + + result = get_top(ws, n=5, kind="cocitation") + + # Corpus con cited_by_id → cocitation no debería estar vacío + # P1 y P2 comparten C1 → 1 par en cocitation + assert len(result["cocitation"]) >= 1 + + pair = result["cocitation"][0] + assert "source" in pair + assert "source_title" in pair + assert "target" in pair + assert "target_title" in pair + assert "weight" in pair + + # Los ids deben ser P1 o P2 + pair_ids = {pair["source"], pair["target"]} + assert pair_ids == {"P1", "P2"} + + # Los títulos deben ser los del corpus + titles = {pair["source_title"], pair["target_title"]} + assert "Paper Uno Seed" in titles + assert "Paper Dos Seed" in titles + + +@pytest.mark.unit +def test_get_top_cocitacion_por_peso_desc(tmp_path: Path) -> None: + """get_top ordena los pares de co-citación por peso descendente.""" + from bib2graph.service.reads import get_top + + # Corpus con múltiples pares y pesos variados + # P1 y P2 comparten C1 y C2 → weight=2 + # P1 y P3 comparten C3 → weight=1 + rows = [ + _row(id="P1", title="P1", is_seed=True, cited_by_id=["C1", "C2", "C3"]), + _row(id="P2", title="P2", is_seed=True, cited_by_id=["C1", "C2"]), + _row(id="P3", title="P3", is_seed=True, cited_by_id=["C3"]), + ] + ws = _init_workspace(tmp_path) + _seed_store(ws, rows) + + result = get_top(ws, n=5, kind="cocitation") + + cocitation = result["cocitation"] + assert len(cocitation) >= 2 + + # Verificar orden descendente por peso + weights = [p["weight"] for p in cocitation] + assert weights == sorted(weights, reverse=True) + + # El par con mayor peso (P1-P2, weight=2) debe ser el primero + first = cocitation[0] + assert {first["source"], first["target"]} == {"P1", "P2"} + assert first["weight"] == 2 + + +# --------------------------------------------------------------------------- +# 3. Kind default = bibliographic_coupling +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_kind_default_es_bibliographic_coupling(tmp_path: Path) -> None: + """get_top sin kind explícito usa bibliographic_coupling.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + result = get_top(ws) # sin kind ni n → defaults + + assert result["kind"] == "bibliographic_coupling" + assert result["top"] == 10 + + +# --------------------------------------------------------------------------- +# 4. Kind inválido → DataError +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_kind_invalido_lanza_dataerror(tmp_path: Path) -> None: + """get_top con kind no reconocido lanza DataError (defensa del servicio).""" + from bib2graph.service.errors import DataError + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + with pytest.raises(DataError, match="no reconocido"): + get_top(ws, kind="red_inventada") + + +# --------------------------------------------------------------------------- +# 5. n <= 0 → DataError +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_n_cero_lanza_dataerror(tmp_path: Path) -> None: + """get_top con n=0 lanza DataError (n debe ser positivo).""" + from bib2graph.service.errors import DataError + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + with pytest.raises(DataError, match="positivo"): + get_top(ws, n=0) + + +@pytest.mark.unit +def test_get_top_n_negativo_lanza_dataerror(tmp_path: Path) -> None: + """get_top con n negativo lanza DataError.""" + from bib2graph.service.errors import DataError + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + with pytest.raises(DataError, match="positivo"): + get_top(ws, n=-5) + + +# --------------------------------------------------------------------------- +# 6. Co-citación vacía (sin cited_by_id) → honest-empty, NO DataError +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_get_top_cocitacion_vacia_es_honest_empty(tmp_path: Path) -> None: + """get_top con corpus sin cited_by_id devuelve cocitation=[] + reason/fix_command. + + Este test verifica explícitamente que NO se lanza DataError cuando la red + de co-citación está vacía (error común: devolver exit 2 en vez de exit 0). + """ + from bib2graph.service.reads import get_top + + # Corpus solo con references_id (bib. coupling OK) pero sin cited_by_id + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) # filas sin cited_by_id + + # NO debe lanzar DataError ni ninguna excepción + result = get_top(ws, n=5, kind="bibliographic_coupling") + + assert result["cocitation"] == [] + # Debe incluir reason y fix_command (honest-empty) + assert "reason" in result, "Se esperaba 'reason' cuando co-citación está vacía" + assert "fix_command" in result, ( + "Se esperaba 'fix_command' cuando co-citación está vacía" + ) + assert result["fix_command"] is not None + # El fix_command debe apuntar al enriquecimiento + assert "enrich" in str(result["fix_command"]).lower() + + +@pytest.mark.unit +def test_get_top_cocitacion_vacia_kind_cocitation_honest_empty( + tmp_path: Path, +) -> None: + """get_top con kind=cocitation y corpus sin cited_by_id → honest-empty.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + # Corpus sin cited_by_id en ningún paper + _seed_store( + ws, + [ + _row(id="P1", title="Paper 1", cited_by_id=None), + _row(id="P2", title="Paper 2", cited_by_id=None), + ], + ) + + result = get_top(ws, n=5, kind="cocitation") + + assert result["central"] == [] # cocitation sin datos → sin nodos + assert result["cocitation"] == [] + assert "reason" in result + assert "fix_command" in result + + +@pytest.mark.unit +def test_get_top_cocitacion_llena_no_tiene_reason(tmp_path: Path) -> None: + """get_top con co-citación no vacía NO incluye reason ni fix_command.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_store(ws, _cocitation_rows()) + + result = get_top(ws, n=5, kind="cocitation") + + # Con datos de co-citación, no debe haber reason + assert "reason" not in result + assert "fix_command" not in result + + +# --------------------------------------------------------------------------- +# 7. CLI read top --json — forma del envelope +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_top_json_envelope_forma(tmp_path: Path) -> None: + """read top --json devuelve envelope schema='1', ok=True, command='read top'.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "top", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["command"] == "read top" + assert data["exit_code"] == 0 + assert "central" in data["data"] + assert "cocitation" in data["data"] + assert "kind" in data["data"] + assert "top" in data["data"] + assert data["data"]["kind"] == "bibliographic_coupling" + assert data["data"]["top"] == 10 + + +@pytest.mark.unit +def test_cli_read_top_json_top_n_flag(tmp_path: Path) -> None: + """read top --top 2 --json respeta el valor de --top.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "top", "--top", "2", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["top"] == 2 + assert len(data["data"]["central"]) <= 2 + + +@pytest.mark.unit +def test_cli_read_top_json_n_shorthand(tmp_path: Path) -> None: + """read top -n 1 --json funciona como alias de --top.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "top", "-n", "1", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["top"] == 1 + assert len(data["data"]["central"]) <= 1 + + +@pytest.mark.unit +def test_cli_read_top_json_kind_cocitation(tmp_path: Path) -> None: + """read top --kind cocitation --json usa la red de co-citación para central.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, _cocitation_rows()) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "top", + "--kind", + "cocitation", + "--json", + ], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + assert data["ok"] is True + assert data["data"]["kind"] == "cocitation" + + +# --------------------------------------------------------------------------- +# 8. CLI read top con co-citación vacía → exit 0 + bloque vacío (NOT exit 2) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_top_cocitacion_vacia_exit0(tmp_path: Path) -> None: + """read top con corpus sin cited_by_id → exit 0 + cocitation vacía. + + Verifica el invariante honest-empty desde el CLI: co-citación vacía + NO debe producir exit 2 (DataError). + """ + ws = _init_workspace(tmp_path) + _seed_store(ws, _bib_coupling_rows()) # sin cited_by_id + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "read", "top", "--json"], + catch_exceptions=False, + ) + data = _assert_one_json_line(result.stdout) + + # Exit 0, ok=True — NO DataError + assert result.exit_code == 0, ( + f"Se esperaba exit 0 (honest-empty), se obtuvo {result.exit_code}.\n" + f"stdout: {result.stdout!r}" + ) + assert data["ok"] is True + assert data["exit_code"] == 0 + assert data["data"]["cocitation"] == [] + # reason y fix_command deben estar en data + assert "reason" in data["data"] + assert "fix_command" in data["data"] + + +# --------------------------------------------------------------------------- +# 9. CLI --kind inválido → exit 2 (Click.Choice intercepta antes del handler) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cli_read_top_kind_invalido_exit_nonzero(tmp_path: Path) -> None: + """read top --kind inválido → Click.Choice → exit no-cero.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "read", + "top", + "--kind", + "red_inventada", + "--json", + ], + ) + # Click.Choice intercepta antes de que el handler lo vea → exit no-cero + assert result.exit_code != 0 + + +# stdout de 1 línea JSON (#151) para read top: ya garantizado por +# _assert_one_json_line en test_cli_read_top_json_envelope_forma (epic #184, sub-tarea 2). + + +# Neutralidad de transporte de service.reads (incl. get_top): consolidada en +# test_service.py::test_service_modulo_neutral_de_transporte (epic #184). diff --git a/tests/unit/test_curate.py b/tests/unit/test_curate.py index 3c49cef..ca89bac 100644 --- a/tests/unit/test_curate.py +++ b/tests/unit/test_curate.py @@ -590,45 +590,6 @@ def test_run_curate_from_csv_devuelve_claves_esperadas(tmp_path: Path) -> None: assert "total_rows" in data -# --------------------------------------------------------------------------- -# 12. Exclusividad de modos: --dump y --from-csv juntos → UsageError -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_dump_y_from_csv_simultaneos_lanzan_usage_error(tmp_path: Path) -> None: - """--dump y --from-csv juntos son mutuamente excluyentes → UsageError.""" - from bib2graph.cli._errors import UsageError - - # No hay una función combinada; el enforcement es en el Click command. - # Lo verificamos directamente a través de la lógica del command. - # Para testear sin Click, reproducimos la lógica de exclusividad. - do_dump = True - from_csv = "algo.csv" - - if do_dump and from_csv: - with pytest.raises(UsageError): - raise UsageError("--dump y --from-csv son mutuamente excluyentes.") - - -# --------------------------------------------------------------------------- -# 13. Ningún modo → UsageError -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_ningún_modo_lanza_usage_error() -> None: - """Sin --dump ni --from-csv, la lógica del comando lanza UsageError.""" - from bib2graph.cli._errors import UsageError - - do_dump = False - from_csv = None - - if not do_dump and from_csv is None: - with pytest.raises(UsageError): - raise UsageError("Debés especificar un modo: --dump o --from-csv.") - - # --------------------------------------------------------------------------- # 14. dump --all incluye accepted y rejected además de candidate # --------------------------------------------------------------------------- diff --git a/tests/unit/test_curate_grp.py b/tests/unit/test_curate_grp.py new file mode 100644 index 0000000..48360db --- /dev/null +++ b/tests/unit/test_curate_grp.py @@ -0,0 +1,881 @@ +"""Tests del grupo noun-verb ``b2g curate`` (sub-issue #155, superficie CLI 0.10.0). + +Cubre: + +Forma del envelope (todos los subcomandos): + 1. curate dump → exit 0, schema="1", command="curate dump", ok=True, stdout puro. + 2. curate apply → exit 0, schema="1", command="curate apply", ok=True, stdout puro. + 3. curate accept → exit 0, schema="1", command="curate accept", ok=True, stdout puro. + 4. curate reject → exit 0, schema="1", command="curate reject", ok=True, stdout puro. + 5. curate filter → exit 0, schema="1", command="curate filter", ok=True, stdout puro. + +Sin subcomando: + 6. b2g curate → help + exit 0. + +Breaking changes: + 7. curate dump --all → "No such option" (opción eliminada). + +FSM (#155, precedente D1 de #159): + 8. curate filter → transiciona a FILTERED. + 9. curate accept, reject, dump, apply → NO transicionan el FSM. + +Round-trip: + 10. curate dump → editar CSV → curate apply → corpus refleja decisiones. + 11. curate apply con IDs huérfanos → not_found_count reportado. + +Regresión #165 (verbos sueltos intactos): + 12. b2g accept --ids … → envelope command="accept", misma transición (ninguna). + 13. b2g reject --ids … → envelope command="reject", misma transición (ninguna). + 14. b2g filter --year-gte … → envelope command="filter", misma transición FILTERED. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import csv +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + *, + id: str, + title: str = "Título de prueba", + year: int = 2020, + is_seed: bool = False, + curation_status: str = "candidate", + language: str = "en", +) -> dict[str, Any]: + """Fila mínima con schema completo para tests.""" + return { + "id": id, + "source_id": None, + "doi": None, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": language, + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": None, + "references_doi": None, + "cited_by_id": None, + } + + +def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: + """Crea y devuelve un Workspace inicializado en tmp_path.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / name + return Workspace.init(ws_dir, name) + + +def _seed_store(ws: Any, rows: list[dict[str, Any]]) -> None: + """Persiste filas en el store del workspace.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + store.close() + + +def _assert_one_json_line(stdout: str, *, schema: str = "1") -> dict[str, Any]: + """Aserta exactamente una línea JSON con schema correcto; devuelve el dict.""" + lines = [ln for ln in stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"Se esperaba exactamente 1 línea en stdout, se obtuvieron {len(lines)}:\n" + f"{stdout!r}" + ) + data = json.loads(lines[0]) + assert data.get("schema") == schema + return data + + +def _get_loop_state(ws: Any) -> Any: + """Lee el loop_state del backend DuckDB del workspace.""" + from bib2graph.stores.duckdb import DuckDBStore + + store = DuckDBStore(ws.library_path) + state = store.backend.loop_state() + store.close() + return state + + +# --------------------------------------------------------------------------- +# 1. curate dump → envelope correcto +# --------------------------------------------------------------------------- + + +def test_curate_dump_envelope_correcto(tmp_path: Path) -> None: + """curate dump --json emite envelope schema='1', command='curate dump', ok=True.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="C1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "curate dump" + assert "csv_path" in data["data"] + assert "papers_exported" in data["data"] + + +def test_curate_dump_stdout_puro(tmp_path: Path) -> None: + """curate dump --json: stdout tiene exactamente 1 línea JSON (sin ruido).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="C1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + lineas = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lineas) == 1 + + +# --------------------------------------------------------------------------- +# 2. curate apply → envelope correcto +# --------------------------------------------------------------------------- + + +def test_curate_apply_envelope_correcto(tmp_path: Path) -> None: + """curate apply --json emite envelope correcto.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1"), _row(id="P2")]) + + # Generar CSV con curate dump + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump"], + catch_exceptions=False, + ) + csv_path = ws.exports_dir / "curacion.csv" + assert csv_path.exists() + + # Aplicar decisiones + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "apply", str(csv_path), "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "curate apply" + assert "accepted_count" in data["data"] + assert "rejected_count" in data["data"] + assert "skipped_count" in data["data"] + assert "not_found_count" in data["data"] + assert "total_rows" in data["data"] + + +# --------------------------------------------------------------------------- +# 3. curate accept → envelope correcto +# --------------------------------------------------------------------------- + + +def test_curate_accept_envelope_correcto(tmp_path: Path) -> None: + """curate accept --ids P1 --json emite envelope correcto.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1"), _row(id="P2")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "accept", + "--ids", + "P1", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "curate accept" + assert data["data"]["accepted_count"] == 1 + + +# --------------------------------------------------------------------------- +# 4. curate reject → envelope correcto +# --------------------------------------------------------------------------- + + +def test_curate_reject_envelope_correcto(tmp_path: Path) -> None: + """curate reject --ids P1 --json emite envelope correcto.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1"), _row(id="P2")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "reject", + "--ids", + "P1", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "curate reject" + assert data["data"]["rejected_count"] == 1 + + +# --------------------------------------------------------------------------- +# 5. curate filter → envelope correcto +# --------------------------------------------------------------------------- + + +def test_curate_filter_envelope_correcto(tmp_path: Path) -> None: + """curate filter --year-gte 1900 --json emite envelope correcto.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020), _row(id="P2", year=2021)]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "filter", + "--year-gte", + "1900", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "curate filter" + assert "steps" in data["data"] + assert "total_papers" in data["data"] + + +# --------------------------------------------------------------------------- +# 6. curate sin subcomando → help + exit 0 +# --------------------------------------------------------------------------- + + +def test_curate_sin_subcomando_imprime_ayuda() -> None: + """b2g curate sin subcomando imprime ayuda y sale con exit 0.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["curate"]) + assert result.exit_code == 0 + assert "dump" in result.output + assert "apply" in result.output + assert "accept" in result.output + assert "reject" in result.output + assert "filter" in result.output + + +# --------------------------------------------------------------------------- +# 7. curate dump --all → "No such option" (BREAKING #155) +# --------------------------------------------------------------------------- + + +def test_curate_dump_all_no_existe(tmp_path: Path) -> None: + """curate dump --all fue eliminado (#155); Click debe reportar 'No such option'.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="C1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump", "--all"], + ) + # Click reporta "No such option" y sale con exit != 0 + assert result.exit_code != 0 + assert "no such option" in result.output.lower() + + +# --------------------------------------------------------------------------- +# 8. curate filter → transiciona a FILTERED (FSM) +# --------------------------------------------------------------------------- + + +def test_curate_filter_transiciona_a_filtered(tmp_path: Path) -> None: + """curate filter transiciona el CycleState a FILTERED (el verbo define la transición).""" + from bib2graph.cycle import CycleState + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "filter", + "--year-gte", + "1900", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + + estado_post = _get_loop_state(ws) + assert estado_post == CycleState.FILTERED, ( + f"curate filter debe transicionar a FILTERED, pero quedó en {estado_post}" + ) + + +# --------------------------------------------------------------------------- +# 9. curate accept/reject/dump/apply → NO transicionan el FSM +# --------------------------------------------------------------------------- + + +def test_curate_accept_no_transiciona_fsm(tmp_path: Path) -> None: + """curate accept NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + estado_previo = _get_loop_state(ws) + + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "accept", "--ids", "P1"], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo, ( + f"curate accept no debe cambiar el CycleState: {estado_previo} → {estado_post}" + ) + + +def test_curate_reject_no_transiciona_fsm(tmp_path: Path) -> None: + """curate reject NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + estado_previo = _get_loop_state(ws) + + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "reject", "--ids", "P1"], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo, ( + f"curate reject no debe cambiar el CycleState: {estado_previo} → {estado_post}" + ) + + +def test_curate_dump_no_transiciona_fsm(tmp_path: Path) -> None: + """curate dump NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="C1")]) + + estado_previo = _get_loop_state(ws) + + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump"], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo, ( + f"curate dump no debe cambiar el CycleState: {estado_previo} → {estado_post}" + ) + + +def test_curate_apply_no_transiciona_fsm(tmp_path: Path) -> None: + """curate apply NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="C1")]) + + estado_previo = _get_loop_state(ws) + + # Generar CSV de decisiones + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump"], + catch_exceptions=False, + ) + csv_path = ws.exports_dir / "curacion.csv" + + runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "apply", str(csv_path)], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo, ( + f"curate apply no debe cambiar el CycleState: {estado_previo} → {estado_post}" + ) + + +# --------------------------------------------------------------------------- +# 10. Round-trip: dump → editar CSV → apply +# --------------------------------------------------------------------------- + + +def test_round_trip_dump_apply(tmp_path: Path) -> None: + """curate dump → editar decision → curate apply → corpus refleja decisiones.""" + from bib2graph.stores.duckdb import DuckDBStore + + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="PA", title="Paper A"), + _row(id="PB", title="Paper B"), + _row(id="PC", title="Paper C"), + ], + ) + + runner = CliRunner() + + # 1) dump + result_dump = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "dump", "--json"], + catch_exceptions=False, + ) + assert result_dump.exit_code == 0 + data_dump = json.loads(result_dump.stdout.strip()) + csv_path = Path(data_dump["data"]["csv_path"]) + assert csv_path.exists() + + # 2) Editar el CSV: PA → accepted, PB → rejected + from bib2graph.service.curate import CSV_COLUMNS + + rows: list[dict[str, str]] = [] + with open(csv_path, newline="", encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + for row in rows: + if row["id"] == "PA": + row["decision"] = "accepted" + elif row["id"] == "PB": + row["decision"] = "rejected" + # PC queda undecided + + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS) + writer.writeheader() + writer.writerows(rows) + + # 3) apply + result_apply = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "apply", str(csv_path), "--json"], + catch_exceptions=False, + ) + assert result_apply.exit_code == 0, result_apply.output + data_apply = json.loads(result_apply.stdout.strip()) + assert data_apply["data"]["accepted_count"] == 1 + assert data_apply["data"]["rejected_count"] == 1 + assert data_apply["data"]["skipped_count"] == 1 + assert data_apply["data"]["not_found_count"] == 0 + + # 4) Verificar estado del corpus (to_arrow antes de close — DuckDB requiere conexión activa) + store = DuckDBStore(ws.library_path) + corpus = store.load() + by_id = {r["id"]: r for r in corpus.to_arrow().to_pylist()} + store.close() + + assert by_id["PA"]["curation_status"] == "accepted" + assert by_id["PB"]["curation_status"] == "rejected" + assert by_id["PC"]["curation_status"] == "candidate" + + +# --------------------------------------------------------------------------- +# 11. curate apply con IDs huérfanos → not_found_count reportado +# --------------------------------------------------------------------------- + + +def test_curate_apply_ids_huerfanos_reportados(tmp_path: Path) -> None: + """curate apply con IDs que no existen en el corpus → not_found_count > 0, sin abort.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + # Crear CSV con un ID huérfano + csv_path = tmp_path / "decisiones.csv" + from bib2graph.service.curate import CSV_COLUMNS + + with open(csv_path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS) + writer.writeheader() + writer.writerow( + {**{c: "" for c in CSV_COLUMNS}, "id": "P1", "decision": "accepted"} + ) + writer.writerow( + {**{c: "" for c in CSV_COLUMNS}, "id": "P_FANTASMA", "decision": "accepted"} + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "curate", "apply", str(csv_path), "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["data"]["accepted_count"] == 1 + assert data["data"]["not_found_count"] == 1 + + +# --------------------------------------------------------------------------- +# 12. Regresión #165: b2g accept suelto sigue funcionando +# --------------------------------------------------------------------------- + + +def test_accept_suelto_envelope_intacto(tmp_path: Path) -> None: + """b2g accept (suelto) sigue emitiendo envelope con command='accept'.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "accept", "--ids", "P1", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "accept" + assert data["data"]["accepted_count"] == 1 + + +def test_accept_suelto_no_transiciona_fsm(tmp_path: Path) -> None: + """b2g accept suelto NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + estado_previo = _get_loop_state(ws) + + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "accept", "--ids", "P1"], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo + + +# --------------------------------------------------------------------------- +# 13. Regresión #165: b2g reject suelto sigue funcionando +# --------------------------------------------------------------------------- + + +def test_reject_suelto_envelope_intacto(tmp_path: Path) -> None: + """b2g reject (suelto) sigue emitiendo envelope con command='reject'.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "reject", "--ids", "P1", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "reject" + assert data["data"]["rejected_count"] == 1 + + +def test_reject_suelto_no_transiciona_fsm(tmp_path: Path) -> None: + """b2g reject suelto NO transiciona el CycleState (curación transversal).""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + estado_previo = _get_loop_state(ws) + + runner = CliRunner() + runner.invoke( + b2g, + ["--workspace", str(ws.root), "reject", "--ids", "P1"], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == estado_previo + + +# --------------------------------------------------------------------------- +# 14. Regresión #165: b2g filter suelto sigue funcionando y transiciona a FILTERED +# --------------------------------------------------------------------------- + + +def test_filter_suelto_envelope_intacto(tmp_path: Path) -> None: + """b2g filter (suelto) sigue emitiendo envelope con command='filter'.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "filter", + "--year-gte", + "1900", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + data = _assert_one_json_line(result.stdout) + assert data["ok"] is True + assert data["command"] == "filter" + + +def test_filter_suelto_transiciona_a_filtered(tmp_path: Path) -> None: + """b2g filter suelto transiciona a FILTERED (mismo comportamiento que curate filter).""" + from bib2graph.cycle import CycleState + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + + runner = CliRunner() + runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "filter", + "--year-gte", + "1900", + ], + catch_exceptions=False, + ) + + estado_post = _get_loop_state(ws) + assert estado_post == CycleState.FILTERED + + +# --------------------------------------------------------------------------- +# 15. curate accept --ids múltiples en una invocación +# --------------------------------------------------------------------------- + + +def test_curate_accept_ids_multiples(tmp_path: Path) -> None: + """curate accept acepta múltiples IDs con --ids repetido.""" + from bib2graph.stores.duckdb import DuckDBStore + + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1"), _row(id="P2"), _row(id="P3")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "accept", + "--ids", + "P1", + "--ids", + "P2", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["data"]["accepted_count"] == 2 + + store = DuckDBStore(ws.library_path) + corpus = store.load() + by_id = {r["id"]: r for r in corpus.to_arrow().to_pylist()} + store.close() + assert by_id["P1"]["curation_status"] == "accepted" + assert by_id["P2"]["curation_status"] == "accepted" + assert by_id["P3"]["curation_status"] == "candidate" + + +# --------------------------------------------------------------------------- +# 16. curate dump --scope seeds exporta solo semillas +# --------------------------------------------------------------------------- + + +def test_curate_dump_scope_seeds(tmp_path: Path) -> None: + """curate dump --scope seeds exporta solo papers con is_seed=True.""" + ws = _init_workspace(tmp_path) + _seed_store( + ws, + [ + _row(id="S1", is_seed=True), + _row(id="S2", is_seed=True), + _row(id="C1", is_seed=False), + ], + ) + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "curate", + "dump", + "--scope", + "seeds", + "--json", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + data = json.loads(result.stdout.strip()) + assert data["data"]["papers_exported"] == 2 + + +# --------------------------------------------------------------------------- +# 17. Neutralidad de reloj (R2 / ADR 0017 enmendado): filter_corpus recibe +# decided_at inyectado y lo pasa a apply_filters +# --------------------------------------------------------------------------- + + +def test_filter_corpus_pasa_decided_at_a_apply_filters(tmp_path: Path) -> None: + """filter_corpus pasa el decided_at inyectado a apply_filters (R2/ADR 0017). + + Verifica que el servicio no genera su propio timestamp: lo recibe del llamador + y lo propaga sin modificarlo al núcleo. + """ + from datetime import UTC, datetime + from unittest.mock import patch + + from bib2graph.service.curate import filter_corpus + + store_path = tmp_path / "test.duckdb" + + # Seed mínimo + import pyarrow as pa + + from bib2graph.corpus import Corpus + from bib2graph.schemas import CORPUS_SCHEMA + from bib2graph.stores.duckdb import DuckDBStore + + row = _row(id="P1", year=2020) + table = pa.Table.from_pylist([row], schema=CORPUS_SCHEMA) + corpus_obj = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus_obj) + store.close() + + sentinel = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC) + + captured: dict[str, Any] = {} + + original_apply_filters = __import__( + "bib2graph.filters.prisma", fromlist=["apply_filters"] + ).apply_filters + + def _spy_apply_filters(corpus, criteria, *, decided_at=None): # type: ignore[no-untyped-def] + captured["decided_at"] = decided_at + return original_apply_filters(corpus, criteria, decided_at=decided_at) + + with patch( + "bib2graph.filters.prisma.apply_filters", side_effect=_spy_apply_filters + ): + filter_corpus(store_path, year_gte=2000, decided_at=sentinel) + + assert captured.get("decided_at") == sentinel, ( + f"filter_corpus no propagó el decided_at inyectado: {captured.get('decided_at')!r}" + ) + + +def test_service_curate_no_llama_datetime_now() -> None: + """service/curate.py no contiene llamadas directas a datetime.now() (R2/ADR 0017). + + El reloj es responsabilidad del llamador (frontera CLI), no del servicio. + Detección por AST sobre el source real del módulo. + """ + import ast + import importlib + import pathlib + + mod = importlib.import_module("bib2graph.service.curate") + source_file = mod.__file__ + assert source_file is not None + tree = ast.parse(pathlib.Path(source_file).read_text(encoding="utf-8")) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + # Detecta patrones: datetime.now(...), _datetime.now(...), UTC.now(...) + if isinstance(func, ast.Attribute) and func.attr == "now": + obj = func.value + obj_name = ( + obj.id + if isinstance(obj, ast.Name) + else obj.attr + if isinstance(obj, ast.Attribute) + else None + ) + if obj_name in {"datetime", "_datetime", "UTC"}: + raise AssertionError( + f"service/curate.py llama a {obj_name}.now() en línea " + f"{node.lineno} — viola R2/ADR 0017: el reloj debe inyectarse " + "desde la frontera CLI, no generarse en el servicio." + ) diff --git a/tests/unit/test_deprecation_aliases.py b/tests/unit/test_deprecation_aliases.py new file mode 100644 index 0000000..b8eb88a --- /dev/null +++ b/tests/unit/test_deprecation_aliases.py @@ -0,0 +1,664 @@ +"""Tests de los alias deprecados y el entry-point ``bib2graph`` (ADR 0038, #165). + +Poda epic #184: solo se conserva la verificación del aviso de deprecación. +La delegación funcional y el contrato de schema/stdout se cubren en los tests +del verbo canónico (test_cli.py y sus homólogos). + +Casos cubiertos por alias: + 1. El aviso de deprecación va a **stderr** (result.stderr contiene "AVISO"). + 2. En modo ``--json``, ``envelope["warnings"]`` contiene el mensaje de deprecación + (el helper _assert_one_json_stdout también verifica stdout==1 línea y schema=="1"). + +Comandos deprecados testeados: + - ``b2g accept`` → ``b2g curate accept`` + - ``b2g reject`` → ``b2g curate reject`` + - ``b2g filter`` → ``b2g curate filter`` + - ``b2g inspect`` → ``b2g read show`` / ``b2g status`` + - ``b2g restore`` → ``b2g snapshot restore`` + - ``b2g networks`` → ``b2g build --spec`` + +Casos de error (sin workspace → error envelope en stdout, aviso igual a stderr): + - ``b2g monitor --json`` → error envelope, pero aviso sigue en stderr + - ``b2g resolve --json`` → error envelope, pero aviso sigue en stderr + - ``b2g enrich --json`` → error envelope, pero aviso sigue en stderr + +Entry-point legado: + - ``bib2graph`` (``main_bib2graph_alias``): avisa a stderr y delega en ``main()``. + +Helper ``emit_deprecation``: + - Escribe el aviso a stderr y devuelve el mensaje. + - El formato es el canónico ("AVISO: '...' está deprecado y se eliminará en ..."). + +Filosofía (AGENTS.md): se testean las funciones detrás de los comandos. +CliRunner solo donde hay integración de flag necesaria. +Marcador: ``unit`` (DuckDB en tmp_path; sin red real). +""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pyarrow as pa +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + *, + id: str, + title: str = "Título de prueba", + year: int = 2020, + is_seed: bool = False, + curation_status: str = "candidate", + language: str = "en", +) -> dict[str, Any]: + """Fila mínima con schema completo.""" + return { + "id": id, + "source_id": None, + "doi": None, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": language, + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": None, + "references_doi": None, + "cited_by_id": None, + } + + +def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: + """Crea y devuelve un Workspace inicializado.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / name + return Workspace.init(ws_dir, name) + + +def _seed_store(ws: Any, rows: list[dict[str, Any]]) -> None: + """Persiste filas en el store del workspace.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + store.close() + + +def _make_parquet(path: Path) -> Path: + """Escribe un parquet mínimo con el schema canónico.""" + import pyarrow.parquet as pq + + rows = [_row(id="P1"), _row(id="P2")] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + pq.write_table(table, str(path)) + return path + + +def _assert_one_json_stdout(result: Any, *, schema: str = "1") -> dict[str, Any]: + """Verifica que result.stdout es exactamente 1 línea JSON con schema correcto. + + En Click 8.4.1, result.stdout es solo stdout (no incluye stderr). + """ + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"stdout debe ser exactamente 1 línea JSON, tuvo {len(lines)}:\n" + f"stdout={result.stdout!r}\nstderr={result.stderr!r}" + ) + data = json.loads(lines[0]) + assert data.get("schema") == schema, ( + f"schema esperado '{schema}', obtenido {data.get('schema')!r}" + ) + return data + + +# --------------------------------------------------------------------------- +# Tests del helper emit_deprecation +# --------------------------------------------------------------------------- + + +class TestEmitDeprecation: + """Verifica el helper puro ``emit_deprecation``.""" + + def test_escribe_a_stderr(self) -> None: + """emit_deprecation imprime el aviso a stderr.""" + from bib2graph.cli._deprecation import emit_deprecation + + buf = io.StringIO() + with patch("sys.stderr", buf): + emit_deprecation("b2g viejo", "b2g nuevo") + assert "AVISO" in buf.getvalue() + assert "b2g viejo" in buf.getvalue() + assert "b2g nuevo" in buf.getvalue() + + def test_retorna_el_mensaje(self) -> None: + """emit_deprecation devuelve el mensaje emitido.""" + from bib2graph.cli._deprecation import emit_deprecation + + buf = io.StringIO() + with patch("sys.stderr", buf): + msg = emit_deprecation("b2g viejo", "b2g nuevo") + assert msg == buf.getvalue().strip() + + def test_formato_canonico(self) -> None: + """El mensaje sigue el formato canónico con versión de retiro.""" + from bib2graph.cli._deprecation import emit_deprecation + + buf = io.StringIO() + with patch("sys.stderr", buf): + msg = emit_deprecation("b2g networks", "b2g build --spec") + assert "0.11.0" in msg + assert "b2g networks" in msg + assert "b2g build --spec" in msg + + def test_version_custom(self) -> None: + """removed_in personalizable.""" + from bib2graph.cli._deprecation import emit_deprecation + + buf = io.StringIO() + with patch("sys.stderr", buf): + msg = emit_deprecation("b2g old", "b2g new", removed_in="1.0.0") + assert "1.0.0" in msg + + def test_no_escribe_a_stdout(self) -> None: + """emit_deprecation NO escribe a stdout.""" + from bib2graph.cli._deprecation import emit_deprecation + + stderr_buf = io.StringIO() + stdout_buf = io.StringIO() + with patch("sys.stderr", stderr_buf), patch("sys.stdout", stdout_buf): + emit_deprecation("b2g viejo", "b2g nuevo") + assert stdout_buf.getvalue() == "" + assert "AVISO" in stderr_buf.getvalue() + + +# --------------------------------------------------------------------------- +# Tests b2g accept (alias deprecado → b2g curate accept) +# --------------------------------------------------------------------------- + + +class TestAcceptDeprecado: + """b2g accept emite aviso de deprecación y delega en curate accept. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g curate accept' (test_cli.py). Aquí solo se conserva el aviso (epic #184). + """ + + def test_aviso_va_a_stderr(self, tmp_path: Path) -> None: + """b2g accept emite aviso de deprecación a stderr.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "accept", "--ids", "P1"], + catch_exceptions=False, + ) + assert "deprecad" in result.stderr.lower(), ( + f"Esperaba aviso en stderr, obtuvo: {result.stderr!r}" + ) + assert "curate accept" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + """b2g accept --json: envelope['warnings'] contiene el aviso de deprecación.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "accept", "--ids", "P1", "--json"], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"], "warnings debe ser no-vacío" + assert any("deprecad" in w.lower() for w in envelope["warnings"]) + assert any("curate accept" in w.lower() for w in envelope["warnings"]) + + +# --------------------------------------------------------------------------- +# Tests b2g reject (alias deprecado → b2g curate reject) +# --------------------------------------------------------------------------- + + +class TestRejectDeprecado: + """b2g reject emite aviso de deprecación y delega en curate reject. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g curate reject' (test_cli.py). Aquí solo se conserva el aviso (epic #184). + """ + + def test_aviso_va_a_stderr(self, tmp_path: Path) -> None: + """b2g reject emite aviso de deprecación a stderr.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "reject", "--ids", "P1"], + catch_exceptions=False, + ) + assert "deprecad" in result.stderr.lower() + assert "curate reject" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + """b2g reject --json: envelope['warnings'] contiene el aviso.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "reject", "--ids", "P1", "--json"], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"] + assert any("curate reject" in w.lower() for w in envelope["warnings"]) + + +# --------------------------------------------------------------------------- +# Tests b2g filter (alias deprecado → b2g curate filter) +# --------------------------------------------------------------------------- + + +class TestFilterDeprecado: + """b2g filter emite aviso de deprecación y delega en curate filter. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g curate filter' (test_cli.py). Aquí solo se conserva el aviso (epic #184). + """ + + def test_aviso_va_a_stderr(self, tmp_path: Path) -> None: + """b2g filter emite aviso de deprecación a stderr.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "filter", "--year-gte", "1900"], + catch_exceptions=False, + ) + assert "deprecad" in result.stderr.lower() + assert "curate filter" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + """b2g filter --json: envelope['warnings'] contiene el aviso.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "filter", "--year-gte", "1900", "--json"], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"] + assert any("curate filter" in w.lower() for w in envelope["warnings"]) + + +# --------------------------------------------------------------------------- +# Tests b2g inspect (alias deprecado → b2g read show / b2g status) +# --------------------------------------------------------------------------- + + +class TestInspectDeprecado: + """b2g inspect emite aviso de deprecación y delega en read show / status. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g read show' y 'b2g status' (test_cli.py). Aquí solo se conserva el aviso + (epic #184). + """ + + def test_aviso_va_a_stderr_sin_id(self, tmp_path: Path) -> None: + """b2g inspect sin --id avisa 'b2g status' en stderr.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "inspect"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert "deprecad" in result.stderr.lower() + assert "b2g status" in result.stderr.lower() + + def test_aviso_va_a_stderr_con_id(self, tmp_path: Path) -> None: + """b2g inspect con --id avisa 'b2g read show' en stderr.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "inspect", "--id", "P1"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert "deprecad" in result.stderr.lower() + assert "read show" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + """b2g inspect --json: envelope['warnings'] contiene el aviso.""" + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1")]) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "inspect", "--json"], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"] + assert any("deprecad" in w.lower() for w in envelope["warnings"]) + + +# --------------------------------------------------------------------------- +# Tests b2g restore (alias deprecado → b2g snapshot restore) +# --------------------------------------------------------------------------- + + +class TestRestoreDeprecado: + """b2g restore emite aviso de deprecación y delega en snapshot restore. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g snapshot restore' (test_cli.py). Aquí solo se conserva el aviso (epic #184). + """ + + def test_aviso_va_a_stderr(self, tmp_path: Path) -> None: + """b2g restore emite aviso de deprecación a stderr.""" + ws = _init_workspace(tmp_path) + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "restore", + "--from-corpus", + str(parquet_path), + ], + catch_exceptions=False, + ) + assert "deprecad" in result.stderr.lower() + assert "snapshot restore" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + """b2g restore --json: envelope['warnings'] contiene el aviso.""" + ws = _init_workspace(tmp_path) + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws.root), + "restore", + "--from-corpus", + str(parquet_path), + "--json", + ], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"] + assert any("snapshot restore" in w.lower() for w in envelope["warnings"]) + + +# --------------------------------------------------------------------------- +# Tests aliases en modo error (sin workspace): +# aviso sigue yendo a stderr, y el error envelope es 1 línea en stdout +# --------------------------------------------------------------------------- + + +class TestAvisosEnModError: + """En el camino de error (sin workspace), el aviso igual va a stderr (#151).""" + + @pytest.mark.parametrize( + "cmd_args", + [ + ["monitor", "--json"], + ["resolve", "--json"], + ["enrich", "--json"], + ], + ) + def test_aviso_a_stderr_en_error(self, cmd_args: list[str]) -> None: + """Sin workspace, el aviso de deprecación sigue yendo a stderr.""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, cmd_args, catch_exceptions=False) + # El aviso fue a stderr + assert "deprecad" in result.stderr.lower(), ( + f"cmd={cmd_args}, stderr={result.stderr!r}" + ) + + @pytest.mark.parametrize( + "cmd_args", + [ + ["monitor", "--json"], + ["resolve", "--json"], + ["enrich", "--json"], + ], + ) + def test_stdout_una_linea_json_en_error(self, cmd_args: list[str]) -> None: + """Sin workspace con --json: stdout es 1 línea de envelope de error (#151).""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, cmd_args, catch_exceptions=False) + # stdout es exactamente 1 línea JSON (el error envelope), sin fuga del aviso + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"stdout debe ser 1 línea, tuvo {len(lines)}: {result.stdout!r}" + ) + envelope = json.loads(lines[0]) + assert envelope["schema"] == "1" + # El error envelope tiene ok=False (sin workspace) + assert envelope["ok"] is False + + +# --------------------------------------------------------------------------- +# Tests del entry-point bib2graph (main_bib2graph_alias) +# --------------------------------------------------------------------------- + + +class TestBib2graphEntryPoint: + """El ejecutable legado ``bib2graph`` avisa y delega en ``b2g``.""" + + def test_avisa_a_stderr(self) -> None: + """main_bib2graph_alias emite aviso de deprecación a stderr.""" + from bib2graph.cli import main_bib2graph_alias + + buf = io.StringIO() + with ( + patch("sys.stderr", buf), + patch("bib2graph.cli.main", return_value=0) as mock_main, + ): + result = main_bib2graph_alias() + assert result == 0 + assert mock_main.called + assert "deprecad" in buf.getvalue().lower() + assert "bib2graph" in buf.getvalue() + assert "b2g" in buf.getvalue() + + def test_delega_en_main(self) -> None: + """main_bib2graph_alias delega el control en main().""" + from bib2graph.cli import main_bib2graph_alias + + with ( + patch("sys.stderr", io.StringIO()), + patch("bib2graph.cli.main", return_value=42) as mock_main, + ): + result = main_bib2graph_alias() + assert mock_main.called + assert result == 42 + + def test_mensaje_contiene_version_retiro(self) -> None: + """El aviso del entry-point menciona la versión de retiro 0.11.0.""" + from bib2graph.cli import main_bib2graph_alias + + buf = io.StringIO() + with patch("sys.stderr", buf), patch("bib2graph.cli.main", return_value=0): + main_bib2graph_alias() + assert "0.11.0" in buf.getvalue() + + def test_entry_point_registrado_en_pyproject(self) -> None: + """El entry-point 'bib2graph' está registrado en pyproject.toml.""" + # Verificar que la función main_bib2graph_alias existe y es importable + from bib2graph.cli import main_bib2graph_alias + + assert callable(main_bib2graph_alias) + + def test_cli_runner_avisa_y_corre(self) -> None: + """Via CliRunner: invocar b2g init avisa en stderr y corre correctamente.""" + runner = CliRunner() + with runner.isolated_filesystem(): + # Verificamos que b2g (main) funciona; bib2graph es un wrapper + result = runner.invoke(b2g, ["init", "test-ws", "--json"]) + assert result.exit_code == 0 + # La función main_bib2graph_alias avisa a sys.stderr; este test verifica + # solo que el wrapper es importable y delega (las pruebas de unit mock main). + + +# --------------------------------------------------------------------------- +# Tests refactor --corpus-scope → emit_deprecation +# --------------------------------------------------------------------------- + + +class TestCorpusScopeDeprecado: + """build --corpus-scope usa emit_deprecation y agrega al warnings[] del envelope.""" + + def test_corpus_scope_aviso_en_stderr(self, tmp_path: Path) -> None: + """build --corpus-scope emite aviso a stderr (via emit_deprecation).""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + rows = [_row(id="P1", is_seed=True)] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + store.close() + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--corpus-scope", "all"], + ) + assert result.exit_code == 0, result.output + assert "deprecad" in result.stderr.lower() + + def test_corpus_scope_warnings_en_envelope_json(self, tmp_path: Path) -> None: + """build --corpus-scope --json agrega el aviso al envelope['warnings'].""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + rows = [_row(id="P1", is_seed=True)] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(ws.library_path) + store.persist(corpus) + store.close() + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--corpus-scope", "all", "--json"], + ) + assert result.exit_code == 0, result.output + # stdout debe ser 1 línea JSON (el envelope) + envelope = json.loads(result.stdout) + assert envelope["schema"] == "1" + # El aviso de deprecación de --corpus-scope debe estar en warnings top-level + assert any("corpus-scope" in w.lower() for w in envelope["warnings"]), ( + f"warnings debe contener 'corpus-scope', got: {envelope['warnings']}" + ) + + +# --------------------------------------------------------------------------- +# Tests b2g networks (alias deprecado -> b2g build --spec) +# --------------------------------------------------------------------------- + + +def _write_spec(tmp_path: Path) -> Path: + spec = tmp_path / "redes.yaml" + spec.write_text("networks:\n - kind: bibliographic_coupling\n", encoding="utf-8") + return spec + + +class TestNetworksDeprecado: + """b2g networks emite aviso de deprecacion y delega en build --spec. + + Delegación funcional y contrato schema/stdout se cubren en los tests de + 'b2g build --spec' (test_cli.py). Aquí solo se conserva el aviso (epic #184). + """ + + def test_aviso_va_a_stderr(self, tmp_path: Path) -> None: + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + spec = _write_spec(tmp_path) + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "networks", "--spec", str(spec)], + catch_exceptions=False, + ) + assert "deprecad" in result.stderr.lower() + assert "build --spec" in result.stderr.lower() + + def test_json_warnings_contiene_deprecacion(self, tmp_path: Path) -> None: + ws = _init_workspace(tmp_path) + _seed_store(ws, [_row(id="P1", year=2020)]) + spec = _write_spec(tmp_path) + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws.root), "networks", "--spec", str(spec), "--json"], + catch_exceptions=False, + ) + envelope = _assert_one_json_stdout(result) + assert envelope["warnings"] + assert any("build --spec" in w.lower() for w in envelope["warnings"]) diff --git a/tests/unit/test_enrich_absorb.py b/tests/unit/test_enrich_absorb.py new file mode 100644 index 0000000..5e22bbb --- /dev/null +++ b/tests/unit/test_enrich_absorb.py @@ -0,0 +1,659 @@ +"""Tests TDD — absorción de ``enrich`` en ``chain``/``build`` (ADR 0038, #162). + +Cubre las decisiones de implementación del sub-issue #162: + +1. ``chain`` ejecuta la pasada refs→DOI automáticamente sobre el corpus + mergeado+dedup, ANTES de ``persist_replace``. +2. ``build`` ejecuta la pasada cited_by automáticamente cuando hay seeds + aceptadas, ANTES de proyectar las redes. +3. ``build`` es no-op (0 requests cited_by) cuando no hay seeds aceptadas; + ``data["enrichment"]`` queda vacío ``{}``. +4. ``enrich`` suelto sigue funcionando idéntico (delega en el helper + ``enrich_corpus`` de ``cli._enrich``). +5. ``data["enrichment"]`` es un bloque ADITIVO en chain y build: + nunca rompe el envelope ``schema="1"``. +6. El helper ``enrich_corpus`` soporta ``pass_name`` inválido con ``ValueError``. +7. Co-citación: tras ``build`` con seeds aceptadas, ``Networks.quick`` + produce 5 redes (co-citación incluida). + +Todos los tests son ``unit``: usan ``tmp_path`` + ``httpx.MockTransport`` +(sin red real). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx +import pyarrow as pa +import pytest + +from bib2graph.constants import CurationStatus +from bib2graph.corpus import Corpus +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + id: str, + *, + source_id: str | None = None, + is_seed: bool = True, + curation_status: str = CurationStatus.CANDIDATE, + references_id: list[str] | None = None, + cited_by_id: list[str] | None = None, + authors_id: list[str] | None = None, + institutions_id: list[str] | None = None, + keywords_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima compatible con el schema canónico.""" + return { + "id": id, + "source_id": source_id, + "doi": None, + "title": f"Paper {id}", + "year": 2020, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": authors_id or ["AUTH_A", "AUTH_B"], + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": keywords_id or ["KW_1"], + "institutions_raw": None, + "institutions_id": institutions_id or ["INST_1"], + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _corpus_from_rows(rows: list[dict[str, Any]]) -> Corpus: + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + return Corpus.from_arrow(table) + + +def _store_with_corpus(tmp_path: Path, rows: list[dict[str, Any]]) -> Path: + """Crea un DuckDBStore temporal con el corpus dado y retorna su ruta.""" + from bib2graph.stores.duckdb import DuckDBStore + + db_path = tmp_path / "test.duckdb" + corpus = _corpus_from_rows(rows) + store = DuckDBStore(db_path) + store.persist(corpus) + store.close() + return db_path + + +# --------------------------------------------------------------------------- +# Mocks HTTP para DOI resolution y cited_by +# --------------------------------------------------------------------------- + + +def _make_doi_transport(ref_id_to_doi: dict[str, str]) -> httpx.MockTransport: + """Transport que responde a ``openalex_id:`` con los DOIs dados.""" + + def handler(request: httpx.Request) -> httpx.Response: + url_str = str(request.url) + results = [] + if "openalex_id" in url_str: + for short_id, doi in ref_id_to_doi.items(): + if short_id in url_str: + results.append( + { + "id": f"https://openalex.org/{short_id}", + "doi": f"https://doi.org/{doi}", + } + ) + body = { + "results": results, + "meta": {"count": len(results), "next_cursor": None}, + } + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +def _make_cited_by_transport( + citing_works: list[dict[str, Any]], +) -> httpx.MockTransport: + """Transport que responde a ``cites:`` con los works dados.""" + + def handler(request: httpx.Request) -> httpx.Response: + url_str = str(request.url) + if "cites" in url_str: + body = { + "results": citing_works, + "meta": {"count": len(citing_works), "next_cursor": None}, + } + else: + body = {"results": [], "meta": {"count": 0, "next_cursor": None}} + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +def _make_counting_transport( + doi_map: dict[str, str] | None = None, + citing_works: list[dict[str, Any]] | None = None, +) -> tuple[httpx.MockTransport, dict[str, list[int]]]: + """Transport que cuenta llamadas por tipo y devuelve datos mockeados.""" + doi_map = doi_map or {} + citing_works = citing_works or [] + counters: dict[str, list[int]] = {"doi": [0], "citing": [0], "other": [0]} + + def handler(request: httpx.Request) -> httpx.Response: + url_str = str(request.url) + if "openalex_id" in url_str: + counters["doi"][0] += 1 + results = [ + {"id": f"https://openalex.org/{k}", "doi": f"https://doi.org/{v}"} + for k, v in doi_map.items() + if k in url_str + ] + body: dict[str, Any] = { + "results": results, + "meta": {"count": len(results), "next_cursor": None}, + } + elif "cites" in url_str: + counters["citing"][0] += 1 + body = { + "results": citing_works, + "meta": {"count": len(citing_works), "next_cursor": None}, + } + else: + counters["other"][0] += 1 + body = {"results": [], "meta": {"count": 0, "next_cursor": None}} + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler), counters + + +def _citing_work(citer_oa_id: str, cites_ids: list[str]) -> dict[str, Any]: + """Work de OpenAlex que cita los seeds dados.""" + return { + "id": f"https://openalex.org/{citer_oa_id}", + "doi": None, + "title": f"Citante {citer_oa_id}", + "display_name": f"Citante {citer_oa_id}", + "publication_year": 2023, + "language": "en", + "abstract_inverted_index": None, + "authorships": [], + "keywords": [], + "referenced_works": [f"https://openalex.org/{oa_id}" for oa_id in cites_ids], + "primary_location": None, + "type": "article", + } + + +# --------------------------------------------------------------------------- +# 1. chain ejecuta la pasada refs→DOI automáticamente +# --------------------------------------------------------------------------- + + +def test_chain_resuelve_references_doi(tmp_path: Path) -> None: + """``run_chain`` aplica la pasada refs→DOI sobre el corpus mergeado+dedup.""" + from bib2graph.cli.commands.chain import run_chain + from bib2graph.stores.duckdb import DuckDBStore + + # Corpus con una semilla que tiene references_id pero references_doi vacío + rows = [_row("P1", references_id=["W1111"], is_seed=True)] + db_path = _store_with_corpus(tmp_path, rows) + + # Mock: W1111 → DOI 10.1000/test + transport, counters = _make_counting_transport(doi_map={"W1111": "10.1000/test"}) + run_chain(db_path, direction="backward", transport=transport) + + # Verificar que references_doi se pobló + store = DuckDBStore(db_path) + corpus = store.load() + table = corpus.to_arrow() # leer ANTES de cerrar la conexión DuckDB + store.close() + refs_doi = table.column("references_doi").to_pylist()[0] + + assert refs_doi is not None, "references_doi debe estar poblado" + assert "10.1000/test" in refs_doi, f"DOI esperado no encontrado: {refs_doi}" + # Se hizo al menos una request de DOI resolution + assert counters["doi"][0] >= 1, "Debe haber llamadas de resolución DOI" + + +def test_chain_data_tiene_bloque_enrichment(tmp_path: Path) -> None: + """``run_chain`` retorna ``data['enrichment']`` con métricas de refs→DOI.""" + from bib2graph.cli.commands.chain import run_chain + + rows = [_row("P1", references_id=["W1111"], is_seed=True)] + db_path = _store_with_corpus(tmp_path, rows) + + transport = _make_doi_transport({"W1111": "10.1000/test"}) + data = run_chain(db_path, direction="backward", transport=transport) + + assert "enrichment" in data, "data debe tener clave 'enrichment'" + enrichment = data["enrichment"] + assert isinstance(enrichment, dict), "enrichment debe ser un dict" + assert "refs_resolved" in enrichment, "enrichment debe tener 'refs_resolved'" + assert "refs_total_unique" in enrichment, ( + "enrichment debe tener 'refs_total_unique'" + ) + assert enrichment["refs_resolved"] == 1 + assert enrichment["refs_total_unique"] == 1 + + +def test_chain_sin_referencias_enrichment_cero(tmp_path: Path) -> None: + """``run_chain`` con corpus sin referencias retorna enrichment con 0.""" + from bib2graph.cli.commands.chain import run_chain + + rows = [_row("P1", references_id=None)] + db_path = _store_with_corpus(tmp_path, rows) + + transport, counters = _make_counting_transport() + data = run_chain(db_path, direction="backward", transport=transport) + + assert data["enrichment"].get("refs_resolved", 0) == 0 + assert counters["doi"][0] == 0, "Sin referencias no debe hacer requests DOI" + + +# --------------------------------------------------------------------------- +# 2. build ejecuta la pasada cited_by cuando hay seeds aceptadas +# --------------------------------------------------------------------------- + + +def test_build_dispara_cited_by_con_seeds_aceptadas(tmp_path: Path) -> None: + """``run_build`` corre cited_by y popula co-citación cuando hay seeds aceptadas.""" + from bib2graph.cli.commands.build import run_build + + # 2 seeds aceptadas con source_id + rows = [ + _row( + "P1", + source_id="W100", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + ), + _row( + "P2", + source_id="W200", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + ), + ] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + # Mock: C1 cita a W100 y W200 → co-citación + citing = [_citing_work("C1", ["W100", "W200"])] + transport, counters = _make_counting_transport(citing_works=citing) + + data = run_build(db_path, out_dir=out_dir, transport=transport) + + # Se hicieron requests de cited_by + assert counters["citing"][0] >= 1, "Debe haber requests cited_by" + # El enrichment block está presente + assert "enrichment" in data + assert data["enrichment"].get("citing_new", 0) >= 1 + assert data["enrichment"].get("citing_targets", 0) == 2 + + +def test_build_co_citacion_en_redes_tras_cited_by(tmp_path: Path) -> None: + """Tras la pasada cited_by en build, Networks.quick produce co-citación.""" + from bib2graph.cli.commands.build import run_build + + rows = [ + _row( + "P1", + source_id="W100", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + ), + _row( + "P2", + source_id="W200", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + ), + ] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + citing = [_citing_work("C1", ["W100", "W200"])] + transport = _make_cited_by_transport(citing) + + data = run_build(db_path, out_dir=out_dir, transport=transport) + + kinds = {n["kind"] for n in data["networks"]} + assert "cocitation" in kinds, ( + f"Co-citación debe estar en redes tras cited_by. Redes: {kinds}" + ) + assert data["networks_built"] == 5, ( + f"Deben ser 5 redes con co-citación. Fueron: {data['networks_built']}" + ) + + +# --------------------------------------------------------------------------- +# 3. build es no-op (sin requests) cuando no hay seeds aceptadas +# --------------------------------------------------------------------------- + + +def test_build_noop_sin_seeds_aceptadas(tmp_path: Path) -> None: + """``run_build`` no hace requests cited_by cuando no hay seeds aceptadas.""" + from bib2graph.cli.commands.build import run_build + + # Solo candidatos — sin seeds aceptadas + rows = [ + _row("P1", is_seed=True, curation_status=CurationStatus.CANDIDATE), + _row("P2", is_seed=True, curation_status=CurationStatus.CANDIDATE), + ] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + transport, counters = _make_counting_transport() + data = run_build(db_path, out_dir=out_dir, transport=transport) + + # Sin seeds aceptadas → 0 requests de cited_by + assert counters["citing"][0] == 0, ( + f"Sin seeds aceptadas no debe hacer requests cited_by; " + f"hizo {counters['citing'][0]}" + ) + # El enrichment block existe pero está vacío + assert "enrichment" in data + assert data["enrichment"] == {}, ( + f"Sin seeds aceptadas, enrichment debe ser vacío: {data['enrichment']}" + ) + + +def test_build_4_redes_sin_seeds_aceptadas(tmp_path: Path) -> None: + """Sin seeds aceptadas build produce 4 redes (sin co-citación) y exit 0.""" + from bib2graph.cli.commands.build import run_build + + rows = [ + _row( + f"P{i}", + is_seed=True, + curation_status=CurationStatus.CANDIDATE, + authors_id=[f"AUTH_{i}", "AUTH_SHARED"], + keywords_id=[f"KW_{i}", "KW_SHARED"], + institutions_id=[f"INST_{i}"], + references_id=[f"REF_{i}", "REF_SHARED"], + ) + for i in range(3) + ] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + transport, counters = _make_counting_transport() + data = run_build(db_path, out_dir=out_dir, transport=transport) + + assert counters["citing"][0] == 0 + kinds = {n["kind"] for n in data["networks"]} + assert "cocitation" not in kinds, "Sin cited_by_id no debe haber co-citación" + # 4 redes base (bibliographic_coupling, coauthorship, cooccurrence, keyword) + assert len(data["networks"]) == 4 or data["networks_built"] == 4, ( + f"Sin co-citación deben ser 4 redes; fueron {data['networks_built']}" + ) + + +# --------------------------------------------------------------------------- +# 4. enrich suelto sigue funcionando idéntico +# --------------------------------------------------------------------------- + + +def test_enrich_suelto_sigue_funcionando(tmp_path: Path) -> None: + """``run_enrich`` (alias) delega en el helper y produce el mismo resultado.""" + from bib2graph.cli.commands.enrich import run_enrich + + rows = [ + _row( + "P1", + source_id="W100", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + references_id=["W999"], + ) + ] + db_path = _store_with_corpus(tmp_path, rows) + + citing = [_citing_work("C1", ["W100"])] + transport, _ = _make_counting_transport( + doi_map={"W999": "10.1000/ref"}, + citing_works=citing, + ) + + data = run_enrich(db_path, transport=transport) + + # Claves estables del contrato original + assert "refs_resolved" in data + assert "refs_total_unique" in data + assert "citing_new" in data + assert "citing_targets" in data + assert "total_papers" in data + assert isinstance(data["refs_resolved"], int) + assert isinstance(data["total_papers"], int) + + +def test_enrich_suelto_claves_numericas(tmp_path: Path) -> None: + """``run_enrich`` retorna exactamente las 5 claves requeridas (contrato original).""" + from bib2graph.cli.commands.enrich import run_enrich + from bib2graph.stores.duckdb import DuckDBStore + + rows = [_row("P1", references_id=["W111"])] + db_path = tmp_path / "e.duckdb" + store = DuckDBStore(db_path) + store.persist(_corpus_from_rows(rows)) + store.close() + + transport = _make_doi_transport({"W111": "10.1000/xyz"}) + data = run_enrich(db_path, transport=transport) + + required = { + "refs_resolved", + "refs_total_unique", + "citing_new", + "citing_targets", + "total_papers", + } + assert required.issubset(data.keys()), ( + f"Faltan claves en data: {required - set(data.keys())}" + ) + assert data["total_papers"] == 1 + assert data["refs_resolved"] == 1 + assert data["refs_total_unique"] == 1 + assert data["citing_new"] == 0 # sin seeds aceptadas → 0 + assert data["citing_targets"] == 0 + + +# --------------------------------------------------------------------------- +# 5. data["enrichment"] es aditivo y no rompe schema="1" +# --------------------------------------------------------------------------- + + +def test_chain_enrichment_no_rompe_schema(tmp_path: Path) -> None: + """``data['enrichment']`` es un dict plano y no cambia el envelope schema.""" + from bib2graph.cli._envelope import build_envelope + from bib2graph.cli.commands.chain import run_chain + + rows = [_row("P1", references_id=["W1111"])] + db_path = _store_with_corpus(tmp_path, rows) + + transport = _make_doi_transport({"W1111": "10.1000/test"}) + data = run_chain(db_path, direction="backward", transport=transport) + + envelope = build_envelope(command="chain", ok=True, data=data, exit_code=0) + assert envelope["schema"] == "1", f"schema debe ser '1', es '{envelope['schema']}'" + assert isinstance(data["enrichment"], dict) + + +def test_build_enrichment_no_rompe_schema(tmp_path: Path) -> None: + """``data['enrichment']`` en build es un dict plano; schema='1' intacto.""" + from bib2graph.cli._envelope import build_envelope + from bib2graph.cli.commands.build import run_build + + rows = [_row("P1", is_seed=True, curation_status=CurationStatus.CANDIDATE)] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + transport, _ = _make_counting_transport() + data = run_build(db_path, out_dir=out_dir, transport=transport) + + envelope = build_envelope(command="build", ok=True, data=data, exit_code=0) + assert envelope["schema"] == "1" + assert "enrichment" in data + assert isinstance(data["enrichment"], dict) + + +# --------------------------------------------------------------------------- +# 6. Helper enrich_corpus: pass_name inválido → ValueError +# --------------------------------------------------------------------------- + + +def test_enrich_corpus_pass_name_invalido() -> None: + """``enrich_corpus`` con ``pass_name`` desconocido lanza ``ValueError``.""" + from bib2graph.cli._enrich import enrich_corpus + from bib2graph.sources.openalex import OpenAlexSource + + corpus = _corpus_from_rows([_row("P1")]) + source = OpenAlexSource() + + with pytest.raises(ValueError, match="pass_name desconocido"): + enrich_corpus(corpus, source, pass_name="invalid_pass") + + +# --------------------------------------------------------------------------- +# 7. Helper enrich_corpus: métricas correctas por pasada +# --------------------------------------------------------------------------- + + +def test_enrich_corpus_refs_doi_solo() -> None: + """``enrich_corpus(pass_name='refs_doi')`` retorna métricas de refs→DOI.""" + from bib2graph.cli._enrich import enrich_corpus + from bib2graph.sources.openalex import OpenAlexSource + + corpus = _corpus_from_rows([_row("P1", references_id=["W111"])]) + transport = _make_doi_transport({"W111": "10.1000/x"}) + source = OpenAlexSource(transport=transport) + + enriched, metrics = enrich_corpus(corpus, source, pass_name="refs_doi") + + assert "refs_resolved" in metrics + assert "refs_total_unique" in metrics + # cited_by keys no deben estar si no se ejecutó esa pasada + assert "citing_new" not in metrics + assert "citing_targets" not in metrics + # Corpus gana el DOI + refs_doi = enriched.to_arrow().column("references_doi").to_pylist()[0] + assert refs_doi is not None + assert "10.1000/x" in refs_doi + + +def test_enrich_corpus_cited_by_solo() -> None: + """``enrich_corpus(pass_name='cited_by')`` retorna métricas de cited_by.""" + from bib2graph.cli._enrich import enrich_corpus + from bib2graph.sources.openalex import OpenAlexSource + + corpus = _corpus_from_rows( + [_row("P1", source_id="W100", curation_status=CurationStatus.ACCEPTED)] + ) + citing = [_citing_work("C1", ["W100"])] + transport = _make_cited_by_transport(citing) + source = OpenAlexSource(transport=transport) + + enriched, metrics = enrich_corpus(corpus, source, pass_name="cited_by") + + assert "citing_new" in metrics + assert "citing_targets" in metrics + # refs_doi keys no deben estar si no se ejecutó esa pasada + assert "refs_resolved" not in metrics + # Corpus gana el citante + cited_by = enriched.to_arrow().column("cited_by_id").to_pylist()[0] + assert cited_by is not None + assert "C1" in cited_by + + +def test_enrich_corpus_both_tiene_todas_las_claves() -> None: + """``enrich_corpus(pass_name='both')`` retorna todas las métricas.""" + from bib2graph.cli._enrich import enrich_corpus + from bib2graph.sources.openalex import OpenAlexSource + + corpus = _corpus_from_rows( + [ + _row( + "P1", + source_id="W100", + curation_status=CurationStatus.ACCEPTED, + references_id=["W999"], + ) + ] + ) + citing = [_citing_work("C1", ["W100"])] + transport, _ = _make_counting_transport( + doi_map={"W999": "10.1000/ref"}, citing_works=citing + ) + source = OpenAlexSource(transport=transport) + + _enriched, metrics = enrich_corpus(corpus, source, pass_name="both") + + assert "refs_resolved" in metrics + assert "refs_total_unique" in metrics + assert "citing_new" in metrics + assert "citing_targets" in metrics + + +# --------------------------------------------------------------------------- +# 8. build: max_citing controla el tope de citantes por seed +# --------------------------------------------------------------------------- + + +def test_build_max_citing_limita_citantes(tmp_path: Path) -> None: + """``run_build(max_citing=1)`` limita cited_by_id a 1 citante por seed.""" + from bib2graph.cli.commands.build import run_build + from bib2graph.stores.duckdb import DuckDBStore + + rows = [ + _row( + "P1", + source_id="W100", + is_seed=True, + curation_status=CurationStatus.ACCEPTED, + ) + ] + db_path = _store_with_corpus(tmp_path, rows) + out_dir = tmp_path / "networks" + + # 5 citantes de W100 + citing = [_citing_work(f"C{i}", ["W100"]) for i in range(1, 6)] + transport = _make_cited_by_transport(citing) + + run_build(db_path, out_dir=out_dir, transport=transport, max_citing=1) + + # Verificar que cited_by_id tiene ≤ 1 elemento + store = DuckDBStore(db_path) + corpus = store.load() + rows_out = corpus.to_arrow().to_pylist() # leer ANTES de cerrar + store.close() + p1 = next(r for r in rows_out if r["id"] == "P1") + cited = p1.get("cited_by_id") or [] + assert len(cited) <= 1, f"max_citing=1 debe limitar a ≤1 citante; hay {len(cited)}" diff --git a/tests/unit/test_enrich_trazabilidad.py b/tests/unit/test_enrich_trazabilidad.py new file mode 100644 index 0000000..c492a11 --- /dev/null +++ b/tests/unit/test_enrich_trazabilidad.py @@ -0,0 +1,245 @@ +"""Tests de trazabilidad de enriquecimiento — issue #141. + +Verifica que: +1. Tras ``run_enrich``, ``manifest.enrichers`` persiste en el store y se + recupera en el próximo ``store.load()`` (no queda ``[]`` vacío). +2. Los params del ``EnricherRef`` persisten con los valores calculados. +3. Re-aplicar ``run_enrich`` (idempotencia) reemplaza las refs anteriores + en vez de acumularlas indefinidamente. +4. El flujo ``chain`` (refs_doi) + ``build`` (cited_by) acumula ambos + ``EnricherRef`` en el manifest tras la segunda carga. + +Marcador: ``unit`` (DuckDB en ``tmp_path``, sin red real; transports mockeados). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx +import pyarrow as pa +import pytest + +from bib2graph.constants import CurationStatus +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_row( + *, + id: str, + source_id: str | None = None, + title: str = "Título de prueba", + year: int = 2020, + is_seed: bool = True, + curation_status: str = CurationStatus.ACCEPTED, + references_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima compatible con el schema canónico.""" + return { + "id": id, + "source_id": source_id, + "doi": None, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": references_id, + "references_doi": None, + "cited_by_id": None, + } + + +def _seed_store(store_path: Path, rows: list[dict[str, Any]]) -> None: + """Puebla un store DuckDB con las filas dadas.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + store.close() + + +def _make_empty_transport() -> httpx.MockTransport: + """MockTransport que devuelve respuestas vacías (sin resolver refs ni citantes).""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"results": [], "meta": {"count": 0, "next_cursor": None}}, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +def _citing_work(citer_oa_id: str, cites_ids: list[str]) -> dict[str, Any]: + """Construye un objeto Work de OpenAlex que representa un citante.""" + return { + "id": f"https://openalex.org/{citer_oa_id}", + "doi": None, + "title": f"Citante {citer_oa_id}", + "display_name": f"Citante {citer_oa_id}", + "publication_year": 2023, + "language": "en", + "abstract_inverted_index": None, + "authorships": [], + "keywords": [], + "referenced_works": [f"https://openalex.org/{oa_id}" for oa_id in cites_ids], + "primary_location": None, + "type": "article", + } + + +def _make_cited_by_transport( + citing_works: list[dict[str, Any]], +) -> httpx.MockTransport: + """MockTransport que responde a ``cites:`` con la lista de citantes dada.""" + + def handler(request: httpx.Request) -> httpx.Response: + url_str = str(request.url) + if "openalex_id" in url_str: + body: dict[str, Any] = { + "results": [], + "meta": {"count": 0, "next_cursor": None}, + } + elif "cites" in url_str: + body = { + "results": citing_works, + "meta": {"count": len(citing_works), "next_cursor": None}, + } + else: + body = {"results": [], "meta": {"count": 0, "next_cursor": None}} + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- +# 1. manifest.enrichers persiste tras run_enrich +# --------------------------------------------------------------------------- + + +def test_manifest_enrichers_persiste_tras_run_enrich(tmp_path: Path) -> None: + """Tras run_enrich, manifest.enrichers no queda vacío en la siguiente carga.""" + from bib2graph.cli.commands.enrich import run_enrich + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "test.duckdb" + _seed_store( + store_path, + [_make_row(id="P1", source_id="W100")], + ) + + citing_works = [_citing_work("C1", ["W100"])] + run_enrich(store_path, transport=_make_cited_by_transport(citing_works)) + + # Reabrir el store en instancia nueva y verificar que manifest.enrichers sobrevivió + store = DuckDBStore(store_path) + corpus = store.load() + store.close() + + assert len(corpus.manifest.enrichers) >= 1, ( + "manifest.enrichers debe tener al menos 1 EnricherRef tras run_enrich" + ) + + +# --------------------------------------------------------------------------- +# 2. Los params del EnricherRef persisten con los valores calculados +# --------------------------------------------------------------------------- + + +def test_manifest_enrichers_params_persisten(tmp_path: Path) -> None: + """Los params del EnricherRef persisten correctamente en el store.""" + from bib2graph.cli.commands.enrich import run_enrich + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "test.duckdb" + _seed_store( + store_path, + [_make_row(id="P1", source_id="W100")], + ) + + citing_works = [_citing_work("C1", ["W100"])] + run_enrich(store_path, transport=_make_cited_by_transport(citing_works)) + + # Reabrir el store y verificar que los params del EnricherRef son correctos + store = DuckDBStore(store_path) + corpus = store.load() + store.close() + + enrichers_by_name = {e.name: e for e in corpus.manifest.enrichers} + + # Pasada cited_by debe registrarse con sus params + assert "openalex_cited_by" in enrichers_by_name, ( + "Debe existir EnricherRef 'openalex_cited_by' tras run_enrich" + ) + cb_ref = enrichers_by_name["openalex_cited_by"] + assert "resolved" in cb_ref.params, ( + "EnricherRef 'openalex_cited_by' debe tener el param 'resolved'" + ) + assert "total" in cb_ref.params, ( + "EnricherRef 'openalex_cited_by' debe tener el param 'total'" + ) + + +# --------------------------------------------------------------------------- +# 3. Idempotencia: re-aplicar run_enrich reemplaza, no acumula +# --------------------------------------------------------------------------- + + +def test_manifest_enrichers_idempotente_no_acumula(tmp_path: Path) -> None: + """Re-aplicar run_enrich reemplaza los EnricherRef, no los duplica.""" + from bib2graph.cli.commands.enrich import run_enrich + from bib2graph.stores.duckdb import DuckDBStore + + store_path = tmp_path / "test.duckdb" + _seed_store( + store_path, + [_make_row(id="P1", source_id="W100")], + ) + + transport = _make_cited_by_transport([_citing_work("C1", ["W100"])]) + + # Dos ejecuciones sucesivas + run_enrich(store_path, transport=transport) + run_enrich( + store_path, + transport=_make_cited_by_transport([_citing_work("C1", ["W100"])]), + ) + + # Reabrir y verificar que no hay duplicados (cada nombre aparece una sola vez) + store = DuckDBStore(store_path) + corpus = store.load() + store.close() + + names = [e.name for e in corpus.manifest.enrichers] + assert len(names) == len(set(names)), ( + f"Hay EnricherRef duplicados en manifest.enrichers: {names}" + ) diff --git a/tests/unit/test_enrichers_8b.py b/tests/unit/test_enrichers_8b.py index 51e18c2..fe722c7 100644 --- a/tests/unit/test_enrichers_8b.py +++ b/tests/unit/test_enrichers_8b.py @@ -323,24 +323,8 @@ def test_reatribucion_citante_sin_referencias_no_se_asigna() -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_enrich_cited_by_idempotente() -> None: - """Re-enrich no duplica en cited_by_id.""" - corpus = _corpus_from_rows([_make_row(id="P1", source_id="W100")]) - citing_works = [_citing_work("C1", ["W100"])] - transport = _make_cited_by_transport(citing_works) - - enricher = _make_enricher(transport) - once = enricher.enrich(corpus) - twice = enricher.enrich(once) - - table = twice.to_arrow() - rows_data = table.to_pylist() - cited_by = rows_data[0].get("cited_by_id") or [] - - # No duplicados - assert len(cited_by) == len(set(cited_by)), "cited_by_id no debe tener duplicados" - assert "C1" in cited_by +# Eliminado epic #184: cubierto por test_enrich_cocitacion_integrado.py::test_run_enrich_idempotente_corpus_hash_y_cocitacion +# (verificación de corpus_hash más fuerte que conteo de cited_by_id). @pytest.mark.unit @@ -363,21 +347,8 @@ def test_enrich_cited_by_enricher_ref_no_duplica() -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_tope_max_citing_per_paper() -> None: - """max_citing_per_paper=2 trunca cited_by_id a 2 citantes por paper.""" - corpus = _corpus_from_rows([_make_row(id="P1", source_id="W100")]) - # 5 citantes que citan a W100 - citing_works = [_citing_work(f"C{i}", ["W100"]) for i in range(1, 6)] - transport = _make_cited_by_transport(citing_works) - - enricher = _make_enricher(transport, max_citing_per_paper=2) - result = enricher.enrich(corpus) - - table = result.to_arrow() - rows_data = table.to_pylist() - cited_by = rows_data[0].get("cited_by_id") or [] - assert len(cited_by) <= 2, f"Debe haber ≤2 citantes, hay {len(cited_by)}" +# Eliminado epic #184: cubierto por test_enrich_cocitacion_integrado.py::test_run_enrich_max_citing_acota_cited_by_id +# y test_enrich_absorb.py::test_build_max_citing_limita_citantes. @pytest.mark.unit @@ -466,181 +437,26 @@ def test_candidato_no_se_enriquece() -> None: # --------------------------------------------------------------------------- -# 6. Networks.quick sin cited_by_id → 4 redes, sin fallo (regresión) +# 6. Networks.quick sin cited_by_id → 4 redes (regresión) +# Eliminado epic #184: cubierto por test_enrich_absorb.py::test_build_4_redes_sin_seeds_aceptadas. # --------------------------------------------------------------------------- - -@pytest.mark.unit -def test_networks_quick_sin_cited_by_omite_cocitacion_sin_fallar() -> None: - """Networks.quick con cited_by_id vacío → 4 redes, sin error.""" - rows = [ - { - "id": f"P{i}", - "source_id": None, - "doi": None, - "title": f"Paper {i}", - "year": 2020, - "abstract": None, - "source": None, - "language": None, - "publisher": None, - "research_areas": None, - "is_seed": True, - "curation_status": "candidate", - "provenance": None, - "authors_raw": None, - "authors_id": [f"AUTH_{i}", "AUTH_0"], - "authors_affiliations": None, - "keywords_raw": None, - "keywords_id": [f"KW_{i}", "KW_SHARED"], - "institutions_raw": None, - "institutions_id": [f"INST_{i}"], - "references_id": [f"REF_{i}", "REF_SHARED"], - "references_doi": None, - "cited_by_id": None, # sin citantes - } - for i in range(3) - ] - table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) - corpus = Corpus.from_arrow(table) - - # No debe lanzar excepción - artifacts = Networks.quick(corpus) - - assert len(artifacts) == 4, "Sin cited_by_id debe haber exactamente 4 redes" - kinds = {a.spec.kind for a in artifacts} - assert "cocitation" not in kinds - - # --------------------------------------------------------------------------- # 7. Networks.quick con cited_by_id → 5 redes +# Eliminado epic #184: cubierto por test_enrich_absorb.py::test_build_co_citacion_en_redes_tras_cited_by +# y test_enrich_cocitacion_integrado.py::test_run_enrich_produce_red_cocitacion_con_arista_esperada. # --------------------------------------------------------------------------- - -@pytest.mark.unit -def test_networks_quick_con_cited_by_incluye_cocitacion() -> None: - """Networks.quick con cited_by_id poblado → 5 redes, incluyendo cocitation.""" - rows = [ - { - "id": "P1", - "source_id": "W100", - "doi": None, - "title": "Paper 1", - "year": 2020, - "abstract": None, - "source": None, - "language": None, - "publisher": None, - "research_areas": None, - "is_seed": True, - "curation_status": "accepted", - "provenance": None, - "authors_raw": None, - "authors_id": ["AUTH_1"], - "authors_affiliations": None, - "keywords_raw": None, - "keywords_id": ["KW_1"], - "institutions_raw": None, - "institutions_id": ["INST_1"], - "references_id": None, - "references_doi": None, - "cited_by_id": ["C1"], # ya tiene citante - }, - { - "id": "P2", - "source_id": "W200", - "doi": None, - "title": "Paper 2", - "year": 2020, - "abstract": None, - "source": None, - "language": None, - "publisher": None, - "research_areas": None, - "is_seed": True, - "curation_status": "accepted", - "provenance": None, - "authors_raw": None, - "authors_id": ["AUTH_2"], - "authors_affiliations": None, - "keywords_raw": None, - "keywords_id": ["KW_2"], - "institutions_raw": None, - "institutions_id": ["INST_2"], - "references_id": None, - "references_doi": None, - "cited_by_id": ["C1"], # mismo citante → co-cita P1 y P2 - }, - ] - table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) - corpus = Corpus.from_arrow(table) - - artifacts = Networks.quick(corpus) - kinds = {a.spec.kind for a in artifacts} - - assert "cocitation" in kinds - assert len(artifacts) == 5 - - # --------------------------------------------------------------------------- # 8. No pierde papers; corpus sin seeds aceptados → 0 fetch, sin error +# Eliminado epic #184: test_corpus_sin_seeds_aceptados_0_fetch cubierto por +# test_enrich_cocitacion_integrado.py::test_run_enrich_sin_seeds_aceptadas_no_altera_corpus +# y test_enrich_absorb.py::test_build_noop_sin_seeds_aceptadas. +# Eliminado epic #184: test_enrich_no_pierde_papers_con_semillas cubierto por +# test_enrichers.py::test_enrich_no_pierde_papers. # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_corpus_sin_seeds_aceptados_0_fetch() -> None: - """Corpus sin seeds aceptados → 0 requests a cited_by, sin error.""" - corpus = _corpus_from_rows( - [ - _make_row( - id="P1", - source_id="W100", - curation_status=CurationStatus.CANDIDATE, - ), - _make_row( - id="P2", - source_id="W200", - is_seed=False, - curation_status=CurationStatus.ACCEPTED, - ), - ] - ) - - transport, counters = _make_counting_cited_by_transport([]) - enricher = _make_enricher(transport) - result = enricher.enrich(corpus) - - # No se hicieron requests de cited_by (ninguna seed aceptada) - assert counters["citing"][0] == 0, ( - "Sin seeds aceptadas no debe hacer requests citing" - ) - # No pierde papers - assert len(result) == len(corpus) - - -@pytest.mark.unit -def test_enrich_no_pierde_papers_con_semillas() -> None: - """El corpus enriquecido tiene exactamente los mismos papers que el original.""" - corpus = _corpus_from_rows( - [ - _make_row(id="P1", source_id="W100"), - _make_row( - id="P2", - source_id="W200", - curation_status=CurationStatus.CANDIDATE, - ), - _make_row(id="P3", source_id=None), - ] - ) - citing_works = [_citing_work("C1", ["W100"])] - transport = _make_cited_by_transport(citing_works) - - enricher = _make_enricher(transport) - result = enricher.enrich(corpus) - - assert len(result) == len(corpus), "No debe perder ni agregar papers" - - # --------------------------------------------------------------------------- # 9. fetch_citing_batch en lotes ≤50 # --------------------------------------------------------------------------- @@ -706,34 +522,10 @@ def test_papers_sin_openalex_id_no_son_objetivo() -> None: # --------------------------------------------------------------------------- # 11. run_enrich expone claves citing_new y citing_targets +# Eliminado epic #184: cubierto por test_enrich_absorb.py::test_enrich_suelto_claves_numericas +# (contrato completo de 5 claves con valores numéricos concretos). # --------------------------------------------------------------------------- - -@pytest.mark.unit -def test_run_enrich_8b_claves_en_salida(tmp_path: Any) -> None: - """``run_enrich`` devuelve las nuevas claves citing_new y citing_targets.""" - from bib2graph.cli.commands.enrich import run_enrich - from bib2graph.stores.duckdb import DuckDBStore - - rows = [_make_row(id="P1", source_id="W100")] - table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) - corpus = Corpus.from_arrow(table) - store = DuckDBStore(tmp_path / "test.duckdb") - store.persist(corpus) - - citing_works = [_citing_work("C1", ["W100"])] - transport = _make_cited_by_transport(citing_works) - - data = run_enrich(tmp_path / "test.duckdb", transport=transport) - - assert "citing_new" in data, "Debe incluir clave citing_new" - assert "citing_targets" in data, "Debe incluir clave citing_targets" - assert isinstance(data["citing_new"], int) - assert isinstance(data["citing_targets"], int) - assert data["citing_targets"] == 1 # 1 seed aceptada - assert data["citing_new"] == 1 # 1 nuevo citante - - # --------------------------------------------------------------------------- # 12. EnricherRef openalex_cited_by en Manifest # --------------------------------------------------------------------------- diff --git a/tests/unit/test_equation_spec.py b/tests/unit/test_equation_spec.py index c7ada04..3d42a75 100644 --- a/tests/unit/test_equation_spec.py +++ b/tests/unit/test_equation_spec.py @@ -321,32 +321,9 @@ def test_seed_spec_equivalente_a_equation_directo(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_seed_sin_modo_lanza_usage_error(tmp_path: Path) -> None: - """b2g seed sin ningún modo → UsageError accionable (exit 1).""" - from click.testing import CliRunner - - from bib2graph.cli import b2g - from bib2graph.workspace import Workspace - - ws_dir = tmp_path / "ws" - Workspace.init(ws_dir, "test") - - runner = CliRunner() - result = runner.invoke( - b2g, - [ - "--workspace", - str(ws_dir), - "seed", - "--json", - ], - ) - - assert result.exit_code == 1, f"Se esperaba exit 1, salida: {result.output}" - envelope = json.loads(result.output) - assert envelope["ok"] is False - assert envelope["error"]["code"] == "USAGE_ERROR" +# "seed sin ningún modo → UsageError" es idéntico a +# test_seed_from_bib.py::test_seed_sin_modo_usage_error (mismo invocar, mismo assert); +# se conserva allí. Epic #184, sub-tarea 7. @pytest.mark.unit diff --git a/tests/unit/test_idempotencia_pipeline_bitabit.py b/tests/unit/test_idempotencia_pipeline_bitabit.py index 3358c3b..08113b3 100644 --- a/tests/unit/test_idempotencia_pipeline_bitabit.py +++ b/tests/unit/test_idempotencia_pipeline_bitabit.py @@ -22,7 +22,8 @@ parquet (coherencia entre el path DuckDB y el path Arrow puro). Sin red. El parquet congelado en ``examples/valoraciones/corpus.parquet`` -sirve de input. Si no existe, los tests se saltean con ``pytest.skip``. +sirve de input. Está commiteado; si falta, los tests FALLAN (no se saltean): +es una regresión de reproducibilidad, no una condición a tolerar. Marcador: ``unit`` (sin red; el parquet es local, ≤200 KB; DuckDB en tmp_path). """ @@ -52,14 +53,17 @@ # --------------------------------------------------------------------------- -def _skip_if_no_parquet() -> None: - """Saltea el test si el parquet del ejemplo no existe.""" - if not _EXAMPLES_PARQUET.exists(): - pytest.skip( - f"No se encontró el parquet del ejemplo en {_EXAMPLES_PARQUET}. " - "Para regenerarlo (con red): ver §Armado desde cero en " - "examples/valoraciones/README.md." - ) +def _require_parquet() -> None: + """Falla (no saltea) si el parquet del ejemplo no existe. + + El parquet está commiteado en examples/valoraciones/; si falta es una + regresión de reproducibilidad, no una condición a saltear. Coherente con + test_example_r2_gate.py::test_parquet_existe_y_carga (epic #184, sub-tarea 8).""" + assert _EXAMPLES_PARQUET.exists(), ( + f"No se encontró el parquet del ejemplo en {_EXAMPLES_PARQUET}. " + "Debe estar commiteado en examples/valoraciones/. Para regenerarlo " + "(con red): ver §Armado desde cero en examples/valoraciones/README.md." + ) def _load_parquet_slice(n: int = 20) -> pa.Table: @@ -93,7 +97,7 @@ def test_corpus_hash_identico_en_dos_cargas_arrow() -> None: (excluyendo provenance y timestamps) y que Arrow no introduce variabilidad al deserializar. """ - _skip_if_no_parquet() + _require_parquet() from bib2graph.corpus import Corpus from bib2graph.schemas import CORPUS_SCHEMA @@ -132,7 +136,7 @@ def test_run_restore_corpus_hash_identico_entre_dos_runs(tmp_path: Path) -> None Usa un slice de 10 filas para mantener el test ágil; el comportamiento del pipeline es independiente del tamaño del corpus. """ - _skip_if_no_parquet() + _require_parquet() slice_table = _load_parquet_slice(10) slice_parquet = tmp_path / "slice.parquet" @@ -168,19 +172,24 @@ def test_run_restore_corpus_hash_identico_entre_dos_runs(tmp_path: Path) -> None def test_comunidades_identicas_entre_dos_runs_pipeline(tmp_path: Path) -> None: """La composición nodo→comunidad es bit-a-bit idéntica entre dos runs del pipeline. - Ejecuta el pipeline completo ``run_restore → run_build`` dos veces sobre - el mismo parquet de entrada, con stores y directorios de output distintos, - y compara el mapeo nodo→comunidad de cada red. + Ejecuta el pipeline ``run_restore → run_build`` dos veces sobre el mismo + parquet, con stores distintos, y compara el mapeo nodo→comunidad que produce + ``Networks.quick`` sobre cada corpus restaurado. Este es el test solicitado en #61: verificar que dos runs independientes - producen el mismo output (idempotencia del pipeline end-to-end). + producen el mismo output (idempotencia del pipeline end-to-end). La aserción + es sobre las comunidades de ``Networks.quick`` (la fuente de no-determinismo + a vigilar: orden de dicts / Louvain). El camino de escritura de artefactos + (``run_build``) se ejercita UNA vez (run A) para mantener su cobertura sin + reconstruir las redes por duplicado (#184): la idempotencia ya la garantiza la + comparación de comunidades, no una segunda escritura a disco. Garantía de Louvain: ``random_state`` se deriva del ``corpus_hash`` de contenido (``_louvain_seed_from_hash``), que es idéntico si el corpus es el mismo → la partición es determinista e independiente de ``PYTHONHASHSEED`` (Python no usa ``hash()`` para Louvain). """ - _skip_if_no_parquet() + _require_parquet() # Usar un slice del parquet real con suficiente contenido para tener # al menos una red de acoplamiento bibliográfico con comunidades. @@ -192,29 +201,23 @@ def test_comunidades_identicas_entre_dos_runs_pipeline(tmp_path: Path) -> None: from bib2graph.cli.commands.build import run_build from bib2graph.cli.commands.restore import run_restore + from bib2graph.stores.duckdb import DuckDBStore - # Run A + # Run A: pipeline completo (incluye run_build para ejercer el camino de escritura) store_a = tmp_path / "runA.duckdb" - out_a = tmp_path / "networks_a" run_restore(store_a, slice_parquet) - - from bib2graph.stores.duckdb import DuckDBStore - corpus_a = DuckDBStore(store_a).load() communities_a = _communities_of_run(corpus_a) + run_build(store_a, out_dir=tmp_path / "networks_a") - run_build(store_a, out_dir=out_a) - - # Run B (store distinto, mismo parquet) + # Run B (store distinto, mismo parquet): solo restore + comunidades. + # No se reconstruye run_build: su determinismo de output no se asevera acá + # (la aserción es sobre las comunidades de Networks.quick). store_b = tmp_path / "runB.duckdb" - out_b = tmp_path / "networks_b" run_restore(store_b, slice_parquet) - corpus_b = DuckDBStore(store_b).load() communities_b = _communities_of_run(corpus_b) - run_build(store_b, out_dir=out_b) - # Las redes producidas deben ser las mismas kinds assert set(communities_a.keys()) == set(communities_b.keys()), ( f"Los dos runs produjeron redes distintas: " @@ -255,7 +258,7 @@ def test_corpus_hash_coherente_store_vs_arrow_puro(tmp_path: Path) -> None: Esta coherencia garantiza que el formato de persistencia no altera el content-hash del corpus (R2: identidad es del contenido, no del transporte). """ - _skip_if_no_parquet() + _require_parquet() slice_table = _load_parquet_slice(10) slice_parquet = tmp_path / "slice.parquet" diff --git a/tests/unit/test_ingest.py b/tests/unit/test_ingest.py index 78df291..41c59ef 100644 --- a/tests/unit/test_ingest.py +++ b/tests/unit/test_ingest.py @@ -20,9 +20,8 @@ - ``Preprocessor().normalize(corpus, applied_at=)`` registra el timestamp inyectado, no ``now()``. -6. Comando b2g thesaurus: - - Contrato JSON (envelope versionado). - - NO transiciona el CycleState. +6. Verbo b2g thesaurus RETIRADO (#164): la capacidad se mueve a build --thesaurus. + Ver test_build_thesaurus_flag.py para tests del nuevo flag. 7. Dedup CROSS-BIBLIOTECA (el bug que faltaba): - seed → seed con variante del mismo autor en paper DISTINTO → colapsado. @@ -475,134 +474,11 @@ def test_preprocessor_apply_thesaurus_usa_applied_at_inyectado( # --------------------------------------------------------------------------- -# 6. b2g thesaurus: contrato JSON + NO transiciona CycleState +# 6. Verbo b2g thesaurus RETIRADO (#164) +# La capacidad se movio a build --thesaurus. Ver test_build_thesaurus_flag.py # --------------------------------------------------------------------------- -def test_thesaurus_cmd_json_envelope(tmp_path: Path) -> None: - """b2g thesaurus --json produce envelope versionado con las claves correctas.""" - import json as _json - - from click.testing import CliRunner - - from bib2graph.cli import b2g - from bib2graph.cli.commands.seed import run_seed - from bib2graph.workspace import Workspace - - # Preparar workspace con corpus - ws_dir = tmp_path / "ws" - ws = Workspace.init(ws_dir, "test") - run_seed(ws.library_path, "ecology", transport=_make_mock_transport()) - - # Thesaurus mínimo - thesaurus = {"concepts": {"ecology": {"aliases_en": ["ecology"]}}} - thesaurus_path = tmp_path / "thesaurus.json" - thesaurus_path.write_text(_json.dumps(thesaurus), encoding="utf-8") - - runner = CliRunner() - result = runner.invoke( - b2g, - [ - "--workspace", - str(ws_dir), - "thesaurus", - "--from", - str(thesaurus_path), - "--json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, f"exit != 0: {result.output}" - envelope = _json.loads(result.output) - - assert envelope["schema"] == "1" - assert envelope["ok"] is True - assert envelope["command"] == "thesaurus" - # Claves del data - assert "keywords_mapped" in envelope["data"] - assert "keywords_total" in envelope["data"] - assert "aliases_loaded" in envelope["data"] - assert "applied_at" in envelope["data"] - assert envelope["data"]["aliases_loaded"] == 1 - - -def test_thesaurus_cmd_no_transiciona_cycle_state(tmp_path: Path) -> None: - """b2g thesaurus NO transiciona el CycleState (transversal al lazo). - - Tras aplicar el thesaurus, el estado del lazo debe ser el mismo que antes. - """ - import json as _json - - from click.testing import CliRunner - - from bib2graph.cli import b2g - from bib2graph.cli.commands.seed import run_seed - from bib2graph.cycle import CycleState - from bib2graph.stores.duckdb import DuckDBStore - from bib2graph.workspace import Workspace - - ws_dir = tmp_path / "ws" - ws = Workspace.init(ws_dir, "test") - run_seed(ws.library_path, "ecology", transport=_make_mock_transport()) - - # Estado inicial: SEEDED - state_before = DuckDBStore(ws.library_path).backend.loop_state() - assert state_before == CycleState.SEEDED - - thesaurus = {"concepts": {"ecology": {"aliases_en": ["ecology"]}}} - thesaurus_path = tmp_path / "thesaurus.json" - thesaurus_path.write_text(_json.dumps(thesaurus), encoding="utf-8") - - runner = CliRunner() - runner.invoke( - b2g, - [ - "--workspace", - str(ws_dir), - "thesaurus", - "--from", - str(thesaurus_path), - ], - catch_exceptions=False, - ) - - # Estado tras thesaurus: SEEDED (sin cambio) - state_after = DuckDBStore(ws.library_path).backend.loop_state() - assert state_after == CycleState.SEEDED, ( - f"thesaurus no debería transicionar el CycleState: " - f"antes={state_before}, después={state_after}" - ) - - -def test_thesaurus_cmd_ruta_inexistente_emite_error(tmp_path: Path) -> None: - """b2g thesaurus con ruta inexistente → error con exit code != 0.""" - from click.testing import CliRunner - - from bib2graph.cli import b2g - from bib2graph.cli.commands.seed import run_seed - from bib2graph.workspace import Workspace - - ws_dir = tmp_path / "ws" - ws = Workspace.init(ws_dir, "test") - run_seed(ws.library_path, "ecology", transport=_make_mock_transport()) - - runner = CliRunner() - result = runner.invoke( - b2g, - [ - "--workspace", - str(ws_dir), - "thesaurus", - "--from", - str(tmp_path / "no_existe.json"), - "--json", - ], - ) - - assert result.exit_code != 0 - - # --------------------------------------------------------------------------- # 7. normalize_and_dedup standalone # --------------------------------------------------------------------------- @@ -822,7 +698,7 @@ def test_seed_openalex_cross_biblioteca_colapsa_autores(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# 9. Thesaurus re-mapeo: persist_replace evita acumulación de canónicos +# 9. build --thesaurus re-mapeo: persist_replace evita acumulacion de canonicos # --------------------------------------------------------------------------- @@ -835,8 +711,8 @@ def test_thesaurus_remapeo_no_acumula_canonicos(tmp_path: Path) -> None: """ import json as _json + from bib2graph.cli.commands.build import run_build from bib2graph.cli.commands.restore import run_restore - from bib2graph.cli.commands.thesaurus import run_thesaurus from bib2graph.constants import Col from bib2graph.stores.duckdb import DuckDBStore @@ -865,7 +741,7 @@ def test_thesaurus_remapeo_no_acumula_canonicos(tmp_path: Path) -> None: } th_v1_path = tmp_path / "thesaurus_v1.json" th_v1_path.write_text(_json.dumps(thesaurus_v1), encoding="utf-8") - run_thesaurus(store_path, th_v1_path) + run_build(store_path, thesaurus_path=th_v1_path) # Verificar estado tras v1 corpus_v1 = DuckDBStore(store_path).load() @@ -885,7 +761,7 @@ def test_thesaurus_remapeo_no_acumula_canonicos(tmp_path: Path) -> None: } th_v2_path = tmp_path / "thesaurus_v2.json" th_v2_path.write_text(_json.dumps(thesaurus_v2), encoding="utf-8") - run_thesaurus(store_path, th_v2_path) + run_build(store_path, thesaurus_path=th_v2_path) # Reload: solo debe haber el canónico v2, no acumulación de v1 + v2 corpus_v2 = DuckDBStore(store_path).load() diff --git a/tests/unit/test_maturity.py b/tests/unit/test_maturity.py new file mode 100644 index 0000000..587f257 --- /dev/null +++ b/tests/unit/test_maturity.py @@ -0,0 +1,623 @@ +"""Tests TDD — bloque ``maturity`` en build/snapshot/read top (#160). + +Cubre: +1. ``compute_maturity`` (helper puro): + a. ``curated=False`` cuando todo es candidate/seed. + b. ``curated=True`` con ≥1 accepted. + c. ``curated=True`` con ≥1 rejected. + d. ``saturated`` siempre False. + e. ``scope`` y ``empty_networks`` pasan literalmente. + f. Key-set consistente (4 claves exactas). + +2. ``build`` — ``maturity`` en data: + a. Presente en el path normal (corpus no vacío). + b. Presente en el early-return (corpus vacío tras scope). + c. ``empty_networks`` de maturity = kinds del data["empty_networks"]. + d. ``schema="1"`` intacto. + +3. ``snapshot`` — ``maturity`` en data: + a. Presente, scope="all", empty_networks=[]. + b. ``schema="1"`` intacto. + +4. ``read top`` — ``maturity`` en result: + a. Presente con corpus bib-coupling (co-cit vacía → maturity.empty_networks=["cocitation"]). + b. Presente con corpus co-citación (no vacía → maturity.empty_networks=[]). + c. Forma consistente (4 claves). + +5. Negativos — maturity AUSENTE en read list/stats/show. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest +from click.testing import CliRunner + +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# Helpers compartidos +# --------------------------------------------------------------------------- + + +def _row( + id: str, + *, + is_seed: bool = True, + curation_status: str = "candidate", + references_id: list[str] | None = None, + cited_by_id: list[str] | None = None, + keywords_id: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima con schema canónico completo.""" + return { + "id": id, + "source_id": None, + "doi": None, + "title": f"Paper {id}", + "year": 2020, + "abstract": None, + "source": None, + "language": None, + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": keywords_id, + "institutions_raw": None, + "institutions_id": None, + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _make_corpus(*rows: dict[str, Any]): # type: ignore[no-untyped-def] + """Construye un Corpus en memoria desde filas dict.""" + from bib2graph.corpus import Corpus + + table = pa.Table.from_pylist(list(rows), schema=CORPUS_SCHEMA) + return Corpus.from_arrow(table) + + +def _seed_store(store_path: Path, rows: list[dict[str, Any]]) -> None: + """Persiste filas en un DuckDB temporal.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + store.close() + + +def _init_workspace(tmp_path: Path, name: str = "test-ws") -> Any: + """Crea y devuelve un Workspace inicializado en tmp_path.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / name + return Workspace.init(ws_dir, name) + + +def _seed_workspace(ws: Any, rows: list[dict[str, Any]]) -> None: + """Persiste filas en el store del workspace.""" + _seed_store(ws.library_path, rows) + + +def _rows_bib_coupling() -> list[dict[str, Any]]: + """3 papers con referencias compartidas (coupling); sin cited_by_id → coc vacía.""" + return [ + _row("P1", references_id=["R1", "R2", "R3"]), + _row("P2", references_id=["R1", "R3"]), + _row("P3", references_id=["R2"]), + ] + + +def _rows_cocitation() -> list[dict[str, Any]]: + """3 papers con cited_by_id → co-citación no vacía.""" + return [ + _row("P1", is_seed=True, cited_by_id=["C1", "C2"]), + _row("P2", is_seed=True, cited_by_id=["C1", "C3"]), + _row("P3", is_seed=True, cited_by_id=["C4"]), + ] + + +def _assert_maturity_shape(mat: dict[str, Any]) -> None: + """Verifica que el bloque maturity tenga exactamente las 4 claves esperadas.""" + assert set(mat.keys()) == { + "curated", + "scope", + "saturated", + "empty_networks", + }, f"Claves de maturity incorrectas: {set(mat.keys())}" + assert isinstance(mat["curated"], bool), "curated debe ser bool" + assert isinstance(mat["saturated"], bool), "saturated debe ser bool" + assert isinstance(mat["empty_networks"], list), "empty_networks debe ser list" + + +# --------------------------------------------------------------------------- +# 1. compute_maturity — helper puro +# --------------------------------------------------------------------------- + + +class TestComputeMaturity: + """Tests unitarios de la función pura compute_maturity.""" + + def test_curated_false_todo_candidate(self) -> None: + """corpus con solo candidates → curated=False.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus( + _row("P1", curation_status="candidate"), + _row("P2", curation_status="candidate"), + ) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat["curated"] is False + + def test_curated_false_solo_seeds_candidate(self) -> None: + """corpus con seeds (is_seed=True) pero curation_status='candidate' → curated=False.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus( + _row("P1", is_seed=True, curation_status="candidate"), + _row("P2", is_seed=True, curation_status="candidate"), + ) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat["curated"] is False + + def test_curated_true_con_accepted(self) -> None: + """corpus con ≥1 accepted → curated=True.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus( + _row("P1", curation_status="candidate"), + _row("P2", curation_status="accepted"), + ) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat["curated"] is True + + def test_curated_true_con_rejected(self) -> None: + """corpus con ≥1 rejected → curated=True.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus( + _row("P1", curation_status="candidate"), + _row("P2", curation_status="rejected"), + ) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat["curated"] is True + + def test_curated_true_con_accepted_y_rejected(self) -> None: + """corpus con accepted y rejected → curated=True.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus( + _row("P1", curation_status="accepted"), + _row("P2", curation_status="rejected"), + ) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat["curated"] is True + + def test_saturated_siempre_false(self) -> None: + """saturated siempre es False, independientemente del corpus.""" + from bib2graph.service.maturity import compute_maturity + + corpus_curado = _make_corpus(_row("P1", curation_status="accepted")) + corpus_puro = _make_corpus(_row("P2", curation_status="candidate")) + + mat_curado = compute_maturity( + corpus_curado, scope="all", empty_network_kinds=[] + ) + mat_puro = compute_maturity(corpus_puro, scope="all", empty_network_kinds=[]) + + assert mat_curado["saturated"] is False + assert mat_puro["saturated"] is False + + def test_scope_se_pasa_literalmente(self) -> None: + """scope se propaga tal cual al bloque maturity.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus(_row("P1")) + + for token in ("all", "seeds", "accepted", None): + mat = compute_maturity(corpus, scope=token, empty_network_kinds=[]) + assert mat["scope"] == token, ( + f"scope esperado {token!r}, got {mat['scope']!r}" + ) + + def test_empty_network_kinds_se_pasan_literalmente(self) -> None: + """empty_network_kinds se propaga literalmente.""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus(_row("P1")) + + mat_vacia = compute_maturity(corpus, scope="all", empty_network_kinds=[]) + assert mat_vacia["empty_networks"] == [] + + mat_con_kinds = compute_maturity( + corpus, + scope="all", + empty_network_kinds=["cocitation", "keyword_cooccurrence"], + ) + assert mat_con_kinds["empty_networks"] == ["cocitation", "keyword_cooccurrence"] + + def test_keyset_consistente_4_claves(self) -> None: + """El bloque maturity tiene exactamente 4 claves (nunca más, nunca menos).""" + from bib2graph.service.maturity import compute_maturity + + corpus = _make_corpus(_row("P1", curation_status="accepted")) + mat = compute_maturity(corpus, scope="all", empty_network_kinds=["cocitation"]) + _assert_maturity_shape(mat) + + +# --------------------------------------------------------------------------- +# 2. build — maturity en data +# --------------------------------------------------------------------------- + + +class TestBuildMaturity: + """``run_build`` incluye ``maturity`` en el dict devuelto.""" + + def test_maturity_presente_en_build_normal(self, tmp_path: Path) -> None: + """run_build con corpus no vacío incluye data['maturity'].""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_bib_coupling()) + + data = run_build(store_path, out_dir=tmp_path / "nets") + + assert "maturity" in data, "data debe tener clave 'maturity'" + _assert_maturity_shape(data["maturity"]) + + # Semántica curated=False/True eliminada aquí (epic #184): + # la invariante vive en TestComputeMaturity::test_curated_false_todo_candidate + # y test_curated_true_con_accepted; este archivo solo verifica presencia y forma. + + def test_maturity_scope_coincide_con_data_scope(self, tmp_path: Path) -> None: + """maturity.scope coincide con data['scope'].""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_bib_coupling()) + + data = run_build( + store_path, + out_dir=tmp_path / "nets", + scope_cli_token="seeds", + corpus_scope="seeds_only", + ) + + assert data["maturity"]["scope"] == data["scope"] == "seeds" + + def test_maturity_empty_networks_kinds_de_data_empty_networks( + self, tmp_path: Path + ) -> None: + """maturity.empty_networks son solo los kinds (sin reason/fix_command duplicados).""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # Sin keywords_id → keyword_cooccurrence vacía + rows = [_row(f"P{i}", references_id=[f"R{i}", "RSHARED"]) for i in range(3)] + _seed_store(store_path, rows) + + data = run_build(store_path, out_dir=tmp_path / "nets") + + empty_kinds_in_data = [en["kind"] for en in data["empty_networks"]] + maturity_empty = data["maturity"]["empty_networks"] + + # Los kinds de maturity.empty_networks deben coincidir con data["empty_networks"] + assert set(maturity_empty) == set(empty_kinds_in_data), ( + f"maturity.empty_networks {maturity_empty!r} != " + f"data.empty_networks kinds {empty_kinds_in_data!r}" + ) + + # Maturity SOLO tiene los kinds (no reason ni fix_command) + for item in maturity_empty: + assert isinstance(item, str), ( + f"maturity.empty_networks debe contener strings, no dicts: {item!r}" + ) + + # Semántica saturated=False eliminada aquí (epic #184): + # la invariante vive en TestComputeMaturity::test_saturated_siempre_false. + + def test_maturity_presente_en_early_return_corpus_vacio( + self, tmp_path: Path + ) -> None: + """Early-return (scope deja 0 papers) también incluye maturity.""" + from bib2graph.cli.commands.build import run_build + + store_path = tmp_path / "lib.duckdb" + # Solo seeds; scope=accepted → corpus_filtrado vacío si ningún accepted + _seed_store( + store_path, + [_row("P1", is_seed=True, curation_status="candidate")], + ) + + run_build( + store_path, + out_dir=tmp_path / "nets", + corpus_scope="accepted", + ) + + # Early-return: corpus_scope='accepted' deja 0 papers (P1 es seed pero candidate, + # el scope 'accepted' incluye is_seed=True → P1 SÍ entra. + # Probamos con corpus_scope='seeds_only' y corpus sin seeds: + store_path2 = tmp_path / "lib2.duckdb" + _seed_store( + store_path2, + [_row("C1", is_seed=False, curation_status="candidate")], + ) + data2 = run_build( + store_path2, + out_dir=tmp_path / "nets2", + corpus_scope="seeds_only", + ) + + assert "maturity" in data2, "Early-return debe incluir 'maturity'" + _assert_maturity_shape(data2["maturity"]) + assert data2["maturity"]["saturated"] is False + + def test_build_schema_1_intacto_con_maturity(self, tmp_path: Path) -> None: + """build --json: schema='1' intacto después de agregar maturity.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_workspace(ws, _rows_bib_coupling()) + + from bib2graph.cli import b2g + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.output) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert "maturity" in envelope["data"] + + def test_build_json_maturity_no_tiene_reason_fix_command( + self, tmp_path: Path + ) -> None: + """maturity en --json NO duplica reason/fix_command de empty_networks.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + # Sin keywords_id → keyword_cooccurrence vacía + rows = [_row(f"P{i}", references_id=[f"R{i}", "RS"]) for i in range(3)] + _seed_workspace(ws, rows) + + from bib2graph.cli import b2g + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "build", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + envelope = json.loads(result.output) + mat = envelope["data"]["maturity"] + + # maturity.empty_networks son strings (kinds), no dicts + for item in mat["empty_networks"]: + assert isinstance(item, str), ( + f"maturity.empty_networks deben ser strings: {item!r}" + ) + + +# --------------------------------------------------------------------------- +# 3. snapshot — maturity en data +# --------------------------------------------------------------------------- + + +class TestSnapshotMaturity: + """``run_snapshot`` incluye ``maturity`` en el dict devuelto.""" + + def test_maturity_presente_en_snapshot(self, tmp_path: Path) -> None: + """run_snapshot incluye data['maturity'].""" + from bib2graph.cli.commands.snapshot import run_snapshot + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_bib_coupling()) + + snap_dir = tmp_path / "snapshots" + data = run_snapshot(store_path, out_dir=snap_dir) + + assert "maturity" in data, "data debe tener clave 'maturity'" + _assert_maturity_shape(data["maturity"]) + + def test_snapshot_maturity_scope_all(self, tmp_path: Path) -> None: + """snapshot: maturity.scope='all' (exporta completo).""" + from bib2graph.cli.commands.snapshot import run_snapshot + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_bib_coupling()) + + data = run_snapshot(store_path, out_dir=tmp_path / "snaps") + + assert data["maturity"]["scope"] == "all" + + def test_snapshot_maturity_empty_networks_vacia(self, tmp_path: Path) -> None: + """snapshot: maturity.empty_networks=[] (snapshot no sabe de redes).""" + from bib2graph.cli.commands.snapshot import run_snapshot + + store_path = tmp_path / "lib.duckdb" + _seed_store(store_path, _rows_bib_coupling()) + + data = run_snapshot(store_path, out_dir=tmp_path / "snaps") + + assert data["maturity"]["empty_networks"] == [] + + # Semántica curated=False/True y saturated=False eliminada aquí (epic #184): + # las invariantes viven en TestComputeMaturity; aquí solo se verifica wiring. + + def test_snapshot_schema_1_intacto(self, tmp_path: Path) -> None: + """snapshot --json: schema='1' intacto después de agregar maturity.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_workspace(ws, _rows_bib_coupling()) + + from bib2graph.cli import b2g + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "snapshot", "create", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.output) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert "maturity" in envelope["data"] + + +# --------------------------------------------------------------------------- +# 4. read top — maturity en result +# --------------------------------------------------------------------------- + + +class TestReadTopMaturity: + """``get_top`` incluye ``maturity`` en el result.""" + + def test_maturity_presente_en_get_top(self, tmp_path: Path) -> None: + """get_top incluye result['maturity'].""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) + + result = get_top(ws, n=3, kind="bibliographic_coupling") + + assert "maturity" in result, "result debe tener clave 'maturity'" + _assert_maturity_shape(result["maturity"]) + + def test_read_top_maturity_scope_all(self, tmp_path: Path) -> None: + """read top: maturity.scope='all'.""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) + + result = get_top(ws, n=3, kind="bibliographic_coupling") + + assert result["maturity"]["scope"] == "all" + + def test_read_top_maturity_cocitacion_vacia_en_empty_networks( + self, tmp_path: Path + ) -> None: + """read top con co-citación vacía → maturity.empty_networks=['cocitation'].""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) # sin cited_by_id → coc vacía + + result = get_top(ws, n=3, kind="bibliographic_coupling") + + assert result["maturity"]["empty_networks"] == ["cocitation"], ( + f"Expected ['cocitation'], got {result['maturity']['empty_networks']!r}" + ) + + def test_read_top_maturity_cocitacion_llena_empty_networks_vacia( + self, tmp_path: Path + ) -> None: + """read top con co-citación no vacía → maturity.empty_networks=[].""" + from bib2graph.service.reads import get_top + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_cocitation()) + + result = get_top(ws, n=5, kind="cocitation") + + assert result["maturity"]["empty_networks"] == [] + + # Semántica curated=False/True y saturated=False eliminada aquí (epic #184): + # las invariantes viven en TestComputeMaturity; aquí solo se verifica wiring. + + def test_read_top_schema_1_intacto_con_maturity(self, tmp_path: Path) -> None: + """read top --json: schema='1' intacto después de agregar maturity.""" + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_workspace(ws, _rows_bib_coupling()) + + from bib2graph.cli import b2g + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "read", "top", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"Error: {result.output}" + envelope = json.loads(result.output) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert "maturity" in envelope["data"] + + +# --------------------------------------------------------------------------- +# 5. Negativos — maturity AUSENTE en read list/stats/show +# --------------------------------------------------------------------------- + + +class TestMaturityAusenteEnOtrasLecturas: + """maturity solo aparece en build/snapshot/read top, NO en list/stats/show.""" + + def test_maturity_ausente_en_list_papers(self, tmp_path: Path) -> None: + """list_papers NO incluye 'maturity' (es plomería, no presentación).""" + from bib2graph.service.reads import list_papers + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) + + result = list_papers(ws) + + assert "maturity" not in result, ( + "list_papers NO debe tener 'maturity'; es solo para build/snapshot/read top" + ) + + def test_maturity_ausente_en_corpus_stats(self, tmp_path: Path) -> None: + """corpus_stats NO incluye 'maturity'.""" + from bib2graph.service.reads import corpus_stats + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) + + result = corpus_stats(ws) + + assert "maturity" not in result, "corpus_stats NO debe tener 'maturity'" + + def test_maturity_ausente_en_get_paper(self, tmp_path: Path) -> None: + """get_paper NO incluye 'maturity'.""" + from bib2graph.service.reads import get_paper + + ws = _init_workspace(tmp_path) + _seed_workspace(ws, _rows_bib_coupling()) + + result = get_paper(ws, "P1") + + assert "maturity" not in result, "get_paper NO debe tener 'maturity'" diff --git a/tests/unit/test_networkspec_yaml.py b/tests/unit/test_networkspec_yaml.py index 533d7e1..0462412 100644 --- a/tests/unit/test_networkspec_yaml.py +++ b/tests/unit/test_networkspec_yaml.py @@ -489,7 +489,8 @@ def test_networks_cmd_json_envelope_correcto(tmp_path: Path) -> None: ) assert result.exit_code == 0, f"Salida inesperada: {result.output}" - envelope = json.loads(result.output) + # Usar result.stdout porque b2g networks emite aviso de deprecación a stderr (#165). + envelope = json.loads(result.stdout) assert envelope["schema"] == "1" assert envelope["ok"] is True @@ -532,7 +533,8 @@ def test_networks_cmd_yaml_invalido_emite_data_error(tmp_path: Path) -> None: ) assert result.exit_code == 2 # DataError → exit 2 - envelope = json.loads(result.output) + # Usar result.stdout porque b2g networks emite aviso de deprecación a stderr (#165). + envelope = json.loads(result.stdout) assert envelope["ok"] is False assert envelope["error"] is not None assert envelope["error"]["code"] == "DATA_ERROR" diff --git a/tests/unit/test_packaging_config.py b/tests/unit/test_packaging_config.py deleted file mode 100644 index e94283d..0000000 --- a/tests/unit/test_packaging_config.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Guard de regresión del empaquetado G5 — config ``force-include`` en pyproject.toml. - -Verifica que la clave ``tool.hatch.build.targets.wheel.force-include`` existe y -mapea ``src/bib2graph/gui/static`` al destino esperado dentro del wheel. - -Esto es un **guard de config**, no un build real: comprueba que nadie eliminó -accidentalmente la clave que vendorea el frontend al wheel (ADR 0028 §B.1, G5). -Sin esta clave, hatchling omite ``gui/static/`` (gitignored) y el wheel sale sin -frontend aunque ``pnpm build`` haya corrido. - -Marcador: ``unit`` (puro, sin I/O de build). -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -pytestmark = pytest.mark.unit - -_PYPROJECT = Path(__file__).parent.parent.parent / "pyproject.toml" - - -@pytest.mark.unit -def test_force_include_clave_presente() -> None: - """pyproject.toml declara force-include para gui/static (guard ADR 0028 §B.1).""" - try: - import tomllib - except ImportError: - import tomli as tomllib # type: ignore[no-redef] - - with open(_PYPROJECT, "rb") as fh: - cfg = tomllib.load(fh) - - force = ( - cfg.get("tool", {}) - .get("hatch", {}) - .get("build", {}) - .get("targets", {}) - .get("wheel", {}) - .get("force-include", {}) - ) - assert force, ( - "tool.hatch.build.targets.wheel.force-include está vacío o ausente. " - "Sin esta clave hatchling omite gui/static/ (gitignored) y el wheel " - "sale sin frontend (ADR 0028 §B.1, G5)." - ) - - -@pytest.mark.unit -def test_force_include_mapea_gui_static() -> None: - """force-include mapea 'src/bib2graph/gui/static' → 'bib2graph/gui/static'.""" - try: - import tomllib - except ImportError: - import tomli as tomllib # type: ignore[no-redef] - - with open(_PYPROJECT, "rb") as fh: - cfg = tomllib.load(fh) - - force = ( - cfg.get("tool", {}) - .get("hatch", {}) - .get("build", {}) - .get("targets", {}) - .get("wheel", {}) - .get("force-include", {}) - ) - src_key = "src/bib2graph/gui/static" - dest_val = "bib2graph/gui/static" - assert src_key in force, ( - f"force-include no tiene la clave '{src_key}'. " - "Verificar pyproject.toml [tool.hatch.build.targets.wheel.force-include]." - ) - assert force[src_key] == dest_val, ( - f"force-include['{src_key}'] = '{force[src_key]}', se esperaba '{dest_val}'. " - "El destino dentro del wheel debe ser 'bib2graph/gui/static'." - ) diff --git a/tests/unit/test_parity_resolve_paths.py b/tests/unit/test_parity_resolve_paths.py new file mode 100644 index 0000000..f190cf8 --- /dev/null +++ b/tests/unit/test_parity_resolve_paths.py @@ -0,0 +1,167 @@ +"""Paridad DOI->source_id entre camino separado y camino encadenado (guardia #165). + +Verifica que el mismo .bib procesado por dos rutas distintas produce +exactamente los mismos ``source_id`` resueltos en el corpus: + + Camino A (separado): + run_seed_from_bib(store_a, bib) # sin resolve + run_resolve(store_a, transport=mock) # resolve explicito post-seed + + Camino B (encadenado): + run_seed_from_bib(store_b, bib, resolve=True, transport=mock) + +Ambos caminos delegan en ``service.resolve._resolve_dois_on_store`` (ADR 0035, +fuente unica): la diferencia es cuando se abre/cierra el store. Si la fuente +unica es verdaderamente compartida, los resultados deben ser identicos. + +Esta es la red de seguridad para que #165 retire el verbo ``resolve`` sin +reconciliar: un fallo aqui indica que los dos caminos divergieron y hay +duplicacion real de logica que reconciliar antes de retirar el verbo. + +Marcador: ``unit`` (DuckDB real en tmp_path, sin red real; MockTransport para OA). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from bib2graph.constants import Col + +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# BibTeX de prueba: 2 papers con DOI, 1 sin DOI +# --------------------------------------------------------------------------- + +BIB_PARITY = """\ +@article{paper_a, + author = {Autor, Uno}, + title = {Paper A con DOI}, + journal = {Test Journal}, + year = {2020}, + doi = {10.1234/paper-a}, +} + +@article{paper_b, + author = {Autor, Dos}, + title = {Paper B con DOI}, + journal = {Test Journal}, + year = {2021}, + doi = {10.5678/paper-b}, +} + +@article{paper_c, + author = {Autor, Tres}, + title = {Paper C sin DOI}, + journal = {Test Journal}, + year = {2022}, +} +""" + +# Mock works: OpenAlex resuelve los 2 DOIs del .bib a source_ids conocidos +_WORKS_MOCK: list[dict[str, Any]] = [ + {"id": "https://openalex.org/WP001", "doi": "https://doi.org/10.1234/paper-a"}, + {"id": "https://openalex.org/WP002", "doi": "https://doi.org/10.5678/paper-b"}, +] + + +def _make_mock_transport(works: list[dict[str, Any]]) -> httpx.MockTransport: + """MockTransport estatico: responde siempre con la lista completa de works. + + No simula paginacion (next_cursor=None), lo que es correcto para lotes + de <=100 DOIs sin cursor. El handler es idempotente: devuelve los mismos + works en cualquier request, permitiendo que el test use una instancia + por camino. + """ + + def handler(request: httpx.Request) -> httpx.Response: + body = { + "results": works, + "meta": {"count": len(works), "next_cursor": None}, + } + return httpx.Response( + 200, + json=body, + headers={"x-openalex-api-version": "2026-05-01"}, + ) + + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_doi_to_source_id(store_path: Path) -> dict[str | None, str | None]: + """Abre el store, extrae el mapa doi->source_id y cierra.""" + from bib2graph.stores.duckdb import DuckDBStore + + store = DuckDBStore(store_path) + rows = store.load().to_arrow().to_pylist() + store.close() + return {r[Col.DOI]: r[Col.SOURCE_ID] for r in rows} + + +# --------------------------------------------------------------------------- +# Test de paridad +# --------------------------------------------------------------------------- + + +def test_parity_seed_resolve_separado_vs_encadenado(tmp_path: Path) -> None: + """Paridad: seed+resolve separados == seed --resolve encadenado. + + Camino A: run_seed_from_bib(store_a, bib) luego run_resolve(store_a, mock) + Camino B: run_seed_from_bib(store_b, bib, resolve=True, transport=mock) + + El assert principal es que el mapa doi->source_id es identico en ambos + stores. Los asserts secundarios verifican valores concretos para que el + test falle con mensaje claro si cambia la logica de resolucion. + """ + from bib2graph.cli.commands.resolve import run_resolve + from bib2graph.cli.commands.seed import run_seed_from_bib + + bib_path = tmp_path / "parity.bib" + bib_path.write_text(BIB_PARITY, encoding="utf-8") + + store_path_a = tmp_path / "store_a.duckdb" + store_path_b = tmp_path / "store_b.duckdb" + + # -- Camino A: dos operaciones secuenciales (store abre y cierra dos veces) -- + run_seed_from_bib(store_path_a, bib_path) + run_resolve(store_path_a, transport=_make_mock_transport(_WORKS_MOCK)) + + # -- Camino B: operacion encadenada (store abre y cierra una sola vez) ---- + run_seed_from_bib( + store_path_b, + bib_path, + resolve=True, + transport=_make_mock_transport(_WORKS_MOCK), + ) + + # -- Comparar resultados --------------------------------------------------- + map_a = _load_doi_to_source_id(store_path_a) + map_b = _load_doi_to_source_id(store_path_b) + + assert map_a == map_b, ( + "Los dos caminos producen source_id distintos -- " + "_resolve_dois_on_store ya no es la fuente unica compartida.\n" + f" Camino A (separado): {map_a}\n" + f" Camino B (encadenado): {map_b}" + ) + + # Asserts concretos: los DOIs conocidos deben resolverse al source_id correcto + assert map_a["10.1234/paper-a"] == "WP001", ( + f"DOI 10.1234/paper-a esperaba WP001, obtuvo {map_a['10.1234/paper-a']!r}" + ) + assert map_a["10.5678/paper-b"] == "WP002", ( + f"DOI 10.5678/paper-b esperaba WP002, obtuvo {map_a['10.5678/paper-b']!r}" + ) + # Paper sin DOI no debe tener source_id (None key en el mapa) + assert map_a[None] is None, ( + f"Paper sin DOI no deberia tener source_id, obtuvo {map_a[None]!r}" + ) diff --git a/tests/unit/test_r3_commands_domain.py b/tests/unit/test_r3_commands_domain.py index e8b3520..94d8c9f 100644 --- a/tests/unit/test_r3_commands_domain.py +++ b/tests/unit/test_r3_commands_domain.py @@ -20,6 +20,13 @@ from bib2graph.cycle import CycleState, apply_transition from bib2graph.schemas import CORPUS_SCHEMA +# Targets de patch: el método en su CLASE DE DEFINICIÓN (robusto al import-path del +# SUT — se parchea el objeto-clase compartido, no un alias). Centralizados acá para +# que un eventual movimiento de módulo de Forager/Networks se actualice en un solo +# lugar (epic #184, sub-tarea 8). +_PATCH_FORAGER_CHAIN = "bib2graph.foraging.forager.Forager.chain" +_PATCH_NETWORKS_QUICK = "bib2graph.networks.facade.Networks.quick" + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -168,7 +175,7 @@ def test_estado_persistido_es_dictado_por_cycle( from bib2graph.cli.commands.chain import run_chain with patch( - "bib2graph.foraging.forager.Forager.chain", + _PATCH_FORAGER_CHAIN, return_value=_make_empty_forage_result(), ): run_chain(store_path, transport=_make_noop_transport()) @@ -180,7 +187,7 @@ def test_estado_persistido_es_dictado_por_cycle( from bib2graph.cli.commands.build import run_build with patch( - "bib2graph.networks.facade.Networks.quick", + _PATCH_NETWORKS_QUICK, return_value=[], ): run_build(store_path) diff --git a/tests/unit/test_resolve.py b/tests/unit/test_resolve.py index 3892949..3a57fa5 100644 --- a/tests/unit/test_resolve.py +++ b/tests/unit/test_resolve.py @@ -374,7 +374,8 @@ def test_cli_resolve_json_envelope(tmp_path: Path) -> None: result = runner.invoke(b2g, ["--workspace", str(ws_path), "resolve", "--json"]) assert result.exit_code == 0, result.output - data = json.loads(result.output) + # Usar result.stdout porque b2g resolve emite aviso de deprecación a stderr (#165). + data = json.loads(result.stdout) assert data["ok"] is True assert data["command"] == "resolve" assert "resolved" in data["data"] @@ -475,7 +476,8 @@ def test_cli_seed_from_bib_resolve_envelope(tmp_path: Path) -> None: ], ) assert result.exit_code == 0, result.output - data = json.loads(result.output) + # Usar result.stdout porque b2g resolve emite aviso de deprecación a stderr (#165). + data = json.loads(result.stdout) assert data["ok"] is True assert data["command"] == "resolve" assert "resolved" in data["data"] diff --git a/tests/unit/test_restore.py b/tests/unit/test_restore.py index 66af198..b0b2737 100644 --- a/tests/unit/test_restore.py +++ b/tests/unit/test_restore.py @@ -298,7 +298,9 @@ def test_restore_cmd_sin_red(tmp_path: Path) -> None: ) assert result.exit_code == 0, f"Salida inesperada: {result.output}" - envelope = json.loads(result.output) + # Usar result.stdout (solo stdout) porque b2g restore emite aviso de deprecación + # a stderr (#165); result.output mezcla ambas streams en Click 8.4.1. + envelope = json.loads(result.stdout) assert envelope["ok"] is True assert envelope["command"] == "restore" assert envelope["data"]["papers_loaded"] == 2 @@ -341,7 +343,8 @@ def test_restore_cmd_envelope_contiene_state(tmp_path: Path) -> None: ) assert result.exit_code == 0 - envelope = json.loads(result.output) + # Usar result.stdout porque b2g restore emite aviso de deprecación a stderr (#165). + envelope = json.loads(result.stdout) assert "state" in envelope["data"] assert envelope["data"]["state"] == "FILTERED" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 53558c1..695c724 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -163,14 +163,28 @@ def test_code_for_excepcion_no_mapeada_lanza_typeerror() -> None: # --------------------------------------------------------------------------- +def _service_module_names() -> list[str]: + """Todos los módulos del paquete service/ (auto-descubrimiento: cubre módulos + futuros sin tocar el test). Fuente única del contrato de neutralidad ADR 0028. + Consolida las copias que vivían en test_service_reads, test_api, test_cli_read + y test_cli_read_top (epic #184).""" + import importlib + import pkgutil + + pkg = importlib.import_module("bib2graph.service") + names = ["bib2graph.service"] + names += [ + f"bib2graph.service.{info.name}" for info in pkgutil.iter_modules(pkg.__path__) + ] + return sorted(names) + + @pytest.mark.unit -@pytest.mark.parametrize( - "module_name", ["bib2graph.service.envelope", "bib2graph.service.errors"] -) +@pytest.mark.parametrize("module_name", _service_module_names()) def test_service_modulo_neutral_de_transporte(module_name: str) -> None: - """Los módulos de service/ son agnósticos de transporte (ADR 0028): no importan - click/fastapi ni hacen sys.exit/print. Detección por AST real (no substrings), - sobre TODOS los módulos del contrato (no solo envelope).""" + """Cada módulo de service/ es agnóstico de transporte (ADR 0028): no importa + click/fastapi ni hace sys.exit/print. Detección por AST real (no substrings), + sobre TODO el paquete service/ (no solo envelope/errors).""" import ast import importlib import pathlib diff --git a/tests/unit/test_service_reads.py b/tests/unit/test_service_reads.py index 92115ac..afe561a 100644 --- a/tests/unit/test_service_reads.py +++ b/tests/unit/test_service_reads.py @@ -543,41 +543,5 @@ def test_get_scent_paper_inexistente_lanza_dataerror(tmp_path: Path) -> None: get_scent(ws, "no-existe") -# --------------------------------------------------------------------------- -# 5. Neutralidad de transporte — service/reads.py es agnóstico -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -def test_service_reads_es_neutral_de_transporte() -> None: - """service/reads.py no importa click, fastapi, sys.exit ni print (ADR 0028).""" - import ast - import importlib - import pathlib - - mod = importlib.import_module("bib2graph.service.reads") - source_file = mod.__file__ - assert source_file is not None - tree = ast.parse(pathlib.Path(source_file).read_text(encoding="utf-8")) - - forbidden = {"click", "fastapi"} - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - assert alias.name.split(".")[0] not in forbidden, ( - f"service.reads importa '{alias.name}' — viola neutralidad" - ) - elif isinstance(node, ast.ImportFrom): - assert (node.module or "").split(".")[0] not in forbidden, ( - f"service.reads importa de '{node.module}' — viola neutralidad" - ) - elif isinstance(node, ast.Call): - if ( - isinstance(node.func, ast.Attribute) - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "sys" - and node.func.attr == "exit" - ): - raise AssertionError("service.reads llama a sys.exit") - if isinstance(node.func, ast.Name) and node.func.id == "print": - raise AssertionError("service.reads llama a print()") +# Neutralidad de transporte de service.reads: consolidada en +# test_service.py::test_service_modulo_neutral_de_transporte (epic #184). diff --git a/tests/unit/test_skill_add.py b/tests/unit/test_skill_add.py new file mode 100644 index 0000000..5660362 --- /dev/null +++ b/tests/unit/test_skill_add.py @@ -0,0 +1,525 @@ +"""Tests del comando ``b2g skill add`` (Epic #188). + +Casos cubiertos: + +Registro en el CLI: + 1. ``skill`` aparece en ``b2g --help``. + 2. ``skill add`` aparece en ``b2g skill --help``. + +Instalación por scope: + 3. ``skill add --user`` escribe SKILL.md + reference/ciclo.md bajo + ``/.claude/skills/bib2graph/``. + 4. ``skill add --project`` escribe SKILL.md bajo + ``/.claude/skills/bib2graph/``. + +Comportamiento de --force: + 5. Sin ``--force`` sobre destino existente → exit ≠ 0 + mensaje accionable. + 6. Con ``--force`` sobre destino existente → pisa y sale con exit 0. + +Default scope: + 7. Sin flag de scope → comportamiento idéntico a ``--user``. + +Envelope JSON: + 8. ``skill add --json`` emite envelope ``schema="1"`` válido en stdout + (parseable, una sola línea). + +Sin workspace: + 9. Corre OK en un directorio sin ``workspace.json``. + +Version-lock (load-bearing): + 10. ``importlib.resources.files("bib2graph") / "skill" / "SKILL.md"`` + existe y es legible — garantiza que skill == paquete. + +Anti-drift (load-bearing — sostiene la promesa central): + 11. Parsea el SKILL.md vendido, extrae todos los comandos invocados con + prefijo ``b2g ``, y verifica que cada top-level y cada subcomando + mencionado existen en el grupo ``b2g``. Si SKILL.md menciona un + verbo que el CLI no expone, este test falla. + +Filosofía (AGENTS.md): se testea la FUNCIÓN detrás del comando cuando es +posible; CliRunner solo donde hay integración necesaria. +Marcador: ``unit`` (sin red ni DuckDB; operaciones de filesystem en tmp_path). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from bib2graph.cli import b2g +from bib2graph.cli.commands.skill import run_skill_add + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Fixtures compartidas +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def runner() -> CliRunner: + """CliRunner (Click 8.2+ separa stdout/stderr por defecto).""" + return CliRunner() + + +# --------------------------------------------------------------------------- +# 1. Registro: skill en b2g --help +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_aparece_en_b2g_help(runner: CliRunner) -> None: + """``skill`` aparece listado en ``b2g --help``.""" + result = runner.invoke(b2g, ["--help"]) + assert result.exit_code == 0, f"b2g --help falló: {result.output!r}" + assert "skill" in result.output, ( + "El grupo 'skill' no aparece en b2g --help. " + "Verificá que skill_grp esté registrado en cli/__init__.py." + ) + + +# --------------------------------------------------------------------------- +# 2. Registro: skill add en b2g skill --help +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_aparece_en_skill_help(runner: CliRunner) -> None: + """``skill add`` aparece listado en ``b2g skill --help``.""" + result = runner.invoke(b2g, ["skill", "--help"]) + assert result.exit_code == 0, f"b2g skill --help falló: {result.output!r}" + assert "add" in result.output, "El subcomando 'add' no aparece en b2g skill --help." + + +# --------------------------------------------------------------------------- +# 3. Instalación --user +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_user_copia_archivos(tmp_path: Path) -> None: + """``skill add --user`` copia SKILL.md + reference/ciclo.md bajo /.claude/.""" + data = run_skill_add(scope="user", force=False, home=tmp_path) + + assert data["scope"] == "user" + install_path = Path(data["install_path"]) + assert install_path == tmp_path / ".claude" / "skills" / "bib2graph" + + skill_md = install_path / "SKILL.md" + ciclo_md = install_path / "reference" / "ciclo.md" + + assert skill_md.exists(), f"SKILL.md no fue copiado a {skill_md}" + assert skill_md.stat().st_size > 0, "SKILL.md está vacío" + assert ciclo_md.exists(), f"reference/ciclo.md no fue copiado a {ciclo_md}" + + +@pytest.mark.unit +def test_skill_add_user_via_cli(tmp_path: Path, runner: CliRunner) -> None: + """``b2g skill add --user`` escribe SKILL.md bajo /.claude/... (CliRunner).""" + with patch.object(Path, "home", return_value=tmp_path): + result = runner.invoke(b2g, ["skill", "add", "--user"], catch_exceptions=False) + + assert result.exit_code == 0, ( + f"Falló con exit {result.exit_code}: {result.output!r}" + ) + skill_md = tmp_path / ".claude" / "skills" / "bib2graph" / "SKILL.md" + assert skill_md.exists(), f"SKILL.md no fue instalado en {skill_md}" + + +# --------------------------------------------------------------------------- +# 4. Instalación --project +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_project_copia_archivos(tmp_path: Path) -> None: + """``skill add --project`` copia SKILL.md bajo /.claude/skills/bib2graph/.""" + data = run_skill_add(scope="project", force=False, cwd=tmp_path) + + assert data["scope"] == "project" + install_path = Path(data["install_path"]) + assert install_path == tmp_path / ".claude" / "skills" / "bib2graph" + + skill_md = install_path / "SKILL.md" + assert skill_md.exists(), f"SKILL.md no fue copiado a {skill_md}" + + +@pytest.mark.unit +def test_skill_add_project_via_cli(tmp_path: Path, runner: CliRunner) -> None: + """``b2g skill add --project`` escribe SKILL.md bajo /.claude/... (CliRunner).""" + with patch.object(Path, "cwd", return_value=tmp_path): + result = runner.invoke( + b2g, ["skill", "add", "--project"], catch_exceptions=False + ) + + assert result.exit_code == 0, ( + f"Falló con exit {result.exit_code}: {result.output!r}" + ) + skill_md = tmp_path / ".claude" / "skills" / "bib2graph" / "SKILL.md" + assert skill_md.exists(), f"SKILL.md no fue instalado en {skill_md}" + + +# --------------------------------------------------------------------------- +# 5. Sin --force sobre destino existente → error accionable +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_idempotente_noop(tmp_path: Path) -> None: + """Re-correr con la versión vendida ya instalada → no-op (idempotente). + + Contrato ADR 0039 / API.md: sin --force, si ya está la versión vendida, + no hace nada y lo reporta (no falla). + """ + d1 = run_skill_add(scope="user", force=False, home=tmp_path) + assert d1["installed"] is True + assert d1["already_present"] is False + + d2 = run_skill_add(scope="user", force=False, home=tmp_path) + assert d2["installed"] is False, "Re-correr idéntico debe ser no-op." + assert d2["already_present"] is True + assert d2["install_path"] == d1["install_path"] + + +@pytest.mark.unit +def test_skill_add_sin_force_destino_difiere_falla(tmp_path: Path) -> None: + """Sin --force, si el destino existe y DIFIERE de la vendida → UsageError.""" + from bib2graph.cli._errors import UsageError + + # Primera instalación + run_skill_add(scope="user", force=False, home=tmp_path) + + # Ensuciar la instalación para que difiera de la versión vendida + installed = tmp_path / ".claude" / "skills" / "bib2graph" / "SKILL.md" + installed.write_text( + installed.read_text(encoding="utf-8") + "\n\n", + encoding="utf-8", + ) + + # Segunda instalación sin --force sobre destino divergente → debe fallar + with pytest.raises(UsageError) as exc_info: + run_skill_add(scope="user", force=False, home=tmp_path) + + assert "--force" in str(exc_info.value), ( + "El mensaje de error debe sugerir --force. " + f"Mensaje obtenido: {exc_info.value!r}" + ) + + +@pytest.mark.unit +def test_skill_add_sin_force_via_cli_exit_no_cero( + tmp_path: Path, runner: CliRunner +) -> None: + """``b2g skill add`` sin --force sobre destino divergente → exit ≠ 0.""" + # Primera instalación OK + with patch.object(Path, "home", return_value=tmp_path): + r1 = runner.invoke(b2g, ["skill", "add", "--user"]) + assert r1.exit_code == 0 + + # Ensuciar la instalación para que difiera + installed = tmp_path / ".claude" / "skills" / "bib2graph" / "SKILL.md" + installed.write_text( + installed.read_text(encoding="utf-8") + "\n\n", + encoding="utf-8", + ) + + # Segunda invocación sin --force → error + with patch.object(Path, "home", return_value=tmp_path): + r2 = runner.invoke(b2g, ["skill", "add", "--user"]) + assert r2.exit_code != 0, ( + "Se esperaba exit ≠ 0 cuando el destino difiere y no se pasa --force." + ) + assert "--force" in r2.output or "--force" in (r2.stderr or ""), ( + "El mensaje de error no sugiere --force." + ) + + +# --------------------------------------------------------------------------- +# 6. Con --force sobre destino existente → pisa OK +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_force_pisa_destino(tmp_path: Path) -> None: + """Con --force, pisa un destino divergente y restaura la versión vendida.""" + # Primera instalación + run_skill_add(scope="user", force=False, home=tmp_path) + install_path = tmp_path / ".claude" / "skills" / "bib2graph" + skill_md = install_path / "SKILL.md" + vendored = skill_md.read_text(encoding="utf-8") + + # Ensuciar la instalación + skill_md.write_text(vendored + "\n\n", encoding="utf-8") + + # --force pisa → restaura exactamente la versión vendida + data = run_skill_add(scope="user", force=True, home=tmp_path) + assert data["installed"] is True + assert Path(data["install_path"]).exists() + assert skill_md.read_text(encoding="utf-8") == vendored, ( + "--force debe restaurar la versión vendida (sin restos de la edición)." + ) + + +@pytest.mark.unit +def test_skill_add_force_via_cli(tmp_path: Path, runner: CliRunner) -> None: + """``b2g skill add --force`` pisa el destino y sale con exit 0 (CliRunner).""" + # Primera instalación + with patch.object(Path, "home", return_value=tmp_path): + r1 = runner.invoke(b2g, ["skill", "add", "--user"]) + assert r1.exit_code == 0 + + # Segunda con --force → debe salir 0 + with patch.object(Path, "home", return_value=tmp_path): + r2 = runner.invoke(b2g, ["skill", "add", "--user", "--force"]) + assert r2.exit_code == 0, ( + f"--force debería pisar OK, pero falló con exit {r2.exit_code}: {r2.output!r}" + ) + + +# --------------------------------------------------------------------------- +# 7. Default scope → --user +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_default_scope_es_user(tmp_path: Path) -> None: + """Sin flag de scope, run_skill_add usa scope='user' por defecto.""" + # Verificamos llamando la función directamente con scope="user" + data_user = run_skill_add(scope="user", force=False, home=tmp_path) + + # Y llamando vía CLI sin flag de scope (usando patch de home) + home2 = tmp_path / "home2" + home2.mkdir() + with patch.object(Path, "home", return_value=home2): + runner = CliRunner() + result = runner.invoke(b2g, ["skill", "add"], catch_exceptions=False) + + assert result.exit_code == 0 + # Debe instalar en home2/.claude/skills/bib2graph/ (scope user por defecto) + expected = home2 / ".claude" / "skills" / "bib2graph" / "SKILL.md" + assert expected.exists(), ( + f"El default scope no fue 'user': SKILL.md no encontrado en {expected}. " + "Verificá que --user tenga default=True en el comando add." + ) + # El path de data_user tampoco debe coincidir con project + assert ".claude/skills" in data_user["install_path"].replace("\\", "/") + + +# --------------------------------------------------------------------------- +# 8. Envelope --json +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_json_envelope_valido(tmp_path: Path, runner: CliRunner) -> None: + """``skill add --json`` emite envelope schema='1' válido en stdout (1 sola línea).""" + with patch.object(Path, "home", return_value=tmp_path): + result = runner.invoke( + b2g, ["skill", "add", "--user", "--json"], catch_exceptions=False + ) + + assert result.exit_code == 0, f"Falló: {result.output!r}" + + # stdout debe tener exactamente una línea no vacía + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"Se esperaba 1 línea en stdout, se obtuvieron {len(lines)}:\n{result.stdout!r}" + ) + + envelope: dict[str, Any] = json.loads(lines[0]) + assert envelope.get("schema") == "1", ( + f"schema incorrecto: {envelope.get('schema')!r}" + ) + assert envelope.get("ok") is True, f"ok debería ser True: {envelope!r}" + assert envelope.get("command") == "skill add", ( + f"command incorrecto: {envelope.get('command')!r}" + ) + assert envelope.get("exit_code") == 0 + + data = envelope.get("data", {}) + assert "install_path" in data, f"data no contiene 'install_path': {data!r}" + assert "scope" in data, f"data no contiene 'scope': {data!r}" + assert data["scope"] == "user" + # Campos agénticos: un agente agnóstico se onboardea desde el --json. + assert "skill_md" in data, f"data no contiene 'skill_md': {data!r}" + assert data["skill_md"].endswith("SKILL.md") + assert "how_to" in data, f"data no contiene 'how_to': {data!r}" + + +# --------------------------------------------------------------------------- +# 9. Sin workspace → OK +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_sin_workspace(tmp_path: Path, runner: CliRunner) -> None: + """``skill add`` corre OK en un directorio sin workspace.json.""" + home_dir = tmp_path / "home" + home_dir.mkdir() + # tmp_path no tiene workspace.json — debe funcionar igual + with ( + patch.object(Path, "home", return_value=home_dir), + runner.isolated_filesystem(temp_dir=tmp_path), + ): + result = runner.invoke(b2g, ["skill", "add", "--user"], catch_exceptions=False) + + assert result.exit_code == 0, ( + f"skill add falló sin workspace (exit {result.exit_code}): {result.output!r}" + ) + + +# --------------------------------------------------------------------------- +# 10. Version-lock (load-bearing) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_md_accesible_via_importlib_resources() -> None: + """importlib.resources.files('bib2graph') / 'skill' / 'SKILL.md' existe y es legible. + + Garantía de que la skill vendida está incluida en el paquete instalado. + Si este test falla, la instalación via 'b2g skill add' entregará un + directorio vacío o fallará. + """ + import importlib.resources as _res + + skill_path = _res.files("bib2graph") / "skill" / "SKILL.md" + # Convertir a Path para poder usar .exists() y .read_text() + skill_file = Path(str(skill_path)) + assert skill_file.exists(), ( + f"SKILL.md no encontrado en {skill_file}. " + "Verificá que src/bib2graph/skill/ esté incluido en el paquete " + "(pyproject.toml [tool.hatch.build.targets.wheel.force-include])." + ) + content = skill_file.read_text(encoding="utf-8") + assert len(content) > 100, ( + f"SKILL.md parece estar vacío o truncado (len={len(content)})." + ) + assert "bib2graph" in content, "SKILL.md no contiene la palabra 'bib2graph'." + + +@pytest.mark.unit +def test_skill_reference_ciclo_accesible() -> None: + """reference/ciclo.md también es accesible vía importlib.resources.""" + import importlib.resources as _res + + ciclo_path = _res.files("bib2graph") / "skill" / "reference" / "ciclo.md" + ciclo_file = Path(str(ciclo_path)) + assert ciclo_file.exists(), ( + f"reference/ciclo.md no encontrado en {ciclo_file}. " + "Verificá que src/bib2graph/skill/reference/ esté incluido en el paquete." + ) + + +# --------------------------------------------------------------------------- +# 11. Anti-drift (load-bearing — sostiene la promesa central) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_md_anti_drift_comandos_en_cli() -> None: + """Todos los verbos b2g mencionados en SKILL.md están registrados en el CLI. + + Parsea el SKILL.md vendido, extrae todos los patrones ``b2g []`` + (ignorando flags ``--foo`` y placeholders ``<...>``), y verifica que: + - Cada ```` de primer nivel está en ``b2g.commands``. + - Cada ```` (cuando existe y es un token-palabra) está en el grupo + correspondiente. + + Si SKILL.md menciona un verbo que el CLI no expone, este test FALLA. + Esto impide que el skill describa comandos inexistentes (drift). + """ + import importlib.resources as _res + + # 1. Leer SKILL.md + skill_path = Path(str(_res.files("bib2graph") / "skill" / "SKILL.md")) + text = skill_path.read_text(encoding="utf-8") + + # 2. Extraer patrones: b2g [] + # \w+ captura solo chars de palabra (a-z, A-Z, 0-9, _) — filtra + # automáticamente flags (--foo) y placeholders (<...>). + # [ \t] (no \s) para NO cruzar saltos de línea: con \s, "b2g build\nb2g + # read" capturaría (build, b2g) — el subcomando debe estar en la misma línea. + pattern = re.compile(r"b2g[ \t]+(\w+)(?:[ \t]+(\w+))?") + raw_matches = pattern.findall(text) + + assert raw_matches, ( + "No se encontraron patrones 'b2g ' en SKILL.md. " + "¿El SKILL.md fue vaciado o la regex rota?" + ) + + # 3. Construir el conjunto único de (cmd, subcmd) a verificar + to_check: set[tuple[str, str]] = set() + for cmd, subcmd in raw_matches: + # cmd nunca puede ser vacío dado el regex; subcmd puede ser '' + to_check.add((cmd, subcmd)) + + # 4. Verificar contra b2g.commands + b2g_cmds: dict[str, Any] = b2g.commands # type: ignore[attr-defined] + + for cmd, subcmd in sorted(to_check): + assert cmd in b2g_cmds, ( + f"Comando top-level '{cmd}' mencionado en SKILL.md no está " + f"registrado en el grupo b2g. " + f"Comandos registrados: {sorted(b2g_cmds.keys())}. " + "Actualizá SKILL.md o registrá el comando." + ) + if subcmd: + group_obj = b2g_cmds[cmd] + # Obtener subcomandos si el objeto es un grupo Click + sub_cmds: dict[str, Any] = getattr(group_obj, "commands", {}) + assert subcmd in sub_cmds, ( + f"Subcomando '{cmd} {subcmd}' mencionado en SKILL.md no está " + f"registrado. Subcomandos de '{cmd}': {sorted(sub_cmds.keys())}. " + "Actualizá SKILL.md o registrá el subcomando." + ) + + +# --------------------------------------------------------------------------- +# 12. Salida agéntica (AgenticExperience) — explícita y agnóstica al proveedor +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_skill_add_data_incluye_rutas_agenticas(tmp_path: Path) -> None: + """El ``data`` trae skill_md, reference_dir y how_to (auto-onboarding agnóstico).""" + data = run_skill_add(scope="user", force=False, home=tmp_path) + install = tmp_path / ".claude" / "skills" / "bib2graph" + + assert data["skill_md"] == str(install / "SKILL.md") + assert data["reference_dir"] == str(install / "reference") + assert "seed→chain→build→read" in data["how_to"], ( + "how_to debe resumir el ciclo a grandes rasgos." + ) + # Las rutas apuntan a archivos realmente instalados. + assert Path(data["skill_md"]).exists() + + +@pytest.mark.unit +def test_skill_add_salida_humana_es_explicita( + tmp_path: Path, runner: CliRunner +) -> None: + """La salida humana dice dónde está el SKILL.md y pide leerlo. + + Es la pieza que vuelve la skill usable por un agente de **cualquier** + proveedor (no solo Claude Code): el CLI es explícito sobre el artículo a leer. + """ + with patch.object(Path, "home", return_value=tmp_path): + result = runner.invoke(b2g, ["skill", "add", "--user"], catch_exceptions=False) + + assert result.exit_code == 0 + out = result.output + assert "SKILL.md" in out, "La salida debe nombrar el SKILL.md." + assert "leé" in out.lower() or "lee" in out.lower(), ( + "La salida debe pedirle al agente que lea el artículo." + ) + assert "seed→chain→build→read" in out or "ciclo" in out.lower(), ( + "La salida debe explicar a grandes rasgos cómo opera bib2graph." + ) diff --git a/tests/unit/test_snapshot_grp.py b/tests/unit/test_snapshot_grp.py new file mode 100644 index 0000000..ae5e0f7 --- /dev/null +++ b/tests/unit/test_snapshot_grp.py @@ -0,0 +1,503 @@ +"""Tests TDD para ``b2g snapshot`` como grupo noun-verb (ADR 0038, #163). + +Casos cubiertos: + +1. ``snapshot create`` crea snapshot (= comportamiento del ex plano). +2. ``snapshot create`` envelope correcto: ``command == "snapshot create"``. +3. ``snapshot restore`` mergea+dedup y transiciona a FILTERED. +4. ``snapshot restore`` envelope correcto: ``command == "snapshot restore"``. +5. ``snapshot`` sin subcomando → help + exit 0 (NO envelope de error). +6. ``restore`` suelto (shim) sigue funcionando idéntico (delega al servicio). +7. ``restore`` suelto: ``command == "restore"`` (compat backward). +8. Reloj inyectado: ``run_restore`` en service acepta ``decided_at`` param. +9. ``run_snapshot`` importable desde ``cli.commands.snapshot`` (backward compat). +10. ``run_restore`` importable desde ``cli.commands.restore`` (backward compat). +11. Stdout puro (#151): en modo JSON, stdout == exactamente 1 línea. + +Filosofía (AGENTS.md): se testea la FUNCIÓN detrás del comando cuando es +posible; CliRunner solo donde hay integración necesaria. +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest + +from bib2graph.schemas import CORPUS_SCHEMA + +# --------------------------------------------------------------------------- +# Helpers comunes +# --------------------------------------------------------------------------- + + +def _make_corpus_row( + *, + id: str, + title: str = "Test", + curation_status: str = "candidate", + is_seed: bool = True, + year: int = 2020, +) -> dict[str, Any]: + """Fila mínima con schema completo.""" + return { + "id": id, + "openalex_id": None, + "doi": None, + "title": title, + "year": year, + "abstract": None, + "source": None, + "language": "en", + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": None, + "authors_affiliations": None, + "keywords_raw": None, + "keywords_id": None, + "institutions_raw": None, + "institutions_id": None, + "references_id": None, + "references_doi": None, + "cited_by_id": None, + } + + +def _make_parquet(path: Path, rows: list[dict[str, Any]] | None = None) -> Path: + """Escribe un parquet con el schema canónico en ``path``.""" + import pyarrow.parquet as pq + + if rows is None: + rows = [ + _make_corpus_row(id="P1"), + _make_corpus_row(id="P2"), + _make_corpus_row(id="P3", curation_status="accepted"), + ] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + pq.write_table(table, str(path)) + return path + + +def _seed_store(store_path: Path, rows: list[dict[str, Any]] | None = None) -> None: + """Puebla un store con filas mínimas.""" + from bib2graph.corpus import Corpus + from bib2graph.stores.duckdb import DuckDBStore + + if rows is None: + rows = [_make_corpus_row(id="P1"), _make_corpus_row(id="P2")] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + + +# --------------------------------------------------------------------------- +# 1. snapshot create crea snapshot +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_create_crea_archivos(tmp_path: Path) -> None: + """``b2g snapshot create`` (vía run_snapshot) crea parquet + manifest.json.""" + from bib2graph.service.snapshot import run_snapshot + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path) + out_dir = tmp_path / "snap" + + data = run_snapshot(store_path, out_dir=out_dir) + + assert "snapshot_dir" in data + assert "corpus_hash" in data + assert "total_papers" in data + assert (out_dir / "corpus.parquet").exists() + assert (out_dir / "manifest.json").exists() + assert data["total_papers"] == 2 + + +# --------------------------------------------------------------------------- +# 2. snapshot create envelope: command == "snapshot create" +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_create_envelope_command(tmp_path: Path) -> None: + """``b2g snapshot create --json`` emite envelope con ``command=="snapshot create"``.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "snapshot", "create", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"Salida inesperada:\n{result.output}" + lines = [ln for ln in result.output.splitlines() if ln.strip()] + assert len(lines) == 1, f"stdout debe ser 1 línea, fue: {result.output!r}" + envelope = json.loads(lines[0]) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "snapshot create" + assert "snapshot_dir" in envelope["data"] + + +# --------------------------------------------------------------------------- +# 3. snapshot restore mergea+dedup y transiciona a FILTERED +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_restore_transiciona_a_filtered(tmp_path: Path) -> None: + """``snapshot restore`` (service) transiciona a FILTERED y mergea corpus.""" + from bib2graph.cycle import CycleState + from bib2graph.service.snapshot import run_restore + from bib2graph.stores.duckdb import DuckDBStore + + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + store_path = tmp_path / "test.duckdb" + + data = run_restore(store_path, parquet_path) + + assert data["state"] == str(CycleState.FILTERED) + assert data["papers_loaded"] == 3 + + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.FILTERED + + +# --------------------------------------------------------------------------- +# 4. snapshot restore envelope: command == "snapshot restore" +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_restore_envelope_command(tmp_path: Path) -> None: + """``b2g snapshot restore --json`` emite envelope con ``command=="snapshot restore"``.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + Workspace.init(ws_dir, "test") + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "snapshot", + "restore", + "--from-corpus", + str(parquet_path), + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"Salida inesperada:\n{result.output}" + lines = [ln for ln in result.output.splitlines() if ln.strip()] + assert len(lines) == 1, f"stdout debe ser 1 línea, fue: {result.output!r}" + envelope = json.loads(lines[0]) + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "snapshot restore" + assert envelope["data"]["state"] == "FILTERED" + + +# --------------------------------------------------------------------------- +# 5. snapshot sin subcomando → help + exit 0 (NO envelope de error) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_sin_subcomando_exit_0(tmp_path: Path) -> None: + """``b2g snapshot`` sin subcomando imprime ayuda y sale con exit 0.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(b2g, ["snapshot"], catch_exceptions=False) + + # exit 0 (no error, solo ayuda) + assert result.exit_code == 0, f"Se esperaba exit 0, fue {result.exit_code}" + # La salida contiene texto de ayuda + assert "snapshot" in result.output.lower() + # NO debe ser una línea JSON de error + assert not result.output.strip().startswith("{") + + +@pytest.mark.unit +def test_snapshot_sin_subcomando_stdout_no_envelope(tmp_path: Path) -> None: + """``b2g snapshot`` sin subcomando muestra ayuda y no emite envelope JSON. + + ``--json`` es una opción post-verbo de los subcomandos (create/restore), + no del grupo snapshot en sí. El grupo invocado sin subcomando muestra + ayuda y sale con exit 0 — no emite ningún envelope JSON de error. + """ + from click.testing import CliRunner + + from bib2graph.cli import b2g + + runner = CliRunner() + with runner.isolated_filesystem(): + # Sin subcomando, sin --json: ayuda + exit 0 + result = runner.invoke(b2g, ["snapshot"], catch_exceptions=False) + + assert result.exit_code == 0 + # La salida contiene texto de ayuda (subcomandos disponibles) + assert "create" in result.output or "restore" in result.output + # NO debe ser un envelope JSON de error + assert not result.output.strip().startswith("{") + + +# --------------------------------------------------------------------------- +# 6 & 7. restore suelto (shim): sigue funcionando, command == "restore" +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_restore_shim_funciona(tmp_path: Path) -> None: + """``b2g restore --from-corpus`` (shim) importa el corpus y transiciona a FILTERED.""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.cycle import CycleState + from bib2graph.stores.duckdb import DuckDBStore + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "restore", + "--from-corpus", + str(parquet_path), + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"Salida inesperada:\n{result.output}" + # Usar result.stdout porque b2g restore (shim deprecado, #165) emite aviso + # a stderr; result.output mezcla ambas streams en Click 8.4.1. + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert len(lines) == 1 + envelope = json.loads(lines[0]) + assert envelope["ok"] is True + assert envelope["command"] == "restore" # backward compat — NO "snapshot restore" + assert envelope["data"]["state"] == "FILTERED" + + store = DuckDBStore(ws.library_path) + assert store.backend.loop_state() == CycleState.FILTERED + + +# --------------------------------------------------------------------------- +# 8. Reloj inyectado: run_restore acepta decided_at +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_run_restore_acepta_decided_at(tmp_path: Path) -> None: + """``service.snapshot.run_restore`` acepta ``decided_at`` inyectado (R2/ADR 0017). + + El servicio no llama ``datetime.now()`` directamente; el CLI lo inyecta. + """ + from bib2graph.service.snapshot import run_restore + + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + store_path = tmp_path / "test.duckdb" + + # Inyectar un timestamp explícito (simulación de la frontera CLI). + fixed_ts = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC) + data = run_restore(store_path, parquet_path, decided_at=fixed_ts) + + # La función no debe lanzar y el resultado debe ser correcto. + assert data["state"] == "FILTERED" + assert data["papers_loaded"] == 3 + + +# --------------------------------------------------------------------------- +# 9 & 10. Backward compat: importar run_snapshot / run_restore desde CLI modules +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_run_snapshot_importable_desde_cli_snapshot(tmp_path: Path) -> None: + """``from bib2graph.cli.commands.snapshot import run_snapshot`` sigue funcionando.""" + from bib2graph.cli.commands.snapshot import run_snapshot + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path) + out_dir = tmp_path / "snap" + + data = run_snapshot(store_path, out_dir=out_dir) + assert "snapshot_dir" in data + assert (out_dir / "corpus.parquet").exists() + + +@pytest.mark.unit +def test_run_restore_importable_desde_cli_restore(tmp_path: Path) -> None: + """``from bib2graph.cli.commands.restore import run_restore`` sigue funcionando.""" + from bib2graph.cli.commands.restore import run_restore + from bib2graph.cycle import CycleState + + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + store_path = tmp_path / "test.duckdb" + + data = run_restore(store_path, parquet_path) + assert data["state"] == str(CycleState.FILTERED) + + +@pytest.mark.unit +def test_run_restore_importable_desde_service_snapshot(tmp_path: Path) -> None: + """``from bib2graph.service.snapshot import run_restore`` funciona directamente.""" + from bib2graph.cycle import CycleState + from bib2graph.service.snapshot import run_restore + + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + store_path = tmp_path / "test.duckdb" + + data = run_restore(store_path, parquet_path) + assert data["state"] == str(CycleState.FILTERED) + + +# --------------------------------------------------------------------------- +# 11. Stdout puro: en modo JSON stdout == exactamente 1 línea +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_create_stdout_puro_una_linea(tmp_path: Path) -> None: + """``b2g snapshot create --json`` escribe exactamente 1 línea en stdout (#151).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + ws = Workspace.init(ws_dir, "test") + _seed_store(ws.library_path) + + runner = CliRunner() + result = runner.invoke( + b2g, + ["--workspace", str(ws_dir), "snapshot", "create", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + lines = [ln for ln in result.output.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"stdout debe tener exactamente 1 línea, tuvo {len(lines)}:\n{result.output!r}" + ) + + +@pytest.mark.unit +def test_snapshot_restore_stdout_puro_una_linea(tmp_path: Path) -> None: + """``b2g snapshot restore --json`` escribe exactamente 1 línea en stdout (#151).""" + from click.testing import CliRunner + + from bib2graph.cli import b2g + from bib2graph.workspace import Workspace + + ws_dir = tmp_path / "ws" + Workspace.init(ws_dir, "test") + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + + runner = CliRunner() + result = runner.invoke( + b2g, + [ + "--workspace", + str(ws_dir), + "snapshot", + "restore", + "--from-corpus", + str(parquet_path), + "--json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + lines = [ln for ln in result.output.splitlines() if ln.strip()] + assert len(lines) == 1, ( + f"stdout debe tener exactamente 1 línea, tuvo {len(lines)}:\n{result.output!r}" + ) + + +# --------------------------------------------------------------------------- +# FSM invariant: snapshot create NO transiciona, snapshot restore -> FILTERED +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_snapshot_create_no_transiciona_fsm(tmp_path: Path) -> None: + """``snapshot create`` (run_snapshot) NO transiciona el CycleState. + + El corpus puede estar en cualquier estado; snapshot create no lo cambia. + """ + from bib2graph.corpus import Corpus + from bib2graph.cycle import CycleState + from bib2graph.schemas import CORPUS_SCHEMA + from bib2graph.service.snapshot import run_snapshot + from bib2graph.stores.duckdb import DuckDBStore + + rows = [_make_corpus_row(id="P1")] + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store_path = tmp_path / "test.duckdb" + store = DuckDBStore(store_path) + store.persist(corpus) + store.backend.set_loop_state(CycleState.SEEDED, cycle_round=1) + + # Snapshot create: no debe cambiar el estado + run_snapshot(store_path, out_dir=tmp_path / "snap") + + store2 = DuckDBStore(store_path) + assert store2.backend.loop_state() == CycleState.SEEDED # sin cambio + + +@pytest.mark.unit +def test_snapshot_restore_transiciona_a_filtered_desde_seeded(tmp_path: Path) -> None: + """``snapshot restore`` transiciona desde SEEDED a FILTERED.""" + from bib2graph.cycle import CycleState + from bib2graph.service.snapshot import run_restore + from bib2graph.stores.duckdb import DuckDBStore + + parquet_path = _make_parquet(tmp_path / "corpus.parquet") + store_path = tmp_path / "test.duckdb" + + data = run_restore(store_path, parquet_path) + + assert data["state"] == str(CycleState.FILTERED) + store = DuckDBStore(store_path) + assert store.backend.loop_state() == CycleState.FILTERED diff --git a/tests/unit/test_sources.py b/tests/unit/test_sources.py index 71a5974..f741665 100644 --- a/tests/unit/test_sources.py +++ b/tests/unit/test_sources.py @@ -334,58 +334,10 @@ def test_seed_executed_query_wrapping() -> None: # --------------------------------------------------------------------------- -@pytest.mark.unit -def test_bibtex_load_sin_keyerror() -> None: - """``BibtexSource.load`` sobre .bib con campos faltantes → sin KeyError.""" - from bib2graph.sources.bibtex import BibtexSource - - source = BibtexSource() - # No debe lanzar KeyError ni ninguna excepción - corpus = source.load(str(MINIMAL_BIB_PATH)) - assert len(corpus) > 0 - - -@pytest.mark.unit -def test_bibtex_load_is_seed_true() -> None: - """Todos los papers cargados desde .bib tienen ``is_seed=True``.""" - from bib2graph.sources.bibtex import BibtexSource - - source = BibtexSource() - corpus = source.load(str(MINIMAL_BIB_PATH)) - - table = corpus.to_arrow() - is_seed_col = table.column("is_seed").to_pylist() - assert all(v is True for v in is_seed_col) - - -@pytest.mark.unit -def test_bibtex_load_curation_status_candidate() -> None: - """Todos los papers BibTeX tienen ``curation_status='candidate'``.""" - from bib2graph.sources.bibtex import BibtexSource - - source = BibtexSource() - corpus = source.load(str(MINIMAL_BIB_PATH)) - - table = corpus.to_arrow() - status_col = table.column("curation_status").to_pylist() - assert all(s == "candidate" for s in status_col) - - -@pytest.mark.unit -def test_bibtex_load_campos_faltantes_none() -> None: - """Entradas sin doi/abstract/journal → None (sin error).""" - from bib2graph.sources.bibtex import BibtexSource - - source = BibtexSource() - corpus = source.load(str(MINIMAL_BIB_PATH)) - - table = corpus.to_arrow() - titles = table.column("title").to_pylist() - dois = table.column("doi").to_pylist() - - # "Deuda ecológica del Perú" no tiene doi - idx = next(i for i, t in enumerate(titles) if "Per" in (t or "")) - assert dois[idx] is None +# El contrato de campos de BibtexSource.load (is_seed=True, curation_status="candidate", +# campos faltantes → None, sin KeyError) vive en test_bibtex_source_contrato.py +# (test_campos_title_autores_anio_keywords_persisten) y se ejercita end-to-end en +# test_seed_from_bib.py (run_seed_from_bib usa BibtexSource.load). Epic #184, sub-tarea 7. @pytest.mark.unit @@ -764,49 +716,13 @@ def test_translate_exclusion_none_sin_efecto() -> None: assert executed_sin == executed_con -@pytest.mark.unit -def test_seed_exclude_en_query_ejecutada() -> None: - """``seed(..., exclude=[...])`` refleja cláusulas NOT dentro del paréntesis de búsqueda.""" - works = _load_fixture_works() - transport = _make_handler(works) - source = OpenAlexSource(transport=transport) - - result = source.seed("sistémico", exclude=["machine learning"]) - - # El campo NO se repite: exactamente un "title_and_abstract.search:" en la query - assert result.executed_query.count("title_and_abstract.search:") == 1 - assert 'AND NOT "machine learning"' in result.executed_query - assert "AND NOT title_and_abstract.search:" not in result.executed_query - - -@pytest.mark.unit -def test_seed_exclude_multiples_en_query_ejecutada() -> None: - """Múltiples términos excluidos aparecen todos dentro del paréntesis en la query ejecutada.""" - works = _load_fixture_works() - transport = _make_handler(works) - source = OpenAlexSource(transport=transport) - - terms = ["machine learning", "algorithm"] - result = source.seed("sujeto", exclude=terms) - - # El campo NO se repite: exactamente un "title_and_abstract.search:" - assert result.executed_query.count("title_and_abstract.search:") == 1 - assert "AND NOT title_and_abstract.search:" not in result.executed_query - for term in terms: - assert f'AND NOT "{term}"' in result.executed_query - - -@pytest.mark.unit -def test_seed_exclude_en_translation_report() -> None: - """Los términos excluidos se mencionan en el ``translation_report``.""" - works = _load_fixture_works() - transport = _make_handler(works) - source = OpenAlexSource(transport=transport) - - result = source.seed("complejidad", exclude=["machine learning"]) - - combined = " ".join(result.translation_report) - assert "machine learning" in combined +# La forma de la query con exclusiones (campo no repetido, AND NOT dentro del +# paréntesis, exclusiones en el report) está cubierta por los tests PUROS de +# _translate (test_translate_con_una_exclusion / _con_multiples_exclusiones / +# _exclusiones_en_translation_report), que corren en el gate; el comportamiento +# REAL contra la API (count>0) lo cubre el test @network +# tests/integration/test_openalex_exclude_integration.py. Estos tests seed+mock +# solo re-verificaban que seed() expone la salida de _translate. Epic #184, sub-tarea 6. @pytest.mark.unit diff --git a/tests/unit/test_status_10.py b/tests/unit/test_status_10.py new file mode 100644 index 0000000..2f5fb03 --- /dev/null +++ b/tests/unit/test_status_10.py @@ -0,0 +1,866 @@ +"""Tests TDD — Fase 1A del hito 0.10.0: campos aditivos en ``b2g status --json``. + +Cubre los tres nuevos campos del contrato ADR 0037 §(e): + +1. ``next_best_action`` — único próximo comando recomendado (derivado del FSM). +2. ``readiness`` — si el próximo paso va a dar fruto (preparación, no alcanzabilidad). +3. ``build_preview`` — por cada red, predice vacío/no-vacío ANTES de correr build. + +Caso canónico (Nota 20): corpus sembrado desde BibTeX sin ``--resolve`` → +las redes de citación/colaboración salen vacías; la red de keywords sale no-vacía +si hay ``keywords_id`` poblados (p. ej. tras ``thesaurus``). + +Los campos son ADITIVOS: ``schema="1"`` se preserva, los campos viejos no cambian. + +Marcador: ``unit`` (DuckDB en tmp_path, sin red real). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pytest + +from bib2graph.constants import NetworkKind +from bib2graph.corpus import Corpus +from bib2graph.cycle import CycleState, next_best_action +from bib2graph.networks.facade import predict_build_preview +from bib2graph.schemas import CORPUS_SCHEMA + +pytestmark = pytest.mark.unit + +# Comandos de arreglo (fix_command) esperados, en un solo lugar: si cambia el copy +# user-facing se edita acá y no en ~10 asserts dispersos (epic #184, sub-tarea 8). +# Espejan los literales de networks/facade.py (_predict/_empty_network_entry). +FIX_RESOLVE = "b2g seed --resolve" +FIX_ENRICH = "b2g enrich" +FIX_THESAURUS = "b2g build --thesaurus " + +# --------------------------------------------------------------------------- +# Helpers de fixture +# --------------------------------------------------------------------------- + + +def _row( + id: str, + *, + is_seed: bool = True, + source_id: str | None = None, + curation_status: str = "candidate", + references_id: list[str] | None = None, + cited_by_id: list[str] | None = None, + authors_id: list[str] | None = None, + institutions_id: list[str] | None = None, + keywords_id: list[str] | None = None, + keywords_raw: list[str] | None = None, +) -> dict[str, Any]: + """Fila mínima con schema canónico completo.""" + return { + "id": id, + "source_id": source_id, + "doi": None, + "title": f"Paper {id}", + "year": 2020, + "abstract": None, + "source": None, + "language": None, + "publisher": None, + "research_areas": None, + "is_seed": is_seed, + "curation_status": curation_status, + "provenance": None, + "authors_raw": None, + "authors_id": authors_id, + "authors_affiliations": None, + "keywords_raw": keywords_raw, + "keywords_id": keywords_id, + "institutions_raw": None, + "institutions_id": institutions_id, + "references_id": references_id, + "references_doi": None, + "cited_by_id": cited_by_id, + } + + +def _make_corpus(*rows: dict[str, Any]) -> Corpus: + """Construye un Corpus en memoria desde filas dict.""" + table = pa.Table.from_pylist(list(rows), schema=CORPUS_SCHEMA) + return Corpus.from_arrow(table) + + +def _seed_store(store_path: Path, rows: list[dict[str, Any]]) -> None: + """Persiste un conjunto de filas en un DuckDB temporal.""" + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + store.close() + + +def _seed_store_with_state( + store_path: Path, + rows: list[dict[str, Any]], + state: CycleState, +) -> None: + """Persiste filas y fija el estado del lazo.""" + from bib2graph.stores.duckdb import DuckDBStore + + table = pa.Table.from_pylist(rows, schema=CORPUS_SCHEMA) + corpus = Corpus.from_arrow(table) + store = DuckDBStore(store_path) + store.persist(corpus) + store.backend.set_loop_state(state, cycle_round=1) + store.close() + + +# --------------------------------------------------------------------------- +# Pruebas unitarias puras: next_best_action (sin store) +# --------------------------------------------------------------------------- + + +class TestNextBestAction: + """Verifica el mapa determinista FSM → comando recomendado.""" + + def test_sin_estado_recomienda_seed(self) -> None: + assert next_best_action(None) == "seed" + + def test_seeded_recomienda_chain(self) -> None: + assert next_best_action(CycleState.SEEDED) == "chain" + + def test_foraged_recomienda_build(self) -> None: + assert next_best_action(CycleState.FORAGED) == "build" + + def test_filtered_recomienda_build(self) -> None: + assert next_best_action(CycleState.FILTERED) == "build" + + def test_built_recomienda_read(self) -> None: + assert next_best_action(CycleState.BUILT) == "read" + + def test_monitored_recomienda_chain(self) -> None: + assert next_best_action(CycleState.MONITORED) == "chain" + + +# --------------------------------------------------------------------------- +# Pruebas unitarias puras: predict_build_preview (sin store, Corpus en memoria) +# --------------------------------------------------------------------------- + + +class TestPredictBuildPreview: + """Verifica predict_build_preview como función pura sobre Corpus.""" + + def test_devuelve_cinco_redes(self) -> None: + """El preview incluye siempre las 5 redes posibles.""" + corpus = _make_corpus(_row("P1")) + preview = predict_build_preview(corpus) + assert len(preview) == 5 + + def test_cada_entrada_tiene_campos_requeridos(self) -> None: + """Cada entrada tiene kind, would_be_empty, reason, fix_command.""" + corpus = _make_corpus(_row("P1")) + preview = predict_build_preview(corpus) + for entry in preview: + assert "kind" in entry + assert "would_be_empty" in entry + assert "reason" in entry + assert "fix_command" in entry + + def test_corpus_sin_datos_id_todo_vacio(self) -> None: + """Sin ningún _id populado, todas las redes saldrían vacías.""" + corpus = _make_corpus( + _row("P1"), + _row("P2"), + ) + preview = predict_build_preview(corpus) + assert all(entry["would_be_empty"] for entry in preview), ( + "Con corpus sin _id cols, todas las redes deben ser vacías" + ) + + def test_reason_no_nula_cuando_vacia(self) -> None: + """Si would_be_empty=True, reason no puede ser None.""" + corpus = _make_corpus(_row("P1")) + preview = predict_build_preview(corpus) + for entry in preview: + if entry["would_be_empty"]: + assert entry["reason"] is not None, ( + f"{entry['kind']}: would_be_empty=True pero reason=None" + ) + assert entry["fix_command"] is not None, ( + f"{entry['kind']}: would_be_empty=True pero fix_command=None" + ) + + def test_reason_nula_cuando_no_vacia(self) -> None: + """Si would_be_empty=False, reason y fix_command deben ser None.""" + # Corpus con keywords_id con >= 2 elementos por paper → keyword_cooccurrence no vacía + corpus = _make_corpus( + _row("P1", keywords_id=["k1", "k2"]), + _row("P2", keywords_id=["k1", "k3"]), + ) + preview = predict_build_preview(corpus) + kw_entry = next( + e for e in preview if e["kind"] == NetworkKind.KEYWORD_COOCCURRENCE + ) + assert kw_entry["would_be_empty"] is False + assert kw_entry["reason"] is None + assert kw_entry["fix_command"] is None + + # --- Caso canónico: BibTeX sin --resolve --- + + def test_canonical_bibtex_sin_resolve_redes_vacias(self) -> None: + """Corpus BibTeX sin --resolve: references_id y cited_by_id vacíos → vacías. + + Este es el caso de la Nota 20: el agente ve el vacío ANTES de build. + """ + # Simula 15 papers de BibTeX sin --resolve: + # todos los _id cols están vacíos, solo keywords_raw está poblado. + rows = [ + _row(f"P{i}", keywords_raw=["ecology", "diversity"]) for i in range(1, 16) + ] + corpus = _make_corpus(*rows) + preview = predict_build_preview(corpus) + + # Indexar por kind para verificar cada una + by_kind = {str(e["kind"]): e for e in preview} + + # bibliographic_coupling vacía: sin references_id + bc = by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING] + assert bc["would_be_empty"] is True + assert "references_id" in str(bc["reason"]) + assert bc["fix_command"] == FIX_RESOLVE + + # cocitation vacía: sin cited_by_id + coc = by_kind[NetworkKind.COCITATION] + assert coc["would_be_empty"] is True + assert "cited_by_id" in str(coc["reason"]) + assert coc["fix_command"] == FIX_ENRICH + + # author_collab vacía: sin authors_id + ac = by_kind[NetworkKind.AUTHOR_COLLAB] + assert ac["would_be_empty"] is True + assert "authors_id" in str(ac["reason"]) + assert ac["fix_command"] == FIX_RESOLVE + + # institution_collab vacía: sin institutions_id + ic = by_kind[NetworkKind.INSTITUTION_COLLAB] + assert ic["would_be_empty"] is True + assert "institutions_id" in str(ic["reason"]) + assert ic["fix_command"] == FIX_RESOLVE + + # keyword_cooccurrence vacía: keywords_raw tiene datos pero keywords_id no + # Fix debe ser build --thesaurus (puede generarlos sin red) + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is True + assert kw["fix_command"] == FIX_THESAURUS + + def test_keywords_id_poblado_produce_red_no_vacia(self) -> None: + """Con keywords_id >= 2 por paper, keyword_cooccurrence sale no-vacía. + + Simula el estado AFTER thesaurus o AFTER seed --resolve donde + keywords_id está disponible. + """ + corpus = _make_corpus( + _row("P1", keywords_id=["ecology", "complexity"]), + _row("P2", keywords_id=["ecology", "networks"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is False + assert kw["reason"] is None + assert kw["fix_command"] is None + + def test_references_id_dos_o_mas_papers_no_vacia(self) -> None: + """Con >= 2 papers con references_id, bibliographic_coupling no sale vacía.""" + corpus = _make_corpus( + _row("P1", references_id=["R1", "R2"]), + _row("P2", references_id=["R1", "R3"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + + bc = by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING] + assert bc["would_be_empty"] is False + + def test_un_paper_con_references_no_alcanza(self) -> None: + """Solo 1 paper con referencias → bibliographic_coupling vacía (no hay par).""" + corpus = _make_corpus( + _row("P1", references_id=["R1", "R2"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + + bc = by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING] + assert bc["would_be_empty"] is True + + def test_cocitation_necesita_dos_seeds_con_cited_by(self) -> None: + """Co-citación: necesita >= 2 seeds con cited_by_id.""" + # Solo 1 seed con cited_by_id → vacía + corpus = _make_corpus( + _row("P1", is_seed=True, cited_by_id=["C1", "C2"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + assert by_kind[NetworkKind.COCITATION]["would_be_empty"] is True + + # 2 seeds con cited_by_id → no vacía + corpus2 = _make_corpus( + _row("P1", is_seed=True, cited_by_id=["C1", "C2"]), + _row("P2", is_seed=True, cited_by_id=["C1", "C3"]), + ) + preview2 = predict_build_preview(corpus2) + by_kind2 = {str(e["kind"]): e for e in preview2} + assert by_kind2[NetworkKind.COCITATION]["would_be_empty"] is False + + def test_author_collab_necesita_un_paper_con_dos_autores(self) -> None: + """author_collab: vacía si ningún paper tiene >= 2 authors_id.""" + # 0 papers con >= 2 autores → vacía + corpus = _make_corpus( + _row("P1", authors_id=["A1"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + assert by_kind[NetworkKind.AUTHOR_COLLAB]["would_be_empty"] is True + + # 1 paper con 2 autores → no vacía + corpus2 = _make_corpus( + _row("P1", authors_id=["A1", "A2"]), + ) + preview2 = predict_build_preview(corpus2) + by_kind2 = {str(e["kind"]): e for e in preview2} + assert by_kind2[NetworkKind.AUTHOR_COLLAB]["would_be_empty"] is False + + def test_fix_thesaurus_cuando_hay_keywords_raw_sin_id(self) -> None: + """Si keywords_raw existe pero keywords_id no, fix_command='b2g thesaurus'.""" + corpus = _make_corpus( + _row("P1", keywords_raw=["ecology", "diversity"]), + ) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is True + assert kw["fix_command"] == FIX_THESAURUS + + def test_fix_seed_resolve_cuando_no_hay_keywords_raw(self) -> None: + """Sin keywords_raw, fix_command para keyword_cooccurrence='b2g seed --resolve'.""" + corpus = _make_corpus(_row("P1")) + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is True + assert kw["fix_command"] == FIX_RESOLVE + + def test_kinds_son_strings_de_networkkind(self) -> None: + """El campo kind de cada entrada es un string válido de NetworkKind.""" + valid_kinds = {str(k) for k in NetworkKind} + corpus = _make_corpus(_row("P1")) + preview = predict_build_preview(corpus) + for entry in preview: + assert str(entry["kind"]) in valid_kinds, ( + f"kind={entry['kind']!r} no es un NetworkKind válido" + ) + + +# --------------------------------------------------------------------------- +# Pruebas de integración: run_status con DuckDB temporal +# --------------------------------------------------------------------------- + + +class TestRunStatusCamposAditivos: + """Verifica que run_status incluye los tres campos aditivos.""" + + def test_run_status_tiene_tres_campos_aditivos(self, tmp_path: Path) -> None: + """run_status devuelve next_best_action, readiness y build_preview.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path, [_row("P1"), _row("P2")]) + + data = run_status(store_path) + + assert "next_best_action" in data, "Falta next_best_action" + assert "readiness" in data, "Falta readiness" + assert "build_preview" in data, "Falta build_preview" + + def test_campos_viejos_siguen_presentes(self, tmp_path: Path) -> None: + """Los campos existentes no desaparecen (contrato aditivo).""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path, [_row("P1")]) + + data = run_status(store_path) + + # Campos pre-existentes + assert "loop_state" in data + assert "transitions_available" in data + assert "curation_available" in data + assert "round" in data + assert "counts_by_status" in data + assert "total_papers" in data + assert "referenced_not_fetched" in data + + # Semántica del FSM (None→seed, FORAGED→build, BUILT→read) eliminada aquí (epic #184): + # las invariantes viven en TestNextBestAction; este smoke verifica que run_status + # expone el campo con el valor correcto para un estado representativo. + def test_next_best_action_seeded_es_chain(self, tmp_path: Path) -> None: + """Tras seed (SEEDED), next_best_action='chain'.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "seeded.duckdb" + _seed_store_with_state(store_path, [_row("P1")], CycleState.SEEDED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + + def test_build_preview_tiene_cinco_entradas(self, tmp_path: Path) -> None: + """build_preview incluye siempre las 5 redes posibles.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path, [_row("P1")]) + + data = run_status(store_path) + + assert len(data["build_preview"]) == 5 + + def test_readiness_tiene_campos_ready_y_reason(self, tmp_path: Path) -> None: + """readiness es un objeto con 'ready' (bool) y 'reason' (str | None).""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "test.duckdb" + _seed_store(store_path, [_row("P1")]) + + data = run_status(store_path) + + readiness = data["readiness"] + assert "ready" in readiness + assert "reason" in readiness + assert isinstance(readiness["ready"], bool) + + def test_readiness_false_cuando_build_y_todas_vacias(self, tmp_path: Path) -> None: + """Si next_action='build' y todas las redes están vacías, readiness.ready=False. + + Este es el corazón del ADR 0037 §(e): el diagnóstico de red-vacía en + status-time, no post-hoc. + """ + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "foraged_empty.duckdb" + # Corpus foraged pero sin ningún _id col poblado + rows = [_row(f"P{i}") for i in range(1, 10)] + _seed_store_with_state(store_path, rows, CycleState.FORAGED) + + data = run_status(store_path) + + assert data["next_best_action"] == "build" + readiness = data["readiness"] + assert readiness["ready"] is False + assert readiness["reason"] is not None + + def test_readiness_true_cuando_build_con_keywords(self, tmp_path: Path) -> None: + """Si next_action='build' y al menos 1 red no sería vacía, readiness.ready=True.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "foraged_kw.duckdb" + rows = [ + _row("P1", keywords_id=["k1", "k2"]), + _row("P2", keywords_id=["k1", "k3"]), + ] + _seed_store_with_state(store_path, rows, CycleState.FORAGED) + + data = run_status(store_path) + + assert data["next_best_action"] == "build" + assert data["readiness"]["ready"] is True + + def test_readiness_true_para_built(self, tmp_path: Path) -> None: + """Para BUILT (next=read), readiness.ready=True independientemente del corpus.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "built.duckdb" + # BibTeX sin --resolve: source_id=None. Aun así, read es siempre productivo. + rows = [_row("P1")] + _seed_store_with_state(store_path, rows, CycleState.BUILT) + + data = run_status(store_path) + + assert data["readiness"]["ready"] is True, ( + "readiness.ready debe ser True para BUILT (next=read)" + ) + + def test_readiness_true_para_chain_con_source_id(self, tmp_path: Path) -> None: + """Para SEEDED con source_id populado, chain es productivo → ready=True.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "seeded_with_source.duckdb" + rows = [_row("P1", source_id="W123456")] + _seed_store_with_state(store_path, rows, CycleState.SEEDED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + assert data["readiness"]["ready"] is True + + +# --------------------------------------------------------------------------- +# Caso canónico completo (Nota 20): BibTeX sin --resolve → diagnóstico en status-time +# --------------------------------------------------------------------------- + + +class TestCasoCanonicoNota20: + """El caso de trampa que motivó el ADR 0037 §(e). + + Un agente que siembra desde BibTeX sin --resolve NO debe llegar a + construir redes vacías sin advertencia. ``status`` debe mostrar el + diagnóstico ANTES de que corra build. + """ + + def test_bibtex_sin_resolve_diagnostico_completo(self, tmp_path: Path) -> None: + """Corpus BibTeX (solo keywords_raw, sin _id cols) → diagnóstico correcto. + + Verifica: + - redes de citación/colaboración → would_be_empty=True con fix + - keyword_cooccurrence → would_be_empty=True, fix='b2g thesaurus' + - readiness.ready=False (build sería inútil) + - next_best_action='build' (FSM en FORAGED) + """ + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "bibtex_no_resolve.duckdb" + rows = [ + _row(f"P{i}", keywords_raw=["ecology", "diversity", "networks"]) + for i in range(1, 16) + ] + _seed_store_with_state(store_path, rows, CycleState.FORAGED) + + data = run_status(store_path) + + # next_best_action = "build" (FSM en FORAGED) + assert data["next_best_action"] == "build" + + # readiness = False (todas las redes vacías) + assert data["readiness"]["ready"] is False + + # build_preview indexado por kind + by_kind = {str(e["kind"]): e for e in data["build_preview"]} + + # Redes de citación vacías + assert by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING]["would_be_empty"] is True + assert by_kind[NetworkKind.COCITATION]["would_be_empty"] is True + + # Fix para citación/colaboración = seed --resolve + assert by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING]["fix_command"] == FIX_RESOLVE + assert by_kind[NetworkKind.COCITATION]["fix_command"] == FIX_ENRICH + + # Keyword fix = build --thesaurus (hay keywords_raw pero no keywords_id) + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is True + assert kw["fix_command"] == FIX_THESAURUS + + def test_bibtex_con_thesaurus_keyword_no_vacia(self, tmp_path: Path) -> None: + """Tras thesaurus, keywords_id se puebla → keyword_cooccurrence no-vacía. + + Simula el estado AFTER 'b2g thesaurus': keywords_id tiene datos + pero references_id / cited_by_id siguen vacíos. + """ + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "bibtex_con_thesaurus.duckdb" + rows = [ + _row( + f"P{i}", + keywords_raw=["ecology", "diversity"], + keywords_id=["ecology", "diversity"], # thesaurus aplicado + ) + for i in range(1, 6) + ] + _seed_store_with_state(store_path, rows, CycleState.FORAGED) + + data = run_status(store_path) + + by_kind = {str(e["kind"]): e for e in data["build_preview"]} + + # Keyword ya no vacía + kw = by_kind[NetworkKind.KEYWORD_COOCCURRENCE] + assert kw["would_be_empty"] is False + + # readiness.ready=True (al menos 1 red no vacía) + assert data["readiness"]["ready"] is True + + # Las demás siguen vacías (aún sin --resolve) + assert by_kind[NetworkKind.BIBLIOGRAPHIC_COUPLING]["would_be_empty"] is True + assert by_kind[NetworkKind.COCITATION]["would_be_empty"] is True + + +# --------------------------------------------------------------------------- +# P2 Tests: readiness para acción "chain" (ADR 0037 §(e)) +# --------------------------------------------------------------------------- + + +class TestChainReadiness: + """Verifica readiness.ready para next_best_action='chain'. + + El forrajeo en OpenAlex arranca del source_id. BibTeX sin --resolve + no tiene source_id → chaining produce 0 papers nuevos → not ready. + """ + + def test_chain_sin_source_id_no_listo(self, tmp_path: Path) -> None: + """SEEDED con corpus BibTeX (sin source_id) → chain not ready.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "bibtex_seeded.duckdb" + rows = [_row(f"P{i}") for i in range(1, 6)] # todos con source_id=None + _seed_store_with_state(store_path, rows, CycleState.SEEDED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + readiness = data["readiness"] + assert readiness["ready"] is False + assert readiness["reason"] is not None + # El reason debe mencionar source_id y la acción correctiva + assert "source_id" in str(readiness["reason"]) + assert "seed --resolve" in str(readiness["reason"]) + + def test_chain_con_source_id_listo(self, tmp_path: Path) -> None: + """SEEDED con al menos 1 seed con source_id → chain ready.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "openalex_seeded.duckdb" + rows = [ + _row("P1", source_id="W1000001"), + _row("P2", source_id="W1000002"), + ] + _seed_store_with_state(store_path, rows, CycleState.SEEDED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + assert data["readiness"]["ready"] is True + assert data["readiness"]["reason"] is None + + def test_chain_mixto_al_menos_uno_con_source_listo(self, tmp_path: Path) -> None: + """Corpus mixto (algunos con source_id, otros sin) → chain ready.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "mixed_seeded.duckdb" + rows = [ + _row("P1", source_id="W1000001"), + _row("P2"), # sin source_id (BibTeX) + _row("P3"), # sin source_id (BibTeX) + ] + _seed_store_with_state(store_path, rows, CycleState.SEEDED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + assert data["readiness"]["ready"] is True + + def test_monitored_sin_source_id_no_listo(self, tmp_path: Path) -> None: + """MONITORED (también lleva a chain) sin source_id → not ready.""" + from bib2graph.cli.commands.status import run_status + + store_path = tmp_path / "monitored_bibtex.duckdb" + rows = [_row("P1")] # source_id=None + _seed_store_with_state(store_path, rows, CycleState.MONITORED) + + data = run_status(store_path) + + assert data["next_best_action"] == "chain" + assert data["readiness"]["ready"] is False + + +# --------------------------------------------------------------------------- +# P1-test: no-divergencia entre predict_build_preview y build real (ADR 0037) +# --------------------------------------------------------------------------- + + +class TestNoDivergenciaPreviewVsBuild: + """P1-test: predict_build_preview debe coincidir con el build real. + + Para cada corpus representativo, construye las redes reales con ``Networks`` + y afirma que ``would_be_empty == (graph.number_of_edges() == 0)`` para cada + tipo de red. + + Cubre específicamente los casos que exponían el bug original: + - refs disjuntas (OLD: predict=False, build=empty) + - autores duplicados (OLD: predict=False, build=empty) + - citantes disjuntos en co-citación (OLD: predict=False, build=empty) + """ + + def _assert_all_kinds_agree(self, corpus: Corpus) -> None: + """Verifica que preview.would_be_empty == build real para las 5 redes.""" + from bib2graph.networks.facade import Networks, predict_build_preview + from bib2graph.networks.spec import NetworkSpec + + preview = predict_build_preview(corpus) + by_kind = {str(e["kind"]): e for e in preview} + + for kind in NetworkKind: + spec = NetworkSpec(kind=str(kind)) + artifact = Networks.build(corpus, spec) + real_empty = artifact.graph.number_of_edges() == 0 + pred_empty = bool(by_kind[str(kind)]["would_be_empty"]) + assert pred_empty == real_empty, ( + f"kind={kind}: preview.would_be_empty={pred_empty!r} " + f"pero el build real tiene {artifact.graph.number_of_edges()} aristas" + ) + + def test_corpus_sin_datos(self) -> None: + """Corpus sin ningún _id → todas las redes vacías.""" + corpus = _make_corpus(_row("P1"), _row("P2")) + self._assert_all_kinds_agree(corpus) + + def test_coupling_refs_disjuntas_bug_p1(self) -> None: + """CASO TRAMPA P1: refs disjuntas → coupling vacía aunque ≥2 papers tienen refs.""" + # Bug original: n_refs=2 → OLD predicaba would_be_empty=False, build daba 0 aristas. + corpus = _make_corpus( + _row("P1", references_id=["R1", "R2"]), + _row("P2", references_id=["R3", "R4"]), # disjuntas con P1 + ) + self._assert_all_kinds_agree(corpus) + + def test_coupling_refs_compartidas(self) -> None: + """Refs compartidas → coupling no-vacía.""" + corpus = _make_corpus( + _row("P1", references_id=["R1", "R2"]), + _row("P2", references_id=["R1", "R3"]), # R1 compartida + ) + self._assert_all_kinds_agree(corpus) + + def test_autor_duplicado_bug_p1(self) -> None: + """CASO TRAMPA P1: [A1, A1] → tras dedup 1 único → author_collab vacía. + + Bug original: _count_rows_with_multi_col contaba 1 paper con len(val)>=2 + → predicaba would_be_empty=False, pero build daba 0 aristas. + """ + corpus = _make_corpus( + _row("P1", authors_id=["A1", "A1"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_autores_distintos(self) -> None: + """[A1, A2] → 2 distintos → author_collab no-vacía.""" + corpus = _make_corpus( + _row("P1", authors_id=["A1", "A2"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_institucion_duplicada_bug_p1(self) -> None: + """CASO TRAMPA P1: [I1, I1] → institution_collab vacía.""" + corpus = _make_corpus( + _row("P1", institutions_id=["I1", "I1"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_instituciones_distintas(self) -> None: + """[I1, I2] → institution_collab no-vacía.""" + corpus = _make_corpus( + _row("P1", institutions_id=["I1", "I2"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_keyword_duplicada_bug_p1(self) -> None: + """CASO TRAMPA P1: [k1, k1] → keyword_cooccurrence vacía.""" + corpus = _make_corpus( + _row("P1", keywords_id=["k1", "k1"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_keywords_distintas(self) -> None: + """[k1, k2] → keyword_cooccurrence no-vacía.""" + corpus = _make_corpus( + _row("P1", keywords_id=["k1", "k2"]), + ) + self._assert_all_kinds_agree(corpus) + + def test_cocitation_citantes_disjuntos_bug_p1(self) -> None: + """CASO TRAMPA P1: citantes disjuntos → cocitation vacía. + + Bug original: n_cited=2 seeds → OLD predicaba would_be_empty=False, + pero build daba 0 aristas porque ningún citante es compartido. + """ + corpus = _make_corpus( + _row("P1", is_seed=True, cited_by_id=["C1"]), + _row("P2", is_seed=True, cited_by_id=["C2"]), # C1≠C2: disjuntos + ) + self._assert_all_kinds_agree(corpus) + + def test_cocitation_citante_compartido(self) -> None: + """Citante compartido → cocitation no-vacía.""" + corpus = _make_corpus( + _row("P1", is_seed=True, cited_by_id=["C1", "C2"]), + _row("P2", is_seed=True, cited_by_id=["C1", "C3"]), # C1 compartido + ) + self._assert_all_kinds_agree(corpus) + + def test_corpus_con_none_en_lista(self) -> None: + """Listas con None como elementos → filtradas correctamente.""" + corpus = _make_corpus( + _row("P1", authors_id=["A1", None]), # solo A1 válido → no multi-distinct + _row("P2", authors_id=[None, None]), # 0 válidos + ) + self._assert_all_kinds_agree(corpus) + + +# --------------------------------------------------------------------------- +# Tests de contrato: schema="1" y compatibilidad con agentes existentes +# --------------------------------------------------------------------------- + + +class TestContratoEnvelope: + """Garantiza que los campos aditivos no rompen el envelope schema='1'.""" + + def test_envelope_schema_sigue_siendo_uno(self, tmp_path: Path) -> None: + """El envelope JSON tiene schema='1' con los campos nuevos.""" + from bib2graph.cli._envelope import build_envelope + + data = { + "loop_state": "FORAGED", + "transitions_available": ["build"], + "curation_available": ["accept", "reject"], + "round": 1, + "counts_by_status": {"candidate": 5}, + "total_papers": 5, + "referenced_not_fetched": 0, + # Campos aditivos ADR 0037 + "next_best_action": "build", + "readiness": {"ready": True, "reason": None}, + "build_preview": [ + { + "kind": "keyword_cooccurrence", + "would_be_empty": False, + "reason": None, + "fix_command": None, + } + ], + "workspace": {"root": "/tmp/ws", "source": "explicit"}, + "networks_cache_stale": False, + } + + envelope = build_envelope( + command="status", + ok=True, + data=data, + exit_code=0, + ) + + # Schema invariante + assert envelope["schema"] == "1" + assert envelope["ok"] is True + assert envelope["command"] == "status" + assert envelope["exit_code"] == 0 + + # Campos viejos siguen en data + assert envelope["data"]["loop_state"] == "FORAGED" + assert envelope["data"]["total_papers"] == 5 + + # Campos nuevos en data + assert envelope["data"]["next_best_action"] == "build" + assert envelope["data"]["readiness"]["ready"] is True + assert len(envelope["data"]["build_preview"]) == 1 diff --git a/tests/unit/test_workspace_remanentes.py b/tests/unit/test_workspace_remanentes.py index 9edb462..2750976 100644 --- a/tests/unit/test_workspace_remanentes.py +++ b/tests/unit/test_workspace_remanentes.py @@ -121,7 +121,7 @@ def test_snapshot_cmd_sin_outdir_resuelve_snapshots_dir(tmp_path: Path) -> None: runner = CliRunner() result = runner.invoke( snapshot_cmd, - ["--json"], + ["create", "--json"], obj={"workspace": str(ws_dir)}, ) @@ -151,7 +151,7 @@ def test_snapshot_cmd_con_outdir_explicito_usa_ese(tmp_path: Path) -> None: runner = CliRunner() result = runner.invoke( snapshot_cmd, - ["--out-dir", str(custom_out), "--json"], + ["create", "--out-dir", str(custom_out), "--json"], obj={"workspace": str(ws_dir)}, ) diff --git a/uv.lock b/uv.lock index b82b51f..25e4f8f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,26 +1,18 @@ version = 1 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version < '3.15'", "python_full_version >= '3.15'", ] -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, -] - [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -31,63 +23,63 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "argcomplete" version = "3.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754 } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846 }, + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] name = "ast-serialize" version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520 }, - { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779 }, - { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750 }, - { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942 }, - { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517 }, - { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081 }, - { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678 }, - { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603 }, - { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332 }, - { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979 }, - { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002 }, - { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231 }, - { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668 }, - { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075 }, - { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347 }, - { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380 }, - { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879 }, - { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529 }, - { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560 }, - { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172 }, - { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072 }, - { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488 }, - { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702 }, - { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182 }, - { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410 }, - { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587 }, - { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171 }, - { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668 }, - { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311 }, - { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931 }, - { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181 }, +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] name = "bib2graph" -version = "0.6.0" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -106,10 +98,6 @@ dependencies = [ bibtex = [ { name = "bibtexparser" }, ] -gui = [ - { name = "fastapi" }, - { name = "uvicorn" }, -] [package.dev-dependencies] dev = [ @@ -129,7 +117,6 @@ requires-dist = [ { name = "bibtexparser", marker = "extra == 'bibtex'", specifier = ">=1.4.4" }, { name = "click", specifier = ">=8.1" }, { name = "duckdb", specifier = ">=0.10" }, - { name = "fastapi", marker = "extra == 'gui'", specifier = ">=0.110" }, { name = "httpx", specifier = ">=0.27" }, { name = "networkx", specifier = ">=3.2" }, { name = "pyarrow", specifier = ">=15" }, @@ -138,8 +125,8 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rapidfuzz", specifier = ">=3,<4" }, { name = "tqdm", specifier = ">=4.66" }, - { name = "uvicorn", marker = "extra == 'gui'", specifier = ">=0.29" }, ] +provides-extras = ["s2", "neo4j", "viz", "bibtex"] [package.metadata.requires-dev] dev = [ @@ -161,113 +148,113 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/1c/577d3ce406e88f370e80a6ebf76ae52a2866521e0b585e8ec612759894f1/bibtexparser-1.4.4.tar.gz", hash = "sha256:093b6c824f7a71d3a748867c4057b71f77c55b8dbc07efc993b781771520d8fb", size = 55594 } +sdist = { url = "https://files.pythonhosted.org/packages/44/1c/577d3ce406e88f370e80a6ebf76ae52a2866521e0b585e8ec612759894f1/bibtexparser-1.4.4.tar.gz", hash = "sha256:093b6c824f7a71d3a748867c4057b71f77c55b8dbc07efc993b781771520d8fb", size = 55594, upload-time = "2026-01-29T18:58:01.366Z" } [[package]] name = "certifi" version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134 }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -275,20 +262,20 @@ name = "click" version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007 } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639 }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -309,108 +296,108 @@ dependencies = [ { name = "termcolor" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/cc/d87b094ef858c67febcd1d8902352c84b42c9ebc8221d6f2e9d553273358/commitizen-4.16.3.tar.gz", hash = "sha256:5cdca4c02715cc770312f4b505c65a6c39024c73ece41b943bccaf81c44436ed", size = 66772 } +sdist = { url = "https://files.pythonhosted.org/packages/17/cc/d87b094ef858c67febcd1d8902352c84b42c9ebc8221d6f2e9d553273358/commitizen-4.16.3.tar.gz", hash = "sha256:5cdca4c02715cc770312f4b505c65a6c39024c73ece41b943bccaf81c44436ed", size = 66772, upload-time = "2026-05-30T06:34:21.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/35/c7995b1e66159193dd31ed5628d59acbaf4611811645eedf0fb2d5a91946/commitizen-4.16.3-py3-none-any.whl", hash = "sha256:ce1be39fe98a16725fd0c960daf0f360acac86db7ae8db1e1df8d3541005b5be", size = 88927 }, + { url = "https://files.pythonhosted.org/packages/98/35/c7995b1e66159193dd31ed5628d59acbaf4611811645eedf0fb2d5a91946/commitizen-4.16.3-py3-none-any.whl", hash = "sha256:ce1be39fe98a16725fd0c960daf0f360acac86db7ae8db1e1df8d3541005b5be", size = 88927, upload-time = "2026-05-30T06:34:20.006Z" }, ] [[package]] name = "coverage" version = "7.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848 }, - { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354 }, - { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771 }, - { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683 }, - { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791 }, - { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748 }, - { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907 }, - { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483 }, - { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545 }, - { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310 }, - { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266 }, - { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174 }, - { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354 }, - { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290 }, - { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953 }, - { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022 }, - { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379 }, - { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888 }, - { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624 }, - { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739 }, - { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998 }, - { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296 }, - { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658 }, - { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803 }, - { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873 }, - { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245 }, - { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567 }, - { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372 }, - { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989 }, - { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044 }, - { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412 }, - { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412 }, - { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008 }, - { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241 }, - { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373 }, - { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635 }, - { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373 }, - { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341 }, - { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497 }, - { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159 }, - { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934 }, - { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584 }, - { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394 }, - { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015 }, - { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733 }, - { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086 }, - { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381 }, - { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458 }, - { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884 }, - { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022 }, - { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631 }, - { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443 }, - { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069 }, - { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780 }, - { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970 }, - { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157 }, - { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259 }, - { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320 }, - { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577 }, - { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091 }, - { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421 }, - { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466 }, - { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973 }, - { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318 }, - { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633 }, - { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488 }, - { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329 }, - { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291 }, - { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564 }, - { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107 }, - { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764 }, - { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837 }, - { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650 }, - { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218 }, - { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822 }, - { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084 }, - { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454 }, - { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578 }, - { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981 }, - { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112 }, - { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558 }, - { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447 }, - { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048 }, - { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781 }, - { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896 }, - { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214 }, - { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624 }, - { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728 }, - { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752 }, - { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [package.optional-dependencies] @@ -422,9 +409,9 @@ toml = [ name = "decli" version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, ] [[package]] @@ -434,88 +421,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523 } +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298 }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] [[package]] name = "distlib" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628 }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] name = "duckdb" version = "1.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465 }, - { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520 }, - { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794 }, - { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666 }, - { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306 }, - { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423 }, - { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740 }, - { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494 }, - { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121 }, - { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283 }, - { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762 }, - { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290 }, - { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308 }, - { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187 }, - { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254 }, - { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917 }, - { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411 }, - { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120 }, - { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266 }, - { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640 }, - { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409 }, - { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685 }, - { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134 }, - { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891 }, - { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499 }, - { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828 }, - { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011 }, - { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554 }, -] - -[[package]] -name = "fastapi" -version = "0.137.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/29/cc5819dc24d3daa80cdaa1aec023bf8652a70dd7fd1c96b0b225c99a7690/fastapi-0.137.2.tar.gz", hash = "sha256:b9d893bebc97dcfbdcb1917e88a292d062844ea19445a5fa4f7eb28c4baea9e3", size = 410332 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/ed/0c6b644e99fb5697d8bdcd36cdb47c52e77a63fc7a1514b1f03a6ecab955/fastapi-0.137.2-py3-none-any.whl", hash = "sha256:791d36261e916a98b25ac85ee591bc3db159394070f6d3d096d94fb378f60ce2", size = 122252 }, +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] name = "filelock" version = "3.29.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028 } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757 }, + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -526,9 +497,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -541,36 +512,36 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567 } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397 }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -580,156 +551,156 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "librt" version = "0.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092 }, - { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035 }, - { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022 }, - { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273 }, - { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083 }, - { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139 }, - { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442 }, - { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230 }, - { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231 }, - { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585 }, - { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509 }, - { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628 }, - { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122 }, - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147 }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614 }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538 }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623 }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082 }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105 }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268 }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348 }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294 }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608 }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879 }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831 }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470 }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119 }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565 }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395 }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383 }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010 }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433 }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595 }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255 }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847 }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920 }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898 }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812 }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448 }, - { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345 }, - { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131 }, - { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024 }, - { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221 }, - { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174 }, - { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216 }, - { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921 }, - { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850 }, - { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237 }, - { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261 }, - { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965 }, - { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151 }, - { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850 }, - { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138 }, - { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976 }, - { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927 }, - { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698 }, - { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162 }, - { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494 }, - { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858 }, - { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318 }, - { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115 }, - { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918 }, - { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562 }, - { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327 }, - { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572 }, +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -743,186 +714,186 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685 }, - { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165 }, - { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376 }, - { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618 }, - { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063 }, - { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564 }, - { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983 }, - { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381 }, - { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501 }, - { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750 }, - { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630 }, - { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831 }, - { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228 }, - { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684 }, - { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174 }, - { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542 }, - { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929 }, - { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200 }, - { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690 }, - { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435 }, - { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052 }, - { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422 }, - { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374 }, - { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743 }, - { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937 }, - { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371 }, - { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429 }, - { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799 }, - { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458 }, - { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697 }, - { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638 }, - { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852 }, - { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695 }, - { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622 }, - { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798 }, - { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302 }, +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194 }, - { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111 }, - { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159 }, - { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936 }, - { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692 }, - { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164 }, - { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877 }, - { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487 }, - { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945 }, - { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406 }, - { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528 }, - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119 }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246 }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410 }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240 }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012 }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538 }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706 }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541 }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825 }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687 }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482 }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648 }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902 }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992 }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944 }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392 }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220 }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800 }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600 }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134 }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598 }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272 }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197 }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287 }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763 }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070 }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752 }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024 }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398 }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971 }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532 }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881 }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458 }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559 }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716 }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947 }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197 }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245 }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587 }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226 }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196 }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334 }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678 }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672 }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731 }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805 }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496 }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616 }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145 }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813 }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982 }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908 }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867 }, - { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511 }, - { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064 }, - { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157 }, - { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728 }, - { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374 }, - { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286 }, - { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263 }, +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743 }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -936,9 +907,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472 }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -948,59 +919,59 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] [[package]] name = "pyarrow" version = "24.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898 }, - { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915 }, - { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931 }, - { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449 }, - { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949 }, - { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986 }, - { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371 }, - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559 }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654 }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394 }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122 }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032 }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490 }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660 }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759 }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471 }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981 }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172 }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733 }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335 }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748 }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554 }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301 }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929 }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365 }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819 }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252 }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127 }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997 }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720 }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852 }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852 }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207 }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117 }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155 }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387 }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102 }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118 }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765 }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890 }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250 }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282 }, +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] [[package]] @@ -1013,9 +984,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] @@ -1025,117 +996,117 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyparsing" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] @@ -1149,9 +1120,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181 } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453 }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -1163,9 +1134,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1176,9 +1147,9 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886 }, + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, ] [[package]] @@ -1189,61 +1160,61 @@ dependencies = [ { name = "networkx" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/0d/8787b021d52eb8764c0bb18ab95f720cf554902044c6a5cb1865daf45763/python-louvain-0.16.tar.gz", hash = "sha256:b7ba2df5002fd28d3ee789a49532baad11fe648e4f2117cf0798e7520a1da56b", size = 204641 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/0d/8787b021d52eb8764c0bb18ab95f720cf554902044c6a5cb1865daf45763/python-louvain-0.16.tar.gz", hash = "sha256:b7ba2df5002fd28d3ee789a49532baad11fe648e4f2117cf0798e7520a1da56b", size = 204641, upload-time = "2022-01-29T15:53:03.532Z" } [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -1253,198 +1224,185 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "prompt-toolkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753 }, + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] [[package]] name = "rapidfuzz" version = "3.14.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372 }, - { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782 }, - { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677 }, - { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906 }, - { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176 }, - { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441 }, - { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628 }, - { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480 }, - { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627 }, - { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977 }, - { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827 }, - { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601 }, - { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293 }, - { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999 }, - { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715 }, - { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304 }, - { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089 }, - { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404 }, - { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709 }, - { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069 }, - { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630 }, - { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137 }, - { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205 }, - { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639 }, - { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194 }, - { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805 }, - { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667 }, - { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246 }, - { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579 }, - { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231 }, - { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519 }, - { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628 }, - { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231 }, - { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394 }, - { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051 }, - { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565 }, - { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113 }, - { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618 }, - { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220 }, - { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027 }, - { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814 }, - { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448 }, - { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932 }, - { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327 }, - { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755 }, - { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571 }, - { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468 }, - { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311 }, - { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228 }, - { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226 }, - { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283 }, - { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614 }, - { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971 }, - { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985 }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517 }, - { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056 }, - { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732 }, - { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902 }, - { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130 }, - { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308 }, - { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210 }, - { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621 }, - { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950 }, - { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357 }, - { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409 }, - { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603 }, - { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599 }, - { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524 }, - { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302 }, - { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889 }, +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, ] [[package]] name = "ruff" version = "0.15.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677 }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443 }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458 }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483 }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497 }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967 }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770 }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441 }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614 }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450 }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524 }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573 }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818 }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901 }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574 }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788 }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275 }, -] - -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 }, + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] name = "termcolor" version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434 } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734 }, + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "tomlkit" version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875 } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328 }, + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] [[package]] @@ -1452,11 +1410,11 @@ name = "tqdm" version = "4.68.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923 } +sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578 }, + { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" }, ] [[package]] @@ -1466,18 +1424,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/d6/86483526df18c9936cb321b33f6df929db20414378f536972c5e9ee20789/types_networkx-3.6.1.20260612.tar.gz", hash = "sha256:9a28345fb3ad985e460667ef2b9c0879920761c8000cd07b424c0bf1b24ca778", size = 73974 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/d6/86483526df18c9936cb321b33f6df929db20414378f536972c5e9ee20789/types_networkx-3.6.1.20260612.tar.gz", hash = "sha256:9a28345fb3ad985e460667ef2b9c0879920761c8000cd07b424c0bf1b24ca778", size = 73974, upload-time = "2026-06-12T06:22:35.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/1d/4694309c774f552a0dad8d357bc8c2b152120c9744dad4f21fdc3e819c37/types_networkx-3.6.1.20260612-py3-none-any.whl", hash = "sha256:829cdad128c10e072aacadd0421d7ebd64cee9731110e9d6b3f22b575fe0bbfd", size = 162459 }, + { url = "https://files.pythonhosted.org/packages/6e/1d/4694309c774f552a0dad8d357bc8c2b152120c9744dad4f21fdc3e819c37/types_networkx-3.6.1.20260612-py3-none-any.whl", hash = "sha256:829cdad128c10e072aacadd0421d7ebd64cee9731110e9d6b3f22b575fe0bbfd", size = 162459, upload-time = "2026-06-12T06:22:33.875Z" }, ] [[package]] name = "types-pyyaml" version = "6.0.12.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850 } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312 }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] @@ -1487,9 +1445,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391 }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] @@ -1499,18 +1457,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/e0/3facccb1ff69970c73fca7a8028286c233d4c1312c475a65fb3d896f56d9/types_tqdm-4.68.0.20260608.tar.gz", hash = "sha256:e1dfddf8770fbc30ecaf95ae57c286397831235064308f7dfc2b1d6684a76107", size = 18470 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/e0/3facccb1ff69970c73fca7a8028286c233d4c1312c475a65fb3d896f56d9/types_tqdm-4.68.0.20260608.tar.gz", hash = "sha256:e1dfddf8770fbc30ecaf95ae57c286397831235064308f7dfc2b1d6684a76107", size = 18470, upload-time = "2026-06-08T06:26:06.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/e8/61d95bfd49d1609fb8e8c5e06f4a094183411988a6f448873f5de6602499/types_tqdm-4.68.0.20260608-py3-none-any.whl", hash = "sha256:450a6e7e9e9b604928968927c414b32970e40091213c4180e1ed470905b13eff", size = 24858 }, + { url = "https://files.pythonhosted.org/packages/53/e8/61d95bfd49d1609fb8e8c5e06f4a094183411988a6f448873f5de6602499/types_tqdm-4.68.0.20260608-py3-none-any.whl", hash = "sha256:450a6e7e9e9b604928968927c414b32970e40091213c4180e1ed470905b13eff", size = 24858, upload-time = "2026-06-08T06:26:05.741Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -1520,31 +1478,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, -] - -[[package]] -name = "uvicorn" -version = "0.49.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -1557,91 +1502,91 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/0e/933bacb37b57ae7928b0030eef205a3dbb3e37afdbdde5be2e113318958f/virtualenv-21.5.0.tar.gz", hash = "sha256:98847aadf5e2037e0e4d2e19528eb3aca6f23906422e59a510bff231a6d32fce", size = 4577424 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/0e/933bacb37b57ae7928b0030eef205a3dbb3e37afdbdde5be2e113318958f/virtualenv-21.5.0.tar.gz", hash = "sha256:98847aadf5e2037e0e4d2e19528eb3aca6f23906422e59a510bff231a6d32fce", size = 4577424, upload-time = "2026-06-13T20:36:45.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/87/b0667ede418386ab631e48924b845d326f366d61e6bd08fe68a748fae4d4/virtualenv-21.5.0-py3-none-any.whl", hash = "sha256:8f7c38605023688c89789f566959006af6d61c99eeeb9e58342eb780c5761e5e", size = 4557937 }, + { url = "https://files.pythonhosted.org/packages/e9/87/b0667ede418386ab631e48924b845d326f366d61e6bd08fe68a748fae4d4/virtualenv-21.5.0-py3-none-any.whl", hash = "sha256:8f7c38605023688c89789f566959006af6d61c99eeeb9e58342eb780c5761e5e", size = 4557937, upload-time = "2026-06-13T20:36:42.967Z" }, ] [[package]] name = "wcwidth" version = "0.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072 } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, ] [[package]] name = "wrapt" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321 }, - { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216 }, - { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208 }, - { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322 }, - { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243 }, - { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231 }, - { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351 }, - { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347 }, - { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562 }, - { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616 }, - { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025 }, - { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021 }, - { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692 }, - { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364 }, - { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079 }, - { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205 }, - { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922 }, - { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388 }, - { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682 }, - { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857 }, - { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825 }, - { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087 }, - { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831 }, - { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375 }, - { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417 }, - { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948 }, - { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148 }, - { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905 }, - { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712 }, - { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560 }, - { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817 }, - { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736 }, - { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099 }, - { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802 }, - { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329 }, - { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937 }, - { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997 }, - { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856 }, - { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654 }, - { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206 }, - { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428 }, - { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448 }, - { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021 }, - { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295 }, - { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879 }, - { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462 }, - { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251 }, - { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316 }, - { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952 }, - { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130 }, - { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604 }, - { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007 }, - { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327 }, - { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144 }, - { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569 }, - { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892 }, - { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333 }, - { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899 }, - { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986 }, - { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893 }, - { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636 }, - { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267 }, - { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378 }, - { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226 }, - { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835 }, - { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722 }, - { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000 }, +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, ]