fix(cli): harden startup logging and explorer API wiring

This commit is contained in:
Zohaib Hassnain
2026-06-02 15:53:10 +05:00
parent eef5f9a850
commit f542fc8652
2 changed files with 85 additions and 5 deletions
+45 -4
View File
@@ -132,6 +132,35 @@ def _build_runtime_config(
return config_manager.load_from_dict(config_data, validate=False)
def _setup_cli_logging(
logging_config: Dict[str, Any],
*,
quiet: bool = False,
json_output: bool = False,
allow_file_fallback: bool = True,
) -> None:
"""Initialize logging without making the default log file a startup blocker.
The library logging default writes to ``semantica.log`` in the current
working directory. CLI commands should still be usable from read-only or
restricted directories, so default file-handler failures fall back to
console-only logging. If the user explicitly configured a log file, keep the
failure actionable instead of silently ignoring their configuration.
"""
try:
setup_logging(config=logging_config)
except OSError as exc:
if not allow_file_fallback:
raise
fallback_config = {**logging_config, "file": None}
setup_logging(config=fallback_config)
# Do not emit a warning: completion scripts and machine-readable
# commands must keep stdout/stderr stable when the default log file is
# unavailable. Explicitly configured log-file failures still raise.
_ = (quiet, json_output, exc)
def _get_framework(cli_ctx: CLIContext) -> "Semantica":
"""Lazily initialize framework only when a command needs it."""
if cli_ctx.framework is None:
@@ -195,7 +224,12 @@ def _run_build_command(
command_config_path, cli_ctx.log_level_override
)
# Re-apply logging so the command-level logging section takes effect.
setup_logging(config=cmd_config.get("logging", {}))
_setup_cli_logging(
cmd_config.get("logging", {}),
quiet=cli_ctx.quiet,
json_output=cli_ctx.json_output,
allow_file_fallback=False,
)
command_ctx = CLIContext(
config_path=command_config_path,
config=cmd_config,
@@ -264,7 +298,12 @@ def main(
config = _build_runtime_config(config_path=config_path, log_level=log_level)
# Always initialize logging so file handlers are installed; --quiet only
# suppresses console output (controlled via _ok/_dry checks).
setup_logging(config=config.get("logging", {}))
_setup_cli_logging(
config.get("logging", {}),
quiet=quiet,
json_output=json_output,
allow_file_fallback=config_path is None,
)
effective_log_level = config.get("logging.level", "INFO")
# Reinitialize the module-level console if --no-color was requested so
# all subsequent console.print() calls in this invocation respect the flag.
@@ -2992,9 +3031,11 @@ def explorer_start(cli_ctx: CLIContext, port: int, api_url: str,
cmd = [sys.executable, "-m", "semantica.explorer", "--port", str(port)]
if graph:
cmd += ["--graph", graph]
proc = sp.Popen(cmd)
env = os.environ.copy()
env["SEMANTICA_API_URL"] = api_url
proc = sp.Popen(cmd, env=env)
_write_pid("explorer", proc.pid)
_ok(cli_ctx, f"Explorer started on port {port} (pid {proc.pid})")
_ok(cli_ctx, f"Explorer started on port {port} using API {api_url} (pid {proc.pid})")
_run_with_error_handling(_action)
+40 -1
View File
@@ -145,6 +145,30 @@ class TestGlobalFlags:
"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(
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 ───────────────────────────────────────────────────────────
@@ -1541,13 +1565,28 @@ class TestExplorer:
for sub in ["start", "stop", "status", "open"]:
assert sub in result.output
def test_start_launches_process(self, runner):
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(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(
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(main, ["explorer", "stop"])