Source code for spectoprep.logging
"""Structured logging for SpectoPrep, built on :mod:`structlog`.
Call :func:`configure_logging` once at application/CLI entry to install the
processor chain, then obtain loggers anywhere with :func:`get_logger`. Library
modules only call :func:`get_logger`; they never configure logging at import
time, so importing SpectoPrep never mutates a host application's logging setup.
Examples
--------
>>> from spectoprep.logging import configure_logging, get_logger
>>> configure_logging(level="INFO")
>>> log = get_logger("example")
>>> log.info("cv_evaluated", rmse=0.12, r2=0.98) # doctest: +SKIP
"""
from __future__ import annotations
import logging as _stdlib_logging
from typing import Any
import structlog
__all__ = ["configure_logging", "get_logger"]
_CONFIGURED = False
[docs]
def configure_logging(level: str = "INFO", json_logs: bool = False, force: bool = False) -> None:
"""Configure structlog and the standard-library logging bridge.
The ``level`` is always validated. If logging has already been configured
(for example by an application entry point) this call becomes a no-op unless
``force=True``, so a library component instantiated later never overrides an
explicit application configuration.
Parameters
----------
level : str, default="INFO"
Minimum level name ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL").
json_logs : bool, default=False
Emit newline-delimited JSON (machine-readable) when True, otherwise a
colourised, human-readable console renderer.
force : bool, default=False
Reconfigure even if logging was already configured.
Raises
------
ValueError
If ``level`` is not a recognised logging level name.
"""
global _CONFIGURED
numeric_level = getattr(_stdlib_logging, level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError(f"Invalid log level: {level}")
# Respect an existing configuration (e.g. set by the CLI) unless forced.
if _CONFIGURED and not force:
return
timestamper = structlog.processors.TimeStamper(fmt="iso")
shared_processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
timestamper,
]
renderer: Any = (
structlog.processors.JSONRenderer() if json_logs else structlog.dev.ConsoleRenderer()
)
structlog.configure(
processors=[
*shared_processors,
structlog.processors.format_exc_info,
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(numeric_level),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
# Route warnings.warn(...) and stdlib logging through the same pipeline
# instead of the previous blanket warnings.filterwarnings("ignore").
_stdlib_logging.basicConfig(level=numeric_level, format="%(message)s")
_stdlib_logging.captureWarnings(True)
_CONFIGURED = True
[docs]
def get_logger(name: str | None = None) -> Any:
"""Return a structlog logger.
This never configures logging as a side effect, so importing SpectoPrep
stays inert. structlog returns a lazy proxy that binds to whatever
configuration is active at first log call; if :func:`configure_logging`
was never called, structlog's built-in defaults apply.
Parameters
----------
name : str, optional
Logger name, bound as the ``logger`` key on every event.
"""
return structlog.get_logger(name)