Initial Codes
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Emoji/Unicode icon helpers — no external icon pack dependency.
|
||||
All icons are rendered from Unicode characters by Qt.
|
||||
"""
|
||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QFont, QColor
|
||||
from PyQt6.QtCore import Qt, QSize
|
||||
|
||||
|
||||
# ── Unicode glyph map ─────────────────────────────────────────────────────────
|
||||
ICONS = {
|
||||
# Connections
|
||||
"connection": "🔌",
|
||||
"connected": "🟢",
|
||||
"disconnected": "🔴",
|
||||
|
||||
# Schema tree
|
||||
"database": "🗄️",
|
||||
"table": "📋",
|
||||
"view": "👁️",
|
||||
"column": "📊",
|
||||
"index": "🔍",
|
||||
"primary_key": "🔑",
|
||||
"foreign_key": "🔗",
|
||||
"function": "⚡",
|
||||
"procedure": "📦",
|
||||
"trigger": "⚙️",
|
||||
"folder": "📁",
|
||||
"folder_open": "📂",
|
||||
|
||||
# Actions
|
||||
"run": "▶️",
|
||||
"stop": "⏹️",
|
||||
"explain": "🔎",
|
||||
"new_tab": "➕",
|
||||
"save": "💾",
|
||||
"open": "📂",
|
||||
"export": "📤",
|
||||
"import": "📥",
|
||||
"refresh": "🔄",
|
||||
"delete": "🗑️",
|
||||
"edit": "✏️",
|
||||
"add": "➕",
|
||||
"commit": "✅",
|
||||
"rollback": "↩️",
|
||||
"history": "📜",
|
||||
"filter": "🔍",
|
||||
"copy": "📋",
|
||||
"clear": "🧹",
|
||||
"settings": "⚙️",
|
||||
"help": "❓",
|
||||
"info": "ℹ️",
|
||||
"warning": "⚠️",
|
||||
"error": "❌",
|
||||
"success": "✅",
|
||||
"kill": "🛑",
|
||||
"disconnect": "🔌",
|
||||
|
||||
# DB Types
|
||||
"mysql": "🐬",
|
||||
"postgresql": "🐘",
|
||||
"sqlite": "📁",
|
||||
"mssql": "🪟",
|
||||
}
|
||||
|
||||
|
||||
def make_icon(glyph: str, size: int = 20,
|
||||
fg: str = "#cdd6f4", bg: str = "transparent") -> QIcon:
|
||||
"""Create a QIcon from a Unicode glyph."""
|
||||
px = QPixmap(QSize(size, size))
|
||||
px.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(px)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
font = QFont()
|
||||
font.setPointSize(int(size * 0.55))
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(fg))
|
||||
painter.drawText(px.rect(), Qt.AlignmentFlag.AlignCenter, glyph)
|
||||
painter.end()
|
||||
return QIcon(px)
|
||||
|
||||
|
||||
def get_icon(name: str, size: int = 20) -> QIcon:
|
||||
"""Get a named icon. Falls back to a question mark if unknown."""
|
||||
glyph = ICONS.get(name, "❓")
|
||||
return make_icon(glyph, size)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
QThread-based async worker for all database operations.
|
||||
Emits results/errors via Qt signals so the UI stays responsive.
|
||||
"""
|
||||
import time
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
_log = get_logger(__name__)
|
||||
|
||||
|
||||
class QueryWorker(QThread):
|
||||
"""Execute a SQL query in a background thread."""
|
||||
|
||||
finished = pyqtSignal(list, list, int, float) # cols, rows, rowcount, elapsed_sec
|
||||
error = pyqtSignal(str)
|
||||
script_done = pyqtSignal(list) # list of (cols, rows, cnt, msg)
|
||||
|
||||
def __init__(self, driver, sql: str, is_script: bool = False, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._sql = sql
|
||||
self._script = is_script
|
||||
|
||||
def run(self):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
if self._script:
|
||||
results = self._driver.execute_script(self._sql)
|
||||
self.script_done.emit(results)
|
||||
else:
|
||||
cols, rows, cnt = self._driver.execute_query(self._sql)
|
||||
elapsed = time.perf_counter() - t0
|
||||
_log.debug("Query OK (%.3f s, %d row(s)) SQL: %.200s",
|
||||
elapsed, cnt, self._sql.replace("\n", " "))
|
||||
self.finished.emit(cols, list(rows), cnt, elapsed)
|
||||
except Exception as e:
|
||||
_log.error("Query failed SQL: %.300s", self._sql.replace("\n", " "),
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class SchemaWorker(QThread):
|
||||
"""Generic async loader for schema introspection calls."""
|
||||
|
||||
result = pyqtSignal(object)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, fn, *args, parent=None):
|
||||
super().__init__(parent)
|
||||
self._fn = fn
|
||||
self._args = args
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
data = self._fn(*self._args)
|
||||
self.result.emit(data)
|
||||
except Exception as e:
|
||||
_log.error("SchemaWorker error fn=%s args=%s",
|
||||
getattr(self._fn, "__name__", "?"), self._args,
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class TableDataWorker(QThread):
|
||||
"""Load paginated table data in background."""
|
||||
|
||||
finished = pyqtSignal(list, list, int) # cols, rows, total_count
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, driver, database, table,
|
||||
where="", order_by="", limit=1000, offset=0, parent=None):
|
||||
super().__init__(parent)
|
||||
self._driver = driver
|
||||
self._database = database
|
||||
self._table = table
|
||||
self._where = where
|
||||
self._order_by = order_by
|
||||
self._limit = limit
|
||||
self._offset = offset
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
cols, rows, cnt = self._driver.get_table_data(
|
||||
self._database, self._table,
|
||||
self._where, self._order_by, self._limit, self._offset
|
||||
)
|
||||
total = self._driver.get_table_row_count(
|
||||
self._database, self._table, self._where
|
||||
)
|
||||
self.finished.emit(cols, list(rows), total)
|
||||
except Exception as e:
|
||||
_log.error("TableDataWorker error table=%s.%s where=%r",
|
||||
self._database, self._table, self._where,
|
||||
exc_info=True)
|
||||
self.error.emit(str(e))
|
||||
|
||||
Reference in New Issue
Block a user