diff --git a/src/nexus_sync/client/__main__.py b/src/nexus_sync/client/__main__.py index 5fa4d9b..7c5ad17 100644 --- a/src/nexus_sync/client/__main__.py +++ b/src/nexus_sync/client/__main__.py @@ -1,6 +1,8 @@ import sys from nexus_sync.client.runtime import main +from nexus_sync.utils import configure_logging if __name__ == "__main__": + configure_logging() raise SystemExit(main(sys.argv[1:])) diff --git a/src/nexus_sync/client/runtime.py b/src/nexus_sync/client/runtime.py index 8847e2a..f75da6c 100644 --- a/src/nexus_sync/client/runtime.py +++ b/src/nexus_sync/client/runtime.py @@ -1,8 +1,8 @@ import json +import logging import os import platform import socket -import sys import urllib.error import urllib.request from dataclasses import dataclass @@ -27,6 +27,7 @@ CLIENT_ID_ENV = "NEXUS_SYNC_CLIENT_ID" CLIENT_TOKEN_ENV = "NEXUS_SYNC_CLIENT_TOKEN" CLIENT_VERSION = "0.1.0" HEARTBEAT_PATH = "/api/v1/client/heartbeat" +logger = logging.getLogger(__name__) class ClientConfigError(ValueError): @@ -142,19 +143,20 @@ def main(argv: list[str] | None = None) -> int: config = load_client_config() result = run_once(config) except (ClientConfigError, HeartbeatError, ValueError) as error: - print(f"nexus-sync client error: {error}", file=sys.stderr) + logger.error("nexus-sync client error: %s", error) return 1 if result is None: - print("heartbeat accepted; no command") + logger.info("heartbeat accepted; no command") return 0 - print( + logger.info( + "command result: %s", json.dumps( result.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":"), - ) + ), ) return 0 diff --git a/src/nexus_sync/server/__main__.py b/src/nexus_sync/server/__main__.py index baabf72..dfa106f 100644 --- a/src/nexus_sync/server/__main__.py +++ b/src/nexus_sync/server/__main__.py @@ -1,9 +1,11 @@ import uvicorn from nexus_sync.server.app import app +from nexus_sync.utils import configure_logging def main() -> None: + configure_logging() uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/nexus_sync/utils/__init__.py b/src/nexus_sync/utils/__init__.py new file mode 100644 index 0000000..44f8239 --- /dev/null +++ b/src/nexus_sync/utils/__init__.py @@ -0,0 +1,5 @@ +from nexus_sync.utils.log_config import configure_logging + +__all__ = [ + "configure_logging", +] diff --git a/src/nexus_sync/utils/log_config.py b/src/nexus_sync/utils/log_config.py new file mode 100644 index 0000000..83f60c6 --- /dev/null +++ b/src/nexus_sync/utils/log_config.py @@ -0,0 +1,19 @@ +import logging +import os + +LOG_LEVEL_ENV = "NEXUS_SYNC_LOG_LEVEL" +DEFAULT_LOG_LEVEL = "INFO" +DEFAULT_LOG_FORMAT = "%(levelname)s %(name)s %(message)s" + + +def configure_logging(level: str | None = None) -> None: + raw_level = level or os.environ.get(LOG_LEVEL_ENV, DEFAULT_LOG_LEVEL) + log_level = getattr(logging, raw_level.upper(), None) + if not isinstance(log_level, int): + raise ValueError(f"invalid log level: {raw_level}") + + logging.basicConfig( + level=log_level, + format=DEFAULT_LOG_FORMAT, + ) + logging.getLogger().setLevel(log_level) diff --git a/tests/test_client_runtime.py b/tests/test_client_runtime.py index da685e4..e910131 100644 --- a/tests/test_client_runtime.py +++ b/tests/test_client_runtime.py @@ -294,19 +294,19 @@ def test_run_once_sends_previous_command_result() -> None: assert seen["last_command_result"] == previous_result -def test_main_returns_non_zero_for_missing_config(monkeypatch, capsys) -> None: +def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog) -> None: monkeypatch.delenv(SERVER_URL_ENV, raising=False) monkeypatch.delenv(CLIENT_ID_ENV, raising=False) monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False) exit_code = main([]) - captured = capsys.readouterr() assert exit_code == 1 - assert SERVER_URL_ENV in captured.err + assert SERVER_URL_ENV in caplog.text -def test_main_prints_success_without_command(monkeypatch, capsys) -> None: +def test_main_logs_success_without_command(monkeypatch, caplog) -> None: + caplog.set_level("INFO") monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") @@ -314,9 +314,31 @@ def test_main_prints_success_without_command(monkeypatch, capsys) -> None: exit_code = main([]) - captured = capsys.readouterr() assert exit_code == 0 - assert captured.out == "heartbeat accepted; no command\n" + assert "heartbeat accepted; no command" in caplog.text + + +def test_main_logs_command_result(monkeypatch, caplog) -> None: + caplog.set_level("INFO") + monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") + monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") + monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") + monkeypatch.setattr( + "nexus_sync.client.runtime.run_once", + lambda _config: CommandResult( + command_id="cmd_01JY3H8V8W8P3FXDR3S2BM7M6B", + status=CommandResultStatus.SUCCEEDED, + return_code=0, + stdout="host\n", + stderr="", + ), + ) + + exit_code = main([]) + + assert exit_code == 0 + assert "command result:" in caplog.text + assert '"command_id":"cmd_01JY3H8V8W8P3FXDR3S2BM7M6B"' in caplog.text class _Response: diff --git a/tests/test_log_config.py b/tests/test_log_config.py new file mode 100644 index 0000000..5109afd --- /dev/null +++ b/tests/test_log_config.py @@ -0,0 +1,28 @@ +import logging + +import pytest + +from nexus_sync.utils.log_config import LOG_LEVEL_ENV, configure_logging + + +def test_configure_logging_uses_default_info_level(monkeypatch) -> None: + monkeypatch.delenv(LOG_LEVEL_ENV, raising=False) + + configure_logging() + + assert logging.getLogger().getEffectiveLevel() == logging.INFO + + +def test_configure_logging_reads_level_from_env(monkeypatch) -> None: + monkeypatch.setenv(LOG_LEVEL_ENV, "debug") + + configure_logging() + + assert logging.getLogger().getEffectiveLevel() == logging.DEBUG + + +def test_configure_logging_rejects_invalid_level(monkeypatch) -> None: + monkeypatch.setenv(LOG_LEVEL_ENV, "verbose") + + with pytest.raises(ValueError, match="invalid log level"): + configure_logging()