From f542fc86528fad3dba767dfc089dca8d1ac7327a Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:53:10 +0500 Subject: [PATCH] fix(cli): harden startup logging and explorer API wiring --- semantica/cli.py | 49 ++++++++++++++++++++++++++++++++++---- tests/test_cli_commands.py | 41 ++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/semantica/cli.py b/semantica/cli.py index 01e69402..39397abf 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -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) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 66ba7e8e..a087614f 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -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"])