Logging
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
51
src/bedit_gui/controllers/log_controller.py
Normal file
51
src/bedit_gui/controllers/log_controller.py
Normal file
@@ -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)
|
||||
@@ -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,6 +30,16 @@ 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)
|
||||
|
||||
32
src/bedit_gui/services/application_logging.py
Normal file
32
src/bedit_gui/services/application_logging.py
Normal file
@@ -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)
|
||||
3
src/bedit_gui/views/models/__init__.py
Normal file
3
src/bedit_gui/views/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .log_list_model import LogListModel
|
||||
|
||||
__all__ = ["LogListModel"]
|
||||
48
src/bedit_gui/views/models/log_list_model.py
Normal file
48
src/bedit_gui/views/models/log_list_model.py
Normal file
@@ -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()
|
||||
96
tests/unit/test_application_logging.py
Normal file
96
tests/unit/test_application_logging.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user