diff --git a/betty/gui/__init__.py b/betty/gui/__init__.py index d9798bf7e..69667fb17 100644 --- a/betty/gui/__init__.py +++ b/betty/gui/__init__.py @@ -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 @@ -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() diff --git a/betty/gui/error.py b/betty/gui/error.py index 5706d5cc5..e7f30eeef 100644 --- a/betty/gui/error.py +++ b/betty/gui/error.py @@ -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') @@ -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), ) @@ -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) @@ -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 report this problem 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) diff --git a/betty/tests/gui/test_app.py b/betty/tests/gui/test_app.py index eda9053b5..1080b4004 100644 --- a/betty/tests/gui/test_app.py +++ b/betty/tests/gui/test_app.py @@ -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 @@ -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,