"""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}")