Source code for mllm_shap._logging
"""Central logging bootstrap for mllm_shap.
@domain core
@see [[knowledge/architecture/core]]
@tested-by [[tests/test_logging.py]]
"""
import json
import logging
import os
import sys
from pathlib import Path
from typing import Final
import logging518.config
PACKAGE_NAME: Final[str] = "mllm_shap"
"""Root logger namespace for the package."""
ENV_LOGGING_CONFIG: Final[str] = "MLLM_SHAP_LOGGING_CONFIG"
"""Environment variable pointing at a TOML file with a ``[tool.logging]`` section."""
PYPROJECT_FILENAME: Final[str] = "pyproject.toml"
"""Default TOML filename searched for logging configuration."""
STANDARD_FORMAT: Final[str] = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
"""Line format of the handler installed by ``configure_logging(level=...)``."""
_bootstrapped: bool = False
"""Whether :func:`bootstrap_logging` has run in this process."""
class JsonLogFormatter(logging.Formatter):
"""Format each record as one JSON object per line."""
def format(self, record: logging.LogRecord) -> str:
"""Return ``time``, ``level``, ``logger``, ``message`` and ``exc_info``."""
payload: dict[str, str] = {
"time": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
payload["exc_info"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def _ensure_null_handler() -> None:
package_logger = logging.getLogger(PACKAGE_NAME)
if not package_logger.handlers:
package_logger.addHandler(logging.NullHandler())
def find_logging_config() -> Path | None:
"""Return the TOML file that defines ``[tool.logging]``, if any."""
env_path = os.getenv(ENV_LOGGING_CONFIG)
if env_path:
path = Path(env_path)
return path if path.is_file() else None
current = Path.cwd()
for directory in (current, *current.parents):
candidate = directory / PYPROJECT_FILENAME
if candidate.is_file():
return candidate
return None
def bootstrap_logging() -> None:
"""Keep the package quiet on import unless ``MLLM_SHAP_LOGGING_CONFIG`` is set.
Runs once per process. With the environment variable pointing at a TOML file,
its ``[tool.logging]`` section is applied through ``logging518``; otherwise
only a ``NullHandler`` is installed and the host application's logging is left
untouched. Call :func:`configure_logging` to opt in explicitly.
"""
global _bootstrapped
if _bootstrapped:
return
_bootstrapped = True
env_path = os.getenv(ENV_LOGGING_CONFIG)
if env_path and Path(env_path).is_file():
_apply_config(Path(env_path))
else:
_ensure_null_handler()
[docs]
def configure_logging(
config_path: Path | str | None = None,
level: int | str | None = None,
json: bool = False,
) -> Path | None:
"""Configure the ``mllm_shap`` loggers from TOML or with one stderr handler.
Without ``level`` and ``json`` the ``[tool.logging]`` section of a TOML file
is applied. With either of them and no ``config_path``, the package logger
gets a single stderr handler instead (replacing earlier handlers) and stops
propagating to the root logger (``propagate=False``), so records are not
printed twice by a root handler; ``configure_logging(level="INFO")`` shows
the pipeline's stage events.
Args:
config_path: TOML file to read. Defaults to ``MLLM_SHAP_LOGGING_CONFIG`` or
the nearest ``pyproject.toml`` above the working directory.
level: Level of the ``mllm_shap`` logger (``"INFO"``, ``logging.DEBUG``...);
overrides the TOML level when both are given. Defaults to ``INFO``
for the stderr handler.
json: Emit one JSON object per record (``time``, ``level``, ``logger``,
``message``) instead of plain lines. Only for the stderr handler.
Returns:
The TOML file that was read, or ``None`` when none was (a ``NullHandler``
is installed when neither a file nor ``level``/``json`` was given).
Raises:
ValueError: If ``json`` is combined with ``config_path``; the TOML file
defines its own formatters.
"""
if json and config_path is not None:
msg = "json=True cannot be combined with config_path; set formatters in TOML"
raise ValueError(msg)
package_logger = logging.getLogger(PACKAGE_NAME)
if config_path is None and (level is not None or json):
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(
JsonLogFormatter() if json else logging.Formatter(STANDARD_FORMAT)
)
package_logger.handlers.clear()
package_logger.addHandler(handler)
package_logger.setLevel(level if level is not None else logging.INFO)
package_logger.propagate = False
return None
path = Path(config_path) if config_path is not None else find_logging_config()
if path is None:
_ensure_null_handler()
return None
_apply_config(path)
if level is not None:
package_logger.setLevel(level)
return path
def _apply_config(path: Path) -> None:
try:
logging518.config.fileConfig(path)
except KeyError:
_ensure_null_handler()