128 lines
4.8 KiB
Python
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}")
|