Source code for mathxlab.exp.logging

"""Logging utilities for experiment runs.

The logging policy is tuned for interactive experimentation:

- INFO+ from all libraries (progress, warnings)
- DEBUG only from this repository's loggers (by prefix), enabled via `verbose`

This keeps third-party debug output (e.g. Matplotlib) from overwhelming logs
while still allowing deep debugging for `mathxlab.*`.
"""

from __future__ import annotations

import contextlib
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from types import TracebackType
from typing import override

__all__ = [
    "LoggingConfig",
    "get_logger",
    "setup_logging",
]


# ------------------------------------------------------------------------------
[docs] @dataclass(frozen=True, slots=True) class LoggingConfig: """ Configuration for experiment logging. Args: package_prefix: Logger name prefix considered "our code" (default: "mathxlab"). verbose: If True, emit DEBUG logs for package_prefix only. log_file: Optional file path for logs (UTF-8). The file is overwritten each run. Examples: >>> from mathxlab.exp.logging import LoggingConfig >>> LoggingConfig # doctest: +SKIP """ package_prefix: str = "mathxlab" verbose: bool = False log_file: Path | None = None
# ------------------------------------------------------------------------------ class _PrefixAndExactLevelFilter(logging.Filter): """Allow records only if the logger name matches a prefix and level matches exactly.""" def __init__(self, *, prefix: str, levelno: int) -> None: super().__init__() self._prefix = prefix self._levelno = levelno @override def filter(self, record: logging.LogRecord) -> bool: """Return True if the record should be logged.""" return record.name.startswith(self._prefix) and record.levelno == self._levelno # ------------------------------------------------------------------------------
[docs] def setup_logging(*, config: LoggingConfig | None = None) -> None: """ Set up logging for experiment runs. Design goal: - Show INFO+ from all libraries (progress + warnings). - Show DEBUG only from *our* code (by logger name prefix). This prevents very noisy third-party DEBUG output (e.g. matplotlib/PIL) while still allowing you to turn on detailed debugging for the repository code. Args: config: Logging configuration. If omitted, defaults are used. Examples: >>> from mathxlab.exp.logging import LoggingConfig, setup_logging >>> setup_logging(config=LoggingConfig(verbose=True)) >>> import logging; logging.getLogger("mathxlab").debug("debug enabled") """ cfg = config or LoggingConfig() # Ensure our console handlers can handle Unicode safely on Windows consoles. # PowerShell/Windows console defaults can be cp1252; that can raise # UnicodeEncodeError for symbols like χ, φ, π in log messages. Using # backslashreplace keeps output readable without forcing a code page. for _stream in (sys.stdout, sys.stderr): with contextlib.suppress(Exception): if hasattr(_stream, "reconfigure"): _stream.reconfigure(errors="backslashreplace") root = logging.getLogger() root.handlers.clear() # Let handlers decide what is emitted. We want to allow DEBUG to reach the DEBUG handler. root.setLevel(logging.DEBUG) fmt = logging.Formatter( fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) # 1) General handler: INFO+ from everyone h_info = logging.StreamHandler(sys.stdout) h_info.setLevel(logging.INFO) h_info.setFormatter(fmt) root.addHandler(h_info) # 2) Optional debug handler: DEBUG only from our code if cfg.verbose: h_dbg = logging.StreamHandler(sys.stdout) h_dbg.setLevel(logging.DEBUG) h_dbg.setFormatter(fmt) h_dbg.addFilter( _PrefixAndExactLevelFilter(prefix=cfg.package_prefix, levelno=logging.DEBUG) ) root.addHandler(h_dbg) # Optional file logging (same policy) if cfg.log_file is not None: cfg.log_file.parent.mkdir(parents=True, exist_ok=True) fh_info = logging.FileHandler(cfg.log_file, mode="w", encoding="utf-8") fh_info.setLevel(logging.INFO) fh_info.setFormatter(fmt) root.addHandler(fh_info) if cfg.verbose: fh_dbg = logging.FileHandler(cfg.log_file, mode="a", encoding="utf-8") fh_dbg.setLevel(logging.DEBUG) fh_dbg.setFormatter(fmt) fh_dbg.addFilter( _PrefixAndExactLevelFilter(prefix=cfg.package_prefix, levelno=logging.DEBUG) ) root.addHandler(fh_dbg) # If a log file is configured, also capture unhandled exceptions into it. # This makes log files useful even when running without shell redirection. if cfg.log_file is not None: def _excepthook( exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None, ) -> None: if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc, tb) return logging.getLogger(cfg.package_prefix).critical( "Unhandled exception", exc_info=(exc_type, exc, tb), ) sys.__excepthook__(exc_type, exc, tb) sys.excepthook = _excepthook # Keep typical noisy libraries at WARNING+ (defensive). This does not hide warnings/errors. for noisy in ("matplotlib", "PIL"): logging.getLogger(noisy).setLevel(logging.WARNING)
# ------------------------------------------------------------------------------
[docs] def get_logger(name: str) -> logging.Logger: """ Get a logger for a specific module. Args: name: Name of the module. Returns: A logging.Logger instance. Examples: >>> from mathxlab.exp.logging import get_logger >>> get_logger # doctest: +SKIP """ return logging.getLogger(name)