"""Logging helpers for Xclif-powered CLIs.
This module builds on Python's standard :mod:`logging` package. It adds a
small Rich-backed default handler and a verbosity-to-level mapping that matches
Xclif's built-in ``-v`` / ``--verbose`` flag.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from rich.console import Console
__all__ = [
"RichLogHandler",
"configure_logging",
"get_logger",
"level_from_verbosity",
]
_MANAGED_HANDLER_ATTR = "_xclif_managed_handler"
_DEFAULT_FORMATTER = logging.Formatter("%(message)s")
_VERBOSITY_LEVELS = (
logging.WARNING,
logging.INFO,
logging.DEBUG,
logging.NOTSET,
)
[docs]
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a standard library logger.
This is a tiny convenience wrapper around :func:`logging.getLogger`; Xclif
intentionally does not introduce its own logger abstraction.
"""
return logging.getLogger(name)
[docs]
def level_from_verbosity(verbosity: int) -> int:
"""Map Xclif verbosity counts to standard logging levels.
``0`` shows warnings and errors, ``1`` enables info logs, ``2`` enables
debug logs, and ``3`` enables every record that reaches the configured
logger. Values outside the supported range are clamped.
"""
index = max(0, min(int(verbosity), len(_VERBOSITY_LEVELS) - 1))
return _VERBOSITY_LEVELS[index]
[docs]
class RichLogHandler(logging.Handler):
"""A lazy wrapper around :class:`rich.logging.RichHandler`.
Constructing Rich's handler imports Rich, which is nice for output but
expensive on the hot path. This wrapper defers that import until a log
record actually passes the configured level filters.
"""
def __init__(
self,
level: int | str = logging.NOTSET,
*,
colors: str = "auto",
console: "Console | None" = None,
show_time: bool = False,
show_level: bool = True,
show_path: bool = False,
markup: bool = False,
rich_tracebacks: bool = True,
tracebacks_show_locals: bool = False,
) -> None:
super().__init__(level)
_validate_colors(colors)
self.colors = colors
self.console = console
self.show_time = show_time
self.show_level = show_level
self.show_path = show_path
self.markup = markup
self.rich_tracebacks = rich_tracebacks
self.tracebacks_show_locals = tracebacks_show_locals
self._inner: logging.Handler | None = None
setattr(self, _MANAGED_HANDLER_ATTR, True)
[docs]
def setLevel(self, level: int | str) -> None: # noqa: N802 - stdlib API
super().setLevel(level)
if self._inner is not None:
self._inner.setLevel(level)
[docs]
def emit(self, record: logging.LogRecord) -> None:
if self._inner is None:
self._inner = self._make_inner_handler()
self._inner.emit(record)
[docs]
def flush(self) -> None:
if self._inner is not None:
self._inner.flush()
super().flush()
[docs]
def close(self) -> None:
if self._inner is not None:
self._inner.close()
super().close()
def _make_inner_handler(self) -> logging.Handler:
from rich.logging import RichHandler
handler = RichHandler(
level=self.level,
console=self.console or _make_console(self.colors),
show_time=self.show_time,
show_level=self.show_level,
show_path=self.show_path,
markup=self.markup,
rich_tracebacks=self.rich_tracebacks,
tracebacks_show_locals=self.tracebacks_show_locals,
)
handler.setFormatter(self.formatter or _DEFAULT_FORMATTER)
return handler
def _resolve_logger(logger: logging.Logger | str | None) -> logging.Logger:
if logger is None:
return logging.getLogger()
if isinstance(logger, str):
return logging.getLogger(logger)
return logger
def _remove_handlers(logger: logging.Logger, handlers: list[logging.Handler]) -> None:
for handler in list(handlers):
logger.removeHandler(handler)
handler.close()
def _coerce_level(level: int | str) -> int:
if isinstance(level, int):
return level
resolved = logging.getLevelNamesMapping().get(level.upper())
if resolved is None:
raise ValueError(f"Unknown logging level: {level!r}")
return resolved
def _make_console(colors: str) -> "Console":
from rich.console import Console
if colors == "always":
return Console(stderr=True, force_terminal=True)
if colors == "never":
return Console(stderr=True, no_color=True, highlight=False)
return Console(stderr=True)
def _validate_colors(colors: str) -> None:
if colors not in {"always", "never", "auto"}:
raise ValueError("colors must be one of: always, never, auto")