"""Logging initialisation — file + console handlers with session isolation.""" from __future__ import annotations import logging import platform import sys from pathlib import Path from app.config import APP_NAME, APP_VERSION, MAX_LOG_FILES _LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-7s] %(name)s: %(message)s" _DATE_FORMAT = "%Y-%m-%d %H:%M:%S" _console_handler: logging.StreamHandler | None = None def setup_logging(log_dir: Path, session_id: str) -> Path: """ Configure the root logger for a new session. Creates: /duck-preview_.log — DEBUG level, all messages console StreamHandler — WARNING by default Prunes oldest log files when count exceeds MAX_LOG_FILES. Args: log_dir: Directory where log files are stored. session_id: Timestamp string used as filename suffix (e.g. "2026-05-12_14-30-00"). Returns: Path to the created log file. """ global _console_handler log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"duck-preview_{session_id}.log" root = logging.getLogger() root.setLevel(logging.DEBUG) # handlers filter individually formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT) # --- File handler — always DEBUG --- file_handler = logging.FileHandler(log_path, encoding="utf-8") file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) root.addHandler(file_handler) # --- Console handler — WARNING by default, toggled by Debug menu --- _console_handler = logging.StreamHandler(sys.stdout) _console_handler.setLevel(logging.WARNING) _console_handler.setFormatter(formatter) root.addHandler(_console_handler) # Write session header to file _write_session_header(log_path, session_id) # Prune old log files _prune_old_logs(log_dir, log_path) return log_path def set_console_level(debug: bool) -> None: """Toggle console handler between DEBUG and WARNING (called from Debug menu).""" if _console_handler is not None: _console_handler.setLevel(logging.DEBUG if debug else logging.WARNING) def _write_session_header(log_path: Path, session_id: str) -> None: """Write a human-readable header block at the top of the log file.""" try: import PySide6 pyside_version = PySide6.__version__ except Exception: pyside_version = "unknown" try: import psutil mem = psutil.virtual_memory() cpu_count = psutil.cpu_count(logical=True) mem_total_gb = mem.total / (1024 ** 3) hw_info = f"{cpu_count} logical CPUs, {mem_total_gb:.1f} GB RAM" except Exception: hw_info = "unknown" lines = [ "=" * 72, f" {APP_NAME} {APP_VERSION}", f" Session: {session_id}", "=" * 72, f" Platform : {platform.platform()}", f" Python : {sys.version.split()[0]}", f" PySide6 : {pyside_version}", f" Hardware : {hw_info}", f" Log file : {log_path.resolve()}", "=" * 72, "", ] with log_path.open("w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") def _prune_old_logs(log_dir: Path, current: Path) -> None: """Delete oldest .log files if total count exceeds MAX_LOG_FILES.""" log_files = sorted( [p for p in log_dir.glob("duck-preview_*.log") if p != current], key=lambda p: p.stat().st_mtime, ) excess = len(log_files) - (MAX_LOG_FILES - 1) for path in log_files[:excess]: try: path.unlink() logging.getLogger(__name__).debug("Pruned old log: %s", path.name) except OSError: pass