Files
semantica/tests/test_cli_foundation.py
T
KaifAhmad1andClaude Sonnet 4.6 8feb8c00c6 fix(cli): address review findings from PR #576
- Remove incorrect # pragma: no cover from _run_with_error_handling
  generic Exception branch (test_runtime_errors_are_click_safe already
  covers it via the monkeypatched RuntimeError path)

- Add _require_ctx() guard: converts None ctx.obj into a clean
  ClickException instead of an AttributeError (protects standalone_mode=False
  / library-use callers); apply to info, kg_build, build_alias commands

- Rename serve group -> services to avoid collision with the future
  `semantica server` flat command specified in issue #568; update docstring
  to document planned subcommand layout

- Fix command-level config logging: re-call setup_logging() with the
  command-level config logging section when -c is used (setup_logging
  clears handlers before adding, so no accumulation risk)

- Fix missing log_level_override in command_ctx: global --log-level was
  silently dropped when a per-command -c config was present, breaking
  the override chain for any nested _build_runtime_config calls

- Add return-shape docstring on _run_build documenting the expected
  build_knowledge_base() return dict structure

- Add type annotation to runner fixture (-> CliRunner) so Pylance
  correctly types runner.invoke() -> Result across all test functions

- Expand test suite: 25 -> 32 tests
  * test_info_command_shows_framework_components
  * test_info_command_shows_config_path_when_supplied
  * test_log_level_global_override_stores_in_context
  * test_command_config_preserves_global_log_level_override
  * test_build_result_with_stats_shows_source_count
  * test_build_result_without_stats_shows_generic_success
  * test_build_result_none_shows_generic_success
  * test_require_ctx_raises_click_exception_on_none
  * test_require_ctx_returns_ctx_unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:29:39 +05:30

447 lines
14 KiB
Python

