diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feccb7..4510f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and Base versions are tracked in the repo-root `VERSION` file. can use the configured workspace root without reaching through user config. - Added `base_cli.testing.invoke(..., manifest={...})` for project-aware tests that need a fixture `base_manifest.yaml`. +- Added optional stream and formatter overrides to `base_cli.configure_logger` + for tests and CI wrappers that need to capture or reshape user-facing logs. - Documented the `base-bash-libs` Homebrew/core readiness path, including the formula-name audit command and future `basefoundry` dependency plan. - Documented the `basectl setup` parallelism evaluation and the decision to diff --git a/lib/python/base_cli/README.md b/lib/python/base_cli/README.md index ba9994a..07a1515 100644 --- a/lib/python/base_cli/README.md +++ b/lib/python/base_cli/README.md @@ -246,6 +246,11 @@ def helper() -> None: - a persistent file handler that records DEBUG logs when persistent logging is enabled +Advanced tests and CI wrappers can call `base_cli.configure_logger(..., +stream=..., formatter=...)` to capture user-facing logs or apply a custom +formatter without replacing Base's logger setup. Leave those arguments as +`None` to keep the default stderr stream and `BaseCliFormatter`. + Commands that inspect runtime artifacts can use `base_cli.App(log_to_file=False)` to keep the standard context and `--debug` behavior without creating default `logs/`, `cache/`, or `tmp//` directories. `base_logs` uses this mode so diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index d267891..d82a33f 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -3,7 +3,7 @@ from .app import App, argument, command, option, run_app from .context import Context, get_current_context from .exit_codes import ExitCode -from .logging import log_critical, log_debug, log_error, log_info, log_warning +from .logging import configure_logger, log_critical, log_debug, log_error, log_info, log_warning __all__ = [ "App", @@ -11,6 +11,7 @@ "ExitCode", "argument", "command", + "configure_logger", "get_current_context", "log_critical", "log_debug", diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index c7ab056..444325a 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -5,6 +5,7 @@ import platform import sys from pathlib import Path +from typing import TextIO from .context import get_current_context from .redaction import redact_argv @@ -14,6 +15,9 @@ def configure_logger( cli_name: str, log_file: Path | None, debug: bool, + *, + stream: TextIO | None = None, + formatter: logging.Formatter | None = None, ) -> logging.Logger: logger = logging.getLogger(f"base_cli.{cli_name}") logger.setLevel(logging.DEBUG) @@ -22,20 +26,26 @@ def configure_logger( handler.close() logger.removeHandler(handler) - user_handler = logging.StreamHandler() + user_handler = logging.StreamHandler(stream) user_handler.setLevel(logging.DEBUG if debug else logging.INFO) - user_handler.setFormatter(BaseCliFormatter()) + user_handler.setFormatter(_handler_formatter(formatter)) logger.addHandler(user_handler) if log_file is not None: file_handler = logging.FileHandler(log_file, encoding="utf-8") secure_log_file_permissions(log_file) file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(BaseCliFormatter()) + file_handler.setFormatter(_handler_formatter(formatter)) logger.addHandler(file_handler) return logger +def _handler_formatter(formatter: logging.Formatter | None) -> logging.Formatter: + if formatter is not None: + return formatter + return BaseCliFormatter() + + def secure_log_file_permissions(log_file: Path) -> None: log_file.chmod(0o600) diff --git a/lib/python/base_cli/tests/test_logging.py b/lib/python/base_cli/tests/test_logging.py new file mode 100644 index 0000000..9c3032e --- /dev/null +++ b/lib/python/base_cli/tests/test_logging.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import io +import logging +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import base_cli +from base_cli.logging import BaseCliFormatter + + +class ConfigureLoggerTests(unittest.TestCase): + def test_configure_logger_accepts_custom_stream(self) -> None: + stream = io.StringIO() + logger = base_cli.configure_logger("custom-stream", None, debug=False, stream=stream) + + logger.info("hello stream") + + self.assertIn("INFO", stream.getvalue()) + self.assertIn("hello stream", stream.getvalue()) + + def test_configure_logger_accepts_custom_formatter(self) -> None: + stream = io.StringIO() + formatter = logging.Formatter("%(levelname)s:%(message)s") + logger = base_cli.configure_logger("custom-formatter", None, debug=False, stream=stream, formatter=formatter) + + logger.info("hello formatter") + + self.assertEqual(stream.getvalue().strip(), "INFO:hello formatter") + + def test_configure_logger_defaults_to_stderr_and_base_formatter(self) -> None: + stream = io.StringIO() + + with mock.patch.object(sys, "stderr", stream): + logger = base_cli.configure_logger("default-stream", None, debug=False) + logger.info("hello default") + + handler = logger.handlers[0] + self.assertIsInstance(handler.formatter, BaseCliFormatter) + self.assertIn("INFO", stream.getvalue()) + self.assertIn("hello default", stream.getvalue()) + + def test_configure_logger_uses_custom_formatter_for_file_handler(self) -> None: + formatter = logging.Formatter("%(levelname)s:%(message)s") + user_stream = io.StringIO() + + with tempfile.TemporaryDirectory() as tmpdir: + log_file = Path(tmpdir) / "test.log" + logger = base_cli.configure_logger( + "custom-file-formatter", + log_file, + debug=False, + stream=user_stream, + formatter=formatter, + ) + logger.info("hello file") + for handler in logger.handlers: + handler.flush() + + log_text = log_file.read_text(encoding="utf-8") + + self.assertEqual(user_stream.getvalue().strip(), "INFO:hello file") + self.assertEqual(log_text.strip(), "INFO:hello file")