181 lines
6.4 KiB
Python
181 lines
6.4 KiB
Python
"""
|
|
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
|