""" Centralised application logging for DBClient. Call ``setup_logging()`` once at startup (in main.py). Then anywhere in the codebase: from app.utils.logger import get_logger log = get_logger(__name__) log.info("Connected to %s", host) log.error("Query failed", exc_info=True) # includes full traceback Log files are written to ~/.dbclient/logs/dbclient.log with daily rotation, keeping the last 7 files. The module also installs: • sys.excepthook — logs every uncaught exception with full traceback • threading.excepthook — logs uncaught exceptions in background threads """ from __future__ import annotations import logging import logging.handlers import os import sys import threading import traceback from pathlib import Path # ── Constants ───────────────────────────────────────────────────────────────── APP_DIR = Path.home() / ".dbclient" LOG_DIR = APP_DIR / "logs" LOG_FILE = LOG_DIR / "dbclient.log" # File: DEBUG and above | Console: WARNING and above FILE_LEVEL = logging.DEBUG CONSOLE_LEVEL = logging.WARNING # Rotate at midnight, keep 7 days of log files BACKUP_COUNT = 7 LOG_FORMAT = ( "[%(asctime)s] [%(levelname)-8s] [%(name)s:%(lineno)d] %(message)s" ) DATE_FORMAT = "%Y-%m-%d %H:%M:%S" # ── Public helpers ──────────────────────────────────────────────────────────── def get_logger(name: str) -> logging.Logger: """Return a module-level logger. Always call as ``get_logger(__name__)``.""" return logging.getLogger(name) def get_log_path() -> Path: """Return the absolute path of the current log file.""" return LOG_FILE # ── Setup ───────────────────────────────────────────────────────────────────── def setup_logging(level: int = FILE_LEVEL) -> None: """ Initialise the root logger. Safe to call multiple times (idempotent). Parameters ---------- level : int Minimum level written to the log file (default: DEBUG). The console handler always uses WARNING regardless of this setting. """ LOG_DIR.mkdir(parents=True, exist_ok=True) root = logging.getLogger() if root.handlers: # Already configured — nothing to do return root.setLevel(logging.DEBUG) fmt = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) # ── Rotating file handler (midnight rollover, 7-day retention) ──────────── try: fh = logging.handlers.TimedRotatingFileHandler( str(LOG_FILE), when="midnight", backupCount=BACKUP_COUNT, encoding="utf-8", delay=False, ) fh.setLevel(level) fh.setFormatter(fmt) root.addHandler(fh) except OSError as exc: # Can't write logs — at least print a warning print(f"[DBClient] WARNING: Could not open log file {LOG_FILE}: {exc}", file=sys.stderr) # ── Console handler (WARNING+ only, for developers) ─────────────────────── ch = logging.StreamHandler(sys.stderr) ch.setLevel(CONSOLE_LEVEL) ch.setFormatter(fmt) root.addHandler(ch) # ── Silence noisy third-party libraries ─────────────────────────────────── for noisy in ("pymysql", "psycopg2", "pyodbc", "urllib3", "PIL"): logging.getLogger(noisy).setLevel(logging.WARNING) _install_exception_hooks() log = get_logger(__name__) log.info("=" * 60) log.info("DBClient started (Python %s)", sys.version.split()[0]) log.info("Log file: %s", LOG_FILE) # ── Unhandled exception hooks ───────────────────────────────────────────────── def _install_exception_hooks() -> None: """Capture all uncaught exceptions — both in the main thread and workers.""" _log = get_logger("dbclient.uncaught") # Main thread def _excepthook(exc_type, exc_value, exc_tb): if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_tb) return _log.critical( "Uncaught exception:\n%s", "".join(traceback.format_exception(exc_type, exc_value, exc_tb)), ) # Still let Qt/Python print it to stderr so the developer sees it live sys.__excepthook__(exc_type, exc_value, exc_tb) sys.excepthook = _excepthook # Background threads (Python 3.8+) def _thread_excepthook(args): if args.exc_type is SystemExit: return _log.critical( "Uncaught exception in thread '%s':\n%s", getattr(args.thread, "name", "?"), "".join(traceback.format_exception( args.exc_type, args.exc_value, args.exc_tb )), ) threading.excepthook = _thread_excepthook # ── Qt message handler ───────────────────────────────────────────────────────── def install_qt_message_handler() -> None: """ Route Qt's own warning/critical messages into the Python log. Call AFTER QApplication is created. """ try: from PyQt6.QtCore import qInstallMessageHandler, QtMsgType _log = get_logger("Qt") _level_map = { QtMsgType.QtDebugMsg: logging.DEBUG, QtMsgType.QtInfoMsg: logging.INFO, QtMsgType.QtWarningMsg: logging.WARNING, QtMsgType.QtCriticalMsg: logging.ERROR, QtMsgType.QtFatalMsg: logging.CRITICAL, } def _handler(msg_type, context, message): lvl = _level_map.get(msg_type, logging.WARNING) loc = "" if context.file: loc = f" [{context.file}:{context.line}]" _log.log(lvl, "%s%s", message, loc) qInstallMessageHandler(_handler) except Exception: pass # Non-fatal — Qt messages just won't be logged