Skip to content

Commit

Permalink
Use only primitives when communicating errors through Qt slots.
Browse files Browse the repository at this point in the history
  • Loading branch information
bartfeenstra committed Feb 21, 2024
1 parent 308a8e9 commit 3432fb2
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 29 deletions.
22 changes: 13 additions & 9 deletions betty/gui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Provide the Graphical User Interface (GUI) for Betty Desktop."""
from __future__ import annotations

from logging import getLogger
import pickle
from typing import Any, TypeVar

from PyQt6.QtCore import pyqtSlot, QObject
Expand Down Expand Up @@ -128,19 +128,23 @@ def __init__(self, *args: Any, app: App, **kwargs: Any):
self._app = app

@pyqtSlot(
Exception,
type,
bytes,
str,
QObject,
bool,
)
def _catch_exception(
def _catch_error(
self,
e: Exception,
parent: QObject,
error_type: type[Exception],
pickled_error_message: bytes,
error_traceback: str | None,
parent: QWidget,
close_parent: bool,
) -> None:
if isinstance(e, UserFacingError):
window = ExceptionError(self._app, e, parent, close_parent=close_parent)
error_message = pickle.loads(pickled_error_message)
if issubclass(error_type, UserFacingError):
window = ExceptionError(parent, self._app, error_type, error_message, close_parent=close_parent)
else:
getLogger(__name__).exception(e)
window = UnexpectedExceptionError(self._app, e, parent, close_parent=close_parent)
window = UnexpectedExceptionError(parent, self._app, error_type, error_message, error_traceback, close_parent=close_parent)
window.show()
61 changes: 45 additions & 16 deletions betty/gui/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
"""
from __future__ import annotations

import pickle
import traceback
from asyncio import CancelledError
from types import TracebackType
from typing import Any, TypeVar, Generic, ParamSpec
from typing import TypeVar, Generic, ParamSpec

from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject
from PyQt6.QtGui import QCloseEvent, QIcon
from PyQt6.QtWidgets import QWidget, QMessageBox
from PyQt6.QtWidgets import QMessageBox, QWidget

from betty.app import App
from betty.error import UserFacingError
from betty.gui.locale import LocalizedObject
from betty.locale import Str, Localizable

T = TypeVar('T')
P = ParamSpec('P')
Expand Down Expand Up @@ -62,9 +65,11 @@ def _catch(self, exception_type: type[BaseExceptionT] | None, exception: BaseExc

QMetaObject.invokeMethod(
BettyApplication.instance(),
'_catch_exception',
'_catch_error',
Qt.ConnectionType.QueuedConnection,
Q_ARG(Exception, exception),
Q_ARG(type, type(exception)),
Q_ARG(bytes, pickle.dumps(exception if isinstance(exception, UserFacingError) else Str.plain(str(exception)))),
Q_ARG(str, ''.join(traceback.format_exception(exception))),
Q_ARG(QObject, self._parent),
Q_ARG(bool, self._close_parent),
)
Expand All @@ -74,16 +79,17 @@ def _catch(self, exception_type: type[BaseExceptionT] | None, exception: BaseExc
class Error(LocalizedObject, QMessageBox):
def __init__(
self,
parent: QObject,
app: App,
message: str,
*args: Any,
message: Localizable,
*,
close_parent: bool = False,
**kwargs: Any,
):
super().__init__(app, *args, **kwargs)
super().__init__(app, parent)
self._message = message
if close_parent and not isinstance(parent, QWidget):
raise ValueError('If `close_parent` is true, `parent` must be `QWidget`.')
self._close_parent = close_parent
self.setWindowTitle('{error} - Betty'.format(error=self._app.localizer._("Error")))
self.setText(message)

standard_button_type = QMessageBox.StandardButton.Close
self.setStandardButtons(standard_button_type)
Expand All @@ -101,21 +107,44 @@ def closeEvent(self, a0: QCloseEvent | None) -> None:
parent.close()
super().closeEvent(a0)

def _set_translatables(self) -> None:
super()._set_translatables()
self.setWindowTitle('{error} - Betty'.format(error=self._app.localizer._("Error")))
self.setText(self._message.localize(self._app.localizer))


ErrorT = TypeVar('ErrorT', bound=Error)


class ExceptionError(Error):
def __init__(self, app: App, exception: Exception, *args: Any, **kwargs: Any):
super().__init__(app, str(exception), *args, **kwargs)
self.exception = exception
def __init__(
self,
parent: QWidget,
app: App,
error_type: type[BaseException],
error_message: Localizable,
*,
close_parent: bool = False,
):
super().__init__(parent, app, error_message, close_parent=close_parent)
self.error_type = error_type


class UnexpectedExceptionError(ExceptionError):
def __init__(self, app: App, exception: Exception, *args: Any, **kwargs: Any):
super().__init__(app, exception, *args, **kwargs)
def __init__(
self,
parent: QWidget,
app: App,
error_type: type[BaseException],
error_message: Localizable,
error_traceback: str | None,
*,
close_parent: bool = False,
):
super().__init__(parent, app, error_type, error_message, close_parent=close_parent)
self.setText(self._app.localizer._('An unexpected error occurred and Betty could not complete the task. Please <a href="{report_url}">report this problem</a> and include the following details, so the team behind Betty can address it.').format(
report_url='https://github.com/bartfeenstra/betty/issues',
))
self.setTextFormat(Qt.TextFormat.RichText)
self.setDetailedText(''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)))
if error_traceback:
self.setDetailedText(error_traceback)
5 changes: 1 addition & 4 deletions betty/tests/gui/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from betty.gui.serve import ServeDemoWindow
from betty.project import ProjectConfiguration
from betty.serde.error import SerdeError
from betty.serde.load import FormatError
from betty.tests import patch_cache
from betty.tests.conftest import BettyQtBot

Expand Down Expand Up @@ -88,9 +87,7 @@ async def test_open_project_with_invalid_file_should_error(
betty_qtbot.qtbot.mouseClick(sut.open_project_button, Qt.MouseButton.LeftButton)

error = betty_qtbot.assert_error(ExceptionError)
exception = error.exception
assert isinstance(exception, SerdeError)
assert exception.raised(FormatError)
assert issubclass(error.error_type, SerdeError)

async def test_open_project_with_valid_file_should_show_project_window(
self,
Expand Down

0 comments on commit 3432fb2

Please sign in to comment.