Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/python/base_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run-id>/` directories. `base_logs` uses this mode so
Expand Down
3 changes: 2 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
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",
"Context",
"ExitCode",
"argument",
"command",
"configure_logger",
"get_current_context",
"log_critical",
"log_debug",
Expand Down
16 changes: 13 additions & 3 deletions lib/python/base_cli/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand Down
66 changes: 66 additions & 0 deletions lib/python/base_cli/tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -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")
Loading