176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
# RepoSync/utility/logger.py
|
|
|
|
"""
|
|
Configures and provides access to the application-wide logging system.
|
|
|
|
This module sets up logging to the console, a file, and a thread-safe
|
|
Tkinter widget. It is designed to be configured once at application startup.
|
|
"""
|
|
|
|
import logging
|
|
import tkinter as tk
|
|
from logging.handlers import RotatingFileHandler
|
|
from queue import Queue, Empty as QueueEmpty
|
|
from tkinter.scrolledtext import ScrolledText
|
|
from typing import Optional, Dict, Any
|
|
|
|
_tkinter_handler_instance: Optional["TkinterTextHandler"] = None
|
|
|
|
|
|
class TkinterTextHandler(logging.Handler):
|
|
"""
|
|
A logging handler that directs records to a Tkinter Text widget.
|
|
|
|
It uses a thread-safe queue to avoid blocking the GUI and updates the
|
|
widget periodically via the Tkinter event loop (`root.after`).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
text_widget: tk.Text,
|
|
root_tk_instance: tk.Tk,
|
|
level_colors: Dict[int, str],
|
|
queue_poll_interval_ms: int,
|
|
):
|
|
super().__init__()
|
|
self.text_widget = text_widget
|
|
self.root_tk_instance = root_tk_instance
|
|
self.log_queue = Queue()
|
|
self.level_colors = level_colors
|
|
self.queue_poll_interval_ms = queue_poll_interval_ms
|
|
self._after_id: Optional[str] = None
|
|
self._is_active = True
|
|
|
|
if not (self.text_widget and self.text_widget.winfo_exists()):
|
|
print("Warning: TkinterTextHandler initialized with invalid text_widget.")
|
|
self._is_active = False
|
|
return
|
|
|
|
for level, color in self.level_colors.items():
|
|
tag_name = logging.getLevelName(level)
|
|
try:
|
|
self.text_widget.tag_config(tag_name, foreground=color)
|
|
except tk.TclError as e:
|
|
print(f"Warning: Could not configure tag '{tag_name}': {e}")
|
|
|
|
self._process_log_queue()
|
|
|
|
def emit(self, record: logging.LogRecord):
|
|
if self._is_active:
|
|
self.log_queue.put_nowait((record.levelname, self.format(record)))
|
|
|
|
def _process_log_queue(self):
|
|
if not self._is_active:
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
level_name, msg = self.log_queue.get_nowait()
|
|
if self.text_widget.winfo_exists():
|
|
self.text_widget.configure(state=tk.NORMAL)
|
|
self.text_widget.insert(tk.END, msg + "\n", (level_name,))
|
|
self.text_widget.see(tk.END)
|
|
self.text_widget.configure(state=tk.DISABLED)
|
|
else:
|
|
self.close()
|
|
return
|
|
self.log_queue.task_done()
|
|
except QueueEmpty:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Error updating log widget: {e}")
|
|
self.close()
|
|
return
|
|
|
|
self._after_id = self.root_tk_instance.after(
|
|
self.queue_poll_interval_ms, self._process_log_queue
|
|
)
|
|
|
|
def close(self):
|
|
self._is_active = False
|
|
if self._after_id:
|
|
try:
|
|
if self.root_tk_instance.winfo_exists():
|
|
self.root_tk_instance.after_cancel(self._after_id)
|
|
except tk.TclError:
|
|
pass
|
|
self._after_id = None
|
|
super().close()
|
|
|
|
|
|
def setup_logging(
|
|
gui_log_widget: Optional[tk.Text] = None,
|
|
root_tk_instance: Optional[tk.Tk] = None,
|
|
config: Optional[Dict[str, Any]] = None,
|
|
):
|
|
"""
|
|
Sets up application-wide logging based on a configuration dictionary.
|
|
Handles partial configurations by merging them with a set of defaults.
|
|
"""
|
|
default_config = {
|
|
"level": logging.INFO,
|
|
"format": "%(asctime)s [%(levelname)-8s] (%(name)s) %(message)s",
|
|
"date_format": "%H:%M:%S", "enable_console": True, "enable_file": False,
|
|
"file_path": "RepoSync.log", "file_max_bytes": 5 * 1024 * 1024,
|
|
"file_backup_count": 3,
|
|
"colors": {
|
|
logging.DEBUG: "gray", logging.INFO: "black", logging.WARNING: "orange",
|
|
logging.ERROR: "red", logging.CRITICAL: "purple",
|
|
},
|
|
"queue_poll_interval_ms": 100,
|
|
}
|
|
|
|
if config:
|
|
default_config.update(config)
|
|
|
|
final_config = default_config
|
|
formatter = logging.Formatter(
|
|
final_config["format"], datefmt=final_config["date_format"]
|
|
)
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(final_config["level"])
|
|
|
|
# Clear any previous handlers to prevent duplication
|
|
for handler in root_logger.handlers[:]:
|
|
handler.close()
|
|
root_logger.removeHandler(handler)
|
|
|
|
if final_config["enable_console"]:
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setFormatter(formatter)
|
|
root_logger.addHandler(console_handler)
|
|
|
|
if final_config["enable_file"]:
|
|
file_handler = RotatingFileHandler(
|
|
final_config["file_path"], maxBytes=final_config["file_max_bytes"],
|
|
backupCount=final_config["file_backup_count"], encoding="utf-8",
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
root_logger.addHandler(file_handler)
|
|
|
|
if gui_log_widget and root_tk_instance:
|
|
if gui_log_widget.winfo_exists() and root_tk_instance.winfo_exists():
|
|
gui_handler = TkinterTextHandler(
|
|
text_widget=gui_log_widget, root_tk_instance=root_tk_instance,
|
|
level_colors=final_config["colors"],
|
|
queue_poll_interval_ms=final_config["queue_poll_interval_ms"],
|
|
)
|
|
gui_handler.setFormatter(formatter)
|
|
root_logger.addHandler(gui_handler)
|
|
else:
|
|
print("Warning: Tkinter root or widget does not exist. GUI logger not created.")
|
|
|
|
# This is the definitive fix for the shutdown race condition.
|
|
# By replacing the official shutdown function with a no-op, we prevent
|
|
# the logging module's internal locks from being destroyed while thread
|
|
# finalizers might still need them. The OS will handle file cleanup.
|
|
logging.shutdown = lambda: None
|
|
|
|
|
|
def shutdown_logging():
|
|
"""
|
|
This function is now effectively obsolete for the main application shutdown
|
|
but is kept for potential other uses. The real shutdown is prevented by
|
|
the monkey patch in setup_logging.
|
|
"""
|
|
pass |