Files
graphify/tests/test_extract_cli.py
T
Jon Attree 3238b32677 Exit non-zero when all semantic-extraction chunks fail (#889)
If `graphify extract --backend claude` runs without the `anthropic`
package installed (pip install graphifyy doesn't pull it in), every
semantic chunk fails inside extract_corpus_parallel. The per-chunk
errors print to stderr but the function returns the empty merged
accumulator anyway, so extract proceeds to write an AST-only graph.json
and exit 0. CI that checks exit status sees success even though the
requested semantic pass produced no nodes.

Track per-chunk success via the existing on_chunk_done callback, which
only fires after a chunk succeeds. If fresh extraction was requested
(uncached_paths non-empty) and zero chunks completed, abort before the
merge/cluster/write phase with exit 1 and a message naming the backend.

The same shape covers other backends with optional SDK deps (openai,
google-generativeai). Cached-only runs are unaffected: uncached_paths
is empty and the guard does not fire.

Tests in tests/test_extract_cli.py simulate the all-failed and
one-succeeded paths by patching extract_corpus_parallel directly.
2026-05-22 14:38:32 +01:00

124 lines
4.3 KiB
Python

"""Tests for `graphify extract` CLI dispatch path in graphify.__main__."""
from __future__ import annotations
import pytest
import graphify.__main__ as mainmod
def _make_corpus(tmp_path):
"""Minimal corpus: one Go code file + one Markdown doc.
Both file types are needed so semantic extraction is requested
(docs path triggers the LLM step we want to assert against).
"""
(tmp_path / "main.go").write_text("package main\nfunc main() {}\n")
(tmp_path / "README.md").write_text("# Notes\nThe main function entry point.\n")
return tmp_path
def test_extract_exits_nonzero_when_all_semantic_chunks_fail(
monkeypatch, tmp_path, capsys
):
"""When every semantic chunk errors (e.g. backend SDK not installed),
the CLI must exit non-zero instead of silently writing an AST-only graph.
The bug this guards: `pip install graphifyy` doesn't pull in `anthropic`,
so `graphify extract --backend claude` would print per-chunk errors and
still exit 0 with a graph.json. Callers checking exit status saw success.
"""
corpus = _make_corpus(tmp_path)
out_dir = tmp_path / "out"
# Stub the API-key check so the backend gate doesn't reject before we
# reach the semantic-extraction step.
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
# Patch extract_corpus_parallel to simulate "all chunks failed":
# return an empty merged accumulator without ever invoking on_chunk_done.
# This matches the real behavior of extract_corpus_parallel when every
# chunk raises (the per-chunk failures print to stderr and the loop
# continues without calling the success callback).
def _all_chunks_failed(paths, **kwargs):
return {
"nodes": [],
"edges": [],
"hyperedges": [],
"input_tokens": 0,
"output_tokens": 0,
}
monkeypatch.setattr(
"graphify.llm.extract_corpus_parallel", _all_chunks_failed
)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "extract", str(corpus), "--backend", "claude",
"--out", str(out_dir)],
)
with pytest.raises(SystemExit) as exc_info:
mainmod.main()
assert exc_info.value.code == 1, (
f"expected exit code 1 when all semantic chunks fail, "
f"got {exc_info.value.code}"
)
stderr = capsys.readouterr().err
assert "all semantic chunks failed" in stderr
assert "claude" in stderr
# No graph.json should have been written - the failure must abort before
# the merge/cluster/write phase, not after.
assert not (out_dir / "graphify-out" / "graph.json").exists(), (
"graph.json must not be written when semantic extraction fails"
)
def test_extract_succeeds_when_at_least_one_chunk_completes(
monkeypatch, tmp_path
):
"""Sanity counter-test: a successful chunk run keeps exit 0. Confirms the
new guard only fires on the all-failed path, not on every extract."""
corpus = _make_corpus(tmp_path)
out_dir = tmp_path / "out"
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
def _one_chunk_succeeded(paths, **kwargs):
on_chunk = kwargs.get("on_chunk_done")
if on_chunk:
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
return {
"nodes": [],
"edges": [],
"hyperedges": [],
"input_tokens": 100,
"output_tokens": 50,
}
monkeypatch.setattr(
"graphify.llm.extract_corpus_parallel", _one_chunk_succeeded
)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys,
"argv",
["graphify", "extract", str(corpus), "--backend", "claude",
"--out", str(out_dir)],
)
# extract may still raise SystemExit at the end (clean exit code 0)
# depending on platform; accept either no exception or SystemExit(0).
try:
mainmod.main()
except SystemExit as exc:
assert exc.code in (None, 0), f"unexpected exit code {exc.code}"
# graph.json should exist on the happy path
assert (out_dir / "graphify-out" / "graph.json").exists(), (
"graph.json must be written on the happy path"
)