Files
semantica/tests/test_cli_commands.py
T
yzxcj797andSameer6305 8cc5d364db fix(cli): write embed generate output in the format embed index reads (#1004)
* fix(cli): write embed generate output in the format embed index reads

* Address review: structured results get their own --output writer

deduplicate --output and ontology align --output were routed through
_write_embeddings_output, a helper for numeric matrices: it rejects the
dict/list shapes these commands produce and the .csv extension deduplicate
documents. New _write_result_output serializes structured results — JSON,
JSON-lines for lists, CSV for rows — and both commands use it. embed
generate keeps the embeddings writer, whose strictness is what #994 fixed.

On the pyarrow gap: the parquet writer already fails with an actionable
message (install pyarrow or use .json). Silently writing JSON bytes to a
.parquet path would recreate #994's magic-bytes failure, so the error stays
an error and the default suggestion stays .json.

* fix(cli): improve structured output serialization

---------

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-08-26 19:17:01 +05:30

2373 lines
101 KiB
Python

"""
Tests for all CLI commands added in issue #568.
Strategy:
- Module-level imports (ingest, kg, etc.) are mocked via monkeypatch so tests
run without optional backends installed.
- Help surfaces: every command/group --help must exit 0 and mention key flags.
- --dry-run: write commands must exit 0 and emit a dry-run message.
- --json: write commands must emit parseable JSON.
- ImportError paths: modules that raise ImportError must produce a clean
ClickException (non-zero exit, no traceback).
- Argument validation: missing required args must exit non-zero cleanly.
- Service commands: subprocess.Popen is mocked so nothing actually launches.
"""
import json
import os
import stat
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
import semantica.cli as cli_module
# ─── fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture(autouse=True)
def silence_logging(monkeypatch):
monkeypatch.setattr(cli_module, "setup_logging", lambda *a, **kw: None)
def _fake_module(**attrs: Any) -> types.ModuleType:
"""Build a minimal fake module with the given attributes."""
m = types.ModuleType("_fake")
for k, v in attrs.items():
setattr(m, k, v)
return m
def _import_side_effect(name: str, **_):
raise ImportError(f"No module named '{name}'")
# ─── helpers ──────────────────────────────────────────────────────────────────
def _ok(result, expected_exit: int = 0, *, substr: str = "") -> None:
"""Assert exit code and optional substring."""
assert result.exit_code == expected_exit, (
f"exit={result.exit_code}, output={result.output!r}"
)
if substr:
assert substr in result.output, f"{substr!r} not in {result.output!r}"
def _json_output(result) -> Any:
"""Parse JSON from command output; raises on bad JSON."""
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
return json.loads(result.output.strip())
# ─── Global flags ─────────────────────────────────────────────────────────────
class TestGlobalFlags:
def test_json_flag_stored_in_context(self, runner, monkeypatch):
captured = {}
def fake_run_build(ctx, sources):
captured["json"] = ctx.json_output
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, ["--json", "kg", "build", "-s", "x.txt"])
assert result.exit_code == 0
assert captured["json"] is True
def test_quiet_flag_stored_in_context(self, runner, monkeypatch):
captured = {}
def fake_run_build(ctx, sources):
captured["quiet"] = ctx.quiet
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, ["--quiet", "kg", "build", "-s", "x.txt"])
assert result.exit_code == 0
assert captured["quiet"] is True
def test_dry_run_global_stored_in_context(self, runner, monkeypatch):
captured = {}
def fake_run_build(ctx, sources):
captured["dry_run"] = ctx.dry_run_global
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, ["--dry-run", "kg", "build", "-s", "x.txt"])
assert result.exit_code == 0
assert captured["dry_run"] is True
def test_store_override_stored_in_context(self, runner, monkeypatch):
captured = {}
def fake_run_build(ctx, sources):
captured["store"] = ctx.store_backend
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, ["--store", "neo4j", "kg", "build", "-s", "x.txt"])
assert result.exit_code == 0
assert captured["store"] == "neo4j"
def test_vector_store_override_stored_in_context(self, runner, monkeypatch):
captured = {}
def fake_run_build(ctx, sources):
captured["vs"] = ctx.vector_store_backend
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, ["--vector-store", "qdrant", "kg", "build", "-s", "x.txt"])
assert result.exit_code == 0
assert captured["vs"] == "qdrant"
def test_root_help_shows_all_global_flags(self, runner):
result = runner.invoke(cli_module.main, ["--help"])
assert result.exit_code == 0
for flag in ["--json", "--quiet", "--dry-run", "--store", "--vector-store",
"--profile", "--no-color"]:
assert flag in result.output, f"{flag} missing from root help"
def test_root_help_shows_all_command_groups(self, runner):
result = runner.invoke(cli_module.main, ["--help"])
assert result.exit_code == 0
for cmd in ["ingest", "parse", "split", "normalize", "extract", "embed",
"deduplicate", "reason", "decision", "temporal", "provenance",
"validate", "ontology", "export", "visualize", "pipeline",
"store", "backup", "server", "explorer", "mcp", "completion"]:
assert cmd in result.output, f"{cmd!r} missing from root help"
def test_default_log_file_failure_falls_back_without_polluting_json(
self,
runner,
monkeypatch,
):
calls = []
def fake_setup_logging(*, config=None, **_kwargs):
calls.append(dict(config or {}))
if len(calls) == 1:
raise PermissionError("readonly semantica.log")
monkeypatch.setattr(cli_module, "setup_logging", fake_setup_logging)
result = runner.invoke(
cli_module.main,
["--json", "backup", "schedule", "--dest", "x", "--freq", "daily"],
)
payload = _json_output(result)
assert payload["cron"].startswith("0 2 * * *")
assert len(calls) == 2
assert calls[1].get("file") is None
assert "readonly semantica.log" not in result.output
# ─── kg subcommands ───────────────────────────────────────────────────────────
class TestKgSubcommands:
@pytest.mark.parametrize("sub", ["query", "stats", "analyze", "find-path",
"resolve", "predict", "validate"])
def test_help_exits_0(self, runner, sub):
result = runner.invoke(cli_module.main, ["kg", sub, "--help"])
_ok(result, substr=sub.replace("-", " ") if sub != "find-path" else "")
assert result.exit_code == 0
def test_kg_query_json_with_mock(self, runner, monkeypatch):
fake_gs = _fake_module(
execute_query=lambda q, **kw: {"query": q, "lang": "cypher", "rows": []},
)
monkeypatch.setitem(__import__("sys").modules, "semantica.graph_store", fake_gs)
result = runner.invoke(cli_module.main, ["kg", "query", "MATCH (n) RETURN n", "--json"])
_ok(result)
data = _json_output(result)
assert "query" in data
def test_kg_query_fails_cleanly_without_backend(self, runner):
result = runner.invoke(cli_module.main, ["kg", "query", "MATCH (n) RETURN n"])
# Either exits 0 (fallback) or non-0 (clean error) — never traceback
assert "Traceback" not in result.output
def test_kg_stats_json_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
GraphAnalyzer=lambda **kw: MagicMock(
compute_metrics=lambda: {"nodes": 10, "edges": 25, "density": 0.5}
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "stats", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, dict)
assert "nodes" in data
def test_kg_analyze_json_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
GraphAnalyzer=lambda **kw: MagicMock(
analyze=lambda mode: {"mode": mode, "communities": 3}
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "analyze", "--mode", "community", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, dict)
def test_kg_find_path_requires_from_and_to(self, runner):
result = runner.invoke(cli_module.main, ["kg", "find-path"])
assert result.exit_code != 0
def test_kg_find_path_json_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
PathFinder=lambda **kw: MagicMock(
find_path=lambda f, t, path_type: {"from": f, "to": t, "path": [f, t]}
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "find-path",
"--from", "Alice", "--to", "Acme", "--json"])
_ok(result)
data = _json_output(result)
assert "from" in data
def test_kg_resolve_exits_0_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
EntityResolver=lambda **kw: MagicMock(resolve=lambda: {"resolved": 5}),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "resolve"])
assert result.exit_code == 0
def test_kg_predict_exits_0_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
LinkPredictor=lambda **kw: MagicMock(predict=lambda: {"predictions": []}),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "predict"])
assert result.exit_code == 0
def test_kg_validate_exits_0_with_mock(self, runner, monkeypatch):
fake_kg = _fake_module(
GraphValidator=lambda **kw: MagicMock(
validate=lambda: {"valid": True},
integrity_check=lambda: {"valid": True},
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["kg", "validate"])
assert result.exit_code == 0
# ─── ingest ───────────────────────────────────────────────────────────────────
class TestIngest:
def test_help_shows_flags(self, runner):
result = runner.invoke(cli_module.main, ["ingest", "--help"])
_ok(result)
for flag in ["--type", "--format", "--recursive", "--watch",
"--batch-size", "--store", "--output", "--dry-run"]:
assert flag in result.output, f"{flag} missing"
def test_dry_run_json_exits_0(self, runner):
# Per-command --json with --dry-run should emit JSON (fixed via json_out param)
result = runner.invoke(cli_module.main, ["ingest", "data.pdf", "--dry-run", "--json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
assert data["action"] == "ingest"
def test_dry_run_global_json_exits_0(self, runner):
# Global --json with per-command --dry-run
result = runner.invoke(cli_module.main, ["--json", "ingest", "data.pdf", "--dry-run"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_dry_run_text_exits_0(self, runner):
result = runner.invoke(cli_module.main, ["ingest", "data.pdf", "--dry-run"])
_ok(result, substr="Dry run")
def test_runtime_path_passes_source_positionally(self, runner, monkeypatch):
captured = {}
def fake_ingest_file(sources, **kwargs):
captured["sources"] = sources
captured["kwargs"] = kwargs
return [{"path": sources}]
monkeypatch.setattr("semantica.ingest.methods.ingest_file", fake_ingest_file)
result = runner.invoke(
cli_module.main,
["ingest", "README.md", "--type", "file", "--format", "csv", "--json"],
)
_ok(result)
data = _json_output(result)
assert data["files"] == [{"path": "README.md"}]
assert captured["sources"] == "README.md"
assert captured["kwargs"]["method"] == "file"
assert captured["kwargs"]["batch_size"] == 500
assert captured["kwargs"]["format"] == "csv"
def test_runtime_path_passes_source_positionally_with_auto_detection(self, runner, monkeypatch):
captured = {}
def fake_ingest_file(sources, **kwargs):
captured["sources"] = sources
captured["kwargs"] = kwargs
return [{"path": sources}]
monkeypatch.setattr("semantica.ingest.methods.ingest_file", fake_ingest_file)
result = runner.invoke(cli_module.main, ["ingest", "README.md", "--json"])
_ok(result)
data = _json_output(result)
assert data["files"] == [{"path": "README.md"}]
assert captured["sources"] == "README.md"
assert captured["kwargs"]["method"] == "file"
def test_import_error_is_clean(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "__import__", _import_side_effect, raising=False)
original_import = __import__
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.ingest") else original_import(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["ingest", "data.pdf"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_type_choice_validation(self, runner):
result = runner.invoke(cli_module.main, ["ingest", "x.pdf", "--type", "invalid_type"])
assert result.exit_code != 0
def test_recursive_flag_accepted(self, runner):
result = runner.invoke(cli_module.main, ["ingest", "./data", "--recursive", "--dry-run"])
_ok(result)
def test_global_dry_run_triggers_ingest_dry(self, runner):
# Both global --dry-run and global --json
result = runner.invoke(cli_module.main, ["--dry-run", "--json", "ingest", "data.pdf"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
# ─── parse ────────────────────────────────────────────────────────────────────
class TestParse:
def test_help_shows_flags(self, runner):
result = runner.invoke(cli_module.main, ["parse", "--help"])
_ok(result)
for flag in ["--parser", "--format"]:
assert flag in result.output
def test_missing_file_arg_fails(self, runner):
result = runner.invoke(cli_module.main, ["parse"])
assert result.exit_code != 0
def test_nonexistent_file_fails(self, runner):
result = runner.invoke(cli_module.main, ["parse", "no_such_file.pdf"])
assert result.exit_code != 0
def test_parse_real_file(self, runner):
with runner.isolated_filesystem():
with open("doc.txt", "w") as f:
f.write("Hello world")
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.parse") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["parse", "doc.txt"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_format_choices(self, runner):
result = runner.invoke(cli_module.main, ["parse", "--help"])
assert "json" in result.output
assert "yaml" in result.output
assert "table" in result.output
# ─── split ────────────────────────────────────────────────────────────────────
class TestSplit:
def test_help_shows_strategy_choices(self, runner):
result = runner.invoke(cli_module.main, ["split", "--help"])
_ok(result)
for strategy in ["recursive", "semantic", "entity-aware", "table"]:
assert strategy in result.output
def test_missing_input_fails(self, runner):
result = runner.invoke(cli_module.main, ["split"])
assert result.exit_code != 0
def test_split_with_import_error(self, runner):
with runner.isolated_filesystem():
with open("doc.txt", "w") as f:
f.write("content")
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.split") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["split", "doc.txt"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_split_writes_output_file(self, runner, monkeypatch):
mock_chunks = [{"text": "chunk1"}, {"text": "chunk2"}]
fake_split = _fake_module(
split_recursive=lambda *a, **kw: mock_chunks,
get_split_method=lambda s: lambda *a, **kw: mock_chunks,
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.split", fake_split
)
with runner.isolated_filesystem():
with open("doc.txt", "w") as f:
f.write("line1\nline2")
result = runner.invoke(
cli_module.main, ["split", "doc.txt", "--output", "out.json"]
)
if result.exit_code == 0:
assert os.path.exists("out.json")
# ─── normalize ────────────────────────────────────────────────────────────────
class TestNormalize:
def test_help_shows_mode_and_domain(self, runner):
result = runner.invoke(cli_module.main, ["normalize", "--help"])
_ok(result)
assert "--mode" in result.output
assert "--domain" in result.output
def test_normalize_text_inline(self, runner, monkeypatch):
fake_norm = _fake_module(
normalize_text=lambda t: t.upper(),
normalize_date=lambda t, **kw: t,
normalize_entity=lambda t, **kw: t,
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.normalize", fake_norm
)
result = runner.invoke(cli_module.main, ["normalize", "hello world", "--mode", "text"])
assert result.exit_code == 0
assert "HELLO WORLD" in result.output
def test_normalize_json(self, runner, monkeypatch):
fake_norm = _fake_module(
normalize_text=lambda t: "normalized",
normalize_date=lambda t, **kw: "normalized",
normalize_entity=lambda t, **kw: "normalized",
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.normalize", fake_norm
)
result = runner.invoke(cli_module.main, ["normalize", "text", "--json"])
_ok(result)
data = _json_output(result)
assert "result" in data
def test_domain_choices(self, runner):
result = runner.invoke(cli_module.main, ["normalize", "--help"])
for d in ["healthcare", "legal", "finance", "general"]:
assert d in result.output
# ─── extract ──────────────────────────────────────────────────────────────────
class TestExtract:
def test_help_shows_mode_method_flags(self, runner):
result = runner.invoke(cli_module.main, ["extract", "--help"])
_ok(result)
for flag in ["--mode", "--method", "--model", "--confidence",
"--temporal", "--format", "--output"]:
assert flag in result.output
def test_dry_run_not_needed_extract_is_read_only(self, runner, monkeypatch):
_ner_result = [MagicMock(text="Alice", label="PER", confidence=0.9,
start_char=0, end_char=5, metadata={})]
fake_ext = _fake_module(
NERExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: _ner_result),
RelationExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
TripletExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
EventDetector=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.semantic_extract", fake_ext
)
result = runner.invoke(
cli_module.main, ["extract", "Alice works at Acme.", "--mode", "ner", "--json"]
)
_ok(result)
data = _json_output(result)
assert isinstance(data, (dict, list))
def test_stdin_input(self, runner, monkeypatch):
_ner_result = [MagicMock(text="Alice", label="PER", confidence=0.9,
start_char=0, end_char=5, metadata={})]
fake_ext = _fake_module(
NERExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: _ner_result),
RelationExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
TripletExtractor=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
EventDetector=lambda **kw: MagicMock(extract=lambda text, **kw2: []),
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.semantic_extract", fake_ext
)
result = runner.invoke(
cli_module.main, ["extract", "-", "--mode", "ner", "--json"], input="Alice\n"
)
_ok(result)
def test_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.semantic_extract") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["extract", "text"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_invalid_method_choice(self, runner):
result = runner.invoke(cli_module.main, ["extract", "text", "--method", "magic"])
assert result.exit_code != 0
# ─── embed ────────────────────────────────────────────────────────────────────
class TestEmbed:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["embed", "--help"])
_ok(result)
for sub in ["generate", "search", "index"]:
assert sub in result.output
def test_generate_help(self, runner):
result = runner.invoke(cli_module.main, ["embed", "generate", "--help"])
_ok(result)
assert "--model" in result.output
def test_search_help(self, runner):
result = runner.invoke(cli_module.main, ["embed", "search", "--help"])
_ok(result)
assert "--top-k" in result.output
assert "--hybrid" in result.output
def test_index_help(self, runner):
result = runner.invoke(cli_module.main, ["embed", "index", "--help"])
_ok(result)
assert "--store" in result.output
def test_generate_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "embeddings" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["embed", "generate", "entities.json"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_search_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "vector_store" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["embed", "search", "CEO query"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_index_requires_existing_file(self, runner):
result = runner.invoke(cli_module.main, ["embed", "index", "nonexistent.parquet"])
assert result.exit_code != 0
def test_index_loads_vectors_from_json(self, runner, monkeypatch, tmp_path):
"""Vectors are loaded from the file; create_index receives List[np.ndarray]."""
import json as _json, numpy as np
records = [{"id": "a", "embedding": [0.1, 0.2, 0.3]},
{"id": "b", "embedding": [0.4, 0.5, 0.6]}]
json_file = tmp_path / "test.json"
json_file.write_text(_json.dumps(records), encoding="utf-8")
captured = {}
def fake_create_index(vectors, ids=None, **kw):
captured["vectors"] = vectors
captured["ids"] = ids
return {"status": "ok"}
fake_vs = _fake_module(create_index=fake_create_index)
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["embed", "index", str(json_file), "--json"])
_ok(result)
assert len(captured["vectors"]) == 2
assert isinstance(captured["vectors"][0], np.ndarray)
def test_index_rejects_unsupported_format(self, runner, monkeypatch, tmp_path):
txt_file = tmp_path / "embeddings.txt"
txt_file.write_text("not a supported format")
fake_vs = _fake_module(create_index=MagicMock(return_value={}))
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["embed", "index", str(txt_file)])
assert result.exit_code != 0
# ─── deduplicate ──────────────────────────────────────────────────────────────
class TestDeduplicate:
def test_help_shows_flags(self, runner):
result = runner.invoke(cli_module.main, ["deduplicate", "--help"])
_ok(result)
for flag in ["--strategy", "--min-similarity", "--action", "--dry-run"]:
assert flag in result.output
def test_dry_run_exits_0(self, runner):
result = runner.invoke(cli_module.main, ["deduplicate", "--dry-run"])
_ok(result)
def test_dry_run_json(self, runner):
result = runner.invoke(cli_module.main, ["deduplicate", "--dry-run", "--json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_detect_runtime_path(self, runner, monkeypatch):
entities = [
{"id": "e1", "name": "Alice", "type": "Person"},
{"id": "e2", "name": "Alice", "type": "Person"},
{"id": "e3", "name": "Bob", "type": "Person"},
]
class FakeStore:
def get_nodes(self, labels=None, properties=None, limit=100, **options):
return entities
monkeypatch.setattr("semantica.graph_store.methods._get_store", lambda: FakeStore())
monkeypatch.setattr("semantica.graph_store.methods.get_nodes", lambda **kwargs: entities)
result = runner.invoke(
cli_module.main,
["deduplicate", "--action", "detect", "--min-similarity", "0.1", "--json"],
)
_ok(result)
assert "Alice" in result.output
assert "Bob" not in result.output or "entities" in result.output
def test_merge_runtime_path(self, runner, monkeypatch):
entities = [
{"id": "e1", "name": "Alice", "type": "Person"},
{"id": "e2", "name": "Alice", "type": "Person"},
]
captured = {}
class FakeStore:
def get_nodes(self, labels=None, properties=None, limit=100, **options):
return entities
monkeypatch.setattr("semantica.graph_store.methods._get_store", lambda: FakeStore())
monkeypatch.setattr("semantica.graph_store.methods.get_nodes", lambda **kwargs: entities)
def fake_merge(self, loaded_entities, **kwargs):
captured["entities"] = loaded_entities
captured["kwargs"] = kwargs
return [{"merged": True, "count": len(loaded_entities)}]
monkeypatch.setattr(
"semantica.deduplication.entity_merger.EntityMerger.merge_duplicates",
fake_merge,
)
result = runner.invoke(
cli_module.main,
["deduplicate", "--action", "merge", "--json"],
)
_ok(result)
data = _json_output(result)
assert data == [{"merged": True, "count": 2}]
assert captured["entities"] == entities
assert captured["kwargs"]["threshold"] == pytest.approx(0.7)
assert captured["kwargs"]["candidate_strategy"] == "hybrid_v2"
assert captured["kwargs"]["sort_by"] == "similarity_score"
def test_global_dry_run_triggers_dry(self, runner):
result = runner.invoke(cli_module.main, ["--dry-run", "--json", "deduplicate"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "deduplication" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["deduplicate"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_invalid_strategy_choice(self, runner):
result = runner.invoke(cli_module.main, ["deduplicate", "--strategy", "magic"])
assert result.exit_code != 0
@pytest.mark.parametrize("action", ["detect", "merge", "report"])
def test_action_choices_accepted(self, runner, action):
result = runner.invoke(cli_module.main, ["deduplicate", "--action", action, "--dry-run"])
_ok(result)
# ─── reason ───────────────────────────────────────────────────────────────────
class TestReason:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["reason", "--help"])
_ok(result)
for sub in ["run", "explain", "query", "list"]:
assert sub in result.output
def test_list_shows_engines(self, runner):
result = runner.invoke(cli_module.main, ["reason", "list"])
_ok(result)
assert "rete" in result.output
def test_list_json(self, runner):
# list command has no --json flag, output via cli_ctx.json_output
result2 = runner.invoke(cli_module.main, ["--json", "reason", "list"])
_ok(result2)
data = json.loads(result2.output.strip())
assert "engines" in data
assert "rete" in data["engines"]
def test_run_help(self, runner):
result = runner.invoke(cli_module.main, ["reason", "run", "--help"])
_ok(result)
assert "--engine" in result.output
def test_run_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "reasoning" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["reason", "run"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_explain_requires_conclusion(self, runner):
result = runner.invoke(cli_module.main, ["reason", "explain"])
assert result.exit_code != 0
def test_explain_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "reasoning" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["reason", "explain", "Alice is-manager-of Eng"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_query_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "reasoning" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["reason", "query", "SELECT ?x WHERE {}"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── decision ─────────────────────────────────────────────────────────────────
class TestDecision:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["decision", "--help"])
_ok(result)
for sub in ["record", "list", "query", "trace", "similar", "impact", "check"]:
assert sub in result.output
def test_record_requires_title(self, runner):
result = runner.invoke(cli_module.main, ["decision", "record"])
assert result.exit_code != 0
def test_record_dry_run_json(self, runner):
result = runner.invoke(cli_module.main, ["decision", "record",
"--title", "Approve X", "--dry-run", "--json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_record_global_dry_run_json(self, runner):
result = runner.invoke(cli_module.main, ["--json", "--dry-run", "decision", "record",
"--title", "Approve X"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_record_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "semantica.context" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["decision", "record", "--title", "X"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_list_json(self, runner, monkeypatch):
import datetime
fake_dq = MagicMock()
d = MagicMock()
d.decision_id = "d1"
d.scenario = "T"
d.category = "general"
d.outcome = "ok"
d.confidence = 1.0
fake_dq.find_by_time_range.return_value = [d]
fake_decision_query = _fake_module(DecisionQuery=lambda *a, **kw: fake_dq)
fake_graph_store = _fake_module(GraphStore=MagicMock(return_value=MagicMock()))
monkeypatch.setitem(__import__("sys").modules,
"semantica.context.decision_query", fake_decision_query)
monkeypatch.setitem(__import__("sys").modules,
"semantica.graph_store", fake_graph_store)
result = runner.invoke(cli_module.main, ["decision", "list", "--format", "json"])
_ok(result)
def test_trace_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "semantica.context" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["decision", "trace", "dec_123"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_check_requires_id(self, runner):
result = runner.invoke(cli_module.main, ["decision", "check"])
assert result.exit_code != 0
@pytest.mark.parametrize("sub", ["similar", "impact"])
def test_sub_requires_id(self, runner, sub):
result = runner.invoke(cli_module.main, ["decision", sub])
assert result.exit_code != 0
# ─── temporal ─────────────────────────────────────────────────────────────────
class TestTemporal:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["temporal", "--help"])
_ok(result)
for sub in ["snapshot", "query", "history", "distance", "allen"]:
assert sub in result.output
def test_snapshot_requires_at(self, runner):
result = runner.invoke(cli_module.main, ["temporal", "snapshot"])
assert result.exit_code != 0
def test_snapshot_json(self, runner, monkeypatch):
fake_kg = _fake_module(
TemporalGraphQuery=lambda **kw: MagicMock(
snapshot=lambda at: {"at": at, "nodes": 5}
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.kg", fake_kg)
result = runner.invoke(cli_module.main, ["temporal", "snapshot",
"--at", "2026-01-01T00:00:00Z", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, dict)
def test_distance_requires_both_events(self, runner):
result = runner.invoke(cli_module.main, ["temporal", "distance", "--event1", "ev1"])
assert result.exit_code != 0
def test_allen_requires_both_intervals(self, runner):
result = runner.invoke(cli_module.main, ["temporal", "allen",
"--interval1", "int1", "--interval2", "int2"])
assert result.exit_code != 0 or result.exit_code == 0 # depends on import
def test_history_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.kg") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["temporal", "history", "entity_alice"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── provenance ───────────────────────────────────────────────────────────────
class TestProvenance:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["provenance", "--help"])
_ok(result)
for sub in ["lineage", "audit", "export", "check"]:
assert sub in result.output
def test_lineage_requires_entity(self, runner):
result = runner.invoke(cli_module.main, ["provenance", "lineage"])
assert result.exit_code != 0
def test_lineage_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "provenance" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["provenance", "lineage", "entity_alice"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_export_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["provenance", "export", "--dry-run"])
_ok(result)
def test_audit_writes_output(self, runner, monkeypatch):
fake_prov = _fake_module(
ProvenanceManager=lambda **kw: MagicMock(
audit_log=lambda **kw2: [{"actor": "user", "action": "ingest"}]
),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.provenance", fake_prov)
with runner.isolated_filesystem():
result = runner.invoke(cli_module.main, ["provenance", "audit", "--output", "audit.json"])
if result.exit_code == 0:
assert os.path.exists("audit.json")
def test_check_exits_0_when_import_error(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "provenance" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["provenance", "check"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── validate ─────────────────────────────────────────────────────────────────
class TestValidate:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["validate", "--help"])
_ok(result)
for sub in ["shacl", "conflicts", "integrity"]:
assert sub in result.output
def test_shacl_help(self, runner):
result = runner.invoke(cli_module.main, ["validate", "shacl", "--help"])
_ok(result)
assert "--strictness" in result.output
def test_shacl_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "ontology" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["validate", "shacl"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_conflicts_json(self, runner, monkeypatch):
fake_conf = _fake_module(
detect_conflicts=lambda **kw: {"conflicts": [], "count": 0},
)
monkeypatch.setitem(__import__("sys").modules, "semantica.conflicts", fake_conf)
result = runner.invoke(cli_module.main, ["validate", "conflicts", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, dict)
def test_integrity_exits_0_with_import_error(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("semantica.kg") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["validate", "integrity"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_strictness_choices(self, runner):
result = runner.invoke(cli_module.main, ["validate", "shacl", "--help"])
for s in ["strict", "moderate", "lenient"]:
assert s in result.output
# ─── ontology ─────────────────────────────────────────────────────────────────
class TestOntology:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "--help"])
_ok(result)
for sub in ["generate", "import", "validate", "shacl", "skos",
"align", "health", "version"]:
assert sub in result.output
def test_generate_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "generate", "--dry-run"])
_ok(result)
def test_generate_json_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "generate", "--dry-run", "--json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_generate_global_json_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["--json", "--dry-run", "ontology", "generate"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_import_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "import", "schema.ttl", "--dry-run"])
_ok(result)
def test_import_requires_source(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "import"])
assert result.exit_code != 0
def test_skos_group_help(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "skos", "--help"])
_ok(result)
for sub in ["search", "hierarchy"]:
assert sub in result.output
def test_skos_search_requires_term(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "skos", "search"])
assert result.exit_code != 0
def test_skos_hierarchy_requires_uri(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "skos", "hierarchy"])
assert result.exit_code != 0
def test_align_requires_source_and_target(self, runner):
result = runner.invoke(cli_module.main, ["ontology", "align"])
assert result.exit_code != 0
def test_align_import_error_is_clean(self, runner):
with runner.isolated_filesystem():
with open("s.ttl", "w") as f:
f.write("")
with open("t.ttl", "w") as f:
f.write("")
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "ontology" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["ontology", "align",
"--source", "s.ttl", "--target", "t.ttl"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_health_exits_0_with_import_error(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "ontology" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["ontology", "health"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── export ───────────────────────────────────────────────────────────────────
class TestExport:
def test_help_shows_14_formats(self, runner):
result = runner.invoke(cli_module.main, ["export", "--help"])
_ok(result)
for fmt in ["turtle", "parquet", "csv", "graphml", "owl", "arangodb"]:
assert fmt in result.output
for flag in ["--with-provenance", "--filter", "--compress", "--dry-run"]:
assert flag in result.output
def test_dry_run_json(self, runner):
result = runner.invoke(cli_module.main, ["export", "--format", "turtle",
"--dry-run", "--json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_dry_run_text(self, runner):
result = runner.invoke(cli_module.main, ["export", "--format", "csv", "--dry-run"])
_ok(result, substr="Dry run")
def test_global_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["--dry-run", "--json", "export", "--format", "json"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_real_export_runtime_path(self, runner, tmp_path, monkeypatch):
class FakeGraphStore:
def get_nodes(self, labels=None, properties=None, limit=100, **options):
return [
{
"id": "n1",
"type": "Person",
"name": "Alice",
"properties": {"name": "Alice"},
}
]
def get_relationships(self, node_id=None, rel_type=None, direction="both", limit=100, **options):
return [
{
"id": "r1",
"source": "n1",
"target": "n1",
"type": "KNOWS",
"properties": {},
}
]
monkeypatch.setattr(
"semantica.graph_store.methods._get_store",
lambda: FakeGraphStore(),
)
monkeypatch.setattr(
"semantica.graph_store.get_nodes",
lambda **kwargs: [
{
"id": "n1",
"type": "Person",
"name": "Alice",
"properties": {"name": "Alice"},
}
],
)
monkeypatch.setattr(
"semantica.graph_store.methods.get_nodes",
lambda **kwargs: [
{
"id": "n1",
"type": "Person",
"name": "Alice",
"properties": {"name": "Alice"},
}
],
)
monkeypatch.setattr(
"semantica.graph_store.get_relationships",
lambda **kwargs: [
{
"id": "r1",
"source": "n1",
"target": "n1",
"type": "KNOWS",
"properties": {},
}
],
)
monkeypatch.setattr(
"semantica.graph_store.methods.get_relationships",
lambda **kwargs: [
{
"id": "r1",
"source": "n1",
"target": "n1",
"type": "KNOWS",
"properties": {},
}
],
)
output_path = tmp_path / "export.json"
result = runner.invoke(cli_module.main, ["export", "--format", "json", "--output", str(output_path)])
_ok(result)
exported = output_path.read_text(encoding="utf-8")
assert "Alice" in exported
assert "KNOWS" in exported
def test_invalid_format_fails(self, runner):
result = runner.invoke(cli_module.main, ["export", "--format", "magic"])
assert result.exit_code != 0
def test_import_error_is_clean(self, runner):
original_import = __import__
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "semantica.export" in n else original_import(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["export", "--format", "json"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── visualize ────────────────────────────────────────────────────────────────
class TestVisualize:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["visualize", "--help"])
_ok(result)
for sub in ["kg", "ontology", "embeddings", "temporal", "analytics"]:
assert sub in result.output
@pytest.mark.parametrize("sub", ["kg", "ontology", "embeddings", "temporal", "analytics"])
def test_subcommand_help(self, runner, sub):
result = runner.invoke(cli_module.main, ["visualize", sub, "--help"])
_ok(result)
for flag in ["--layout", "--format", "--output"]:
assert flag in result.output
@pytest.mark.parametrize("sub", ["kg", "ontology", "embeddings", "temporal", "analytics"])
def test_import_error_is_clean(self, runner, sub):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "visualization" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["visualize", sub])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_format_choices(self, runner):
result = runner.invoke(cli_module.main, ["visualize", "kg", "--help"])
for fmt in ["html", "svg", "png", "pdf"]:
assert fmt in result.output
def test_layout_choices(self, runner):
result = runner.invoke(cli_module.main, ["visualize", "kg", "--help"])
for layout in ["forceatlas2", "spring", "hierarchical"]:
assert layout in result.output
# ─── pipeline ─────────────────────────────────────────────────────────────────
class TestPipeline:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "--help"])
_ok(result)
for sub in ["init", "validate", "run", "status", "stop"]:
assert sub in result.output
def test_init_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "init", "--dry-run"])
_ok(result)
def test_init_creates_file(self, runner, monkeypatch):
fake_pl = _fake_module(
PipelineTemplateManager=lambda: MagicMock(scaffold=lambda t: "steps: []\n"),
)
monkeypatch.setitem(__import__("sys").modules, "semantica.pipeline", fake_pl)
with runner.isolated_filesystem():
result = runner.invoke(cli_module.main, ["pipeline", "init",
"--template", "rag", "--output", "pl.yaml"])
_ok(result)
assert os.path.exists("pl.yaml")
def test_init_template_choices(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "init", "--help"])
for t in ["ingest-extract-kg", "rag", "ontology-build", "decision-track", "full"]:
assert t in result.output
def test_validate_requires_file(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "validate"])
assert result.exit_code != 0
def test_validate_nonexistent_file_fails(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "validate", "no_such.yaml"])
assert result.exit_code != 0
def test_run_dry_run(self, runner):
with runner.isolated_filesystem():
with open("pl.yaml", "w") as f:
f.write("steps: []\n")
result = runner.invoke(cli_module.main, ["pipeline", "run", "pl.yaml", "--dry-run"])
_ok(result)
def test_run_requires_file(self, runner):
result = runner.invoke(cli_module.main, ["pipeline", "run"])
assert result.exit_code != 0
def test_status_exits_0(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "pipeline" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["pipeline", "status"])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_stop_exits_cleanly_on_import_error(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "pipeline" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["pipeline", "stop"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── store ────────────────────────────────────────────────────────────────────
class TestStore:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["store", "--help"])
_ok(result)
for sub in ["list", "connect", "stats", "migrate", "flush"]:
assert sub in result.output
def test_list_json_empty_config(self, runner):
result = runner.invoke(cli_module.main, ["store", "list", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, dict)
def test_list_table(self, runner):
result = runner.invoke(cli_module.main, ["store", "list"])
_ok(result)
def test_connect_requires_backend(self, runner):
result = runner.invoke(cli_module.main, ["store", "connect"])
assert result.exit_code != 0
def test_connect_reports_status(self, runner):
result = runner.invoke(cli_module.main, ["store", "connect", "--backend", "neo4j"])
_ok(result)
def test_migrate_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "faiss", "--to", "qdrant", "--dry-run"])
_ok(result)
def test_migrate_requires_from_and_to(self, runner):
result = runner.invoke(cli_module.main, ["store", "migrate", "--from", "faiss"])
assert result.exit_code != 0
def test_flush_requires_confirm(self, runner):
result = runner.invoke(cli_module.main, ["store", "flush"])
assert result.exit_code != 0
assert "confirm" in result.output.lower() or result.exit_code == 2
def test_flush_with_confirm(self, runner, monkeypatch):
fake_vs = _fake_module(delete_vectors=lambda **kw: None)
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["store", "flush", "--confirm"])
_ok(result)
def test_stats_requires_backend(self, runner):
result = runner.invoke(cli_module.main, ["store", "stats"])
assert result.exit_code != 0
# ─── backup ───────────────────────────────────────────────────────────────────
class TestBackup:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["backup", "--help"])
_ok(result)
for sub in ["info", "create", "sync", "restore", "schedule"]:
assert sub in result.output
def test_info_json_empty_config(self, runner):
result = runner.invoke(cli_module.main, ["backup", "info", "--json"])
_ok(result)
data = _json_output(result)
assert isinstance(data, list)
def test_info_redacts_credentials(self, runner):
# Config normalizes store.graph → graph_db
with runner.isolated_filesystem():
with open("cfg.yaml", "w") as f:
f.write(
"graph_db:\n"
" backend: neo4j\n"
" uri: bolt://user:secret123@host:7687\n"
)
result = runner.invoke(cli_module.main, ["--config", "cfg.yaml", "backup", "info"])
_ok(result)
assert "secret123" not in result.output
def test_info_shows_redacted_uri_in_output(self, runner):
with runner.isolated_filesystem():
with open("cfg.yaml", "w") as f:
f.write(
"graph_db:\n"
" backend: neo4j\n"
" uri: bolt://user:secret123@host:7687\n"
)
result = runner.invoke(cli_module.main, ["--config", "cfg.yaml", "backup", "info"])
_ok(result)
assert "neo4j" in result.output
assert "graph" in result.output
def test_info_flags_cloud_backends_as_export(self, runner):
with runner.isolated_filesystem():
with open("cfg.yaml", "w") as f:
# vector_store is the correct key in Config
f.write("vector_store:\n backend: pinecone\n host: x\n")
result = runner.invoke(cli_module.main, ["--config", "cfg.yaml", "backup", "info"])
_ok(result)
assert "export" in result.output
def test_create_dry_run(self, runner):
with runner.isolated_filesystem():
result = runner.invoke(cli_module.main, ["backup", "create", "backup.tar.gz", "--dry-run"])
_ok(result)
def test_create_unencrypted_with_config_requires_confirm(self, runner):
with runner.isolated_filesystem():
with open("cfg.yaml", "w") as f:
f.write("graph_db:\n backend: neo4j\n uri: bolt://localhost\n")
result = runner.invoke(
cli_module.main,
["--config", "cfg.yaml", "backup", "create", "out.tar.gz"],
input="n\n",
)
assert result.exit_code != 0
def test_create_strip_config_skips_confirm(self, runner):
with runner.isolated_filesystem():
with open("cfg.yaml", "w") as f:
f.write("graph_db:\n backend: neo4j\n uri: bolt://localhost\n")
result = runner.invoke(
cli_module.main,
["--config", "cfg.yaml", "backup", "create", "out.tar.gz",
"--strip-config", "--quiet"],
)
assert "Traceback" not in result.output
def test_create_dry_run_json(self, runner):
# backup create has no per-command --json; use global --json
result = runner.invoke(cli_module.main, ["--json", "backup", "create", "backup.tar.gz",
"--dry-run"])
_ok(result)
data = _json_output(result)
assert data["dry_run"] is True
def test_create_keyfile_world_readable_rejected(self, runner):
with runner.isolated_filesystem():
with open("keyfile.txt", "w") as f:
f.write("secret")
try:
os.chmod("keyfile.txt", stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
result = runner.invoke(
cli_module.main,
["backup", "create", "out.tar.gz",
"--keyfile", "keyfile.txt", "--encrypt"],
)
assert result.exit_code != 0
assert "readable" in result.output # covers both "world-readable" and group-readable
except OSError:
pytest.skip("Cannot set file permissions on this OS")
def test_create_keyfile_nonexistent_rejected(self, runner):
result = runner.invoke(
cli_module.main,
["backup", "create", "out.tar.gz",
"--keyfile", "no_such_keyfile.txt", "--encrypt"],
)
assert result.exit_code != 0
def test_sync_dry_run(self, runner):
result = runner.invoke(cli_module.main, ["backup", "sync", "/tmp/bk", "--dry-run"])
_ok(result)
def test_sync_creates_directory(self, runner):
with runner.isolated_filesystem():
result = runner.invoke(cli_module.main, ["backup", "sync", "sync_dest"])
_ok(result)
assert os.path.isdir("sync_dest")
def test_restore_dry_run(self, runner):
with runner.isolated_filesystem():
with open("backup.tar.gz", "w") as f:
f.write("")
result = runner.invoke(cli_module.main, ["backup", "restore", "backup.tar.gz", "--dry-run"])
_ok(result)
def test_restore_requires_source(self, runner):
result = runner.invoke(cli_module.main, ["backup", "restore"])
assert result.exit_code != 0
def test_restore_nonexistent_source_fails(self, runner):
result = runner.invoke(cli_module.main, ["backup", "restore", "no_such_file.tar.gz"])
assert result.exit_code != 0
def test_schedule_prints_cron(self, runner):
result = runner.invoke(cli_module.main, ["backup", "schedule",
"--dest", "/mnt/bk", "--freq", "daily"])
_ok(result)
assert "0 2 * * *" in result.output
assert "/mnt/bk" in result.output
def test_schedule_weekly(self, runner):
result = runner.invoke(cli_module.main, ["backup", "schedule",
"--dest", "/mnt/bk", "--freq", "weekly"])
_ok(result)
assert "0 2 * * 0" in result.output
def test_schedule_with_encrypt(self, runner):
result = runner.invoke(cli_module.main, ["backup", "schedule",
"--dest", "/mnt/bk", "--encrypt"])
_ok(result)
assert "--encrypt" in result.output
def test_schedule_json(self, runner):
result = runner.invoke(cli_module.main, ["--json", "backup", "schedule",
"--dest", "/mnt/bk"])
_ok(result)
data = json.loads(result.output.strip())
assert "cron" in data
# ─── server ───────────────────────────────────────────────────────────────────
class TestServer:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["server", "--help"])
_ok(result)
for sub in ["start", "stop", "status"]:
assert sub in result.output
def test_start_help(self, runner):
result = runner.invoke(cli_module.main, ["server", "start", "--help"])
_ok(result)
for flag in ["--port", "--workers", "--reload", "--host"]:
assert flag in result.output
def test_start_launches_process(self, runner):
mock_proc = MagicMock()
mock_proc.pid = 12345
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
result = runner.invoke(cli_module.main, ["server", "start", "--port", "9000"])
_ok(result)
mock_popen.assert_called_once()
call_args = mock_popen.call_args[0][0]
assert "9000" in call_args
def test_start_with_reload(self, runner):
mock_proc = MagicMock()
mock_proc.pid = 12346
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
result = runner.invoke(cli_module.main, ["server", "start", "--reload"])
_ok(result)
call_args = mock_popen.call_args[0][0]
assert "--reload" in call_args
def test_stop_when_not_running(self, runner, tmp_path, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["server", "stop"])
_ok(result)
assert "not running" in result.output.lower()
def test_stop_sends_sigterm(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: 99999)
monkeypatch.setattr(cli_module, "_pid_file", lambda n: MagicMock(
exists=lambda: True, unlink=lambda missing_ok=False: None
))
with patch("os.kill") as mock_kill:
result = runner.invoke(cli_module.main, ["server", "stop"])
_ok(result)
mock_kill.assert_called_once()
def test_status_when_stopped(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["server", "status"])
_ok(result)
assert "stopped" in result.output
def test_status_json(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["server", "status", "--json"])
_ok(result)
data = _json_output(result)
assert data["service"] == "server"
assert "status" in data
# ─── explorer ─────────────────────────────────────────────────────────────────
class TestExplorer:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["explorer", "--help"])
_ok(result)
for sub in ["start", "stop", "status", "open"]:
assert sub in result.output
def test_start_launches_process(self, runner, monkeypatch):
mock_proc = MagicMock()
mock_proc.pid = 22222
monkeypatch.setattr(cli_module, "_write_pid", lambda *_args: None)
with patch("subprocess.Popen", return_value=mock_proc):
result = runner.invoke(cli_module.main, ["explorer", "start", "--port", "5173"])
_ok(result)
def test_start_forwards_api_url_to_child_environment(self, runner, monkeypatch):
mock_proc = MagicMock()
mock_proc.pid = 22223
monkeypatch.setattr(cli_module, "_write_pid", lambda *_args: None)
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
result = runner.invoke(
cli_module.main,
["explorer", "start", "--api-url", "http://localhost:9000"],
)
_ok(result)
assert mock_popen.call_args.kwargs["env"]["SEMANTICA_API_URL"] == (
"http://localhost:9000"
)
def test_stop_when_not_running(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["explorer", "stop"])
_ok(result)
assert "not running" in result.output.lower()
def test_status_json(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["explorer", "status", "--json"])
_ok(result)
data = _json_output(result)
assert data["service"] == "explorer"
def test_open_calls_webbrowser(self, runner):
with patch("webbrowser.open") as mock_wb:
result = runner.invoke(cli_module.main, ["explorer", "open", "--port", "5173"])
_ok(result)
mock_wb.assert_called_once_with("http://localhost:5173")
# ─── mcp ──────────────────────────────────────────────────────────────────────
class TestMCP:
def test_group_help(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "--help"])
_ok(result)
for sub in ["start", "stop", "status", "list-tools", "call"]:
assert sub in result.output
def test_start_launches_process(self, runner):
mock_proc = MagicMock()
mock_proc.pid = 33333
with patch("subprocess.Popen", return_value=mock_proc):
result = runner.invoke(cli_module.main, ["mcp", "start"])
_ok(result)
def test_start_http_includes_port(self, runner):
mock_proc = MagicMock()
mock_proc.pid = 33334
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
result = runner.invoke(cli_module.main, ["mcp", "start", "--transport", "http",
"--port", "4000"])
_ok(result)
call_args = mock_popen.call_args[0][0]
assert "4000" in call_args
def test_stop_when_not_running(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["mcp", "stop"])
_ok(result)
def test_status_json(self, runner, monkeypatch):
monkeypatch.setattr(cli_module, "_read_pid", lambda n: None)
result = runner.invoke(cli_module.main, ["mcp", "status", "--json"])
_ok(result)
data = _json_output(result)
assert data["service"] == "mcp"
def test_list_tools_shows_tools(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "list-tools"])
_ok(result)
# Table renders correctly — at minimum the column header is present
assert "Tool" in result.output or "tool" in result.output.lower()
def test_list_tools_with_mock_shows_known_tools(self, runner, monkeypatch):
fake_tools = _fake_module(__all__=["extract_entities", "query_graph"])
monkeypatch.setitem(__import__("sys").modules, "mcp.tools", fake_tools)
result = runner.invoke(cli_module.main, ["mcp", "list-tools"])
_ok(result)
assert "extract_entities" in result.output
def test_list_tools_json(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "list-tools", "--json"])
_ok(result)
data = _json_output(result)
assert "tools" in data
assert isinstance(data["tools"], list)
def test_call_requires_tool_name(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "call"])
assert result.exit_code != 0
def test_call_invalid_json_args_fails_cleanly(self, runner):
result = runner.invoke(cli_module.main, ["mcp", "call", "some_tool", "--args", "{bad json}"])
assert result.exit_code != 0
assert "Traceback" not in result.output
assert "Invalid JSON" in result.output
def test_call_import_error_is_clean(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if n.startswith("mcp") else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["mcp", "call", "extract_entities"])
assert result.exit_code != 0
assert "Traceback" not in result.output
# ─── services group (backward-compat wrapper) ─────────────────────────────────
class TestServicesGroup:
def test_services_group_help_shows_subgroups(self, runner):
result = runner.invoke(cli_module.main, ["services", "--help"])
_ok(result)
for sub in ["server", "explorer", "mcp"]:
assert sub in result.output
def test_services_server_help(self, runner):
result = runner.invoke(cli_module.main, ["services", "server", "--help"])
_ok(result)
for sub in ["start", "stop", "status"]:
assert sub in result.output
def test_services_explorer_help(self, runner):
result = runner.invoke(cli_module.main, ["services", "explorer", "--help"])
_ok(result)
def test_services_mcp_help(self, runner):
result = runner.invoke(cli_module.main, ["services", "mcp", "--help"])
_ok(result)
# ─── completion ───────────────────────────────────────────────────────────────
class TestCompletion:
@pytest.mark.parametrize("shell", ["bash", "zsh", "fish", "powershell"])
def test_completion_exits_0(self, runner, shell):
result = runner.invoke(cli_module.main, ["completion", shell])
assert result.exit_code == 0
@pytest.mark.parametrize("shell", ["bash", "zsh", "fish", "powershell"])
def test_completion_output_not_empty(self, runner, shell):
result = runner.invoke(cli_module.main, ["completion", shell])
assert result.exit_code == 0
assert len(result.output.strip()) > 0
@pytest.mark.parametrize("shell,install_path", [
("bash", "~/.bashrc"),
("zsh", "~/.zshrc"),
("fish", "~/.config/fish"),
("powershell", "$PROFILE"),
])
def test_completion_mentions_install_path(self, runner, shell, install_path):
result = runner.invoke(cli_module.main, ["completion", shell])
assert result.exit_code == 0
assert install_path in result.output
def test_invalid_shell_fails(self, runner):
result = runner.invoke(cli_module.main, ["completion", "csh"])
assert result.exit_code != 0
# ─── cross-cutting: --json propagated from global flag ────────────────────────
class TestGlobalJsonPropagation:
"""--json set at root should trigger JSON output in all subcommands."""
def test_global_json_on_backup_schedule(self, runner):
result = runner.invoke(cli_module.main, ["--json", "backup", "schedule", "--dest", "/d"])
_ok(result)
data = json.loads(result.output.strip())
assert "cron" in data
def test_global_json_on_store_list(self, runner):
result = runner.invoke(cli_module.main, ["--json", "store", "list"])
_ok(result)
assert json.loads(result.output.strip()) is not None
def test_global_json_on_mcp_list_tools(self, runner):
result = runner.invoke(cli_module.main, ["--json", "mcp", "list-tools"])
_ok(result)
data = json.loads(result.output.strip())
assert "tools" in data
# ─── exit codes ───────────────────────────────────────────────────────────────
class TestExitCodes:
"""Exit codes must match the spec: 0 success, 1 general, 2 validation."""
def test_success_is_0(self, runner):
result = runner.invoke(cli_module.main, ["info"])
assert result.exit_code == 0
def test_missing_required_arg_is_2(self, runner):
result = runner.invoke(cli_module.main, ["kg", "build"])
assert result.exit_code == 2
def test_missing_required_arg_for_find_path_is_nonzero(self, runner):
result = runner.invoke(cli_module.main, ["kg", "find-path"])
assert result.exit_code != 0
def test_import_error_is_nonzero(self, runner):
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
(_ for _ in ()).throw(ImportError(n))
if "deduplication" in n else __import__(n, *a, **k)
)):
result = runner.invoke(cli_module.main, ["deduplicate"])
assert result.exit_code != 0
def test_no_traceback_on_any_error(self, runner):
for argv in [
["kg", "build"],
["deduplicate", "--strategy", "bad"],
["export", "--format", "bad"],
["mcp", "call", "tool", "--args", "{invalid}"],
]:
result = runner.invoke(cli_module.main, argv)
assert "Traceback" not in result.output, (
f"Traceback found for {argv}: {result.output}"
)
class TestDoctorEmbeddings:
"""#994: doctor must surface non-functional embedding backends instead of
reporting all green. Default = import-level check; --deep-embeddings (or
SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder."""
def _doctor_checks(self, runner, *extra):
result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
_ok(result)
import json as _json
return {c["check"]: c for c in _json.loads(result.output)}
def _with_fake_st(self, monkeypatch, **embedder_attrs):
fake_st = _fake_module(
__version__="9.9.9",
SentenceTransformer=object,
)
monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
def test_doctor_reports_embedding_checks(self, runner):
checks = self._doctor_checks(runner)
assert "Embeddings (sentence-transformers)" in checks
assert "Embeddings (fastembed)" in checks
def test_import_failure_is_fail_status_with_hint(self, runner, monkeypatch):
# Force the 'import sentence_transformers' inside _embedding_backend to
# raise ImportError regardless of whether the package is installed on
# this machine. Setting a module entry to None is the standard Python
# mechanism: any subsequent 'import <name>' raises
# "import of <name> halted; None in sys.modules".
monkeypatch.setitem(
__import__("sys").modules, "sentence_transformers", None
)
checks = self._doctor_checks(runner)
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "fail"
assert st["hint"] == "pip install sentence-transformers"
def test_deep_probe_detects_fallback_active(self, runner, monkeypatch):
self._with_fake_st(monkeypatch)
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
checks = self._doctor_checks(runner, "--deep-embeddings")
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "fail"
assert "hash fallback" in st["note"]
def test_deep_probe_ok_when_model_loads(self, runner, monkeypatch):
self._with_fake_st(monkeypatch)
import numpy as np
fake_embedder = types.SimpleNamespace(
model=object(),
fastembed_model=None,
embed_text=lambda text: np.zeros(384, dtype=np.float32),
)
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
checks = self._doctor_checks(runner, "--deep-embeddings")
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "ok"
assert "384-dim" in st["note"]
def test_env_var_enables_deep_mode(self, runner, monkeypatch):
monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "1")
self._with_fake_st(monkeypatch)
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
checks = self._doctor_checks(runner)
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "fail"
assert "hash fallback" in st["note"]
class TestDoctorEmbeddingHintsAndEnv:
"""Review follow-ups: deep failures must not carry the pip-install hint,
and the env toggle tolerates case/whitespace variants."""
def _doctor_checks(self, runner, *extra):
result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
_ok(result)
import json as _json
return {c["check"]: c for c in _json.loads(result.output)}
def _with_fake_st(self, monkeypatch):
fake_st = _fake_module(
__version__="9.9.9",
SentenceTransformer=object,
)
monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
def test_deep_failure_hint_is_not_pip_install(self, runner, monkeypatch):
self._with_fake_st(monkeypatch)
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
checks = self._doctor_checks(runner, "--deep-embeddings")
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "fail"
assert "pip install" not in (st["hint"] or ""), (
"a deep probe failure means the package imported fine — pointing "
"users at pip sends them to reinstall for a runtime/model problem"
)
assert "runtime/model-load" in st["hint"]
def test_env_var_tolerates_case_and_whitespace(self, runner, monkeypatch):
monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", " TRUE ")
self._with_fake_st(monkeypatch)
fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
checks = self._doctor_checks(runner)
st = checks["Embeddings (sentence-transformers)"]
assert st["status"] == "fail"
assert "hash fallback" in st["note"], "padded/caps env value must enable deep mode"
class TestEmbedGenerateOutput:
"""#994: `embed generate --output` must write files `embed index` can read."""
def _patch_generate(self, monkeypatch, retval):
import numpy as np
fake_emb = _fake_module(generate_embeddings=lambda *a, **k: np.asarray(retval))
monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb)
def test_writes_valid_parquet(self, runner, monkeypatch, tmp_path):
pytest.importorskip("pyarrow", reason="parquet writer regression needs pyarrow")
import numpy as np
import pandas as pd
self._patch_generate(monkeypatch, [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
out = tmp_path / "embeddings.parquet"
result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
_ok(result)
df = pd.read_parquet(out)
assert "embedding" in df.columns
assert len(df) == 2
# Use allclose: the writer may store float32 or float64 depending on
# the model backend; exact == fails for float32-precision values.
assert np.allclose(df["embedding"].iloc[0], [0.1, 0.2, 0.3], atol=1e-6)
def test_writes_1d_result_as_single_row_parquet(self, runner, monkeypatch, tmp_path):
pytest.importorskip("pyarrow", reason="parquet writer regression needs pyarrow")
import numpy as np
import pandas as pd
self._patch_generate(monkeypatch, [0.1, 0.2, 0.3])
out = tmp_path / "embeddings.parquet"
result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
_ok(result)
df = pd.read_parquet(out)
assert len(df) == 1
assert np.allclose(df["embedding"].iloc[0], [0.1, 0.2, 0.3], atol=1e-6)
def test_writes_json_records_not_repr_strings(self, runner, monkeypatch, tmp_path):
import json as _json
import numpy as np
import pandas as pd
self._patch_generate(monkeypatch, [[0.1, 0.2], [0.3, 0.4]])
out = tmp_path / "embeddings.json"
result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
_ok(result)
records = _json.loads(out.read_text(encoding="utf-8"))
assert records == [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]
# Verify embed index can read the file back (round-trip contract).
df = pd.read_json(out, orient="records")
vector_col = next(
(c for c in df.columns if isinstance(df[c].iloc[0], (list, np.ndarray))),
None,
)
assert vector_col == "embedding", (
f"embed index would not find a vector column; got columns {list(df.columns)}"
)
def test_rejects_unsupported_output_format(self, runner, monkeypatch, tmp_path):
self._patch_generate(monkeypatch, [[0.1, 0.2]])
out = tmp_path / "embeddings.txt"
result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
assert result.exit_code != 0
assert "Unsupported output format" in result.output
assert not out.exists()
class TestWriteResultOutput:
"""Unit-level regression tests for _write_result_output().
Covers every branch: JSON, JSONL, CSV, unsupported extension, no-extension,
dict+JSONL, empty list, NumPy scalar/array values, and round-trip readback.
"""
# ── helpers ───────────────────────────────────────────────────────────────
def _write(self, tmp_path, filename, result):
"""Call _write_result_output and return the output Path."""
from semantica.cli import _write_result_output
out = tmp_path / filename
_write_result_output(out, result)
return out
# ── JSON ─────────────────────────────────────────────────────────────────
def test_json_dict_produces_valid_json(self, tmp_path):
import json
out = self._write(tmp_path, "r.json", {"pairs": 3, "score": 0.9})
data = json.loads(out.read_text(encoding="utf-8"))
assert data == {"pairs": 3, "score": 0.9}
def test_json_list_produces_valid_json(self, tmp_path):
import json
out = self._write(tmp_path, "r.json", [{"a": 1}, {"a": 2}])
data = json.loads(out.read_text(encoding="utf-8"))
assert data == [{"a": 1}, {"a": 2}]
def test_json_numpy_scalar_serialises_as_number_not_repr(self, tmp_path):
"""np.float32 values must round-trip as JSON numbers, not repr strings."""
import json
import numpy as np
out = self._write(tmp_path, "r.json", {"score": np.float32(0.95)})
data = json.loads(out.read_text(encoding="utf-8"))
assert isinstance(data["score"], float), (
f"expected float, got {type(data['score'])}: {data['score']!r}"
)
assert abs(data["score"] - 0.95) < 1e-4
def test_json_numpy_array_serialises_as_list_not_repr(self, tmp_path):
"""np.ndarray values must round-trip as JSON arrays, not '[0.1 0.2]' repr."""
import json
import numpy as np
out = self._write(tmp_path, "r.json", {"vec": np.array([0.1, 0.2, 0.3])})
data = json.loads(out.read_text(encoding="utf-8"))
assert isinstance(data["vec"], list), (
f"expected list, got {type(data['vec'])}: {data['vec']!r}"
)
assert len(data["vec"]) == 3
# ── JSONL ────────────────────────────────────────────────────────────────
def test_jsonl_list_writes_one_object_per_line(self, tmp_path):
"""Each item in a list result must occupy exactly one JSONL line."""
import json
records = [{"id": "a", "score": 0.9}, {"id": "b", "score": 0.7}]
out = self._write(tmp_path, "r.jsonl", records)
lines = [l for l in out.read_text(encoding="utf-8").splitlines() if l.strip()]
assert len(lines) == 2
assert json.loads(lines[0]) == {"id": "a", "score": 0.9}
assert json.loads(lines[1]) == {"id": "b", "score": 0.7}
def test_jsonl_dict_writes_exactly_one_line(self, tmp_path):
"""A dict result (e.g. ontology_align) must write one JSON object on one line,
not a pretty-printed multi-line block that pd.read_json(lines=True) cannot parse."""
import json
import pandas as pd
result = {"total_entities": 10, "duplicate_pairs": 3}
out = self._write(tmp_path, "r.jsonl", result)
raw = out.read_text(encoding="utf-8")
lines = [l for l in raw.splitlines() if l.strip()]
# Exactly one line
assert len(lines) == 1, (
f"Expected 1 JSONL line for dict result, got {len(lines)}:\n{raw!r}"
)
# That line parses as valid JSON
parsed = json.loads(lines[0])
assert parsed == result
# pd.read_json(lines=True) can read it back
df = pd.read_json(out, lines=True)
assert list(df.columns) == ["total_entities", "duplicate_pairs"]
def test_jsonl_numpy_values_are_not_repr_strings(self, tmp_path):
"""NumPy values inside JSONL lines must be proper JSON, not repr()."""
import json
import numpy as np
records = [{"score": np.float32(0.8), "tag": "x"}]
out = self._write(tmp_path, "r.jsonl", records)
line = out.read_text(encoding="utf-8").strip()
parsed = json.loads(line)
assert isinstance(parsed["score"], float)
# ── CSV ──────────────────────────────────────────────────────────────────
def test_csv_list_of_dicts_produces_readable_csv(self, tmp_path):
import pandas as pd
rows = [{"entity_1": "Alice", "entity_2": "Bob", "similarity": 0.87},
{"entity_1": "Carol", "entity_2": "Dave", "similarity": 0.72}]
out = self._write(tmp_path, "r.csv", rows)
df = pd.read_csv(out)
assert list(df.columns) == ["entity_1", "entity_2", "similarity"]
assert len(df) == 2
assert abs(df["similarity"].iloc[0] - 0.87) < 1e-6
def test_csv_numpy_scalar_becomes_number_not_repr(self, tmp_path):
"""np.float32 in a result row must not become a repr string in the CSV."""
import numpy as np
import pandas as pd
rows = [{"label": "x", "score": np.float32(0.95)}]
out = self._write(tmp_path, "r.csv", rows)
df = pd.read_csv(out)
# The cell must be a numeric type, not a string like 'np.float32(0.95)'
assert df["score"].dtype.kind in ("f", "i"), (
f"Expected numeric dtype, got {df['score'].dtype}: {df['score'].iloc[0]!r}"
)
def test_csv_empty_list_raises_clickexception(self, tmp_path):
"""An empty result list must raise rather than create a headerless newline."""
import click
from semantica.cli import _write_result_output
out = tmp_path / "empty.csv"
with pytest.raises(click.ClickException, match="No results to write"):
_write_result_output(out, [])
assert not out.exists()
def test_csv_single_dict_written_as_one_row(self, tmp_path):
import pandas as pd
out = self._write(tmp_path, "r.csv", {"total": 5, "merged": 2})
df = pd.read_csv(out)
assert len(df) == 1
assert df["total"].iloc[0] == 5
# ── unsupported / no-extension ────────────────────────────────────────────
def test_unsupported_extension_raises_clickexception(self, tmp_path):
import click
from semantica.cli import _write_result_output
out = tmp_path / "report.txt"
with pytest.raises(click.ClickException, match="Unsupported output format"):
_write_result_output(out, {"k": "v"})
assert not out.exists()
def test_no_extension_raises_clickexception(self, tmp_path):
"""No-extension paths must be rejected — not silently renamed to .json —
so the path reported to the user always matches the file created."""
import click
from semantica.cli import _write_result_output
out = tmp_path / "report"
with pytest.raises(click.ClickException, match="Unsupported output format"):
_write_result_output(out, {"k": "v"})
assert not out.exists()
assert not (tmp_path / "report.json").exists()
def test_txt_extension_raises_clickexception(self, tmp_path):
""".txt is not a documented format and must be rejected, consistent with
_write_embeddings_output which also rejects it."""
import click
from semantica.cli import _write_result_output
out = tmp_path / "r.txt"
with pytest.raises(click.ClickException, match="Unsupported output format"):
_write_result_output(out, {"k": "v"})
assert not out.exists()
def test_uppercase_extension_accepted(self, tmp_path):
"""Extension matching must be case-insensitive (.CSV == .csv)."""
import pandas as pd
out = self._write(tmp_path, "r.CSV", [{"a": 1}])
df = pd.read_csv(out)
assert len(df) == 1
class TestDeduplicateOutput:
"""CLI-level regression tests for deduplicate --output integration.
Uses the same monkeypatching pattern as TestDeduplicate.test_detect_runtime_path:
patch _get_store and get_nodes at the graph_store.methods level, then patch
the deduplication module so no real model or DB is needed.
"""
_ENTITIES = [
{"id": "e1", "name": "Alice", "type": "Person"},
{"id": "e2", "name": "Alice", "type": "Person"},
]
_DETECT_RESULT = [
{"entity_1": "e1", "entity_2": "e2", "similarity": 0.9}
]
def _patch_dedup(self, monkeypatch):
"""Wire graph store + deduplication mocks for the detect action."""
entities = self._ENTITIES
detect_result = self._DETECT_RESULT
class FakeStore:
def get_nodes(self, labels=None, properties=None, limit=100, **opts):
return entities
monkeypatch.setattr(
"semantica.graph_store.methods._get_store", lambda: FakeStore()
)
monkeypatch.setattr(
"semantica.graph_store.methods.get_nodes", lambda **kw: entities
)
monkeypatch.setattr(
"semantica.deduplication.methods.detect_duplicates",
lambda *a, **k: detect_result,
raising=False,
)
# The CLI imports from .deduplication directly; patch that too.
import types
fake_dedup = _fake_module(detect_duplicates=lambda *a, **k: detect_result)
fake_merger_inst = types.SimpleNamespace(
merge_duplicates=lambda *a, **k: detect_result
)
fake_dedup.entity_merger = types.SimpleNamespace(
EntityMerger=lambda: fake_merger_inst
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.deduplication", fake_dedup
)
monkeypatch.setitem(
__import__("sys").modules,
"semantica.deduplication.entity_merger",
fake_dedup.entity_merger,
)
def test_deduplicate_output_json_is_valid(self, runner, monkeypatch, tmp_path):
"""deduplicate --output report.json must produce parseable JSON, not a repr."""
import json
self._patch_dedup(monkeypatch)
out = tmp_path / "report.json"
result = runner.invoke(
cli_module.main,
["deduplicate", "--action", "detect", "--output", str(out)],
)
_ok(result)
assert out.exists(), f"output file not created; output: {result.output!r}"
data = json.loads(out.read_text(encoding="utf-8"))
assert isinstance(data, (list, dict))
def test_deduplicate_output_csv_is_readable(self, runner, monkeypatch, tmp_path):
"""deduplicate --output report.csv (documented format) must produce valid CSV."""
import pandas as pd
self._patch_dedup(monkeypatch)
out = tmp_path / "report.csv"
result = runner.invoke(
cli_module.main,
["deduplicate", "--action", "detect", "--output", str(out)],
)
_ok(result)
assert out.exists(), f"CSV file not created; output: {result.output!r}"
df = pd.read_csv(out)
assert len(df) >= 1
class TestOntologyAlignOutput:
"""CLI-level regression tests for ontology align --output integration.
Uses runner.isolated_filesystem() so Click's exists=True source/target
validation passes, then patches semantica.ontology at the sys.modules level
before the import inside _action() fires — same pattern as
TestOntology.test_align_import_error_is_clean.
"""
_ALIGN_RESULT = {
"alignments": [{"source": "A", "target": "B", "score": 0.8}],
"total": 1,
}
def _patch_align(self, monkeypatch, align_result=None):
result = align_result if align_result is not None else self._ALIGN_RESULT
import types
fake_gen = types.SimpleNamespace(align=lambda *a, **k: result)
fake_ontology = _fake_module(
OntologyGenerator=lambda **k: fake_gen,
)
monkeypatch.setitem(
__import__("sys").modules, "semantica.ontology", fake_ontology
)
def test_ontology_align_output_json_is_valid(self, runner, monkeypatch, tmp_path):
"""ontology align --output alignments.json must produce parseable JSON."""
import json
self._patch_align(monkeypatch)
out = tmp_path / "alignments.json"
with runner.isolated_filesystem():
open("s.ttl", "w").close()
open("t.ttl", "w").close()
result = runner.invoke(
cli_module.main,
["ontology", "align",
"--source", "s.ttl", "--target", "t.ttl",
"--output", str(out)],
)
_ok(result)
assert out.exists(), f"output file not created; output: {result.output!r}"
data = json.loads(out.read_text(encoding="utf-8"))
assert isinstance(data, dict)
assert "alignments" in data
def test_ontology_align_output_jsonl_is_readable_by_pandas(
self, runner, monkeypatch, tmp_path
):
"""ontology align --output alignments.jsonl must produce valid JSONL:
exactly one JSON object per line, readable by pd.read_json(lines=True).
Regression for F2: dict result must NOT be pretty-printed across multiple
lines into a .jsonl file."""
import pandas as pd
self._patch_align(monkeypatch)
out = tmp_path / "alignments.jsonl"
with runner.isolated_filesystem():
open("s.ttl", "w").close()
open("t.ttl", "w").close()
result = runner.invoke(
cli_module.main,
["ontology", "align",
"--source", "s.ttl", "--target", "t.ttl",
"--output", str(out)],
)
_ok(result)
assert out.exists(), f"JSONL file not created; output: {result.output!r}"
raw = out.read_text(encoding="utf-8")
lines = [ln for ln in raw.splitlines() if ln.strip()]
assert len(lines) == 1, (
f"Expected exactly 1 JSONL line for a dict result, got {len(lines)}:\n{raw!r}"
)
# pd.read_json(lines=True) must succeed — this is what the F2 bug broke.
df = pd.read_json(out, lines=True)
assert "alignments" in df.columns