import click
import pytest
from click.testing import CliRunner, Result
import semantica.cli as cli_module
@pytest.fixture
def runner() -> CliRunner:
# Click's CliRunner captures all output in result.output by default; error
# messages from ClickException / UsageError are included. If the project
# ever moves to a Click version that separates stderr (mix_stderr=False),
# update assertions that check error text to use result.stderr instead.
return CliRunner()
@pytest.fixture(autouse=True)
def disable_cli_logging(monkeypatch):
monkeypatch.setattr(cli_module, "setup_logging", lambda *args, **kwargs: None)
# ---------------------------------------------------------------------------
# Help surfaces
# ---------------------------------------------------------------------------
def test_root_help_shows_expected_groups(runner):
result = runner.invoke(cli_module.main, ["--help"])
assert result.exit_code == 0
assert (
"Semantica - Semantic Layer & Knowledge Engineering Framework"
in result.output
)
assert "kg" in result.output
assert "pipeline" in result.output
assert "services" in result.output
def test_kg_group_help_shows_build_command(runner):
result = runner.invoke(cli_module.main, ["kg", "--help"])
assert result.exit_code == 0
assert "Knowledge graph and semantic build commands." in result.output
assert "build" in result.output
def test_kg_build_help_shows_source_and_config_flags(runner):
result = runner.invoke(cli_module.main, ["kg", "build", "--help"])
assert result.exit_code == 0
assert "--source" in result.output
assert "-s" in result.output
assert "--config" in result.output
assert "-c" in result.output
# ---------------------------------------------------------------------------
# info command
# ---------------------------------------------------------------------------
def test_info_command_shows_framework_components(runner):
result = runner.invoke(cli_module.main, ["info"])
assert result.exit_code == 0
assert "Semantica Framework" in result.output
assert "Core Orchestrator" in result.output
assert "CLI Log Level" in result.output
def test_info_command_shows_config_path_when_supplied(runner):
# click.Path(resolve_path=True) turns cfg.yml into an absolute path, and
# Rich's table may truncate long paths with '…'. Assert the row is present
# and the "(none)" placeholder has been replaced by some path value.
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("logging:\n level: INFO\n")
result: Result = runner.invoke(
cli_module.main, ["--config", "cfg.yml", "info"]
)
assert result.exit_code == 0
assert "CLI Config File" in result.output
assert "(none)" not in result.output
# ---------------------------------------------------------------------------
# Global --log-level override
# ---------------------------------------------------------------------------
def test_log_level_global_override_stores_in_context(runner, monkeypatch):
"""--log-level at the root level propagates into CLIContext."""
captured = {}
def fake_run_build(cli_ctx, sources):
captured["log_level_override"] = cli_ctx.log_level_override
captured["log_level"] = cli_ctx.log_level
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(
cli_module.main,
["--log-level", "DEBUG", "kg", "build", "-s", "src.txt"],
)
assert result.exit_code == 0
assert captured["log_level_override"] == "DEBUG"
# ---------------------------------------------------------------------------
# Command-level config
# ---------------------------------------------------------------------------
def test_command_config_preserves_global_log_level_override(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Global --log-level is forwarded into command_ctx.log_level_override."""
captured: dict[str, object] = {}
def fake_run_build(cli_ctx: cli_module.CLIContext, _: object) -> None:
captured["log_level_override"] = cli_ctx.log_level_override
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("logging:\n level: INFO\n")
result = runner.invoke(
cli_module.main,
["--log-level", "DEBUG", "kg", "build", "-s", "src.txt", "-c", "cfg.yml"],
)
assert result.exit_code == 0
assert captured["log_level_override"] == "DEBUG"
def test_command_config_keeps_own_logging_without_global_override(runner, monkeypatch):
captured = {}
def fake_run_build(cli_ctx, sources):
captured["logging_level"] = cli_ctx.config.get("logging.level")
captured["sources"] = list(sources)
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("logging:\n level: DEBUG\n")
result = runner.invoke(
cli_module.main,
["kg", "build", "-s", "README.md", "-c", "cfg.yml"],
)
assert result.exit_code == 0
assert captured["sources"] == ["README.md"]
assert captured["logging_level"] == "DEBUG"
# ---------------------------------------------------------------------------
# Legacy / new command parity
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"argv",
[
["kg", "build", "-s", "README.md"],
["build", "-s", "README.md"],
],
)
def test_build_paths_invoke_shared_wrapper(runner, monkeypatch, argv):
captured = {}
def fake_run_build(cli_ctx, sources):
captured["config_path"] = cli_ctx.config_path
captured["sources"] = list(sources)
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
result = runner.invoke(cli_module.main, argv)
assert result.exit_code == 0
assert captured["sources"] == ["README.md"]
@pytest.mark.parametrize(
"argv",
[
["kg", "build", "-s", "README.md", "-c", "cfg.yml"],
["kg", "build", "-s", "README.md", "--config", "cfg.yml"],
["build", "-s", "README.md", "-c", "cfg.yml"],
["build", "-s", "README.md", "--config", "cfg.yml"],
],
)
def test_build_config_short_and_long_flags_are_compatible(runner, monkeypatch, argv):
captured = {}
def fake_run_build(cli_ctx, sources):
captured["config_path"] = cli_ctx.config_path
captured["sources"] = list(sources)
monkeypatch.setattr(cli_module, "_run_build", fake_run_build)
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("logging:\n level: INFO\n")
result = runner.invoke(cli_module.main, argv)
assert result.exit_code == 0
assert captured["sources"] == ["README.md"]
assert captured["config_path"] is not None
# ---------------------------------------------------------------------------
# build_knowledge_base result handling
# ---------------------------------------------------------------------------
class _MockFramework:
"""Lightweight stand-in for Semantica used to test _run_build result paths."""
def __init__(self, return_value):
self._return_value = return_value
def build_knowledge_base(self, sources):
return self._return_value
def test_build_result_with_stats_shows_source_count(runner, monkeypatch):
"""When build returns statistics.sources_processed, that count appears in output."""
monkeypatch.setattr(
cli_module,
"_get_framework",
lambda _: _MockFramework({"statistics": {"sources_processed": 3}}),
)
result = runner.invoke(
cli_module.main,
["kg", "build", "-s", "a.txt", "-s", "b.txt", "-s", "c.txt"],
)
assert result.exit_code == 0
assert "3 source(s)" in result.output
def test_build_result_without_stats_shows_generic_success(runner, monkeypatch):
"""When build result has no statistics key, generic success message is shown."""
monkeypatch.setattr(
cli_module,
"_get_framework",
lambda _: _MockFramework({}),
)
result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"])
assert result.exit_code == 0
assert "Knowledge base build completed" in result.output
assert "source(s)" not in result.output
def test_build_result_none_shows_generic_success(runner, monkeypatch):
"""When build_knowledge_base returns None, generic success message is shown."""
monkeypatch.setattr(
cli_module,
"_get_framework",
lambda _: _MockFramework(None),
)
result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"])
assert result.exit_code == 0
assert "Knowledge base build completed" in result.output
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"argv",
[
["kg", "build"],
["build"],
],
)
def test_missing_input_errors_are_clean_and_click_safe(runner, argv):
result = runner.invoke(cli_module.main, argv)
assert result.exit_code != 0
assert "At least one source is required" in result.output
assert "Traceback" not in result.output
def test_invalid_root_config_error_is_clean_and_click_safe(runner):
with runner.isolated_filesystem():
with open("bad.md", "w", encoding="utf-8") as handle:
handle.write("not-a-config")
result = runner.invoke(cli_module.main, ["--config", "bad.md", "info"])
assert result.exit_code != 0
assert "Unsupported configuration file format" in result.output
assert "Traceback" not in result.output
def test_invalid_command_config_error_is_clean_and_click_safe(runner):
with runner.isolated_filesystem():
with open("bad.md", "w", encoding="utf-8") as handle:
handle.write("not-a-config")
result = runner.invoke(
cli_module.main,
["kg", "build", "-s", "README.md", "-c", "bad.md"],
)
assert result.exit_code != 0
assert "Unsupported configuration file format" in result.output
assert "Traceback" not in result.output
def test_empty_yaml_config_is_accepted(runner):
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("")
result = runner.invoke(cli_module.main, ["--config", "cfg.yml", "info"])
assert result.exit_code == 0
def test_malformed_logging_section_is_clean_and_click_safe(runner):
with runner.isolated_filesystem():
with open("cfg.yml", "w", encoding="utf-8") as handle:
handle.write("logging: []\n")
result = runner.invoke(
cli_module.main,
["--config", "cfg.yml", "--log-level", "INFO", "info"],
)
assert result.exit_code != 0
assert (
"Logging configuration section must contain a mapping/object"
in result.output
)
assert "Traceback" not in result.output
@pytest.mark.parametrize(
"file_name, config_text, expected_error",
[
("cfg.json", "{not-json", "Failed to parse configuration file"),
(
"cfg.yml",
"- item\n- item2\n",
"Configuration file must contain a mapping/object",
),
],
)
def test_command_config_parse_errors_are_clean_and_click_safe(
runner,
file_name,
config_text,
expected_error,
):
with runner.isolated_filesystem():
with open(file_name, "w", encoding="utf-8") as handle:
handle.write(config_text)
result = runner.invoke(
cli_module.main,
["kg", "build", "-s", "README.md", "-c", file_name],
)
assert result.exit_code != 0
assert expected_error in result.output
assert "Traceback" not in result.output
def test_runtime_errors_are_click_safe_without_traceback(runner, monkeypatch):
def boom(_cli_ctx, _sources):
raise RuntimeError("boom")
monkeypatch.setattr(cli_module, "_run_build", boom)
result = runner.invoke(cli_module.main, ["kg", "build", "-s", "README.md"])
assert result.exit_code != 0
assert "Unexpected error: boom" in result.output
assert "Traceback" not in result.output
# ---------------------------------------------------------------------------
# Lazy initialization
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"argv",
[
["--help"],
["kg", "--help"],
["kg", "build", "--help"],
["build", "--help"],
],
)
def test_help_calls_do_not_initialize_framework(runner, monkeypatch, argv):
def fail_get_framework(_):
raise AssertionError("framework initialization must not happen on help")
monkeypatch.setattr(cli_module, "_get_framework", fail_get_framework)
result = runner.invoke(cli_module.main, argv)
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# _require_ctx guard
# ---------------------------------------------------------------------------
def test_require_ctx_raises_click_exception_on_none():
"""_require_ctx converts None ctx into a clean ClickException."""
with pytest.raises(
click.ClickException,
match="CLI context is uninitialized",
):
cli_module._require_ctx(None)
def test_require_ctx_returns_ctx_unchanged():
"""_require_ctx is a pass-through when ctx is valid."""
from semantica.core.config_manager import Config, ConfigManager
cfg = ConfigManager().load_from_dict({}, validate=False)
ctx = cli_module.CLIContext(config_path=None, config=cfg, log_level="INFO")
assert cli_module._require_ctx(ctx) is ctx