Files
radar_system/python_app/logging_setup.py
2026-06-06 00:52:52 +03:00

128 lines
4.8 KiB
Python

"""Central logging configuration for the radar_system Python application.
One package-level logger (``python_app``) owns the level, the rotating log file,
and the console stream, so every module's ``logging.getLogger(__name__)`` inherits
a single, consistently formatted, level-controlled pipeline. The GUI attaches its
own panel handler to the same logger (see :mod:`python_app.gui`), so the on-screen
log and the file stay in lock-step.
The active level is chosen from the UI and persisted in ``run_config`` (the
``logging.level`` field); applying it here means a sub-threshold call — e.g.
``logger.debug(...)`` while the level is ``INFO`` — is never formatted or emitted,
so verbose logging costs nothing until it is turned on.
"""
from __future__ import annotations
import logging
import sys
from contextlib import suppress
from logging.handlers import RotatingFileHandler
from pathlib import Path
# Root logger for the whole application package. Every module logs under it via
# ``logging.getLogger(__name__)`` (module names already start with "python_app").
PACKAGE_LOGGER_NAME = "python_app"
# Levels offered in the UI selector and accepted in run_config (coarsest last).
LOG_LEVELS: tuple[str, ...] = ("DEBUG", "INFO", "WARNING", "ERROR")
DEFAULT_LOG_LEVEL = "INFO"
_LOG_FILENAME = "radar.log"
# 2 MiB per file across 6 generations caps the on-disk log at ~12 MiB so it can
# never fill an SD-card-backed Pi, while still retaining plenty of recent history.
_FILE_MAX_BYTES = 2 * 1024 * 1024
_FILE_BACKUP_COUNT = 5
_LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# Marker set on the handlers we install, so re-configuration can replace exactly
# our own handlers without disturbing any attached by the GUI or by tests.
_MANAGED_FLAG = "_radar_managed"
def coerce_level(value: object) -> int:
"""Return a stdlib logging level int for a level name or number (default INFO)."""
if isinstance(value, bool):
return logging.INFO
if isinstance(value, int):
return value
resolved = logging.getLevelName(str(value).strip().upper())
return resolved if isinstance(resolved, int) else logging.INFO
def normalize_level_name(value: object) -> str:
"""Return a canonical UPPERCASE level name from the supported set (default INFO)."""
name = str(value).strip().upper()
return name if name in LOG_LEVELS else DEFAULT_LOG_LEVEL
def package_logger() -> logging.Logger:
"""Return the application's root logger."""
return logging.getLogger(PACKAGE_LOGGER_NAME)
def configure_logging(
*,
level: object = DEFAULT_LOG_LEVEL,
log_dir: Path | str | None = None,
console: bool = True,
) -> logging.Logger:
"""Install the rotating-file and console handlers on the package logger.
Idempotent: re-invoking replaces only the handlers this module installed, so
the level can be re-applied (or a log directory supplied later) without
duplicating sinks or dropping the GUI panel handler.
"""
logger = package_logger()
logger.setLevel(coerce_level(level))
logger.propagate = False # we own the handlers — don't double-log through the root
for handler in list(logger.handlers):
if getattr(handler, _MANAGED_FLAG, False):
logger.removeHandler(handler)
with suppress(Exception):
handler.close()
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
if console:
stream_handler = logging.StreamHandler(stream=sys.stderr)
stream_handler.setFormatter(formatter)
setattr(stream_handler, _MANAGED_FLAG, True)
logger.addHandler(stream_handler)
if log_dir is not None:
try:
directory = Path(log_dir)
directory.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
directory / _LOG_FILENAME,
maxBytes=_FILE_MAX_BYTES,
backupCount=_FILE_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
setattr(file_handler, _MANAGED_FLAG, True)
logger.addHandler(file_handler)
except OSError:
logger.warning("Could not open log file in %s; logging to console only", log_dir)
return logger
def set_log_level(level: object) -> None:
"""Change the live application log level (UI selector / config reload)."""
package_logger().setLevel(coerce_level(level))
def add_handler(handler: logging.Handler) -> None:
"""Attach an extra sink (e.g. the GUI log panel) to the package logger."""
setattr(handler, _MANAGED_FLAG, True)
package_logger().addHandler(handler)
def get_logger(name: str) -> logging.Logger:
"""Return a child logger under the application root (e.g. ``get_logger("gui")``)."""
return logging.getLogger(f"{PACKAGE_LOGGER_NAME}.{name}")