From cf1c537720ada9ed579bcfb3c944a6211787d4b7 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Thu, 15 Oct 2026 11:38:42 -0700 Subject: [PATCH] feat!: fold validate-infores into validate-kgx --- docs/cli.md | 88 +++++----- llms.txt | 2 +- src/tablassert/cli.py | 225 ++++++++++++------------ tests/test_cli_validate_infores.py | 184 -------------------- tests/test_cli_validate_kgx.py | 263 +++++++++++++++++++++++++++++ tests/test_docs_cli_coverage.py | 2 - 6 files changed, 414 insertions(+), 350 deletions(-) delete mode 100644 tests/test_cli_validate_infores.py create mode 100644 tests/test_cli_validate_kgx.py diff --git a/docs/cli.md b/docs/cli.md index 390f333..7a61272 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,8 +2,8 @@ Tablassert extracts knowledge assertions from tabular data into KGX NDJSON. The `tablassert` app requires the `[cli]` extra (`pip install "tablassert[cli]"` or `uv tool install "tablassert[cli]"`) and -exposes **six subcommands**: `build-fullmap`, `build-kg`, `quick-map`, `validate`, -`validate-infores`, and `validate-kgx`, plus an app-level `--version` flag. Run `tablassert --help` (or ` --help`) +exposes **five subcommands**: `build-fullmap`, `build-kg`, `quick-map`, `validate`, +and `validate-kgx`, plus an app-level `--version` flag. Run `tablassert --help` (or ` --help`) for the live surface. ## Command index @@ -14,8 +14,7 @@ for the live surface. | [`build-kg`](#build-kg) | Build a KGX NDJSON knowledge graph from a YAML configuration | | [`quick-map`](#quick-map) | Show what fullmap entity resolution does with one or more terms | | [`validate`](#validate) | Validate a graph or table configuration without executing it | -| [`validate-infores`](#validate-infores) | Validate emitted infores CURIEs against the Translator registry | -| [`validate-kgx`](#validate-kgx) | Validate built KGX NDJSON against the Biolink Model | +| [`validate-kgx`](#validate-kgx) | Validate built KGX NDJSON against the Biolink Model and the Translator registry | ## App flags @@ -240,11 +239,13 @@ tablassert validate graph.yaml --schema graph ## validate-kgx -Use this to check that a completed build is actually Biolink-compliant. Where +Use this to check that a completed build is actually Biolink-compliant and that the knowledge-source +identifiers it emits are real. Where [`validate`](#validate) checks your *configuration*, `validate-kgx` checks the *output*: every node and edge is constructed as the Biolink Pydantic class named by its own `category`, the same classes [`NCATSTranslator/translator-ingests`](https://github.com/NCATSTranslator/translator-ingests) builds -when it ingests your files. +when it ingests your files, and every emitted `infores:` CURIE is classified against the NCATS +Translator registry (see [Checking knowledge-source identity](#checking-knowledge-source-identity)). ```bash tablassert validate-kgx --nodes MY_KG_1.0.0.nodes.ndjson --edges MY_KG_1.0.0.edges.ndjson @@ -254,8 +255,12 @@ tablassert validate-kgx --nodes MY_KG_1.0.0.nodes.ndjson --edges MY_KG_1.0.0.edg | --- | --- | --- | --- | --- | | `--nodes`, `-n` | Path | Yes | n/a | Built `*.nodes.ndjson` file to validate | | `--edges`, `-e` | Path | Yes | n/a | Built `*.edges.ndjson` file to validate | -| `--limit` | int | No | `20` | Maximum example failures to retain per file | +| `--limit` | int | No | `20` | Maximum example failures to retain per file (structural and identity examples alike) | | `--prune`, `-p` | Flag | No | `False` | Rewrite the edges file in place, keeping only edges that validate strictly (pending carryovers are dropped too); nodes are never touched, and a clean file is not rewritten | +| `--rig` | Path | No | n/a | RIG yaml to include in the identity scan (its `source_info.infores_id` and target `primary_knowledge_sources` are checked) | +| `--allow-infores` | list[str] | No | n/a | Registry-exempt CURIE, repeatable; use for the locally-minted graph identifier and other deliberate locals | +| `--registry` | snapshot\\|refresh\\|off | No | `snapshot` | `snapshot` validates offline against the bundled copy; `refresh` fetches the live upstream catalog (network) and caches it under the user cache dir; `off` skips the identity half entirely | +| `--strict` | Flag | No | `False` | Exit non-zero while any unregistered CURIE remains; without it the identity half is advisory | Failures are grouped by field and error type, so a systematic modelling problem shows up as one line rather than a million: @@ -285,6 +290,29 @@ edges: 1200000/2000085 valid (800085 failures; 800085 pending biolink-model supp The strict count is what `ok` and the exit code use; the pending count is reported separately, so a deliberate gap never reads as a modelling error. +### Checking knowledge-source identity + +Alongside structure, `validate-kgx` checks *identity*: every `infores:` CURIE on your edges +(`primary_knowledge_source`, `sources[].resource_id`, `sources[].upstream_resource_ids`), nodes +(`provided_by`), and, with `--rig`, the RIG document, is classified against the NCATS Translator +registry ([`biolink/information-resource-registry`](https://github.com/biolink/information-resource-registry)'s +`infores_catalog.yaml`). The registry ships as a bundled snapshot (the check is offline by default); +`--registry refresh` fetches the live catalog instead, and `--registry off` skips the identity half +entirely. Values without the `infores:` prefix are reported as malformed, not unregistered. + +```text +registry: .../infores_catalog.yaml (508 entries) +curies: 3 registered, 1 allowed, 1 unregistered, 0 malformed + e.g. infores:totally-made-up (edges.primary_knowledge_source): unregistered +infores warnings present; pass --strict to fail on them. +``` + +The default posture is advisory and exits 0: Tablassert legitimately mints graph-local CURIEs (the +graph's own `infores:` id from the `rig:` section's name) that will never sit in the registry. Allow +those with repeatable `--allow-infores`, and pass `--strict` in CI to exit non-zero while any +unregistered CURIE remains. A missing or misspelled path is reported as `file not found` and exits +non-zero in every mode: a file that was never read must never count as a pass. + ### Repairing a graph with `--prune` `--prune` (`-p`) repairs an already-emitted graph instead of just scoring it: the edges file is @@ -304,7 +332,8 @@ The rewrite is atomic (written to a sibling temp file, then swapped in) and kept byte-identical, so a pruned graph differs from the original only by the removed records. A clean file is not rewritten at all, and a missing edges file is reported without ever being pruned. After pruning, every kept edge is strictly valid, so the exit code goes green as soon as the -nodes are clean too: +nodes are clean too. The identity half runs after the rewrite and classifies the pruned file: +only what ships is judged. ```text edges: pruned 12/424159 non-compliant records @@ -323,46 +352,6 @@ once a model release drops the requirement. --- -## validate-infores - -Use this to prove the knowledge-source identifiers a build emits are real. `validate-kgx` checks -*structure* against the Biolink Model; `validate-infores` checks *identity*: every `infores:` CURIE -on your edges (`primary_knowledge_source`, `sources[].resource_id`, `sources[].upstream_resource_ids`), -nodes (`provided_by`), and, with `--rig`, the RIG document, is classified against the NCATS -Translator registry ([`biolink/information-resource-registry`](https://github.com/biolink/information-resource-registry)'s -`infores_catalog.yaml`). The registry ships as a bundled snapshot (`validate-infores` is offline by -default); `--registry refresh` fetches the live catalog instead. Values without the `infores:` -prefix are reported as malformed, not unregistered. - -```bash -tablassert validate-infores --nodes MY_KG_1.0.0.nodes.ndjson --edges MY_KG_1.0.0.edges.ndjson --rig MY_KG_1.0.0.RIG.yaml -``` - -```text -registry: .../infores_catalog.yaml (508 entries) -curies: 3 registered, 1 allowed, 1 unregistered, 0 malformed - e.g. infores:totally-made-up (edges.primary_knowledge_source): unregistered -infores warnings present; pass --strict to fail on them. -``` - -The default posture is advisory and exits 0: Tablassert legitimately mints graph-local CURIEs (the -graph's own `infores:` id from the `rig:` section's name) that will never sit in the registry. Allow -those with repeatable `--allow-infores`, and pass `--strict` in CI to exit non-zero while any -unregistered CURIE remains. A missing or misspelled path is reported as `file not found` and exits -non-zero in every mode: a file that was never read must never count as a pass. - -| Option | Type | Required | Default | Description | -| --- | --- | --- | --- | --- | -| `--nodes`, `-n` | Path | Yes | n/a | Built `*.nodes.ndjson` file to validate | -| `--edges`, `-e` | Path | Yes | n/a | Built `*.edges.ndjson` file to validate | -| `--rig` | Path | No | n/a | RIG yaml to include in the scan (its `source_info.infores_id` and target `primary_knowledge_sources` are checked) | -| `--allow-infores` | list[str] | No | n/a | Registry-exempt CURIE, repeatable; use for the locally-minted graph identifier and other deliberate locals | -| `--registry` | snapshot\\|refresh\\|off | No | `snapshot` | `snapshot` validates offline against the bundled copy; `refresh` fetches the live upstream catalog (network) and caches it under the user cache dir; `off` skips membership classification and reports structure only | -| `--strict` | Flag | No | `False` | Exit non-zero while any unregistered CURIE remains; without it the run is advisory | -| `--limit` | int | No | `20` | Maximum example unregistered/malformed CURIEs retained for the `e.g.` report lines | - ---- - ## Typical workflow 1. Author a table config, then a graph config that references it. @@ -371,7 +360,8 @@ non-zero in every mode: a file that was never read must never count as a pass. 4. `tablassert quick-map TERM... -f `: spot-check how a term resolves under a column's exact filters when a mapping needs debugging. 5. `tablassert validate-kgx -n MY_KG_1.0.0.nodes.ndjson -e MY_KG_1.0.0.edges.ndjson`: confirm the - output validates against the Biolink Model before shipping it downstream. + output validates against the Biolink Model and its knowledge-source CURIEs against the + Translator registry before shipping it downstream. ## Next Steps diff --git a/llms.txt b/llms.txt index c57c533..1141769 100644 --- a/llms.txt +++ b/llms.txt @@ -10,7 +10,7 @@ Current CLI behavior queries an embedded redb database whose primary file is `fu - [README](README.md): high-level overview, install snippets, and one-command graph build. - [Documentation Site Home](docs/index.md): docs landing page and navigation context. - [Installation Guide](docs/installation.md): three install paths (PyPI, GitHub main, development from source), platform and Rust-toolchain notes, and the missing-extra behavior. Optional extras: `cli`, `rt`, `aria2`, `qc`, and `log`. -- [CLI Reference](docs/cli.md): the six commands (`build-fullmap`, `build-kg`, `quick-map`, `validate`, `validate-infores`, and `validate-kgx`) plus the app-level `--version` flag. +- [CLI Reference](docs/cli.md): the five commands (`build-fullmap`, `build-kg`, `quick-map`, `validate`, and `validate-kgx`) plus the app-level `--version` flag. - [Tutorial](docs/tutorial.md): first end-to-end run from CSV input to KGX NDJSON output. ## YAML Authoring diff --git a/src/tablassert/cli.py b/src/tablassert/cli.py index 6629aa0..33663cf 100644 --- a/src/tablassert/cli.py +++ b/src/tablassert/cli.py @@ -984,19 +984,52 @@ def _print_kgx_section(label: str, section: dict[str, Any], path: Path) -> None: logger.info(f"validate-kgx {label}: {section['valid']}/{section['total']} valid") +def _print_infores_report(report: dict[str, Any], paths: dict[str, Path | None]) -> None: + """Print one identity-half report: absent inputs, registry provenance, counts, examples.""" + for label, absent in report["missing"].items(): + if absent: + print(f"{label}: file not found ({paths.get(label)})", file=sys.stderr) + print(f"registry: {report['registry']['source']} ({report['registry']['entries']} entries)", file=sys.stderr) + counts: str = ( + f"curies: {report['registered']} registered, {report['allowed']} allowed, " + f"{report['unregistered']} unregistered, {report['malformed']} malformed" + ) + print(counts, file=sys.stderr) + logger.info(f"validate-kgx infores: {counts}") + for example in report["examples"][:3]: + print(f" e.g. {example['curie']} ({example['where']}.{example['field']}): {example['problem']}", file=sys.stderr) + + @APP.command(name="validate-kgx") def validate_kgx_command( nodes: Annotated[Path, cyclopts.Parameter(name=["--nodes", "-n"])], edges: Annotated[Path, cyclopts.Parameter(name=["--edges", "-e"])], limit: Annotated[int, cyclopts.Parameter(name=["--limit"])] = 20, prune: Annotated[bool, cyclopts.Parameter(name=["--prune", "-p"])] = False, + rig: Annotated[Path | None, cyclopts.Parameter(name=["--rig"])] = None, + allow_infores: Annotated[list[str] | None, cyclopts.Parameter(name=["--allow-infores"], negative="", allow_leading_hyphen=False)] = None, + registry: Annotated[Literal["snapshot", "refresh", "off"], cyclopts.Parameter(name=["--registry"])] = "snapshot", + strict: Annotated[bool, cyclopts.Parameter(name=["--strict"])] = False, ) -> None: - """Validate built KGX NDJSON against the Biolink Model. - - Constructs every node and edge as the Biolink Pydantic class named by its own - ``category`` -- the same classes ``NCATSTranslator/translator-ingests`` builds -- - and reports failures grouped by field and error type. Exits non-zero when any - record fails, so a build can be gated in CI. + """Validate built KGX NDJSON against the Biolink Model and the Translator registry. + + The structural half constructs every node and edge as the Biolink Pydantic class + named by its own ``category`` -- the same classes ``NCATSTranslator/translator- + ingests`` builds -- and reports failures grouped by field and error type. The + identity half classifies every emitted knowledge-source identifier (edge + ``primary_knowledge_source``, ``sources[].resource_id`` and + ``sources[].upstream_resource_ids``, node ``provided_by``, and, with ``--rig``, the + RIG document's infores values) against ``biolink/information-resource-registry``'s + ``infores_catalog.yaml`` (vendored as the bundled snapshot, so the default path is + offline). Values without the ``infores:`` prefix are malformed, not unregistered. + + The structural half exits non-zero when any record fails, so a build can be gated + in CI. The identity half is advisory by default -- unregistered CURIEs are reported + and the command exits 0, because Tablassert legitimately mints graph-local CURIEs + that will never sit in the registry -- and ``--strict`` (CI gates) exits 1 while any + unregistered CURIE remains; allowlist legitimate local ids with repeatable + ``--allow-infores``. Missing input files fail in every mode: a typo'd path must + never read as a clean bill of health. ``--prune`` rewrites the EDGES file in place, keeping only edges that validate strictly. Real defects (non-coercible values, extras the model will never declare, @@ -1007,137 +1040,101 @@ def validate_kgx_command( stay byte-identical; a clean file is not rewritten at all, and a missing edges file is reported without ever being pruned. Nodes are validated and reported, never pruned. The exit code stays strict over the post-prune state: after a prune, every - kept edge is strictly valid, so the run is compliant exactly when the nodes are. + kept edge is strictly valid, so the run is compliant exactly when the nodes are, + and the identity half classifies the PRUNED file -- what ships is what is checked. Args: nodes: Path to the built nodes NDJSON file (``_.nodes.ndjson``). edges: Path to the built edges NDJSON file (``_.edges.ndjson``). - limit: Maximum example failures retained per file for the ``e.g.`` report lines; - every record is still validated regardless of this cap. A missing file is - reported and fails the run rather than reading as a pass. + limit: Maximum example failures retained per file for the ``e.g.`` report lines + (structural and identity examples alike); every record is still validated + regardless of this cap. A missing file is reported and fails the run rather + than reading as a pass. prune: Rewrite the edges file in place, removing every edge record that is not strictly KGX/Biolink-compliant, including the deliberate pending carryovers. The nodes file is never modified; a clean edges file is left untouched. + rig: Optional RIG yaml (``_.RIG.yaml``); its infores values are + classified too. + allow-infores: Registry-exempt CURIE, repeatable; use for the locally-minted + graph identifier (``rig.infores()``) and other deliberate locals. + registry: Registry source. ``snapshot`` (default) validates offline against the + bundled copy; ``refresh`` fetches the live upstream catalog (network) and + caches it under the user cache dir; ``off`` skips the identity half + entirely (structure-only validation). + strict: Exit non-zero when any unregistered CURIE remains. Without it the + identity half is advisory and never fails a run once inputs exist. """ from tablassert.biolink import _validate_file, prune_kgx_edges, validate_kgx + from tablassert.infores_registry import REGISTRY_RAW_URL, refresh_registry, validate_infores + + ids: frozenset[str] | None = refresh_registry() if registry == "refresh" else None if not prune: report: dict[str, Any] = validate_kgx(nodes, edges, limit=limit) print(f"biolink-model {report['biolink_version']}", file=sys.stderr) _print_kgx_section("nodes", report["nodes"], nodes) _print_kgx_section("edges", report["edges"], edges) - if not report["ok"]: + structural_ok: bool = report["ok"] + structural_missing: bool = report["nodes"]["missing"] or report["edges"]["missing"] + else: + # The prune path validates nodes-only itself and drives prune_kgx_edges' own single + # pass for both edges sections, so the edges file is never read and written twice. + nodes_section: dict[str, Any] = _validate_file(nodes, edge=False, limit=limit) + pruned_report: dict[str, Any] = prune_kgx_edges(edges, limit=limit) + print(f"biolink-model {pruned_report['biolink_version']}", file=sys.stderr) + _print_kgx_section("nodes", nodes_section, nodes) + _print_kgx_section("edges", pruned_report["before"], edges) + if pruned_report["before"]["missing"]: print("KGX output is not Biolink-compliant.", file=sys.stderr) raise SystemExit(1) - print("KGX output is Biolink-compliant.", file=sys.stderr) - return - - # The prune path validates nodes-only itself and drives prune_kgx_edges' own single - # pass for both edges sections, so the edges file is never read and written twice. - nodes_section: dict[str, Any] = _validate_file(nodes, edge=False, limit=limit) - pruned_report: dict[str, Any] = prune_kgx_edges(edges, limit=limit) - print(f"biolink-model {pruned_report['biolink_version']}", file=sys.stderr) - _print_kgx_section("nodes", nodes_section, nodes) - _print_kgx_section("edges", pruned_report["before"], edges) - if pruned_report["before"]["missing"]: + malformed_note: str = f" ({pruned_report['malformed']} malformed lines)" if pruned_report["malformed"] else "" + print(f"edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} non-compliant records{malformed_note}", file=sys.stderr) + logger.info( + f"validate-kgx edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} (rewritten={pruned_report['rewritten']})" + ) + for problem, count in pruned_report["dropped"]["problems"].items(): + print(f" {count:>9} {problem}", file=sys.stderr) + for example in pruned_report["dropped"]["examples"][:3]: + print(f" e.g. {example['id']}: {', '.join(example['errors'])}", file=sys.stderr) + _print_kgx_section("edges", pruned_report["after"], edges) + nodes_ok: bool = not nodes_section["missing"] and nodes_section["total"] == nodes_section["valid"] + structural_ok = nodes_ok and pruned_report["ok"] + structural_missing = nodes_section["missing"] + + # The identity half classifies what ships: in prune mode the edges file has already + # been rewritten, so the scan reads the post-prune state. When the structural half + # already failed on absent inputs and no RIG was given, a second scan would only + # echo the same file-not-found lines. + infores_ok: bool = True + if registry != "off" and (rig is not None or not structural_missing): + infores_report: dict[str, Any] = validate_infores(nodes, edges, rig_path=rig, allow=tuple(allow_infores or ()), registry=ids, limit=limit) + if ids is not None: + infores_report["registry"]["source"] = f"{REGISTRY_RAW_URL} (refreshed)" + _print_infores_report(infores_report, {"nodes": nodes, "edges": edges, "rig": rig}) + if any(infores_report["missing"].values()): + print("infores validation failed: missing input files.", file=sys.stderr) + infores_ok = False + else: + clean: bool = not infores_report["unregistered"] and not infores_report["malformed"] + if strict: + if infores_report["ok_strict"]: + print("All emitted infores CURIEs are registered." if clean else "infores validation passed (strict).", file=sys.stderr) + else: + print("infores validation failed (strict): unregistered CURIEs remain.", file=sys.stderr) + infores_ok = False + elif not clean: + print("infores warnings present; pass --strict to fail on them.", file=sys.stderr) + else: + print("All emitted infores CURIEs are registered.", file=sys.stderr) + + if not structural_ok: print("KGX output is not Biolink-compliant.", file=sys.stderr) raise SystemExit(1) - malformed_note: str = f" ({pruned_report['malformed']} malformed lines)" if pruned_report["malformed"] else "" - print(f"edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} non-compliant records{malformed_note}", file=sys.stderr) - logger.info(f"validate-kgx edges: pruned {pruned_report['pruned']}/{pruned_report['before']['total']} (rewritten={pruned_report['rewritten']})") - for problem, count in pruned_report["dropped"]["problems"].items(): - print(f" {count:>9} {problem}", file=sys.stderr) - for example in pruned_report["dropped"]["examples"][:3]: - print(f" e.g. {example['id']}: {', '.join(example['errors'])}", file=sys.stderr) - _print_kgx_section("edges", pruned_report["after"], edges) - nodes_ok: bool = not nodes_section["missing"] and nodes_section["total"] == nodes_section["valid"] - if not (nodes_ok and pruned_report["ok"]): - print("KGX output is not Biolink-compliant.", file=sys.stderr) + if not infores_ok: raise SystemExit(1) print("KGX output is Biolink-compliant.", file=sys.stderr) -@APP.command(name="validate-infores") -def validate_infores_command( - nodes: Annotated[Path, cyclopts.Parameter(name=["--nodes", "-n"])], - edges: Annotated[Path, cyclopts.Parameter(name=["--edges", "-e"])], - rig: Annotated[Path | None, cyclopts.Parameter(name=["--rig"])] = None, - allow_infores: Annotated[list[str] | None, cyclopts.Parameter(name=["--allow-infores"], negative="", allow_leading_hyphen=False)] = None, - registry: Annotated[Literal["snapshot", "refresh", "off"], cyclopts.Parameter(name=["--registry"])] = "snapshot", - strict: Annotated[bool, cyclopts.Parameter(name=["--strict"])] = False, - limit: Annotated[int, cyclopts.Parameter(name=["--limit"])] = 20, -) -> None: - """Validate emitted infores CURIEs against the NCATS Translator registry. - - Every knowledge-source identifier the build emits -- edge ``primary_knowledge_source``, - ``sources[].resource_id`` and ``sources[].upstream_resource_ids``, node ``provided_by``, - and, with ``--rig``, the RIG document's infores values -- is classified against - ``biolink/information-resource-registry``'s ``infores_catalog.yaml`` (vendored as the - bundled snapshot, so the default path is offline). Values without the ``infores:`` - prefix are malformed, not unregistered. - - The default posture is advisory: unregistered CURIEs are reported and the command - exits 0, because Tablassert legitimately mints graph-local CURIEs that will never sit - in the registry. ``--strict`` (CI gates) exits 1 when any unregistered CURIE remains; - allowlist legitimate local ids with repeatable ``--allow-infores``. Missing input - files fail in every mode: a typo'd path must never read as a clean bill of health. - - Args: - nodes: Path to the built nodes NDJSON file (``_.nodes.ndjson``). - edges: Path to the built edges NDJSON file (``_.edges.ndjson``). - rig: Optional RIG yaml (``_.RIG.yaml``); its infores values are - classified too. - allow-infores: Registry-exempt CURIE, repeatable; use for the locally-minted - graph identifier (``rig.infores()``) and other deliberate locals. - registry: Registry source. ``snapshot`` (default) validates offline against the - bundled copy; ``refresh`` fetches the live upstream catalog (network) and - caches it under the user cache dir; ``off`` skips membership classification - and reports structure only. - strict: Exit non-zero when any unregistered CURIE remains. Without it the run - is advisory and always exits 0 once inputs exist. - limit: Maximum number of example unregistered/malformed CURIEs retained for the - ``e.g.`` report lines; every observation is still classified regardless. - """ - from tablassert.infores_registry import REGISTRY_RAW_URL, refresh_registry, validate_infores - - ids: frozenset[str] | None = None - if registry == "refresh": - ids = refresh_registry() - report: dict[str, Any] = validate_infores( - nodes, edges, rig_path=rig, allow=tuple(allow_infores or ()), registry=ids, check_registry=registry != "off", limit=limit - ) - if ids is not None: - report["registry"]["source"] = f"{REGISTRY_RAW_URL} (refreshed)" - for label, absent in report["missing"].items(): - if absent: - path_label = {"nodes": nodes, "edges": edges, "rig": rig}.get(label) - print(f"{label}: file not found ({path_label})", file=sys.stderr) - if any(report["missing"].values()): - print("infores validation failed: missing input files.", file=sys.stderr) - raise SystemExit(1) - print(f"registry: {report['registry']['source']} ({report['registry']['entries']} entries)", file=sys.stderr) - counts: str = ( - f"curies: {report['registered']} registered, {report['allowed']} allowed, " - f"{report['unregistered']} unregistered, {report['malformed']} malformed" - ) - print(counts, file=sys.stderr) - logger.info(f"validate-infores: {counts}") - for example in report["examples"][:3]: - print(f" e.g. {example['curie']} ({example['where']}.{example['field']}): {example['problem']}", file=sys.stderr) - clean: bool = not report["unregistered"] and not report["malformed"] - passed: str = "infores membership check skipped (--registry off)." if registry == "off" else "All emitted infores CURIEs are registered." - if strict: - if report["ok_strict"]: - print(passed if clean else "infores validation passed (strict).", file=sys.stderr) - return - print("infores validation failed (strict): unregistered CURIEs remain.", file=sys.stderr) - raise SystemExit(1) - if not clean: - print("infores warnings present; pass --strict to fail on them.", file=sys.stderr) - return - print(passed, file=sys.stderr) - - @APP.command(name="quick-map") def quick_map_command( # negative="" suppresses cyclopts' auto-generated --empty-terms help row: the reset diff --git a/tests/test_cli_validate_infores.py b/tests/test_cli_validate_infores.py deleted file mode 100644 index ac34a36..0000000 --- a/tests/test_cli_validate_infores.py +++ /dev/null @@ -1,184 +0,0 @@ -"""CLI-level tests for ``tablassert validate-infores``. - -The core classifier is covered in ``test_infores_registry.py``; these tests pin the -command's contract: advisory by default, strict only on the flag, missing inputs never -a pass in any mode, ``--registry off`` skips membership, and ``--registry refresh`` -fetches through the bounded-retry network layer and caches the document. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest -import yaml - -from tablassert import cli, infores_registry - -REGISTERED = "infores:monarchinitiative" - - -def write_ndjson(path: Path, records: list[dict[str, Any]]) -> Path: - """Write one NDJSON fixture file.""" - path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") - return path - - -def run(cli_kwargs: dict[str, Any], capsys: pytest.CaptureFixture[str]) -> str: - """Invoke the command directly (the registered callback is the plain function).""" - cli.validate_infores_command(**cli_kwargs) - return capsys.readouterr().err - - -class TestValidateInforesCommand: - def test_advisory_default_exits_zero(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """Unregistered CURIEs warn and exit 0 without --strict: the advisory contract.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": ["infores:made-up"]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - out = run({"nodes": nodes, "edges": edges}, capsys) - assert "1 unregistered" in out - assert "pass --strict" in out - - def test_strict_fails_on_unregistered(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """--strict is the CI gate: unregistered CURIEs exit 1.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": ["infores:made-up"]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - with pytest.raises(SystemExit) as excinfo: - run({"nodes": nodes, "edges": edges, "strict": True}, capsys) - assert excinfo.value.code == 1 - - def test_strict_passes_registered(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """A fully registered graph reads as a clean bill under --strict.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": [REGISTERED]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - out = run({"nodes": nodes, "edges": edges, "strict": True}, capsys) - assert "All emitted infores CURIEs are registered." in out - - def test_allow_infores_absolves_locally_minted(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """The graph's own CURIE is exempt via --allow-infores and strict stays green.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": ["infores:my-local-kg"]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - out = run({"nodes": nodes, "edges": edges, "allow_infores": ["infores:my-local-kg"], "strict": True}, capsys) - assert "1 allowed" in out - assert "All emitted infores CURIEs are registered." in out - - def test_missing_input_fails_in_advisory_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """A typo'd path exits 1 even without --strict: never a silent pass.""" - edges = write_ndjson(tmp_path / "e.ndjson", []) - with pytest.raises(SystemExit) as excinfo: - run({"nodes": tmp_path / "absent.ndjson", "edges": edges}, capsys) - assert excinfo.value.code == 1 - assert "file not found" in capsys.readouterr().err - - def test_malformed_fails_strict(self, tmp_path: Path) -> None: - """Non-infores values are defects too: --strict fails on them.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": ["PMID:123"]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - with pytest.raises(SystemExit) as excinfo: - cli.validate_infores_command(nodes=nodes, edges=edges, strict=True) - assert excinfo.value.code == 1 - - def test_registry_off_skips_membership(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - """--registry off reports structure only and never claims registry verification.""" - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": ["infores:made-up"]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - out = run({"nodes": nodes, "edges": edges, "registry": "off", "strict": True}, capsys) - assert "0 entries" in out - assert "infores membership check skipped (--registry off)." in out - - def test_registry_refresh_fetches_and_caches(self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: - """--registry refresh pulls the live catalog via the retry layer and caches the body.""" - catalog = yaml.safe_dump({"information_resources": [{"id": REGISTERED}]}) - monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: catalog) - monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: tmp_path / "cache" / "infores_catalog.yaml") - edges = write_ndjson(tmp_path / "e.ndjson", [{"id": "urn:1", "primary_knowledge_source": [REGISTERED]}]) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - out = run({"nodes": nodes, "edges": edges, "registry": "refresh", "strict": True}, capsys) - assert "(refreshed)" in out - assert (tmp_path / "cache" / "infores_catalog.yaml").read_text(encoding="utf-8") == catalog - - def test_registry_refresh_rejects_garbage_body(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A fetched body without the stanza list raises and never poisons the cache.""" - monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: yaml.safe_dump({"unrelated": []})) - monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: tmp_path / "cache.yaml") - edges = write_ndjson(tmp_path / "e.ndjson", []) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - with pytest.raises(infores_registry.TablassertError) as excinfo: - cli.validate_infores_command(nodes=nodes, edges=edges, registry="refresh") - assert "information_resources" in str(excinfo.value) - assert not (tmp_path / "cache.yaml").exists() - - def test_rig_option_scanned(self, tmp_path: Path) -> None: - """--rig includes the RIG document's infores values in the classification.""" - edges = write_ndjson(tmp_path / "e.ndjson", []) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - rig = tmp_path / "rig.yaml" - rig.write_text(yaml.safe_dump({"source_info": {"infores_id": "infores:rig-made-up"}}), encoding="utf-8") - with pytest.raises(SystemExit) as excinfo: - cli.validate_infores_command(nodes=nodes, edges=edges, rig=rig, strict=True) - assert excinfo.value.code == 1 - - def test_non_json_lines_count_as_malformed(self, tmp_path: Path) -> None: - """A line that is not JSON at all is malformed output, never a traceback. - - Mirrors validate-kgx's explicit handling of its own non-JSON lines: the report - stays structured so one corrupt line cannot crash a build gate. - """ - from tablassert.infores_registry import validate_infores - - edges = tmp_path / "e.ndjson" - edges.write_text('{"id": "urn:1", "primary_knowledge_source": ["infores:monarchinitiative"]}\nnot json at all\n', encoding="utf-8") - nodes = write_ndjson(tmp_path / "n.ndjson", []) - report = validate_infores(nodes, edges, registry=frozenset({REGISTERED})) - assert report["malformed"] == 1 - assert report["registered"] == 1 - assert report["ok"] is False - assert any(example["field"] == "" for example in report["examples"]) - - def test_corrupt_rig_raises_coded_error(self, tmp_path: Path) -> None: - """An unparsable --rig yaml raises the coded error, not a raw yaml traceback.""" - from tablassert.errors import TablassertError, error_code_of - from tablassert.infores_registry import validate_infores - - edges = write_ndjson(tmp_path / "e.ndjson", []) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - rig = tmp_path / "rig.yaml" - rig.write_text("source_info: [unclosed", encoding="utf-8") - with pytest.raises(TablassertError) as excinfo: - validate_infores(nodes, edges, rig_path=rig, registry=frozenset({REGISTERED})) - assert error_code_of(excinfo.value) == "infores-registry-unreadable" - - def test_refresh_rejects_empty_stanza_list_before_caching(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A fetched body with an empty stanza list fails and never reaches the cache. - - Such a body would classify every real CURIE unregistered on later inspection; - it must be rejected at fetch time, not cached as a valid-looking registry. - """ - from tablassert.errors import TablassertError, error_code_of - - monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: yaml.safe_dump({"information_resources": []})) - cache = tmp_path / "cache.yaml" - monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: cache) - edges = write_ndjson(tmp_path / "e.ndjson", []) - nodes = write_ndjson(tmp_path / "n.ndjson", []) - with pytest.raises(TablassertError) as excinfo: - cli.validate_infores_command(nodes=nodes, edges=edges, registry="refresh") - assert error_code_of(excinfo.value) == "infores-registry-unreadable" - assert not cache.exists() - - def test_help_renders_every_flag(self) -> None: - """The help page must self-describe: an agent picks flags from help alone.""" - import io - - from rich.console import Console - - buffer = io.StringIO() - cli.APP.help_print(["validate-infores"], console=Console(file=buffer, width=120, legacy_windows=False)) - help_text = " ".join(buffer.getvalue().replace("│", " ").split()) - for flag in ("--nodes", "--edges", "--rig", "--allow-infores", "--registry", "--strict", "--limit"): - assert flag in help_text, f"help missing {flag}" - assert "snapshot" in help_text - assert "refresh" in help_text - assert "off" in help_text diff --git a/tests/test_cli_validate_kgx.py b/tests/test_cli_validate_kgx.py new file mode 100644 index 0000000..03d93ee --- /dev/null +++ b/tests/test_cli_validate_kgx.py @@ -0,0 +1,263 @@ +"""CLI-level tests for the merged ``tablassert validate-kgx`` gate. + +The identity classifier is covered in ``test_infores_registry.py``; these tests pin the +merged command's contract: structural failures always fail, the identity half is +advisory by default and strict only on ``--strict``, missing inputs never pass in any +mode, ``--registry off`` skips identity entirely, ``--prune`` classifies the PRUNED +file, and ``--registry refresh`` fetches through the bounded-retry network layer and +caches the document. Edge fixtures reuse the DAKP-shaped otherwise-valid edge so +identity verdicts are not drowned in structural noise. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from tablassert import cli, infores_registry + +REGISTERED = "infores:monarchinitiative" + + +def valid_edge(curie: str, edge_id: str = "urn:1") -> dict[str, Any]: + """A minimal edge that passes the structural gate, carrying one knowledge source.""" + return { + "id": edge_id, + "subject": "HGNC:11998", + "predicate": "biolink:associated_with", + "object": "MONDO:0008903", + "category": ["biolink:GeneToDiseaseAssociation"], + "knowledge_level": "statistical_association", + "agent_type": "data_analysis_pipeline", + "primary_knowledge_source": curie, + } + + +def write_ndjson(path: Path, records: list[dict[str, Any]]) -> Path: + """Write one NDJSON fixture file.""" + path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") + return path + + +def run(cli_kwargs: dict[str, Any], capsys: pytest.CaptureFixture[str]) -> str: + """Invoke the command directly (the registered callback is the plain function).""" + cli.validate_kgx_command(**cli_kwargs) + return capsys.readouterr().err + + +class TestIdentityGate: + def test_advisory_default_warns_and_passes(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Unregistered CURIEs warn and exit 0 without --strict: the advisory contract.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge("infores:made-up")]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges}, capsys) + assert "1 unregistered" in out + assert "pass --strict" in out + # The structural verdict is unaffected by identity warnings. + assert "KGX output is Biolink-compliant." in out + + def test_strict_fails_on_unregistered(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """--strict is the CI gate: unregistered CURIEs exit 1.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge("infores:made-up")]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + run({"nodes": nodes, "edges": edges, "strict": True}, capsys) + assert excinfo.value.code == 1 + + def test_strict_passes_registered(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A fully registered graph reads as a clean bill under --strict.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge(REGISTERED)]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges, "strict": True}, capsys) + assert "All emitted infores CURIEs are registered." in out + assert "KGX output is Biolink-compliant." in out + + def test_allow_infores_absolves_locally_minted(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The graph's own CURIE is exempt via --allow-infores and strict stays green.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge("infores:my-local-kg")]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges, "allow_infores": ["infores:my-local-kg"], "strict": True}, capsys) + assert "1 allowed" in out + assert "All emitted infores CURIEs are registered." in out + + def test_malformed_fails_strict(self, tmp_path: Path) -> None: + """Non-infores values are defects too: --strict fails on them.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge("PMID:123")]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + cli.validate_kgx_command(nodes=nodes, edges=edges, strict=True) + assert excinfo.value.code == 1 + + def test_rig_values_are_classified(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """--rig includes the RIG document's infores values in the classification.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge(REGISTERED)]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + rig = tmp_path / "rig.yaml" + rig.write_text(yaml.safe_dump({"source_info": {"infores_id": "infores:rig-made-up"}}), encoding="utf-8") + with pytest.raises(SystemExit) as excinfo: + run({"nodes": nodes, "edges": edges, "rig": rig, "strict": True}, capsys) + assert excinfo.value.code == 1 + + def test_missing_rig_fails_even_advisory(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A typo'd --rig path exits 1 without --strict: never a silent pass.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge(REGISTERED)]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + run({"nodes": nodes, "edges": edges, "rig": tmp_path / "absent.yaml"}, capsys) + assert excinfo.value.code == 1 + assert "rig: file not found" in capsys.readouterr().err + + def test_missing_structural_input_fails(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A missing nodes file fails the structural half; identity does not echo it.""" + edges = write_ndjson(tmp_path / "e.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + run({"nodes": tmp_path / "absent.ndjson", "edges": edges}, capsys) + assert excinfo.value.code == 1 + out = capsys.readouterr().err + assert "file not found" in out + assert "KGX output is not Biolink-compliant." in out + + def test_structural_failures_still_fail_under_the_default(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A structurally broken edge exits 1 even when its identity is registered.""" + broken = {**valid_edge(REGISTERED), "p_value": "not-a-number"} + edges = write_ndjson(tmp_path / "e.ndjson", [broken]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + run({"nodes": nodes, "edges": edges}, capsys) + assert excinfo.value.code == 1 + assert "KGX output is not Biolink-compliant." in capsys.readouterr().err + + def test_registry_off_skips_identity_entirely(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """--registry off restores pure structural validation: no identity output at all.""" + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge("infores:made-up")]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges, "registry": "off"}, capsys) + assert "registry:" not in out + assert "unregistered" not in out + assert "KGX output is Biolink-compliant." in out + + +class TestPruneInterplay: + def test_prune_classifies_the_pruned_file(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Identity scans the post-prune state: only what ships is judged. + + Why: ``--prune`` rewrites the edges file in place, so a pre-prune scan would + report CURIEs from records the rewrite deleted -- and both the strict verdict + and the printed counts would disagree with the artifact on disk. The dropped + record here carries the only registered CURIE, so a pre-prune scan would print + ``1 registered``; the merged command must print ``0 registered`` and fail on + the surviving unregistered edge. + """ + dropped = {**valid_edge(REGISTERED, edge_id="urn:dropped"), "p_value": "not-a-number"} + kept = valid_edge("infores:made-up", edge_id="urn:kept") + edges = write_ndjson(tmp_path / "e.ndjson", [dropped, kept]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(SystemExit) as excinfo: + run({"nodes": nodes, "edges": edges, "prune": True, "strict": True}, capsys) + assert excinfo.value.code == 1 + out = capsys.readouterr().err + assert "0 registered" in out + assert "1 unregistered" in out + + def test_prune_passes_when_the_survivors_are_registered(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """A prune that leaves only registered edges exits 0 on both halves.""" + dropped = {**valid_edge("infores:made-up", edge_id="urn:dropped"), "p_value": "not-a-number"} + kept = valid_edge(REGISTERED, edge_id="urn:kept") + edges = write_ndjson(tmp_path / "e.ndjson", [dropped, kept]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges, "prune": True, "strict": True}, capsys) + assert "KGX output is Biolink-compliant." in out + assert "All emitted infores CURIEs are registered." in out + + +class TestRegistryRefresh: + def test_registry_refresh_fetches_and_caches(self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + """--registry refresh pulls the live catalog via the retry layer and caches the body.""" + catalog = yaml.safe_dump({"information_resources": [{"id": REGISTERED}]}) + monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: catalog) + monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: tmp_path / "cache" / "infores_catalog.yaml") + edges = write_ndjson(tmp_path / "e.ndjson", [valid_edge(REGISTERED)]) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + out = run({"nodes": nodes, "edges": edges, "registry": "refresh", "strict": True}, capsys) + assert "(refreshed)" in out + assert (tmp_path / "cache" / "infores_catalog.yaml").read_text(encoding="utf-8") == catalog + + def test_registry_refresh_rejects_garbage_body(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A fetched body without the stanza list raises and never poisons the cache.""" + monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: yaml.safe_dump({"unrelated": []})) + monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: tmp_path / "cache.yaml") + edges = write_ndjson(tmp_path / "e.ndjson", []) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(infores_registry.TablassertError) as excinfo: + cli.validate_kgx_command(nodes=nodes, edges=edges, registry="refresh") + assert "information_resources" in str(excinfo.value) + assert not (tmp_path / "cache.yaml").exists() + + def test_refresh_rejects_empty_stanza_list_before_caching(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A fetched body with an empty stanza list fails and never reaches the cache. + + Such a body would classify every real CURIE unregistered on later inspection; + it must be rejected at fetch time, not cached as a valid-looking registry. + """ + from tablassert.errors import TablassertError, error_code_of + + monkeypatch.setattr(infores_registry.net, "http_get_text", lambda url, **kwargs: yaml.safe_dump({"information_resources": []})) + cache = tmp_path / "cache.yaml" + monkeypatch.setattr(infores_registry, "registry_cache_path", lambda: cache) + edges = write_ndjson(tmp_path / "e.ndjson", []) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + with pytest.raises(TablassertError) as excinfo: + cli.validate_kgx_command(nodes=nodes, edges=edges, registry="refresh") + assert error_code_of(excinfo.value) == "infores-registry-unreadable" + assert not cache.exists() + + +def test_non_json_lines_count_as_malformed(tmp_path: Path) -> None: + """A line that is not JSON at all is malformed output, never a traceback. + + Mirrors validate-kgx's explicit handling of its own non-JSON lines: the report + stays structured so one corrupt line cannot crash a build gate. + """ + from tablassert.infores_registry import validate_infores + + edges = tmp_path / "e.ndjson" + edges.write_text('{"id": "urn:1", "primary_knowledge_source": ["infores:monarchinitiative"]}\nnot json at all\n', encoding="utf-8") + nodes = write_ndjson(tmp_path / "n.ndjson", []) + report = validate_infores(nodes, edges, registry=frozenset({REGISTERED})) + assert report["malformed"] == 1 + assert report["registered"] == 1 + assert report["ok"] is False + assert any(example["field"] == "" for example in report["examples"]) + + +def test_corrupt_rig_raises_coded_error(tmp_path: Path) -> None: + """An unparsable --rig yaml raises the coded error, not a raw yaml traceback.""" + from tablassert.errors import TablassertError, error_code_of + from tablassert.infores_registry import validate_infores + + edges = write_ndjson(tmp_path / "e.ndjson", []) + nodes = write_ndjson(tmp_path / "n.ndjson", []) + rig = tmp_path / "rig.yaml" + rig.write_text("source_info: [unclosed", encoding="utf-8") + with pytest.raises(TablassertError) as excinfo: + validate_infores(nodes, edges, rig_path=rig, registry=frozenset({REGISTERED})) + assert error_code_of(excinfo.value) == "infores-registry-unreadable" + + +def test_help_renders_every_flag() -> None: + """The help page must self-describe: an agent picks flags from help alone.""" + import io + + from rich.console import Console + + buffer = io.StringIO() + cli.APP.help_print(["validate-kgx"], console=Console(file=buffer, width=120, legacy_windows=False)) + help_text = " ".join(buffer.getvalue().replace("│", " ").split()) + for flag in ("--nodes", "--edges", "--rig", "--allow-infores", "--registry", "--strict", "--limit", "--prune"): + assert flag in help_text, f"help missing {flag}" + for token in ("snapshot", "refresh", "off"): + assert token in help_text, f"help missing registry mode {token}" diff --git a/tests/test_docs_cli_coverage.py b/tests/test_docs_cli_coverage.py index 83b4e67..6c802d6 100644 --- a/tests/test_docs_cli_coverage.py +++ b/tests/test_docs_cli_coverage.py @@ -25,7 +25,6 @@ "build-kg": ("cli.md",), "quick-map": ("cli.md", "fullmap.md"), "validate": ("cli.md",), - "validate-infores": ("cli.md",), "validate-kgx": ("cli.md",), } COMMAND_FLAG_DOCS: dict[str, tuple[str, ...]] = { @@ -33,7 +32,6 @@ "build-kg": ("cli.md",), "quick-map": ("cli.md", "fullmap.md"), "validate": ("cli.md",), - "validate-infores": ("cli.md",), "validate-kgx": ("cli.md",), }