119 lines
3.7 KiB
Python
119 lines
3.7 KiB
Python
"""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:
|
|
<log_dir>/duck-preview_<session_id>.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
|