Files
semantica/tests/test_cli_commands.py
KaifAhmad1 311a7b43b1 feat(cli): modern Rich terminal styling across all modules
## Summary

Overhaul the CLI and all library modules to produce polished, modern
terminal output comparable to tools like uv, gh, and cargo. Rich was
already a declared dependency but barely used — this commit wires it
throughout every layer.

## Changes by layer

### semantica/cli.py — visual overhaul
- Add imports: `box`, `Panel`, `Rule`, `Syntax`, `Text` from Rich
- Add 7 style constants (`_BRAND`, `_KEY`, `_VAL`, `_DIM`, `_SUCCESS`,
  `_WARN_STY`, `_TABLE_BOX`) for a consistent colour palette
- `_ok()` now prefixes output with a green ✓ checkmark
- New `_info()` helper (neutral · bullet, respects --quiet)
- New `_warn()` helper (yellow ⚠ prefix, never suppressed)
- New `_pprint()` helper: renders dicts/lists as syntax-highlighted JSON
  (Rich Syntax, monokai theme) instead of raw Python repr; strings
  pass through unchanged; respects --quiet
- `info` command: banner replaced with a rounded Rich Panel showing
  version + tagline; component table uses SIMPLE_HEAD box
- All 7 table sites updated: `box=SIMPLE_HEAD`, `show_edge=False`,
  consistent `_KEY`/`_VAL` column styles (KG Stats, Reasoning Engines,
  Recent Decisions, Configured Backends, Backup Info, MCP Tools)
- `_run_build()`: `console.status(spinner="dots")` wraps the blocking
  build call; skipped under --quiet / --json
- `parse`, `extract`, `embed generate`, `reason run`, `reason explain`,
  `deduplicate`: each wraps its long-running operation in a status
  spinner, guarded by --quiet / --json
- All 30+ `console.print(result)` calls replaced with `_pprint()`
- All raw `[yellow]Warning:[/yellow]` and "not running" patterns
  replaced with the new `_warn()` / `_WARN_STY` style

### semantica/explorer/__init__.py
- Error messages use `Console(stderr=True)` with `[bold red]Error:[/bold red]`
- Graph loading wrapped in `console.status()` spinner
- Startup info replaced with a cyan-bordered Rich Panel showing URL,
  API docs, and health endpoint

### Library internals — replace print() with structured logger calls
All modules below had active `print()` calls that bypassed the logging
framework, corrupted spinners, and polluted stdout in piped/programmatic
use. All replaced with appropriate `self.logger.*` calls:

- `semantica/kg/graph_builder.py` — 23 calls: entity resolution
  progress, graph structure steps, GraphStore persistence timing, and
  the two `='*60` completion banners → `self.logger.info/debug()`
- `semantica/semantic_extract/methods.py` — 4 verbose-mode debug
  prints → `logger.debug()`
- `semantica/semantic_extract/relation_extractor.py` — progress +
  error prints → `self.logger.debug/warning()` with `exc_info`
- `semantica/semantic_extract/triplet_extractor.py` — same pattern
- `semantica/semantic_extract/semantic_network_extractor.py` — batch
  error prints → `self.logger.warning/error()`
- `semantica/semantic_extract/coreference_resolver.py` — error print
  → `self.logger.error()`
- `semantica/semantic_extract/providers.py` — debug print →
  `self.logger.debug()`

### Tooling
- `benchmarks/benchmarks_runner.py`: Rule banner, ✓/✗/⚠ status lines,
  Rule separators around regression alert
- `benchmarks/infrastructure/compare.py`: removed manual ANSI escape
  codes; comparison output is now a Rich Table with SIMPLE_HEAD;
  summary uses coloured Rule + styled SUCCESS/FAILURE messages
- `cookbook/advanced/snowflake_ingestion_examples.py`: `_section()`
  helper using Rule; tabular data rendered as Rich Table; result lines
  use ✓/✗/⚠ prefixes; logger.error already present, retained
- `docs_check.py`: `pass`/`FAIL` lines use `[bold green]` /
  `[bold red]`; summary uses styled output

## Tests
- `tests/test_cli_commands.py`: fix 3 pre-existing mock mismatches
  - `test_kg_stats_json_with_mock`: mock now uses `compute_metrics()`
    (the method the code actually calls) instead of `get_statistics()`
  - `test_dry_run_not_needed_extract_is_read_only` and
    `test_stdin_input`: mock now provides `NERExtractor`,
    `RelationExtractor`, `TripletExtractor`, `EventDetector`
    (the classes the code imports) instead of `SemanticAnalyzer`
  Result: 230/230 tests pass (was 227/230)
- `tests/verify_rich_cli.py`: new verification script; exercises all
  14 command groups (92 --help checks, table rendering, dry-run
  formatting, --json mode, _pprint helper); 111 pass, 0 fail
2026-06-04 12:34:12 +05:30

1854 lines
78 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}"
)