Source code for xraylabtool.logging_utils
"""Centralized logging utilities for XRayLabTool.
This module provides a single entry point to configure logging across the library,
CLI, and GUI. It keeps setup lightweight (stdlib-only) while still offering:
- Rotating file logs (default location: ~/.cache/xraylabtool/logs/xraylabtool.log)
- Optional console output (stderr) with readable timestamps
- Environment-variable overrides for level, file path, and rotation
- Quieting of noisy third-party libraries (matplotlib, asyncio)
Environment variables
---------------------
- ``XRAYLABTOOL_LOG_LEVEL``: Logging level (DEBUG, INFO, WARNING, ERROR). Default: INFO.
- ``XRAYLABTOOL_LOG_FILE``: Path to log file. If empty, file logging is disabled.
- ``XRAYLABTOOL_LOG_DIR``: Directory for default log file (overrides cache path).
- ``XRAYLABTOOL_LOG_MAX_BYTES``: Max size per log file before rotation (bytes). Default: 5_000_000.
- ``XRAYLABTOOL_LOG_BACKUPS``: Number of rotated backups to keep. Default: 3.
- ``XRAYLABTOOL_LOG_CONSOLE``: ``1``/``0`` toggle for console logging. Default: on.
Library consumers can opt in by calling ``configure_logging()`` early. The CLI and
GUI call it automatically.
"""
# ruff: noqa: I001
from __future__ import annotations
import logging
import os
import platform
import sys
from collections.abc import Iterable
from logging.handlers import RotatingFileHandler
from pathlib import Path
from xraylabtool import __version__
_STATE: dict[str, object | None] = {"configured": False, "log_file": None}
def _bool_env(name: str, default: bool) -> bool:
val = os.getenv(name)
if val is None:
return default
return val.strip().lower() in {"1", "true", "yes", "on"}
def _coerce_int_env(name: str, default: int) -> int:
try:
return int(os.getenv(name, default))
except Exception:
return default
[docs]
def configure_logging(
*,
level: str | int | None = None,
log_file: str | os.PathLike[str] | None = None,
console: bool | None = None,
force: bool = False,
) -> logging.Logger:
"""Configure package-wide logging once and return the base logger.
Parameters
----------
level : str | int | None
Logging level (DEBUG/INFO/...). Defaults to ``XRAYLABTOOL_LOG_LEVEL`` or INFO.
log_file : str | PathLike | None
Path to log file. If ``""`` or ``None`` after env resolution, file logging is
disabled. Defaults to ``XRAYLABTOOL_LOG_FILE`` or
``~/.cache/xraylabtool/logs/xraylabtool.log``.
console : bool | None
Whether to emit logs to stderr. Defaults to ``XRAYLABTOOL_LOG_CONSOLE`` (on).
force : bool
If True, reconfigure even if logging was already set up (useful in tests).
"""
if _STATE["configured"] and not force:
return logging.getLogger("xraylabtool")
level_env = os.getenv("XRAYLABTOOL_LOG_LEVEL", "INFO").upper()
level_value = level if level is not None else level_env
resolved_level = (
logging.getLevelName(level_value)
if isinstance(level_value, str)
else level_value
)
resolved_level = resolved_level if isinstance(resolved_level, int) else logging.INFO
console_enabled = (
_bool_env("XRAYLABTOOL_LOG_CONSOLE", True) if console is None else console
)
if log_file is None:
env_file = os.getenv("XRAYLABTOOL_LOG_FILE")
if env_file is not None:
log_file = env_file
else:
base_dir = os.getenv(
"XRAYLABTOOL_LOG_DIR",
Path.home() / ".cache" / "xraylabtool" / "logs",
)
log_dir = Path(base_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "xraylabtool.log"
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s [%(name)s] [%(process)d:%(threadName)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("xraylabtool")
logger.setLevel(resolved_level)
logger.propagate = False
if _STATE["configured"] or force:
for h in list(logger.handlers):
logger.removeHandler(h)
if console_enabled:
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(resolved_level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
_STATE["log_file"] = str(log_file) if log_file not in (None, "") else None
if log_file not in (None, ""):
max_bytes = _coerce_int_env("XRAYLABTOOL_LOG_MAX_BYTES", 5_000_000)
backups = _coerce_int_env("XRAYLABTOOL_LOG_BACKUPS", 3)
file_handler = RotatingFileHandler(
log_file, maxBytes=max_bytes, backupCount=backups
)
file_handler.setLevel(resolved_level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Quiet common noisy libraries without muting real warnings
logging.getLogger("matplotlib").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
_STATE["configured"] = True
logger.debug(
"Logging configured",
extra={
"level": resolved_level,
"console": console_enabled,
"log_file": str(log_file) if log_file else None,
},
)
return logger
[docs]
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a child logger under the xraylabtool namespace.
Calling this function ensures logging is configured once.
"""
configure_logging()
if name:
return logging.getLogger(f"xraylabtool.{name}")
return logging.getLogger("xraylabtool")
[docs]
def get_log_file_path() -> str | None:
"""Return the configured log file path if file logging is enabled."""
configure_logging()
return _STATE.get("log_file") # type: ignore[return-value]
[docs]
def log_environment(
logger: logging.Logger,
*,
component: str,
extra_keys: Iterable[tuple[str, str]] | None = None,
) -> None:
"""Emit a single structured line capturing runtime context."""
base = {
"component": component,
"version": __version__,
"python": platform.python_version(),
"platform": platform.platform(),
"pid": os.getpid(),
}
if extra_keys:
base.update(dict(extra_keys))
logger.info("Runtime environment", extra=base)
[docs]
def reset_logging() -> None:
"""Reset logging configuration (intended for tests)."""
logger = logging.getLogger("xraylabtool")
for h in list(logger.handlers):
logger.removeHandler(h)
_STATE["configured"] = False
_STATE["log_file"] = None