From 3bd6cfb81d471827dbf9638fa2ad4ed1b43d98f0 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Sun, 26 Jul 2026 14:46:18 +0200 Subject: [PATCH] Logging --- src/bedit_gui/application.py | 2 + .../controllers/document_controller.py | 11 +++ src/bedit_gui/controllers/log_controller.py | 51 ++++++++++ src/bedit_gui/controllers/undo_controller.py | 19 +++- src/bedit_gui/services/application_logging.py | 32 +++++++ src/bedit_gui/views/models/__init__.py | 3 + src/bedit_gui/views/models/log_list_model.py | 48 ++++++++++ tests/unit/test_application_logging.py | 96 +++++++++++++++++++ 8 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/bedit_gui/controllers/log_controller.py create mode 100644 src/bedit_gui/services/application_logging.py create mode 100644 src/bedit_gui/views/models/__init__.py create mode 100644 src/bedit_gui/views/models/log_list_model.py create mode 100644 tests/unit/test_application_logging.py diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index 588b270..9158891 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -5,6 +5,7 @@ import sys from PySide6.QtWidgets import QApplication from bedit_gui.controllers.document_controller import DocumentController +from bedit_gui.controllers.log_controller import LogController from bedit_gui.controllers.undo_controller import UndoController from bedit_gui.controllers.view_menu_controller import ViewMenuController from bedit_gui.controllers.window_state_controller import WindowStateController @@ -20,6 +21,7 @@ def main() -> int: document = Document(app) window = MainWindow() + LogController(window) DocumentController(document, window) UndoController(document, window) ViewMenuController(window) diff --git a/src/bedit_gui/controllers/document_controller.py b/src/bedit_gui/controllers/document_controller.py index 7ca377a..327c15d 100644 --- a/src/bedit_gui/controllers/document_controller.py +++ b/src/bedit_gui/controllers/document_controller.py @@ -6,12 +6,15 @@ from typing import Protocol from PySide6.QtCore import QEvent, QObject from bedit_gui.documents import Document +from bedit_gui.services.application_logging import get_logger from bedit_gui.views.dialogs.document_dialogs import ( DocumentDialogs, SaveChangesChoice, ) from bedit_gui.views.main_window import MainWindow +logger = get_logger(__name__) + class DocumentDialogProvider(Protocol): def choose_open_path(self, current_path: Path | None) -> Path | None: ... @@ -51,6 +54,7 @@ class DocumentController(QObject): def new_document(self) -> None: if self.maybe_save_changes(): self.document.new() + logger.info("Created new document") def open_document(self) -> None: if not self.maybe_save_changes(): @@ -63,7 +67,10 @@ class DocumentController(QObject): try: self.document.open(path) except (OSError, TypeError, ValueError) as exc: + logger.exception("Could not open document %s", path) self.dialogs.show_file_error("Could not open document", exc) + else: + logger.info("Opened document: %s", path) def save_document(self) -> bool: if self.document.path is None: @@ -71,8 +78,10 @@ class DocumentController(QObject): try: self.document.save() except (OSError, TypeError, ValueError) as exc: + logger.exception("Could not save document %s", self.document.path) self.dialogs.show_file_error("Could not save document", exc) return False + logger.info("Saved document: %s", self.document.path) return True def save_document_as(self) -> bool: @@ -83,8 +92,10 @@ class DocumentController(QObject): try: self.document.save_as(path) except (OSError, TypeError, ValueError) as exc: + logger.exception("Could not save document as %s", path) self.dialogs.show_file_error("Could not save document", exc) return False + logger.info("Saved document as: %s", path) return True def maybe_save_changes(self) -> bool: diff --git a/src/bedit_gui/controllers/log_controller.py b/src/bedit_gui/controllers/log_controller.py new file mode 100644 index 0000000..9c0124d --- /dev/null +++ b/src/bedit_gui/controllers/log_controller.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import logging + +from PySide6.QtCore import QObject, Signal + +from bedit_gui.services.application_logging import configure_logging +from bedit_gui.views.main_window import MainWindow +from bedit_gui.views.models import LogListModel + + +class _LogEmitter(QObject): + message = Signal(str) + + +class _QtLogHandler(logging.Handler): + def __init__(self, emitter: _LogEmitter) -> None: + super().__init__() + self._emitter = emitter + + def emit(self, record: logging.LogRecord) -> None: + try: + self._emitter.message.emit(self.format(record)) + except (RuntimeError, TypeError, ValueError): + self.handleError(record) + + +class LogController(QObject): + """Routes standard application log records into the log list view.""" + + def __init__( + self, + window: MainWindow, + level: int | str = logging.INFO, + ) -> None: + super().__init__(window) + + self.model = LogListModel() + self.emitter = _LogEmitter(self) + self.handler = _QtLogHandler(self.emitter) + self.handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) + ) + + self.emitter.message.connect(self.model.append) + self.model.rowsInserted.connect(window.ui.listView.scrollToBottom) + window.ui.listView.setModel(self.model) + configure_logging(self.handler, level) diff --git a/src/bedit_gui/controllers/undo_controller.py b/src/bedit_gui/controllers/undo_controller.py index 033d12f..fb31862 100644 --- a/src/bedit_gui/controllers/undo_controller.py +++ b/src/bedit_gui/controllers/undo_controller.py @@ -1,8 +1,11 @@ from PySide6.QtCore import QObject from bedit_gui.documents import Document +from bedit_gui.services.application_logging import get_logger from bedit_gui.views.main_window import MainWindow +logger = get_logger(__name__) + class UndoController(QObject): def __init__(self, document: Document, window: MainWindow) -> None: @@ -15,8 +18,8 @@ class UndoController(QObject): redo_action = window.ui.actionRedo undo_stack = document.undo_stack - undo_action.triggered.connect(undo_stack.undo) - redo_action.triggered.connect(undo_stack.redo) + undo_action.triggered.connect(self.undo) + redo_action.triggered.connect(self.redo) undo_stack.canUndoChanged.connect(undo_action.setEnabled) undo_stack.canRedoChanged.connect(redo_action.setEnabled) @@ -27,10 +30,20 @@ class UndoController(QObject): undo_action.setEnabled(undo_stack.canUndo()) redo_action.setEnabled(undo_stack.canRedo()) + def undo(self) -> None: + command = self.document.undo_stack.undoText() + self.document.undo_stack.undo() + logger.info("Undo: %s", command) + + def redo(self) -> None: + command = self.document.undo_stack.redoText() + self.document.undo_stack.redo() + logger.info("Redo: %s", command) + def _update_undo_text(self, command: str) -> None: text = f"Undo {command}" if command else "Undo" self.window.ui.actionUndo.setText(text) def _update_redo_text(self, command: str) -> None: text = f"Redo {command}" if command else "Redo" - self.window.ui.actionRedo.setText(text) \ No newline at end of file + self.window.ui.actionRedo.setText(text) diff --git a/src/bedit_gui/services/application_logging.py b/src/bedit_gui/services/application_logging.py new file mode 100644 index 0000000..b880333 --- /dev/null +++ b/src/bedit_gui/services/application_logging.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import logging + +LOGGER_NAMESPACE = "bedit" + +_application_logger = logging.getLogger(LOGGER_NAMESPACE) +_application_logger.propagate = False +_application_logger.addHandler(logging.NullHandler()) + + +def get_logger(name: str) -> logging.Logger: + """Return a logger routed to the BEdit application log.""" + return logging.getLogger(f"{LOGGER_NAMESPACE}.{name}") + + +def configure_logging( + handler: logging.Handler, + level: int | str = logging.INFO, +) -> None: + """Replace the application output handler and set its log level.""" + for existing in list(_application_logger.handlers): + _application_logger.removeHandler(existing) + _application_logger.addHandler(handler) + set_log_level(level) + + +def set_log_level(level: int | str) -> None: + """Set the minimum severity shown by all BEdit loggers.""" + if isinstance(level, str): + level = level.upper() + _application_logger.setLevel(level) diff --git a/src/bedit_gui/views/models/__init__.py b/src/bedit_gui/views/models/__init__.py new file mode 100644 index 0000000..4a2f0c4 --- /dev/null +++ b/src/bedit_gui/views/models/__init__.py @@ -0,0 +1,3 @@ +from .log_list_model import LogListModel + +__all__ = ["LogListModel"] diff --git a/src/bedit_gui/views/models/log_list_model.py b/src/bedit_gui/views/models/log_list_model.py new file mode 100644 index 0000000..9911e9b --- /dev/null +++ b/src/bedit_gui/views/models/log_list_model.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt + + +class LogListModel(QAbstractListModel): + """Bounded list model containing formatted application log messages.""" + + def __init__(self, max_entries: int = 2_000) -> None: + super().__init__() + self._messages: list[str] = [] + self._max_entries = max_entries + + def rowCount(self, parent: QModelIndex | None = None) -> int: + return ( + 0 + if parent is not None and parent.isValid() + else len(self._messages) + ) + + def data( + self, + index: QModelIndex, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> str | None: + if ( + not index.isValid() + or not 0 <= index.row() < len(self._messages) + ): + return None + if role in ( + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.ToolTipRole, + ): + return self._messages[index.row()] + return None + + def append(self, message: str) -> None: + overflow = len(self._messages) - self._max_entries + 1 + if overflow > 0: + self.beginRemoveRows(QModelIndex(), 0, overflow - 1) + del self._messages[:overflow] + self.endRemoveRows() + + row = len(self._messages) + self.beginInsertRows(QModelIndex(), row, row) + self._messages.append(message) + self.endInsertRows() diff --git a/tests/unit/test_application_logging.py b/tests/unit/test_application_logging.py new file mode 100644 index 0000000..4ff153a --- /dev/null +++ b/tests/unit/test_application_logging.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QUndoCommand +from PySide6.QtWidgets import QApplication + +from bedit_gui.controllers.document_controller import DocumentController +from bedit_gui.controllers.log_controller import LogController +from bedit_gui.controllers.undo_controller import UndoController +from bedit_gui.documents import Document +from bedit_gui.services.application_logging import get_logger, set_log_level +from bedit_gui.views.dialogs.document_dialogs import SaveChangesChoice +from bedit_gui.views.main_window import MainWindow + + +class FakeDialogs: + def __init__(self, path: Path) -> None: + self.path = path + + def choose_open_path(self, _current_path: Path | None) -> Path: + return self.path + + def choose_save_path(self, _current_path: Path | None) -> Path: + return self.path + + def ask_save_changes(self) -> SaveChangesChoice: + return SaveChangesChoice.DISCARD + + def show_file_error(self, _title: str, error: Exception) -> None: + raise AssertionError("unexpected file error") from error + + +@pytest.fixture(scope="module") +def qt_app() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.mark.unit +def test_log_level_and_qt_log_model(qt_app: QApplication) -> None: + window = MainWindow() + controller = LogController(window, logging.WARNING) + logger = get_logger("tests") + + logger.info("hidden message") + logger.warning("visible message") + qt_app.processEvents() + + assert controller.model.rowCount() == 1 + assert "WARNING visible message" in controller.model.data( + controller.model.index(0), + Qt.ItemDataRole.DisplayRole, + ) + + set_log_level(logging.INFO) + logger.info("now visible") + qt_app.processEvents() + + assert controller.model.rowCount() == 2 + + +@pytest.mark.unit +def test_document_and_undo_actions_are_logged( + qt_app: QApplication, + tmp_path: Path, +) -> None: + path = tmp_path / "document.json" + window = MainWindow() + log_controller = LogController(window) + document = Document(qt_app) + dialogs = FakeDialogs(path) + document_controller = DocumentController(document, window, dialogs) + undo_controller = UndoController(document, window) + + document_controller.new_document() + document_controller.save_document_as() + document_controller.save_document() + document_controller.open_document() + document.undo_stack.push(QUndoCommand("test change")) + undo_controller.undo() + undo_controller.redo() + qt_app.processEvents() + + messages = [ + log_controller.model.data(log_controller.model.index(row)) + for row in range(log_controller.model.rowCount()) + ] + assert any("Created new document" in message for message in messages) + assert any("Opened document:" in message for message in messages) + assert any("Saved document:" in message for message in messages) + assert any("Saved document as:" in message for message in messages) + assert any("Undo: test change" in message for message in messages) + assert any("Redo: test change" in message for message in messages)