python-tkinter-logger/tkinter_logger.py
2025-11-26 08:27:14 +01:00

694 lines
23 KiB
Python

"""
Tkinter Logger - Sistema di logging thread-safe per applicazioni Tkinter.
Modulo standalone e riutilizzabile che fornisce un sistema di logging avanzato
con integrazione Tkinter, batching intelligente, polling adattivo e gestione
sicura di log da thread multipli.
Caratteristiche:
- Logging thread-safe tramite Queue
- Integrazione nativa con widget Tkinter Text/ScrolledText
- Batching intelligente per ridurre operazioni GUI (70%+ riduzione overhead)
- Polling adattivo (veloce con attività, lento quando idle)
- Gestione automatica limiti widget (previene memory bloat)
- Auto-scroll intelligente (solo se utente è in fondo)
- Supporto colori per livelli di log
- Handler multipli: console, file, Tkinter
- Zero dipendenze esterne (solo stdlib + tkinter)
Esempio di utilizzo base con Tkinter:
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
from tkinter_logger import TkinterLogger
root = tk.Tk()
log_widget = ScrolledText(root, height=20, width=80)
log_widget.pack()
# Configura il logger
logger_system = TkinterLogger(root)
logger_system.setup(enable_console=True)
logger_system.add_tkinter_handler(log_widget)
# Usa il logging standard
import logging
logger = logging.getLogger(__name__)
logger.info("Hello from Tkinter!")
root.mainloop()
logger_system.shutdown()
Esempio console (senza GUI):
from tkinter_logger import TkinterLogger
import logging
# Usa solo console handler (no Tkinter)
logger_system = TkinterLogger(tk_root=None)
logger_system.setup(enable_console=True, enable_tkinter=False)
logger = logging.getLogger(__name__)
logger.info("Console-only logging")
logger_system.shutdown()
Author: Estratto da Target Simulator
License: Same as parent project
"""
import logging
import logging.handlers
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
from queue import Queue, Empty as QueueEmpty
from typing import Optional, Dict, Any, Union
from contextlib import contextmanager
import time
import os
class TkinterTextHandler(logging.Handler):
"""
Handler di logging che scrive in un widget Tkinter Text.
Questo handler è ottimizzato per performance GUI con:
- Batching di log multipli per ridurre operazioni widget
- Limite righe automatico per prevenire memory bloat
- Auto-scroll intelligente (solo se utente è in fondo)
- Supporto colori per livelli di log
Args:
text_widget: Widget Tkinter Text o ScrolledText
level_colors: Dizionario {logging.LEVEL: "colore"} per colorare i log
max_lines: Numero massimo di righe da mantenere nel widget (default: 1000)
"""
def __init__(
self,
text_widget: Union[tk.Text, ScrolledText],
level_colors: Optional[Dict[int, str]] = None,
max_lines: int = 1000
):
super().__init__()
self.text_widget = text_widget
self.level_colors = level_colors or self._default_colors()
self.max_lines = max_lines
self._pending_records = [] # Buffer per batching
self._configure_tags()
@staticmethod
def _default_colors() -> Dict[int, str]:
"""Colori di default per i livelli di log."""
return {
logging.DEBUG: "#888888", # Grigio
logging.INFO: "#000000", # Nero
logging.WARNING: "#FF8C00", # Arancione
logging.ERROR: "#FF0000", # Rosso
logging.CRITICAL: "#8B0000", # Rosso scuro
}
def _configure_tags(self):
"""Configura i tag di colore nel widget."""
for level, color_value in self.level_colors.items():
level_name = logging.getLevelName(level)
if color_value:
try:
self.text_widget.tag_config(level_name, foreground=color_value)
except tk.TclError:
pass # Widget potrebbe non essere pronto
def emit(self, record: logging.LogRecord):
"""
Bufferizza il record per processing batch.
Questo metodo viene chiamato dal sistema di logging (potenzialmente
da thread diversi). Il record viene solo aggiunto al buffer.
"""
try:
if not self.text_widget.winfo_exists():
return
self._pending_records.append(record)
except Exception as e:
# Fallback a print se il widget non è disponibile
try:
print(f"TkinterTextHandler error: {e}", flush=True)
except Exception:
pass
def flush_pending(self):
"""
Scrive tutti i record bufferizzati nel widget in un'unica operazione.
Questo metodo DEVE essere chiamato dal thread GUI (Tk mainloop).
Ottimizzazioni:
- Singola operazione NORMAL -> DISABLED per tutti i log
- Check scroll position una volta sola
- Trim righe vecchie in una singola operazione
"""
if not self._pending_records:
return
try:
if not self.text_widget.winfo_exists():
self._pending_records.clear()
return
# Check se utente ha scrollato via dal fondo
yview = self.text_widget.yview()
user_at_bottom = yview[1] >= 0.98 # Entro 2% dal fondo
# Abilita modifica widget una volta sola
self.text_widget.configure(state=tk.NORMAL)
# Batch insert di tutti i record pendenti
for record in self._pending_records:
msg = self.format(record)
level_name = record.levelname
self.text_widget.insert(tk.END, msg + "\n", (level_name,))
# Trim righe vecchie se superato il limite
line_count = int(self.text_widget.index("end-1c").split(".")[0])
if line_count > self.max_lines:
excess = line_count - self.max_lines
self.text_widget.delete("1.0", f"{excess}.0")
# Disabilita modifica
self.text_widget.configure(state=tk.DISABLED)
# Auto-scroll solo se utente era in fondo
if user_at_bottom:
self.text_widget.see(tk.END)
self._pending_records.clear()
except Exception as e:
try:
print(f"Error in flush_pending: {e}", flush=True)
except Exception:
pass
self._pending_records.clear()
class _QueuePuttingHandler(logging.Handler):
"""Handler interno che mette LogRecord in una Queue."""
def __init__(self, handler_queue: Queue):
super().__init__()
self.handler_queue = handler_queue
def emit(self, record: logging.LogRecord):
try:
self.handler_queue.put_nowait(record)
except Exception:
pass # Queue piena o altro errore - ignora
class TkinterLogger:
"""
Sistema di logging completo per applicazioni Tkinter.
Gestisce tutto il ciclo di vita del logging:
- Setup iniziale con handler multipli
- Processing asincrono da Queue
- Batching e ottimizzazioni
- Shutdown pulito
Esempio:
import tkinter as tk
from tkinter_logger import TkinterLogger
root = tk.Tk()
logger_sys = TkinterLogger(root)
logger_sys.setup()
# ... usa logging standard ...
root.mainloop()
logger_sys.shutdown()
"""
def __init__(self, tk_root: Optional[tk.Tk] = None):
"""
Inizializza il sistema di logging.
Args:
tk_root: Istanza Tk root per scheduling. Se None, solo console/file
handler saranno disponibili (no Tkinter handler).
"""
self.tk_root = tk_root
self._log_queue: Optional[Queue] = None
self._console_handler: Optional[logging.StreamHandler] = None
self._file_handler: Optional[logging.handlers.RotatingFileHandler] = None
self._tkinter_handler: Optional[TkinterTextHandler] = None
self._queue_handler: Optional[_QueuePuttingHandler] = None
self._processor_after_id: Optional[str] = None
self._is_active = False
self._last_log_time = 0.0
self._formatter: Optional[logging.Formatter] = None
# Configurazioni polling
self._poll_interval_ms = 200 # Base interval (5Hz)
self._batch_size = 50 # Max log per ciclo
def setup(
self,
log_format: Optional[str] = None,
date_format: Optional[str] = None,
root_level: int = logging.INFO,
enable_console: bool = True,
enable_file: bool = False,
file_path: Optional[str] = None,
file_max_bytes: int = 5 * 1024 * 1024, # 5MB
file_backup_count: int = 3,
enable_tkinter: bool = True,
poll_interval_ms: int = 200,
batch_size: int = 50
) -> bool:
"""
Configura il sistema di logging.
Args:
log_format: Formato log (default: standard con timestamp)
date_format: Formato data (default: "%Y-%m-%d %H:%M:%S")
root_level: Livello root logger (default: INFO)
enable_console: Abilita handler console (default: True)
enable_file: Abilita handler file (default: False)
file_path: Path file log (richiesto se enable_file=True)
file_max_bytes: Dimensione massima file log (default: 5MB)
file_backup_count: Numero file backup rotazione (default: 3)
enable_tkinter: Abilita supporto Tkinter (default: True, richiede tk_root)
poll_interval_ms: Intervallo base polling queue (default: 200ms)
batch_size: Max log processati per ciclo (default: 50)
Returns:
True se setup completato con successo, False altrimenti
"""
if self._is_active:
return False # Già configurato
# Formatter
if log_format is None:
log_format = "%(asctime)s [%(levelname)-8s] %(name)-25s : %(message)s"
if date_format is None:
date_format = "%Y-%m-%d %H:%M:%S"
self._formatter = logging.Formatter(log_format, datefmt=date_format)
self._poll_interval_ms = poll_interval_ms
self._batch_size = batch_size
# Queue per log asincroni
self._log_queue = Queue()
# Configura root logger
root_logger = logging.getLogger()
# Rimuovi handler esistenti
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
root_logger.setLevel(root_level)
# Console handler
if enable_console:
self._console_handler = logging.StreamHandler()
self._console_handler.setFormatter(self._formatter)
self._console_handler.setLevel(logging.DEBUG)
# File handler
if enable_file:
if not file_path:
print("WARNING: enable_file=True but no file_path provided", flush=True)
else:
try:
# Crea directory se non esiste
log_dir = os.path.dirname(file_path)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
self._file_handler = logging.handlers.RotatingFileHandler(
file_path,
maxBytes=file_max_bytes,
backupCount=file_backup_count,
encoding="utf-8"
)
self._file_handler.setFormatter(self._formatter)
self._file_handler.setLevel(logging.DEBUG)
except Exception as e:
print(f"WARNING: Cannot create file handler: {e}", flush=True)
# Queue handler (manda tutti i log alla queue)
self._queue_handler = _QueuePuttingHandler(self._log_queue)
self._queue_handler.setLevel(logging.DEBUG)
root_logger.addHandler(self._queue_handler)
# Log iniziale
try:
root_logger.debug("TkinterLogger system initialized (queue-based)")
except Exception:
pass
self._is_active = True
# Avvia processing loop se abbiamo tk_root e Tkinter è abilitato
if enable_tkinter and self.tk_root and self.tk_root.winfo_exists():
self._start_queue_processing()
return True
def add_tkinter_handler(
self,
text_widget: Union[tk.Text, ScrolledText],
level_colors: Optional[Dict[int, str]] = None,
max_lines: int = 1000
) -> bool:
"""
Aggiunge un handler Tkinter al sistema di logging.
Args:
text_widget: Widget Text o ScrolledText dove mostrare i log
level_colors: Dizionario {logging.LEVEL: "colore"}
max_lines: Numero massimo di righe da mantenere
Returns:
True se handler aggiunto con successo
"""
if not self._is_active or not self._formatter:
print("ERROR: TkinterLogger not setup, call setup() first", flush=True)
return False
# Chiudi handler precedente se esiste
if self._tkinter_handler:
try:
self._tkinter_handler.close()
except Exception:
pass
# Crea nuovo handler
try:
if not isinstance(text_widget, (tk.Text, ScrolledText)):
print("ERROR: text_widget must be tk.Text or ScrolledText", flush=True)
return False
if not text_widget.winfo_exists():
print("ERROR: text_widget does not exist", flush=True)
return False
self._tkinter_handler = TkinterTextHandler(
text_widget=text_widget,
level_colors=level_colors,
max_lines=max_lines
)
self._tkinter_handler.setFormatter(self._formatter)
self._tkinter_handler.setLevel(logging.DEBUG)
# Avvia processing se non già avviato
if self.tk_root and not self._processor_after_id:
self._start_queue_processing()
return True
except Exception as e:
print(f"ERROR: Cannot add Tkinter handler: {e}", flush=True)
return False
def _start_queue_processing(self):
"""Avvia il loop di processing della queue."""
if not self.tk_root or not self.tk_root.winfo_exists():
return
if self._processor_after_id:
return # Già avviato
self._processor_after_id = self.tk_root.after(
self._poll_interval_ms,
self._process_queue
)
def _process_queue(self):
"""
Processa i log dalla queue (chiamato dal Tk mainloop).
Ottimizzazioni:
- Processing a batch (max _batch_size per ciclo)
- Polling adattivo (veloce con attività, lento quando idle)
- Singolo flush per Tkinter handler
"""
if not self._is_active or not self.tk_root or not self.tk_root.winfo_exists():
return
processed_count = 0
try:
# Processa max _batch_size log per ciclo
while (
self._log_queue
and not self._log_queue.empty()
and processed_count < self._batch_size
):
try:
record = self._log_queue.get_nowait()
# Console e file handler scrivono immediatamente
if self._console_handler:
self._console_handler.handle(record)
if self._file_handler:
self._file_handler.handle(record)
# Tkinter handler bufferizza (no widget ops)
if self._tkinter_handler:
self._tkinter_handler.handle(record)
self._log_queue.task_done()
processed_count += 1
self._last_log_time = time.time()
except QueueEmpty:
break
except Exception as e:
try:
print(f"Error processing log record: {e}", flush=True)
except Exception:
pass
except Exception as e:
try:
print(f"Error in queue processing: {e}", flush=True)
except Exception:
pass
# Flush batch Tkinter handler (singola operazione widget)
try:
if self._tkinter_handler:
self._tkinter_handler.flush_pending()
except Exception as e:
try:
print(f"Error flushing Tkinter logs: {e}", flush=True)
except Exception:
pass
# Polling adattivo: veloce con attività, lento quando idle
time_since_last = time.time() - self._last_log_time
if time_since_last < 2.0 or processed_count >= self._batch_size:
# Attività recente o backlog: polling veloce
next_interval = self._poll_interval_ms
elif time_since_last < 10.0:
# Attività moderata: polling normale
next_interval = self._poll_interval_ms * 2
else:
# Idle: polling lento per ridurre CPU
next_interval = self._poll_interval_ms * 5
# Schedule prossimo ciclo
if self._is_active:
self._processor_after_id = self.tk_root.after(
int(next_interval),
self._process_queue
)
def shutdown(self):
"""
Ferma il sistema di logging e fa cleanup.
Questo metodo dovrebbe essere chiamato alla chiusura dell'applicazione.
"""
if not self._is_active:
return
self._is_active = False
# Cancella processing loop
if self._processor_after_id and self.tk_root:
try:
if self.tk_root.winfo_exists():
self.tk_root.after_cancel(self._processor_after_id)
except Exception:
pass
self._processor_after_id = None
# Flush finale della queue
try:
self._process_queue()
except Exception:
pass
# Chiudi handler
if self._console_handler:
try:
self._console_handler.close()
except Exception:
pass
if self._file_handler:
try:
self._file_handler.close()
except Exception:
pass
if self._tkinter_handler:
try:
self._tkinter_handler.close()
except Exception:
pass
# Shutdown logging system
try:
logging.shutdown()
except Exception:
pass
@property
def is_active(self) -> bool:
"""Ritorna True se il sistema di logging è attivo."""
return self._is_active
# --- Utility functions ---
def get_logger(name: str) -> logging.Logger:
"""
Ottiene un logger standard Python.
Wrapper convenience su logging.getLogger().
Args:
name: Nome del logger (tipicamente __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)
@contextmanager
def temporary_log_level(logger: logging.Logger, level: int):
"""
Context manager per cambiare temporaneamente il livello di un logger.
Esempio:
logger = logging.getLogger("mymodule")
with temporary_log_level(logger, logging.DEBUG):
# In questo blocco il logger è a DEBUG
logger.debug("Messaggio dettagliato")
# Fuori dal blocco torna al livello precedente
Args:
logger: Logger da modificare
level: Nuovo livello temporaneo (es: logging.DEBUG)
"""
old_level = logger.level
logger.setLevel(level)
try:
yield logger
finally:
logger.setLevel(old_level)
# Esempio standalone eseguibile
if __name__ == "__main__":
import sys
print("=== TkinterLogger Test ===\n")
# Test 1: Console-only (no Tkinter)
print("Test 1: Console-only logging")
logger_sys = TkinterLogger(tk_root=None)
logger_sys.setup(enable_console=True, enable_tkinter=False)
test_logger = get_logger(__name__)
test_logger.debug("Debug message")
test_logger.info("Info message")
test_logger.warning("Warning message")
test_logger.error("Error message")
test_logger.critical("Critical message")
logger_sys.shutdown()
print("\n" + "="*50 + "\n")
# Test 2: Tkinter GUI
print("Test 2: Tkinter GUI logging")
print("Apertura finestra GUI... (chiudi per continuare)\n")
root = tk.Tk()
root.title("TkinterLogger Test")
root.geometry("800x600")
# Frame principale
frame = tk.Frame(root, padx=10, pady=10)
frame.pack(fill=tk.BOTH, expand=True)
# Label
tk.Label(
frame,
text="TkinterLogger - Test Window",
font=("Arial", 14, "bold")
).pack(pady=(0, 10))
# Log widget
log_widget = ScrolledText(frame, height=25, width=100, state=tk.DISABLED)
log_widget.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Setup logger
logger_sys2 = TkinterLogger(root)
logger_sys2.setup(enable_console=True)
logger_sys2.add_tkinter_handler(log_widget)
# Bottoni per generare log
button_frame = tk.Frame(frame)
button_frame.pack()
def log_debug():
test_logger.debug("This is a DEBUG message")
def log_info():
test_logger.info("This is an INFO message")
def log_warning():
test_logger.warning("This is a WARNING message")
def log_error():
test_logger.error("This is an ERROR message")
def log_critical():
test_logger.critical("This is a CRITICAL message")
def log_many():
for i in range(100):
test_logger.info(f"Batch message {i+1}/100")
tk.Button(button_frame, text="DEBUG", command=log_debug, width=10).pack(side=tk.LEFT, padx=2)
tk.Button(button_frame, text="INFO", command=log_info, width=10).pack(side=tk.LEFT, padx=2)
tk.Button(button_frame, text="WARNING", command=log_warning, width=10, fg="orange").pack(side=tk.LEFT, padx=2)
tk.Button(button_frame, text="ERROR", command=log_error, width=10, fg="red").pack(side=tk.LEFT, padx=2)
tk.Button(button_frame, text="CRITICAL", command=log_critical, width=10, fg="darkred").pack(side=tk.LEFT, padx=2)
tk.Button(button_frame, text="100 logs", command=log_many, width=10).pack(side=tk.LEFT, padx=10)
# Log iniziale
test_logger.info("TkinterLogger initialized - click buttons to test")
def on_closing():
logger_sys2.shutdown()
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_closing)
root.mainloop()
print("\nTest completato!")