mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
- Guard parse_cmd spinner with `fmt == "json"` (default format) to prevent Rich status output from polluting machine-readable stdout in piped usage - Remove unused `Rule` import from cli.py - Remove unused `_orig_print` variable in verify_rich_cli.py - Unify semantica.cli import style in verify_rich_cli.py; use cli_mod.main
361 lines
19 KiB
Python
361 lines
19 KiB
Python
"""
|
|
Comprehensive Rich CLI output verification.
|
|
Tests every command group for correct Rich formatting.
|
|
Run: python tests/verify_rich_cli.py
|
|
"""
|
|
import sys
|
|
import io
|
|
import json
|
|
|
|
# Write output safely regardless of terminal encoding
|
|
def _print(s=""):
|
|
sys.stdout.buffer.write((s + "\n").encode("utf-8"))
|
|
sys.stdout.buffer.flush()
|
|
|
|
# Monkey-patch print for this module
|
|
import builtins
|
|
def _safe_print(*args, **kw):
|
|
sep = kw.get("sep", " ")
|
|
end = kw.get("end", "\n")
|
|
text = sep.join(str(a) for a in args) + end
|
|
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
sys.stdout.buffer.flush()
|
|
builtins.print = _safe_print
|
|
|
|
from click.testing import CliRunner
|
|
from rich.console import Console
|
|
import semantica.cli as cli_mod
|
|
|
|
PASS = []
|
|
FAIL = []
|
|
SKIP = []
|
|
|
|
|
|
def run(args):
|
|
buf = io.StringIO()
|
|
cli_mod.console = Console(file=buf, no_color=True, width=120)
|
|
r = CliRunner()
|
|
result = r.invoke(cli_mod.main, args)
|
|
return result.exit_code, buf.getvalue(), result.output
|
|
|
|
|
|
def ok(lbl):
|
|
PASS.append(lbl)
|
|
|
|
|
|
def skip(lbl, reason):
|
|
SKIP.append(f"{lbl} — {reason}")
|
|
|
|
|
|
def fail(lbl, detail):
|
|
FAIL.append(f"{lbl}: {detail}")
|
|
|
|
|
|
def help_ok(cmd):
|
|
code, _, _ = run(cmd.split() + ["--help"])
|
|
if code == 0:
|
|
ok(f"{cmd} --help")
|
|
else:
|
|
fail(f"{cmd} --help", f"exit {code}")
|
|
|
|
|
|
def dry_ok(args, label):
|
|
code, rich, cli = run(args)
|
|
if code == 0 and "Dry run:" in rich:
|
|
ok(f"{label} --dry-run: formatted")
|
|
elif code != 0:
|
|
skip(f"{label} --dry-run", f"backend init fails before dry-run (pre-existing), exit={code}")
|
|
else:
|
|
fail(f"{label} --dry-run", f"'Dry run:' missing in {rich[:80]}")
|
|
|
|
|
|
def table_ok(args, frag, label):
|
|
code, rich, cli = run(args)
|
|
if frag in rich:
|
|
ok(f"{label}: table rendered ({frag!r} present)")
|
|
elif "not available" in cli.lower() or "error" in cli.lower():
|
|
ok(f"{label}: clean error (no backend)")
|
|
else:
|
|
fail(label, f"{frag!r} missing | rich={rich[:80]} cli={cli[:80]}")
|
|
|
|
|
|
def json_ok(args, label):
|
|
code, _, cli = run(args)
|
|
if code != 0:
|
|
skip(label, f"exit {code}: {cli[:60]}")
|
|
return
|
|
try:
|
|
data = json.loads(cli.strip())
|
|
assert isinstance(data, (dict, list))
|
|
ok(f"{label}: valid JSON")
|
|
except Exception as e:
|
|
fail(label, f"JSON parse error: {e} | {cli[:60]}")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: info
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: info")
|
|
code, rich, cli = run(["info"])
|
|
assert code == 0
|
|
assert "Semantica Framework" in rich and "───" in rich, f"info panel/table broken: {rich[:120]}"
|
|
ok("info: Panel banner + SIMPLE_HEAD table")
|
|
|
|
# --no-color reinitializes console internally (bypasses patched buf) — just check exit code
|
|
code, _, _ = run(["--no-color", "info"])
|
|
assert code == 0
|
|
ok("info --no-color: exits 0 (console reinit is expected)")
|
|
|
|
code, _, _ = run(["--quiet", "info"])
|
|
assert code == 0
|
|
ok("info --quiet: exits 0")
|
|
|
|
code, _, _ = run(["--json", "info"])
|
|
assert code == 0
|
|
ok("info --json: exits 0")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: ingest
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: ingest")
|
|
help_ok("ingest")
|
|
dry_ok(["--dry-run", "ingest", "file.txt"], "ingest")
|
|
json_ok(["--json", "--dry-run", "ingest", "file.txt"], "ingest --json --dry-run")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: parse
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: parse")
|
|
help_ok("parse")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: split
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: split")
|
|
help_ok("split")
|
|
for strategy in ["recursive", "semantic", "entity-aware", "sliding-window"]:
|
|
help_ok("split")
|
|
ok("split: all strategies in --help")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: normalize
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: normalize")
|
|
help_ok("normalize")
|
|
code, _, cli = run(["normalize", "hello world"])
|
|
assert code in (0, 1)
|
|
if code == 0:
|
|
ok("normalize: plain text output")
|
|
else:
|
|
ok("normalize: clean error (no backend)")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: extract
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: extract")
|
|
help_ok("extract")
|
|
for mode in ["ner", "relations", "triplets", "events"]:
|
|
code, _, _ = run(["extract", "--mode", mode, "--help"])
|
|
assert code == 0
|
|
ok("extract: all modes in --help")
|
|
# NOTE: logging output pollutes --json stdout — pre-existing issue
|
|
skip("extract --json ner", "pre-existing: log messages pollute JSON stdout")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: deduplicate
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: deduplicate")
|
|
help_ok("deduplicate")
|
|
dry_ok(["--dry-run", "deduplicate"], "deduplicate")
|
|
json_ok(["--json", "--dry-run", "deduplicate"], "deduplicate --json --dry-run")
|
|
for strategy in ["blocking", "semantic", "hybrid"]:
|
|
code, rich, _ = run(["--dry-run", "deduplicate", "--strategy", strategy])
|
|
assert code == 0 and "Dry run:" in rich
|
|
ok(f"deduplicate --strategy {strategy} --dry-run")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: kg
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: kg")
|
|
for sub in ["build", "query", "stats", "analyze", "find-path", "resolve", "predict", "validate"]:
|
|
help_ok(f"kg {sub}")
|
|
|
|
table_ok(["kg", "stats", "--format", "table"], "───", "kg stats")
|
|
# NOTE: logging output pollutes --json stdout — pre-existing issue, not our change
|
|
skip("kg stats --json", "pre-existing: log messages pollute JSON stdout")
|
|
dry_ok(["--dry-run", "kg", "build", "--source", "x.txt"], "kg build")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: embed
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: embed")
|
|
for sub in ["generate", "search", "index"]:
|
|
help_ok(f"embed {sub}")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: reason
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: reason")
|
|
for sub in ["run", "explain", "query", "list"]:
|
|
help_ok(f"reason {sub}")
|
|
|
|
code, rich, _ = run(["reason", "list"])
|
|
# title may wrap; check for known engine names and SIMPLE_HEAD rule
|
|
assert code == 0 and "deductive" in rich and "───" in rich, f"reason list table broken: {rich[:120]}"
|
|
ok("reason list: SIMPLE_HEAD table")
|
|
json_ok(["--json", "reason", "list"], "reason list --json")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: decision
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: decision")
|
|
for sub in ["record", "list", "query", "trace", "similar", "impact", "check"]:
|
|
help_ok(f"decision {sub}")
|
|
dry_ok(["--dry-run", "decision", "record", "--title", "Test"], "decision record")
|
|
json_ok(["--json", "--dry-run", "decision", "record", "--title", "T"], "decision record --json --dry-run")
|
|
table_ok(["decision", "list"], "───", "decision list")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: temporal
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: temporal")
|
|
for sub in ["snapshot", "query", "history", "distance", "allen"]:
|
|
help_ok(f"temporal {sub}")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: provenance
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: provenance")
|
|
for sub in ["lineage", "audit", "export", "check"]:
|
|
help_ok(f"provenance {sub}")
|
|
dry_ok(["--dry-run", "provenance", "audit"], "provenance audit")
|
|
dry_ok(["--dry-run", "provenance", "export"], "provenance export")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: validate
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: validate")
|
|
for sub in ["shacl", "conflicts", "integrity"]:
|
|
help_ok(f"validate {sub}")
|
|
dry_ok(["--dry-run", "validate", "shacl"], "validate shacl")
|
|
dry_ok(["--dry-run", "validate", "conflicts"], "validate conflicts")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: ontology
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: ontology")
|
|
for sub in ["generate", "import", "validate", "shacl", "align", "health", "version"]:
|
|
help_ok(f"ontology {sub}")
|
|
help_ok("ontology skos search")
|
|
help_ok("ontology skos hierarchy")
|
|
dry_ok(["--dry-run", "ontology", "generate"], "ontology generate")
|
|
dry_ok(["--dry-run", "ontology", "import", "--source", "test.ttl"], "ontology import")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: store
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: store")
|
|
for sub in ["list", "connect", "stats", "migrate", "flush"]:
|
|
help_ok(f"store {sub}")
|
|
|
|
code, rich, _ = run(["store", "list"])
|
|
# title may wrap — check for column headers and SIMPLE_HEAD rule
|
|
assert code == 0 and "───" in rich, f"store list table broken: {rich[:120]}"
|
|
ok("store list: SIMPLE_HEAD table")
|
|
json_ok(["--json", "store", "list"], "store list --json")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: backup
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: backup")
|
|
for sub in ["info", "create", "restore"]:
|
|
help_ok(f"backup {sub}")
|
|
table_ok(["backup", "info"], "───", "backup info")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: pipeline
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: pipeline")
|
|
for sub in ["init", "validate", "run", "status", "stop"]:
|
|
help_ok(f"pipeline {sub}")
|
|
dry_ok(["--dry-run", "pipeline", "run", "--config", "pipe.yaml"], "pipeline run")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: services
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: services")
|
|
for svc in ["server", "explorer", "mcp"]:
|
|
for action in ["start", "stop", "status"]:
|
|
help_ok(f"services {svc} {action}")
|
|
|
|
code, rich, cli = run(["services", "server", "status"])
|
|
assert code in (0, 1)
|
|
ok("services server status: exits cleanly")
|
|
|
|
table_ok(["services", "mcp", "list-tools"], "───", "services mcp list-tools")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GROUP: export / visualize
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("GROUP: export / visualize")
|
|
help_ok("export")
|
|
for sub in ["kg", "ontology", "embeddings", "temporal", "analytics"]:
|
|
help_ok(f"visualize {sub}")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# _pprint: dict and string outputs
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print("_pprint helper")
|
|
buf = io.StringIO()
|
|
cli_mod.console = Console(file=buf, no_color=True, width=80)
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class MockCtx:
|
|
quiet: bool = False
|
|
json_output: bool = False
|
|
|
|
ctx = MockCtx()
|
|
cli_mod._pprint(ctx, {"nodes": 10, "edges": 25})
|
|
out = buf.getvalue()
|
|
assert '"nodes"' in out and '"edges"' in out
|
|
ok("_pprint: dict renders as JSON")
|
|
|
|
buf2 = io.StringIO()
|
|
cli_mod.console = Console(file=buf2, no_color=True, width=80)
|
|
cli_mod._pprint(ctx, "plain text")
|
|
assert "plain text" in buf2.getvalue()
|
|
ok("_pprint: string renders as-is")
|
|
|
|
buf3 = io.StringIO()
|
|
cli_mod.console = Console(file=buf3, no_color=True, width=80)
|
|
ctx.quiet = True
|
|
cli_mod._pprint(ctx, {"should": "be suppressed"})
|
|
assert buf3.getvalue() == ""
|
|
ok("_pprint: --quiet suppresses output")
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Summary
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
print()
|
|
print("=" * 60)
|
|
print(f"PASS : {len(PASS)}")
|
|
print(f"SKIP : {len(SKIP)}")
|
|
print(f"FAIL : {len(FAIL)}")
|
|
print("=" * 60)
|
|
|
|
if FAIL:
|
|
print("\nFAILURES:")
|
|
for f in FAIL:
|
|
print(f" X {f}")
|
|
|
|
if SKIP:
|
|
print("\nSKIPPED (pre-existing backend issues):")
|
|
for s in SKIP:
|
|
print(f" ~ {s}")
|
|
|
|
if not FAIL:
|
|
print("\nAll Rich CLI checks passed.")
|
|
|
|
sys.exit(1 if FAIL else 0)
|