improved logging
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user