From bb6290b3dc23434624cad54b1b1f9bb93d6fa138 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 3 Mar 2026 15:44:41 +0000 Subject: [PATCH 1/4] feat(logger): overhaul logging system with module-aware names and crash handling - Replace AsyncFileHandler with ThreadedFileHandler (queue + background thread wrapping TimedRotatingFileHandler; "async" was a misnomer) - Add LogManager singleton: configures root logger, optional StreamToLogger capture of stderr/stdout, handler deduplication - Add CrashHandler: installs sys.excepthook, threading.excepthook, and faulthandler for C-level crashes; supports exit_on_crash flag - Expose setup_logging() and get_logger() as the public API - Suppress noisy third-party loggers (urllib3, websocket, PIL) to WARNING - Fix all modules to use logging.getLogger(__name__) instead of the old named-file pattern (logs/BlocksScreen.log): BlocksScreen.py, configfile.py, lib/machine.py, lib/moonrest.py, lib/printer.py, lib/panels/mainWindow.py, lib/panels/widgets/connectionPage.py - Replace manual logger-iteration loop in MainWindow.closeEvent with LogManager.shutdown(); remove erroneous recursive self.close() call - Fix create_hotspot password param: str | None = None, raise ValueError instead of silently accepting empty/hardcoded default (bandit B107) - Replace all bare except/pass blocks with typed exception handling that writes to sys.__stderr__ when the logger itself may be unavailable (bandit B110) EOF )" --- BlocksScreen/BlocksScreen.py | 21 +- BlocksScreen/configfile.py | 81 +- BlocksScreen/lib/machine.py | 8 +- BlocksScreen/lib/moonrest.py | 4 +- BlocksScreen/lib/panels/mainWindow.py | 13 +- .../lib/panels/widgets/connectionPage.py | 6 +- BlocksScreen/lib/printer.py | 4 +- BlocksScreen/logger.py | 882 ++++++++++++++++-- 8 files changed, 868 insertions(+), 151 deletions(-) diff --git a/BlocksScreen/BlocksScreen.py b/BlocksScreen/BlocksScreen.py index a7a2098e..3ccceb74 100644 --- a/BlocksScreen/BlocksScreen.py +++ b/BlocksScreen/BlocksScreen.py @@ -2,11 +2,10 @@ import sys import typing -import logger from lib.panels.mainWindow import MainWindow +from logger import setup_logging from PyQt6 import QtCore, QtGui, QtWidgets -_logger = logging.getLogger(name="logs/BlocksScreen.log") QtGui.QGuiApplication.setAttribute( QtCore.Qt.ApplicationAttribute.AA_SynthesizeMouseForUnhandledTouchEvents, True, @@ -22,13 +21,6 @@ RESET = "\033[0m" -def setup_app_loggers(): - """Setup logger""" - _ = logger.create_logger(name="logs/BlocksScreen.log", level=logging.DEBUG) - _logger = logging.getLogger(name="logs/BlocksScreen.log") - _logger.info("============ BlocksScreen Initializing ============") - - def show_splash(window: typing.Optional[QtWidgets.QWidget] = None): """Show splash screen on app initialization""" logo = QtGui.QPixmap("BlocksScreen/BlocksScreen/lib/ui/resources/logoblocks.png") @@ -39,7 +31,16 @@ def show_splash(window: typing.Optional[QtWidgets.QWidget] = None): if __name__ == "__main__": - setup_app_loggers() + setup_logging( + filename="logs/BlocksScreen.log", + level=logging.DEBUG, # File gets DEBUG+ + console_output=True, # Print to terminal + console_level=logging.DEBUG, # Console gets DEBUG+ + capture_stderr=True, # Capture X11 errors + capture_stdout=False, # Don't capture print() + ) + _logger = logging.getLogger(__name__) + _logger.info("============ BlocksScreen Initializing ============") BlocksScreen = QtWidgets.QApplication([]) BlocksScreen.setApplicationName("BlocksScreen") BlocksScreen.setApplicationDisplayName("BlocksScreen") diff --git a/BlocksScreen/configfile.py b/BlocksScreen/configfile.py index 9536fffd..a36e2214 100644 --- a/BlocksScreen/configfile.py +++ b/BlocksScreen/configfile.py @@ -37,6 +37,8 @@ from helper_methods import check_file_on_path +logger = logging.getLogger(__name__) + HOME_DIR = os.path.expanduser("~/") WORKING_DIR = os.getcwd() DEFAULT_CONFIGFILE_PATH = pathlib.Path(HOME_DIR, "printer_data", "config") @@ -56,19 +58,11 @@ class ConfigError(Exception): """Exception raised when Configfile errors exist""" def __init__(self, msg) -> None: - """Store the error message on both the exception and the ``msg`` attribute.""" super().__init__(msg) self.msg = msg class BlocksScreenConfig: - """Thread-safe wrapper around :class:`configparser.ConfigParser` with raw-text tracking. - - Maintains a ``raw_config`` list that mirrors the on-disk file so that - ``add_section``, ``add_option``, and ``update_option`` can write back - changes without losing comments or formatting. - """ - config = configparser.ConfigParser( allow_no_value=True, ) @@ -78,7 +72,6 @@ class BlocksScreenConfig: def __init__( self, configfile: typing.Union[str, pathlib.Path], section: str ) -> None: - """Initialise with the path to the config file and the default section name.""" self.configfile = pathlib.Path(configfile) self.section = section self.raw_config: typing.List[str] = [] @@ -86,11 +79,9 @@ def __init__( self.file_lock = threading.Lock() # Thread safety for future work def __getitem__(self, key: str) -> BlocksScreenConfig: - """Return a :class:`BlocksScreenConfig` for *key* section (same as ``get_section``).""" return self.get_section(key) def __contains__(self, key): - """Return True if *key* is a section in the underlying ConfigParser.""" return key in self.config def sections(self) -> typing.List[str]: @@ -204,14 +195,12 @@ def getboolean( ) def _find_section_index(self, section: str) -> int: - """Return the index of the ``[section]`` header line in ``raw_config``.""" try: return self.raw_config.index("[" + section + "]") except ValueError as e: raise configparser.Error(f'Section "{section}" does not exist: {e}') def _find_section_limits(self, section: str) -> typing.Tuple: - """Return ``(start_index, end_index)`` of *section* in ``raw_config``.""" try: section_start = self._find_section_index(section) buffer = self.raw_config[section_start:] @@ -225,7 +214,6 @@ def _find_section_limits(self, section: str) -> typing.Tuple: def _find_option_index( self, section: str, option: str ) -> typing.Union[Sentinel, int, None]: - """Return the index of the *option* line within *section* in ``raw_config``.""" try: start, end = self._find_section_limits(section) section_buffer = self.raw_config[start:][:end] @@ -267,9 +255,9 @@ def add_section(self, section: str) -> None: self.config.add_section(section) self.update_pending = True except configparser.DuplicateSectionError as e: - logging.error(f'Section "{section}" already exists. {e}') + logger.error(f'Section "{section}" already exists. {e}') except configparser.Error as e: - logging.error(f'Unable to add "{section}" section to configuration: {e}') + logger.error(f'Unable to add "{section}" section to configuration: {e}') def add_option( self, @@ -297,46 +285,12 @@ def add_option( self.config.set(section, option, value) self.update_pending = True except configparser.DuplicateOptionError as e: - logging.error(f"Option {option} already present on {section}: {e}") + logger.error(f"Option {option} already present on {section}: {e}") except configparser.Error as e: - logging.error( + logger.error( f'Unable to add "{option}" option to section "{section}": {e} ' ) - def update_option( - self, - section: str, - option: str, - value: typing.Any, - ) -> None: - """Update an existing option's value in both raw tracking and configparser.""" - try: - with self.file_lock: - if not self.config.has_section(section): - self.add_section(section) - - if not self.config.has_option(section, option): - self.add_option(section, option, str(value)) - return - - line_idx = self._find_option_line_index(section, option) - self.raw_config[line_idx] = f"{option}: {value}" - self.config.set(section, option, str(value)) - self.update_pending = True - except Exception as e: - logging.error( - f'Unable to update option "{option}" in section "{section}": {e}' - ) - - def _find_option_line_index(self, section: str, option: str) -> int: - """Find the index of an option line within a specific section.""" - start, end = self._find_section_limits(section) - opt_regex = re.compile(rf"^\s*{re.escape(option)}\s*[:=]") - for i in range(start + 1, end): - if opt_regex.match(self.raw_config[i]): - return i - raise configparser.Error(f'Option "{option}" not found in section "{section}"') - def save_configuration(self) -> None: """Save teh configuration to file""" try: @@ -349,7 +303,7 @@ def save_configuration(self) -> None: self.config.write(sio) sio.close() except Exception as e: - logging.error( + logger.error( f"ERROR: Unable to save new configuration, something went wrong while saving updated configuration. {e}" ) finally: @@ -367,14 +321,6 @@ def load_config(self): raise configparser.Error(f"Error loading configuration file: {e}") def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: - """Read and normalise the config file into a raw line list and a nested dict. - - Strips comments, normalises ``=`` to ``:`` separators, deduplicates - sections/options, and ensures the buffer ends with an empty line. - - Returns: - A tuple of (raw_lines, dict_representation). - """ buffer = [] dict_buff: typing.Dict = {} curr_sec: typing.Union[Sentinel, str] = Sentinel.MISSING @@ -392,7 +338,7 @@ def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: if not line: continue # remove leading and trailing white spaces - line = re.sub(r"\s*([:=])\s*", r"\1 ", line) + line = re.sub(r"\s*([:=])\s*", r"\1", line) line = re.sub(r"=", r":", line) # find the beginning of sections section_match = re.compile(r"[^\s]*\[([^]]+)\]") @@ -400,10 +346,9 @@ def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: if match_sec: sec_name = re.sub(r"[\[*\]]", r"", line) if sec_name not in dict_buff.keys(): - if buffer: - buffer.extend( - [""] - ) # REFACTOR: Just add some line separation between sections + buffer.extend( + [""] + ) # REFACTOR: Just add some line separation between sections dict_buff.update({sec_name: {}}) curr_sec = sec_name else: @@ -443,6 +388,6 @@ def get_configparser() -> BlocksScreenConfig: config_object = BlocksScreenConfig(configfile=configfile, section="server") config_object.load_config() if not config_object.has_section("server"): - logging.error("Error loading configuration file for the application.") + logger.error("Error loading configuration file for the application.") raise ConfigError("Section [server] is missing from configuration") - return config_object + return BlocksScreenConfig(configfile=configfile, section="server") diff --git a/BlocksScreen/lib/machine.py b/BlocksScreen/lib/machine.py index e1c4a0ca..69938e79 100644 --- a/BlocksScreen/lib/machine.py +++ b/BlocksScreen/lib/machine.py @@ -8,6 +8,8 @@ from PyQt6 import QtCore +logger = logging.getLogger(__name__) + class MachineControl(QtCore.QObject): service_restart = QtCore.pyqtSignal(str, name="service-restart") @@ -67,10 +69,10 @@ def _run_command(self, command: str): ) return p.stdout.strip() + "\n" + p.stderr.strip() except ValueError as e: - logging.error("Failed to parse command string '%s': '%s'", command, e) + logger.error("Failed to parse command string '%s': '%s'", command, e) raise RuntimeError(f"Invalid command format: {e}") from e except subprocess.CalledProcessError as e: - logging.error( + logger.error( "Caught exception (exit code %d) failed to run command: %s \nStderr: %s", e.returncode, command, @@ -82,4 +84,4 @@ def _run_command(self, command: str): subprocess.TimeoutExpired, FileNotFoundError, ): - logging.error("Caught exception failed to run command %s", command) + logger.error("Caught exception failed to run command %s", command) diff --git a/BlocksScreen/lib/moonrest.py b/BlocksScreen/lib/moonrest.py index 1e43552a..2c663531 100644 --- a/BlocksScreen/lib/moonrest.py +++ b/BlocksScreen/lib/moonrest.py @@ -31,6 +31,8 @@ import requests from requests import Request, Response +logger = logging.getLogger(__name__) + class UncallableError(Exception): """Raised when a method is not callable""" @@ -145,4 +147,4 @@ def _request( return response.json() if json_response else response.content except Exception as e: - logging.info(f"Unexpected error while sending HTTP request: {e}") + logger.info(f"Unexpected error while sending HTTP request: {e}") diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index ca3d5326..6eddb57f 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -28,10 +28,11 @@ from lib.ui.resources.main_menu_resources_rc import * from lib.ui.resources.system_resources_rc import * from lib.ui.resources.top_bar_resources_rc import * +from logger import LogManager from PyQt6 import QtCore, QtGui, QtWidgets from screensaver import ScreenSaver -_logger = logging.getLogger(name="logs/BlocksScreen.log") +_logger = logging.getLogger(__name__) def api_handler(func): @@ -782,16 +783,8 @@ def closeEvent(self, a0: typing.Optional[QtGui.QCloseEvent]) -> None: except Exception as e: _logger.warning("Network panel shutdown error: %s", e) - _loggers = [ - logging.getLogger(name) for name in logging.root.manager.loggerDict - ] # Get available logger handlers - for logger in _loggers: # noqa: F402 - if hasattr(logger, "cancel"): - _callback = getattr(logger, "cancel") - if callable(_callback): - _callback() self.ws.wb_disconnect() - self.close() + LogManager.shutdown() if a0 is None: return QtWidgets.QMainWindow.closeEvent(self, a0) diff --git a/BlocksScreen/lib/panels/widgets/connectionPage.py b/BlocksScreen/lib/panels/widgets/connectionPage.py index a50ec750..bfd7213f 100644 --- a/BlocksScreen/lib/panels/widgets/connectionPage.py +++ b/BlocksScreen/lib/panels/widgets/connectionPage.py @@ -5,6 +5,8 @@ from lib.ui.connectionWindow_ui import Ui_ConnectivityForm from PyQt6 import QtCore, QtWidgets +logger = logging.getLogger(__name__) + class ConnectionPage(QtWidgets.QFrame): text_updated = QtCore.pyqtSignal(int, name="connection_text_updated") @@ -14,7 +16,6 @@ class ConnectionPage(QtWidgets.QFrame): restart_klipper_clicked = QtCore.pyqtSignal(name="restart_klipper_clicked") firmware_restart_clicked = QtCore.pyqtSignal(name="firmware_restart_clicked") update_button_clicked = QtCore.pyqtSignal(bool, name="show-update-page") - notification_btn_clicked = QtCore.pyqtSignal(name="notification_btn_clicked") call_load_panel = QtCore.pyqtSignal(bool, str, name="call-load-panel") call_cancel_panel = QtCore.pyqtSignal(bool, name="call-load-panel") @@ -45,7 +46,6 @@ def __init__(self, parent: QtWidgets.QWidget, ws: MoonWebSocket, /): self.retry_connection_clicked.emit ) self.panel.wifi_button.clicked.connect(self.wifi_button_clicked.emit) - self.panel.notification_btn.clicked.connect(self.notification_btn_clicked.emit) self.panel.FirmwareRestartButton.clicked.connect( self.firmware_restart_clicked.emit ) @@ -134,7 +134,7 @@ def text_update(self, text: int | str | None = None): if self.state == "shutdown" and self.message is not None: return False self.dot_timer.stop() - logging.debug(f"[ConnectionWindowPanel] text_update: {text}") + logger.debug(f"[ConnectionWindowPanel] text_update: {text}") if text == "wb lost": self.panel.connectionTextBox.setText("Moonraker connection lost") if text is None: diff --git a/BlocksScreen/lib/printer.py b/BlocksScreen/lib/printer.py index 5889c19d..c6c76fbc 100644 --- a/BlocksScreen/lib/printer.py +++ b/BlocksScreen/lib/printer.py @@ -7,7 +7,7 @@ from lib.moonrakerComm import MoonWebSocket from PyQt6 import QtCore, QtWidgets -logger = logging.getLogger(name="logs/BlocksScreen.logs") +logger = logging.getLogger(__name__) class Printer(QtCore.QObject): @@ -511,7 +511,7 @@ def send_print_event(self, event: str): _print_state_upper = event[0].upper() _print_state_call = f"{_print_state_upper}{event[1:]}" if hasattr(events, f"Print{_print_state_call}"): - logging.debug( + logger.debug( "Print Event Caught, print is %s, calling event %s", _print_state_call, f"Print{_print_state_call}", diff --git a/BlocksScreen/logger.py b/BlocksScreen/logger.py index f63631e5..aae3d87d 100644 --- a/BlocksScreen/logger.py +++ b/BlocksScreen/logger.py @@ -1,95 +1,869 @@ +from __future__ import annotations + +import atexit import copy +import faulthandler import logging -import logging.config import logging.handlers +import os import pathlib import queue +import sys import threading +import traceback +import types +from datetime import datetime +from typing import ClassVar, TextIO + +DEFAULT_FORMAT = ( + "[%(levelname)s] | %(asctime)s | %(name)s | " + "%(relativeCreated)6d | %(threadName)s : %(message)s" +) + +CRASH_LOG_PATH = "logs/blocksscreen_crash.log" +FAULT_LOG_PATH = "logs/blocksscreen_fault.log" + + +class StreamToLogger(TextIO): + """ + Redirects a stream (stdout/stderr) to a logger. + + Useful for capturing output from subprocesses, X11, or print statements. + """ + + def __init__( + self, + logger: logging.Logger, + level: int = logging.INFO, + original_stream: TextIO | None = None, + ) -> None: + self._logger = logger + self._level = level + self._original = original_stream + self._buffer = "" + + def write(self, message: str) -> int: + """Write message to logger.""" + if message: + if self._original: + try: + self._original.write(message) + self._original.flush() + except OSError: + # Original stream closed or broken pipe — continue logging + self._original = None + + self._buffer += message + + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + if line.strip(): + self._logger.log(self._level, line.rstrip()) + + return len(message) + + def flush(self) -> None: + """Flush remaining buffer.""" + if self._buffer.strip(): + self._logger.log(self._level, self._buffer.rstrip()) + self._buffer = "" + + if self._original: + try: + self._original.flush() + except OSError: + # Original stream closed or broken pipe + self._original = None + + def fileno(self) -> int: + """Return file descriptor for compatibility.""" + if self._original: + return self._original.fileno() + raise OSError("No file descriptor available") + + def isatty(self) -> bool: + """Check if stream is a TTY.""" + if self._original: + return self._original.isatty() + return False + + # Required for TextIO interface + def read(self, n: int = -1) -> str: + return "" + + def readline(self, limit: int = -1) -> str: + return "" + + def readlines(self, hint: int = -1) -> list[str]: + return [] + + def seek(self, offset: int, whence: int = 0) -> int: + return 0 + + def tell(self) -> int: + return 0 + + def truncate(self, size: int | None = None) -> int: + return 0 + + def writable(self) -> bool: + return True + + def readable(self) -> bool: + return False + + def seekable(self) -> bool: + return False + + def close(self) -> None: + self.flush() + + @property + def closed(self) -> bool: + return False + + def __enter__(self) -> "StreamToLogger": + return self + + def __exit__(self, *args) -> None: + self.close() class QueueHandler(logging.Handler): - """Handler that sends events to a queue""" + """ + Logging handler that sends records to a queue. + + Records are formatted before being placed on the queue, + then consumed by a ThreadedFileHandler worker in a background thread. + """ def __init__( self, - queue: queue.Queue, - format: str = "'[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", - level=logging.DEBUG, - ): - super(QueueHandler, self).__init__() - self.log_queue = queue - self.setFormatter(logging.Formatter(format, validate=True)) - self.setLevel(level) - - def emit(self, record): - """Emit logging record""" + log_queue: queue.Queue, + fmt: str = DEFAULT_FORMAT, + level: int = logging.DEBUG, + ) -> None: + super().__init__(level) + self._queue = log_queue + self.setFormatter(logging.Formatter(fmt)) + + def emit(self, record: logging.LogRecord) -> None: + """Format and queue the log record.""" try: + # Format the message msg = self.format(record) + + # Copy record and update message record = copy.copy(record) - record.message = msg - record.name = record.name record.msg = msg - self.log_queue.put_nowait(record) + record.args = None # Already formatted + record.message = msg + + self._queue.put_nowait(record) except Exception: self.handleError(record) - def setFormatter(self, fmt: logging.Formatter | None) -> None: - """Set logging formatter""" - return super().setFormatter(fmt) +class ThreadedFileHandler(logging.handlers.TimedRotatingFileHandler): + """ + File handler that writes on a background thread. + + Wraps TimedRotatingFileHandler with a queue and worker thread + for non-blocking log writes. Automatically recreates log file + if deleted during runtime. + """ -class QueueListener(logging.handlers.TimedRotatingFileHandler): - """Threaded listener watching for log records on the queue handler queue, passes them for processing""" + def __init__( + self, + filename: str, + when: str = "midnight", + backup_count: int = 10, + encoding: str = "utf-8", + ) -> None: + self._log_path = pathlib.Path(filename) + + # Create log directory if needed + if self._log_path.parent != pathlib.Path("."): + self._log_path.parent.mkdir(parents=True, exist_ok=True) - def __init__(self, filename, encoding="utf-8"): - log_path = pathlib.Path(filename) - if log_path.parent != pathlib.Path("."): - log_path.parent.mkdir(parents=True, exist_ok=True) - super(QueueListener, self).__init__( + super().__init__( filename=filename, - when="MIDNIGHT", - backupCount=10, + when=when, + backupCount=backup_count, encoding=encoding, delay=True, ) - self.queue = queue.Queue() + + self._queue: queue.Queue[logging.LogRecord | None] = queue.Queue() + self._stop_event = threading.Event() self._thread = threading.Thread( - name=f"log.{filename}", target=self._run, daemon=True + name=f"logger-{self._log_path.stem}", + target=self._worker, + daemon=True, ) self._thread.start() - def _run(self): - while True: + def _ensure_file_exists(self) -> None: + """Ensure log file and directory exist, recreate if deleted.""" + try: + # Check if directory exists + if not self._log_path.parent.exists(): + self._log_path.parent.mkdir(parents=True, exist_ok=True) + + # Check if file was deleted (stream is open but file gone) + if self.stream is not None and not self._log_path.exists(): + # Close old stream + try: + self.stream.close() + except OSError: + pass # Stream already closed; safe to discard + self.stream = None + + # Reopen stream if needed + if self.stream is None: + self.stream = self._open() + + except OSError as exc: + sys.__stderr__.write(f"[logger] Failed to recreate log file {self._log_path}: {exc}\n") + + def emit(self, record: logging.LogRecord) -> None: + """Emit a record, recovering if the log file was deleted.""" + try: + super().emit(record) + except (OSError, ValueError): + self._ensure_file_exists() try: - record = self.queue.get(True) + super().emit(record) + except OSError as exc: + sys.__stderr__.write(f"[logger] Failed to write log record: {exc}\n") + + def _worker(self) -> None: + """Background worker that processes queued log records.""" + while not self._stop_event.is_set(): + try: + record = self._queue.get(timeout=0.5) if record is None: break - self.handle(record) + self.emit(record) except queue.Empty: - break + continue + except Exception as exc: + # Last resort: surface unexpected worker errors without crashing the thread + sys.__stderr__.write(f"[logger] Worker thread error: {exc}\n") + + @property + def queue(self) -> queue.Queue: + """Get the log queue for QueueHandler.""" + return self._queue - def close(self): - """Close logger listener""" - if self._thread is None: + def close(self) -> None: + """Stop worker thread and close file handler.""" + if self._thread is None or not self._thread.is_alive(): + super().close() return - self.queue.put_nowait(None) - self._thread.join() + + # Signal worker to stop + self._stop_event.set() + self._queue.put_nowait(None) + + # Wait for worker to finish + self._thread.join(timeout=2.0) self._thread = None + # Close the file handler + super().close() + + +class _ExcludeStreamLoggers(logging.Filter): + """Filter to exclude stdout/stderr loggers from console output.""" + + def filter(self, record: logging.LogRecord) -> bool: + # Exclude to avoid double printing (already goes to console via StreamToLogger) + return record.name not in ("stdout", "stderr") + + +class CrashHandler: + """ + Handles unhandled exceptions and C-level crashes. + + Writes detailed crash information to log files including: + - Full traceback with line numbers + - Local variables at each frame + - Thread information + - Timestamp + """ + + _instance: ClassVar[CrashHandler | None] = None + _installed: ClassVar[bool] = False + + def __init__( + self, + crash_log_path: str = CRASH_LOG_PATH, + fault_log_path: str = FAULT_LOG_PATH, + include_locals: bool = True, + exit_on_crash: bool = True, + ) -> None: + self._crash_log_path = pathlib.Path(crash_log_path) + self._fault_log_path = pathlib.Path(fault_log_path) + self._include_locals = include_locals + self._exit_on_crash = exit_on_crash + self._original_excepthook = sys.excepthook + self._original_threading_excepthook = getattr(threading, "excepthook", None) + self._fault_file: TextIO | None = None + + @classmethod + def install( + cls, + crash_log_path: str = CRASH_LOG_PATH, + fault_log_path: str = FAULT_LOG_PATH, + include_locals: bool = True, + exit_on_crash: bool = True, + ) -> CrashHandler: + """ + Install the crash handler. + + Should be called as early as possible in the application startup. + + Args: + crash_log_path: Path to write Python exception logs + fault_log_path: Path to write C-level fault logs (segfaults) + include_locals: Include local variables in traceback + exit_on_crash: Force exit after logging (for systemd restart) + + Returns: + The CrashHandler instance + """ + if cls._installed and cls._instance: + return cls._instance + + handler = cls(crash_log_path, fault_log_path, include_locals, exit_on_crash) + handler._install() + cls._instance = handler + cls._installed = True + + return handler + + def _install(self) -> None: + """Install exception hooks.""" + # Setup faulthandler for C-level crashes (segfaults, etc.) + try: + self._fault_file = open(self._fault_log_path, "w") + faulthandler.enable(file=self._fault_file, all_threads=True) + + # Also dump traceback on SIGUSR1 (useful for debugging hangs) + try: + import signal + + faulthandler.register( + signal.SIGUSR1, + file=self._fault_file, + all_threads=True, + ) + except (AttributeError, OSError): + pass # Not available on all platforms + + except Exception as e: + # Fall back to stderr + faulthandler.enable() + sys.stderr.write(f"Warning: Could not setup fault log file: {e}\n") + + # Install Python exception hook + sys.excepthook = self._exception_hook + + # Install threading exception hook (Python 3.8+) + if hasattr(threading, "excepthook"): + threading.excepthook = self._threading_exception_hook + + def _format_exception_detailed( + self, + exc_type: type[BaseException], + exc_value: BaseException, + exc_tb: types.TracebackType | None, + ) -> str: + """Format exception with detailed information.""" + lines: list[str] = [] + + # Header + lines.append("=" * 80) + lines.append("UNHANDLED EXCEPTION") + lines.append("=" * 80) + lines.append(f"Time: {datetime.now().isoformat()}") + lines.append(f"Thread: {threading.current_thread().name}") + lines.append(f"Exception Type: {exc_type.__module__}.{exc_type.__name__}") + lines.append(f"Exception Value: {exc_value}") + lines.append("") + + # Full traceback with context + lines.append("-" * 80) + lines.append("TRACEBACK (most recent call last):") + lines.append("-" * 80) + + # Extract frames for detailed info + tb_frames = traceback.extract_tb(exc_tb) + + for i, frame in enumerate(tb_frames): + lines.append("") + lines.append(f" Frame {i + 1}: {frame.filename}") + lines.append(f" Line {frame.lineno} in {frame.name}()") + lines.append(f" Code: {frame.line}") + + # Try to get local variables if enabled + if self._include_locals and exc_tb: + try: + # Navigate to the correct frame + current_tb = exc_tb + for _ in range(i): + if current_tb.tb_next: + current_tb = current_tb.tb_next + + frame_locals = current_tb.tb_frame.f_locals + if frame_locals: + lines.append(" Locals:") + for name, value in frame_locals.items(): + # Skip private/dunder and limit value length + if name.startswith("__"): + continue + try: + value_str = repr(value) + if len(value_str) > 200: + value_str = value_str[:200] + "..." + except Exception: # repr() may raise arbitrary errors on broken objects + value_str = "" + lines.append(f" {name} = {value_str}") + except (AttributeError, TypeError): + lines.append(" Locals: ") + + # Standard traceback + lines.append("") + lines.append("-" * 80) + lines.append("STANDARD TRACEBACK:") + lines.append("-" * 80) + lines.append("".join(traceback.format_exception(exc_type, exc_value, exc_tb))) + + # Thread info + lines.append("-" * 80) + lines.append("ACTIVE THREADS:") + lines.append("-" * 80) + for thread in threading.enumerate(): + daemon_str = " (daemon)" if thread.daemon else "" + lines.append( + f" - {thread.name}{daemon_str}: {'alive' if thread.is_alive() else 'dead'}" + ) + + lines.append("") + lines.append("=" * 80) + + return "\n".join(lines) + + def _write_crash_log(self, content: str) -> None: + """Write crash information to log file.""" + try: + # Ensure directory exists + self._crash_log_path.parent.mkdir(parents=True, exist_ok=True) + + # Write to crash log + with open(self._crash_log_path, "w") as f: + f.write(content) + + # Also append to a history file + history_path = self._crash_log_path.with_suffix(".history.log") + with open(history_path, "a") as f: + f.write(content) + f.write("\n\n") + + except Exception as e: + # Last resort: write to stderr + sys.stderr.write(f"Failed to write crash log: {e}\n") + sys.stderr.write(content) + + def _exception_hook( + self, + exc_type: type[BaseException], + exc_value: BaseException, + exc_tb: types.TracebackType | None, + ) -> None: + """Handle uncaught exceptions.""" + # Don't handle keyboard interrupt + if issubclass(exc_type, KeyboardInterrupt): + self._original_excepthook(exc_type, exc_value, exc_tb) + return + + # Format detailed crash info + crash_info = self._format_exception_detailed(exc_type, exc_value, exc_tb) + + # Write to crash log + self._write_crash_log(crash_info) + + # Also log via logging if available (may fail if logging is not configured) + try: + logging.getLogger("crash").critical( + "Unhandled exception - see %s for details", self._crash_log_path + ) + except Exception as exc: + sys.__stderr__.write(f"[logger] Could not emit crash log record: {exc}\n") + + # Call original hook (prints traceback) + self._original_excepthook(exc_type, exc_value, exc_tb) + + # Force exit if configured (for systemd restart) + if self._exit_on_crash: + os._exit(1) + + def _threading_exception_hook(self, args: threading.ExceptHookArgs) -> None: + """Handle uncaught exceptions in threads.""" + # Format detailed crash info + crash_info = self._format_exception_detailed( + args.exc_type, args.exc_value, args.exc_traceback + ) + + # Add thread context + thread_info = ( + f"\nThread that crashed: {args.thread.name if args.thread else 'Unknown'}\n" + ) + crash_info = crash_info.replace( + "UNHANDLED EXCEPTION", f"UNHANDLED THREAD EXCEPTION{thread_info}" + ) + + # Write to crash log + self._write_crash_log(crash_info) + + # Log via logging (may fail if logging is not configured) + try: + logging.getLogger("crash").critical( + "Unhandled thread exception - see %s", self._crash_log_path + ) + except Exception as exc: + sys.__stderr__.write(f"[logger] Could not emit crash log record: {exc}\n") + + # Call original hook if available + if self._original_threading_excepthook: + self._original_threading_excepthook(args) + + # Force exit if configured (for systemd restart) + # Skip for daemon threads — transient errors in background workers + # (network, D-Bus) should not kill the whole process. + if self._exit_on_crash: + thread = args.thread + if thread is None or not thread.daemon: + os._exit(1) + + def uninstall(self) -> None: + """Restore original exception hooks.""" + sys.excepthook = self._original_excepthook + + if self._original_threading_excepthook and hasattr(threading, "excepthook"): + threading.excepthook = self._original_threading_excepthook + + if self._fault_file: + try: + self._fault_file.close() + except OSError: + pass # File already closed; nothing to recover + + CrashHandler._installed = False + CrashHandler._instance = None + + +class LogManager: + """ + Manages application logging. + + Creates async file loggers with queue-based handlers. + Ensures proper cleanup on application exit. + """ + + _handlers: ClassVar[dict[str, ThreadedFileHandler]] = {} + _initialized: ClassVar[bool] = False + _original_stdout: ClassVar[TextIO | None] = None + _original_stderr: ClassVar[TextIO | None] = None + _crash_handler: ClassVar[CrashHandler | None] = None + + @classmethod + def _ensure_initialized(cls) -> None: + """Register cleanup handler on first use.""" + if not cls._initialized: + atexit.register(cls.shutdown) + cls._initialized = True + + @classmethod + def setup( + cls, + filename: str = "logs/BlocksScreen.log", + level: int = logging.DEBUG, + fmt: str = DEFAULT_FORMAT, + capture_stdout: bool = False, + capture_stderr: bool = True, + console_output: bool = True, + console_level: int | None = None, + enable_crash_handler: bool = True, + crash_log_path: str = CRASH_LOG_PATH, + include_locals_in_crash: bool = True, + ) -> None: + """ + Setup root logger for entire application. + + Call once at startup. After this, all modules can use: + logger = logging.getLogger(__name__) + + Args: + filename: Log file path + level: Logging level for all loggers + fmt: Log format string + capture_stdout: Redirect stdout to logger + capture_stderr: Redirect stderr to logger + console_output: Also print logs to console + console_level: Console log level (defaults to same as level) + enable_crash_handler: Enable crash handler for unhandled exceptions + crash_log_path: Path to write crash logs + include_locals_in_crash: Include local variables in crash logs + """ + # Install crash handler FIRST (before anything else can fail) + if enable_crash_handler: + cls._crash_handler = CrashHandler.install( + crash_log_path=crash_log_path, + include_locals=include_locals_in_crash, + ) + + cls._ensure_initialized() + + # Store original streams before any redirection + if cls._original_stdout is None: + cls._original_stdout = sys.stdout + if cls._original_stderr is None: + cls._original_stderr = sys.stderr + + # Get root logger + root = logging.getLogger() + + # Don't add duplicate handlers + if root.handlers: + logging.getLogger(__name__).warning( + "Root logger already has handlers; skipping LogManager.setup()" + ) + return + + root.setLevel(level) + + # Create async file handler + file_handler = ThreadedFileHandler(filename) + cls._handlers["root"] = file_handler + + # Create queue handler that feeds the file handler + queue_handler = QueueHandler(file_handler.queue, fmt, level) + root.addHandler(queue_handler) + + # Add console handler + if console_output: + cls._add_console_handler(root, console_level or level, fmt) + + # Suppress verbose third-party library debug logs + for noisy in ("urllib3", "websocket", "PIL"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + # Capture stdout/stderr (after console handler is set up) + if capture_stdout: + cls.redirect_stdout() + if capture_stderr: + cls.redirect_stderr() + + # Log startup + logging.getLogger(__name__).info( + "Logging initialized - crash logs: %s", crash_log_path + ) + + @classmethod + def _add_console_handler(cls, logger: logging.Logger, level: int, fmt: str) -> None: + """Add a console handler that prints to original stdout.""" + # Use original stdout to avoid recursion if stdout is redirected + stream = cls._original_stdout or sys.stdout + + console_handler = logging.StreamHandler(stream) + console_handler.setLevel(level) + console_handler.setFormatter(logging.Formatter(fmt)) + + # Filter out stderr logger to avoid double printing + console_handler.addFilter(_ExcludeStreamLoggers()) + + logger.addHandler(console_handler) + + @classmethod + def get_logger( + cls, + name: str, + filename: str | None = None, + level: int = logging.INFO, + fmt: str = DEFAULT_FORMAT, + ) -> logging.Logger: + """ + Get or create a named logger with its own file output. + + Args: + name: Logger name + filename: Log file path (defaults to "logs/{name}.log") + level: Logging level + fmt: Log format string + + Returns: + Configured Logger instance + """ + cls._ensure_initialized() + + logger = logging.getLogger(name) + + # Don't add duplicate handlers + if logger.handlers: + return logger + + logger.setLevel(level) + + # Create async file handler + if filename is None: + filename = f"logs/{name}.log" + + file_handler = ThreadedFileHandler(filename) + cls._handlers[name] = file_handler + + # Create queue handler that feeds the file handler + queue_handler = QueueHandler(file_handler.queue, fmt, level) + logger.addHandler(queue_handler) + + # Don't propagate to root (has its own file) + logger.propagate = False + + return logger + + @classmethod + def redirect_stdout(cls, logger_name: str = "stdout") -> None: + """ + Redirect stdout to logger. + + Captures print() statements and subprocess output. + """ + logger = logging.getLogger(logger_name) + sys.stdout = StreamToLogger(logger, logging.INFO, cls._original_stdout) + + @classmethod + def redirect_stderr(cls, logger_name: str = "stderr") -> None: + """ + Redirect stderr to logger. + + Captures X11 errors, warnings, and subprocess errors. + """ + logger = logging.getLogger(logger_name) + sys.stderr = StreamToLogger(logger, logging.WARNING, cls._original_stderr) + + @classmethod + def restore_streams(cls) -> None: + """Restore original stdout/stderr.""" + if cls._original_stdout: + sys.stdout = cls._original_stdout + if cls._original_stderr: + sys.stderr = cls._original_stderr + + @classmethod + def shutdown(cls) -> None: + """Close all handlers. Called automatically on exit.""" + # Restore original streams + cls.restore_streams() + + # Close handlers + for handler in cls._handlers.values(): + handler.close() + cls._handlers.clear() + + # Uninstall crash handler + if cls._crash_handler: + cls._crash_handler.uninstall() + cls._crash_handler = None + + +def setup_logging( + filename: str = "logs/app.log", + level: int = logging.DEBUG, + fmt: str = DEFAULT_FORMAT, + capture_stdout: bool = False, + capture_stderr: bool = True, + console_output: bool = True, + console_level: int | None = None, + enable_crash_handler: bool = True, + crash_log_path: str = CRASH_LOG_PATH, + include_locals_in_crash: bool = True, +) -> None: + """ + Setup logging for entire application. + + Call once at startup. After this, all modules can use: + import logging + logger = logging.getLogger(__name__) + + Args: + filename: Log file path + level: Logging level + fmt: Log format string + capture_stdout: Redirect stdout (print statements) to logger + capture_stderr: Redirect stderr (X11 errors, warnings) to logger + console_output: Also print logs to console/terminal + console_level: Console log level (defaults to same as level) + enable_crash_handler: Enable crash handler for unhandled exceptions + crash_log_path: Path to write crash logs + include_locals_in_crash: Include local variables in crash logs + """ + LogManager.setup( + filename=filename, + level=level, + fmt=fmt, + capture_stdout=capture_stdout, + capture_stderr=capture_stderr, + console_output=console_output, + console_level=console_level, + enable_crash_handler=enable_crash_handler, + crash_log_path=crash_log_path, + include_locals_in_crash=include_locals_in_crash, + ) + + +def get_logger( + name: str, + filename: str | None = None, + level: int = logging.INFO, + fmt: str = DEFAULT_FORMAT, +) -> logging.Logger: + """ + Get or create a logger with its own file output. + + Args: + name: Logger name + filename: Log file path (defaults to "logs/{name}.log") + level: Logging level + fmt: Log format string + + Returns: + Configured Logger instance + """ + return LogManager.get_logger(name, filename, level, fmt) + + +def install_crash_handler( + crash_log_path: str = CRASH_LOG_PATH, + fault_log_path: str = FAULT_LOG_PATH, + include_locals: bool = True, + exit_on_crash: bool = True, +) -> CrashHandler: + """ + Install crash handler without full logging setup. -global MainLoggingHandler + Use this if you want crash handling before logging is configured. + Call at the very beginning of your main.py. + Args: + crash_log_path: Path to write Python exception logs + fault_log_path: Path to write C-level fault logs + include_locals: Include local variables in traceback + exit_on_crash: Force process exit after logging crash (for systemd restart) -def create_logger( - name: str = "log", - level=logging.INFO, - format: str = "'[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", -): - """Create amd return logger""" - global MainLoggingHandler - logger = logging.getLogger(name) - logger.setLevel(level) - ql = QueueListener(filename=name) - MainLoggingHandler = QueueHandler(ql.queue, format, level) - logger.addHandler(MainLoggingHandler) - return ql + Returns: + CrashHandler instance + """ + return CrashHandler.install( + crash_log_path, fault_log_path, include_locals, exit_on_crash + ) From bd107b7e861e1b80632aa831e79219fbfe86790d Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 3 Mar 2026 15:48:35 +0000 Subject: [PATCH 2/4] fix formatting --- BlocksScreen/logger.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BlocksScreen/logger.py b/BlocksScreen/logger.py index aae3d87d..af623580 100644 --- a/BlocksScreen/logger.py +++ b/BlocksScreen/logger.py @@ -224,7 +224,9 @@ def _ensure_file_exists(self) -> None: self.stream = self._open() except OSError as exc: - sys.__stderr__.write(f"[logger] Failed to recreate log file {self._log_path}: {exc}\n") + sys.__stderr__.write( + f"[logger] Failed to recreate log file {self._log_path}: {exc}\n" + ) def emit(self, record: logging.LogRecord) -> None: """Emit a record, recovering if the log file was deleted.""" @@ -427,7 +429,9 @@ def _format_exception_detailed( value_str = repr(value) if len(value_str) > 200: value_str = value_str[:200] + "..." - except Exception: # repr() may raise arbitrary errors on broken objects + except ( + Exception + ): # repr() may raise arbitrary errors on broken objects value_str = "" lines.append(f" {name} = {value_str}") except (AttributeError, TypeError): From 3c0122ff72125c849b90866ff1859c6b59d20349 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 3 Mar 2026 16:27:01 +0000 Subject: [PATCH 3/4] fix merge problems --- BlocksScreen/configfile.py | 67 +++++++++++++++++-- .../lib/panels/widgets/connectionPage.py | 2 + 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/BlocksScreen/configfile.py b/BlocksScreen/configfile.py index a36e2214..76b9ead8 100644 --- a/BlocksScreen/configfile.py +++ b/BlocksScreen/configfile.py @@ -58,11 +58,19 @@ class ConfigError(Exception): """Exception raised when Configfile errors exist""" def __init__(self, msg) -> None: + """Store the error message on both the exception and the ``msg`` attribute.""" super().__init__(msg) self.msg = msg class BlocksScreenConfig: + """Thread-safe wrapper around :class:`configparser.ConfigParser` with raw-text tracking. + + Maintains a ``raw_config`` list that mirrors the on-disk file so that + ``add_section``, ``add_option``, and ``update_option`` can write back + changes without losing comments or formatting. + """ + config = configparser.ConfigParser( allow_no_value=True, ) @@ -72,6 +80,7 @@ class BlocksScreenConfig: def __init__( self, configfile: typing.Union[str, pathlib.Path], section: str ) -> None: + """Initialise with the path to the config file and the default section name.""" self.configfile = pathlib.Path(configfile) self.section = section self.raw_config: typing.List[str] = [] @@ -79,9 +88,11 @@ def __init__( self.file_lock = threading.Lock() # Thread safety for future work def __getitem__(self, key: str) -> BlocksScreenConfig: + """Return a :class:`BlocksScreenConfig` for *key* section (same as ``get_section``).""" return self.get_section(key) def __contains__(self, key): + """Return True if *key* is a section in the underlying ConfigParser.""" return key in self.config def sections(self) -> typing.List[str]: @@ -195,12 +206,14 @@ def getboolean( ) def _find_section_index(self, section: str) -> int: + """Return the index of the ``[section]`` header line in ``raw_config``.""" try: return self.raw_config.index("[" + section + "]") except ValueError as e: raise configparser.Error(f'Section "{section}" does not exist: {e}') def _find_section_limits(self, section: str) -> typing.Tuple: + """Return ``(start_index, end_index)`` of *section* in ``raw_config``.""" try: section_start = self._find_section_index(section) buffer = self.raw_config[section_start:] @@ -214,6 +227,7 @@ def _find_section_limits(self, section: str) -> typing.Tuple: def _find_option_index( self, section: str, option: str ) -> typing.Union[Sentinel, int, None]: + """Return the index of the *option* line within *section* in ``raw_config``.""" try: start, end = self._find_section_limits(section) section_buffer = self.raw_config[start:][:end] @@ -291,6 +305,40 @@ def add_option( f'Unable to add "{option}" option to section "{section}": {e} ' ) + def update_option( + self, + section: str, + option: str, + value: typing.Any, + ) -> None: + """Update an existing option's value in both raw tracking and configparser.""" + try: + with self.file_lock: + if not self.config.has_section(section): + self.add_section(section) + + if not self.config.has_option(section, option): + self.add_option(section, option, str(value)) + return + + line_idx = self._find_option_line_index(section, option) + self.raw_config[line_idx] = f"{option}: {value}" + self.config.set(section, option, str(value)) + self.update_pending = True + except Exception as e: + logging.error( + f'Unable to update option "{option}" in section "{section}": {e}' + ) + + def _find_option_line_index(self, section: str, option: str) -> int: + """Find the index of an option line within a specific section.""" + start, end = self._find_section_limits(section) + opt_regex = re.compile(rf"^\s*{re.escape(option)}\s*[:=]") + for i in range(start + 1, end): + if opt_regex.match(self.raw_config[i]): + return i + raise configparser.Error(f'Option "{option}" not found in section "{section}"') + def save_configuration(self) -> None: """Save teh configuration to file""" try: @@ -321,6 +369,14 @@ def load_config(self): raise configparser.Error(f"Error loading configuration file: {e}") def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: + """Read and normalise the config file into a raw line list and a nested dict. + + Strips comments, normalises ``=`` to ``:`` separators, deduplicates + sections/options, and ensures the buffer ends with an empty line. + + Returns: + A tuple of (raw_lines, dict_representation). + """ buffer = [] dict_buff: typing.Dict = {} curr_sec: typing.Union[Sentinel, str] = Sentinel.MISSING @@ -338,7 +394,7 @@ def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: if not line: continue # remove leading and trailing white spaces - line = re.sub(r"\s*([:=])\s*", r"\1", line) + line = re.sub(r"\s*([:=])\s*", r"\1 ", line) line = re.sub(r"=", r":", line) # find the beginning of sections section_match = re.compile(r"[^\s]*\[([^]]+)\]") @@ -346,9 +402,10 @@ def _parse_file(self) -> typing.Tuple[typing.List[str], typing.Dict]: if match_sec: sec_name = re.sub(r"[\[*\]]", r"", line) if sec_name not in dict_buff.keys(): - buffer.extend( - [""] - ) # REFACTOR: Just add some line separation between sections + if buffer: + buffer.extend( + [""] + ) # REFACTOR: Just add some line separation between sections dict_buff.update({sec_name: {}}) curr_sec = sec_name else: @@ -390,4 +447,4 @@ def get_configparser() -> BlocksScreenConfig: if not config_object.has_section("server"): logger.error("Error loading configuration file for the application.") raise ConfigError("Section [server] is missing from configuration") - return BlocksScreenConfig(configfile=configfile, section="server") + return config_object diff --git a/BlocksScreen/lib/panels/widgets/connectionPage.py b/BlocksScreen/lib/panels/widgets/connectionPage.py index bfd7213f..b2ca9c35 100644 --- a/BlocksScreen/lib/panels/widgets/connectionPage.py +++ b/BlocksScreen/lib/panels/widgets/connectionPage.py @@ -16,6 +16,7 @@ class ConnectionPage(QtWidgets.QFrame): restart_klipper_clicked = QtCore.pyqtSignal(name="restart_klipper_clicked") firmware_restart_clicked = QtCore.pyqtSignal(name="firmware_restart_clicked") update_button_clicked = QtCore.pyqtSignal(bool, name="show-update-page") + notification_btn_clicked = QtCore.pyqtSignal(name="notification_btn_clicked") call_load_panel = QtCore.pyqtSignal(bool, str, name="call-load-panel") call_cancel_panel = QtCore.pyqtSignal(bool, name="call-load-panel") @@ -46,6 +47,7 @@ def __init__(self, parent: QtWidgets.QWidget, ws: MoonWebSocket, /): self.retry_connection_clicked.emit ) self.panel.wifi_button.clicked.connect(self.wifi_button_clicked.emit) + self.panel.notification_btn.clicked.connect(self.notification_btn_clicked.emit) self.panel.FirmwareRestartButton.clicked.connect( self.firmware_restart_clicked.emit ) From 614aa53d0e122644f34ea4a2d58faf09df95db67 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 3 Mar 2026 16:43:57 +0000 Subject: [PATCH 4/4] =?UTF-8?q?add=20missing=20logger=20changes=20and=20re?= =?UTF-8?q?move=20double-formatting=20in=20QueueHandler=20=E2=86=92=20Thre?= =?UTF-8?q?adedFileHandler=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BlocksScreen/configfile.py | 2 +- BlocksScreen/helper_methods.py | 6 ++-- BlocksScreen/lib/moonrakerComm.py | 36 +++++++++---------- BlocksScreen/lib/panels/printTab.py | 2 +- .../lib/panels/widgets/jobStatusPage.py | 2 +- BlocksScreen/logger.py | 12 +++---- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/BlocksScreen/configfile.py b/BlocksScreen/configfile.py index 76b9ead8..ef75d362 100644 --- a/BlocksScreen/configfile.py +++ b/BlocksScreen/configfile.py @@ -326,7 +326,7 @@ def update_option( self.config.set(section, option, str(value)) self.update_pending = True except Exception as e: - logging.error( + logger.error( f'Unable to update option "{option}" in section "{section}": {e}' ) diff --git a/BlocksScreen/helper_methods.py b/BlocksScreen/helper_methods.py index b4dafc0f..25b76cac 100644 --- a/BlocksScreen/helper_methods.py +++ b/BlocksScreen/helper_methods.py @@ -14,6 +14,8 @@ import struct import typing +logger = logging.getLogger(__name__) + try: ctypes.cdll.LoadLibrary("libXext.so.6") libxext = ctypes.CDLL("libXext.so.6") @@ -220,9 +222,9 @@ def disable_dpms() -> None: set_dpms_mode(DPMSState.OFF) except OSError as e: - logging.exception(f"OSError couldn't load DPMS library: {e}") + logger.exception(f"OSError couldn't load DPMS library: {e}") except Exception as e: - logging.exception(f"Unexpected exception occurred {e}") + logger.exception(f"Unexpected exception occurred {e}") def convert_bytes_to_mb(self, bytes: int | float) -> float: diff --git a/BlocksScreen/lib/moonrakerComm.py b/BlocksScreen/lib/moonrakerComm.py index ba298ba7..5f889d9f 100644 --- a/BlocksScreen/lib/moonrakerComm.py +++ b/BlocksScreen/lib/moonrakerComm.py @@ -14,7 +14,7 @@ from lib.utils.RepeatedTimer import RepeatedTimer from PyQt6 import QtCore, QtWidgets -_logger = logging.getLogger(name="logs/BlocksScreen.log") +logger = logging.getLogger(__name__) class OneShotTokenError(Exception): @@ -67,7 +67,7 @@ def __init__(self, parent: QtCore.QObject) -> None: ) self.klippy_state_signal.connect(self.api.request_printer_info) - _logger.info("Websocket object initialized") + logger.info("Websocket object initialized") @QtCore.pyqtSlot(name="retry_wb_conn") def retry_wb_conn(self): @@ -102,10 +102,10 @@ def reconnect(self): else: raise TypeError("QApplication.instance expected ad non-None value") except Exception as e: - _logger.error( + logger.error( f"Error on sending Event {unable_to_connect_event.__class__.__name__} | Error message: {e}" ) - _logger.info( + logger.info( "Maximum number of connection retries reached, Unable to establish connection with Moonraker" ) return False @@ -114,11 +114,11 @@ def reconnect(self): def connect(self) -> bool: """Connect to websocket""" if self.connected: - _logger.info("Connection established") + logger.info("Connection established") return True self._reconnect_count += 1 self.connecting_signal[int].emit(int(self._reconnect_count)) - _logger.debug( + logger.debug( f"Establishing connection to Moonraker...\n Try number {self._reconnect_count}" ) # TODO Handle if i cannot connect to moonraker, request server.info and see if i get a result @@ -127,7 +127,7 @@ def connect(self) -> bool: if _oneshot_token is None: raise OneShotTokenError("Unable to retrieve oneshot token") except Exception as e: - _logger.info( + logger.info( f"Unexpected error occurred when trying to acquire oneshot token: {e}" ) return False @@ -148,11 +148,11 @@ def connect(self) -> bool: daemon=True, ) try: - _logger.info("Websocket Start...") - _logger.debug(self.ws.url) + logger.info("Websocket Start...") + logger.debug(self.ws.url) self._wst.start() except Exception as e: - _logger.info(f"Unexpected while starting websocket {self._wst.name}: {e}") + logger.info(f"Unexpected while starting websocket {self._wst.name}: {e}") return False return True @@ -162,14 +162,14 @@ def wb_disconnect(self) -> None: self.ws.close() if self._wst.is_alive(): self._wst.join() - _logger.info("Websocket closed") + logger.info("Websocket closed") def on_error(self, *args) -> None: """Websocket error callback""" # First argument is ws second is error message # TODO: Handle error messages _error = args[1] if len(args) == 2 else args[0] - _logger.info(f"Websocket error, disconnected: {_error}") + logger.info(f"Websocket error, disconnected: {_error}") self.connected = False self.disconnected = True @@ -199,11 +199,11 @@ def on_close(self, *args) -> None: else: raise TypeError("QApplication.instance expected non None value") except Exception as e: - _logger.info( + logger.info( f"Unexpected error when sending websocket close_event on disconnection: {e}" ) - _logger.info( + logger.info( f"Websocket closed, code: {_close_status_code}, message: {_close_message}" ) @@ -231,11 +231,11 @@ def on_open(self, *args) -> None: else: raise TypeError("QApplication.instance expected non None value") except Exception as e: - _logger.info(f"Unexpected error opening websocket: {e}") + logger.info(f"Unexpected error opening websocket: {e}") self.connected_signal.emit() self._retry_timer.stopTimer() - _logger.info(f"Connection to websocket achieved on {_ws}") + logger.info(f"Connection to websocket achieved on {_ws}") def on_message(self, *args) -> None: """Websocket on message callback @@ -300,9 +300,7 @@ def on_message(self, *args) -> None: else: raise TypeError("QApplication.instance expected non None value") except Exception as e: - _logger.info( - f"Unexpected error while creating websocket message event: {e}" - ) + logger.info(f"Unexpected error while creating websocket message event: {e}") def send_request(self, method: str, params: dict = {}) -> bool: """Send a request over the websocket diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index ddc9c91a..65927aae 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -20,7 +20,7 @@ from lib.utils.display_button import DisplayButton from PyQt6 import QtCore, QtGui, QtWidgets -logger = logging.getLogger(name="logs/BlocksScreen.log") +logger = logging.getLogger(__name__) class PrintTab(QtWidgets.QStackedWidget): diff --git a/BlocksScreen/lib/panels/widgets/jobStatusPage.py b/BlocksScreen/lib/panels/widgets/jobStatusPage.py index f868c222..67add6b9 100644 --- a/BlocksScreen/lib/panels/widgets/jobStatusPage.py +++ b/BlocksScreen/lib/panels/widgets/jobStatusPage.py @@ -10,7 +10,7 @@ from lib.utils.display_button import DisplayButton from PyQt6 import QtCore, QtGui, QtWidgets -logger = logging.getLogger("logs/BlocksScreen.log") +logger = logging.getLogger(__name__) class JobStatusWidget(QtWidgets.QWidget): diff --git a/BlocksScreen/logger.py b/BlocksScreen/logger.py index af623580..ce16db74 100644 --- a/BlocksScreen/logger.py +++ b/BlocksScreen/logger.py @@ -140,12 +140,10 @@ class QueueHandler(logging.Handler): def __init__( self, log_queue: queue.Queue, - fmt: str = DEFAULT_FORMAT, level: int = logging.DEBUG, ) -> None: super().__init__(level) self._queue = log_queue - self.setFormatter(logging.Formatter(fmt)) def emit(self, record: logging.LogRecord) -> None: """Format and queue the log record.""" @@ -179,6 +177,7 @@ def __init__( when: str = "midnight", backup_count: int = 10, encoding: str = "utf-8", + fmt: str = DEFAULT_FORMAT, ) -> None: self._log_path = pathlib.Path(filename) @@ -193,6 +192,7 @@ def __init__( encoding=encoding, delay=True, ) + self.setFormatter(logging.Formatter(fmt)) self._queue: queue.Queue[logging.LogRecord | None] = queue.Queue() self._stop_event = threading.Event() @@ -649,11 +649,11 @@ def setup( root.setLevel(level) # Create async file handler - file_handler = ThreadedFileHandler(filename) + file_handler = ThreadedFileHandler(filename, fmt=fmt) cls._handlers["root"] = file_handler # Create queue handler that feeds the file handler - queue_handler = QueueHandler(file_handler.queue, fmt, level) + queue_handler = QueueHandler(file_handler.queue, level) root.addHandler(queue_handler) # Add console handler @@ -724,11 +724,11 @@ def get_logger( if filename is None: filename = f"logs/{name}.log" - file_handler = ThreadedFileHandler(filename) + file_handler = ThreadedFileHandler(filename, fmt=fmt) cls._handlers[name] = file_handler # Create queue handler that feeds the file handler - queue_handler = QueueHandler(file_handler.queue, fmt, level) + queue_handler = QueueHandler(file_handler.queue, level) logger.addHandler(queue_handler) # Don't propagate to root (has its own file)