Source code for betty.gui.error

"""
Provide error handling for the Graphical User Interface.
"""

from __future__ import annotations

import pickle
from asyncio import CancelledError
from logging import getLogger
from traceback import format_exception
from typing import TypeVar, Generic, ParamSpec, TYPE_CHECKING

from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject
from PyQt6.QtWidgets import (
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QPushButton,
    QScrollArea,
    QFrame,
)
from typing_extensions import override

from betty.error import UserFacingError
from betty.gui.text import Code, Text
from betty.gui.window import BettyMainWindow
from betty.locale import Str, Localizable

if TYPE_CHECKING:
    from PyQt6.QtGui import QCloseEvent
    from betty.app import App
    from types import TracebackType

_T = TypeVar("_T")
_P = ParamSpec("_P")

_BaseExceptionT = TypeVar("_BaseExceptionT", bound=BaseException)


[docs] class ExceptionCatcher(Generic[_P, _T]): """ Catch any exception and show an error window instead. """ _SUPPRESS_EXCEPTION_TYPES = (CancelledError,)
[docs] def __init__( self, parent: QObject, *, close_parent: bool = False, ): self._parent = parent self._close_parent = close_parent
def __enter__(self) -> None: pass def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> bool | None: return self._catch(exc_type, exc_val) async def __aenter__(self) -> None: pass async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> bool | None: return self._catch(exc_type, exc_val) def _catch( self, exception_type: type[_BaseExceptionT] | None, exception: _BaseExceptionT | None, ) -> bool | None: from betty.gui import BettyApplication if exception_type is None or exception is None: return None if isinstance(exception, self._SUPPRESS_EXCEPTION_TYPES): return None if isinstance(exception, UserFacingError): QMetaObject.invokeMethod( BettyApplication.instance(), "_show_user_facing_error", Qt.ConnectionType.QueuedConnection, Q_ARG(type, exception_type), Q_ARG(bytes, pickle.dumps(exception)), Q_ARG(QObject, self._parent), Q_ARG(bool, self._close_parent), ) else: getLogger(__name__).exception(exception) QMetaObject.invokeMethod( BettyApplication.instance(), "_show_unexpected_exception", Qt.ConnectionType.QueuedConnection, Q_ARG(type, exception_type), Q_ARG(str, str(exception)), Q_ARG(str, "".join(format_exception(exception))), Q_ARG(QObject, self._parent), Q_ARG(bool, self._close_parent), ) return True
[docs] class Error(BettyMainWindow): """ An error window. """ window_height = 300 window_width = 500
[docs] def __init__( self, app: App, message: Localizable, *, parent: QObject, close_parent: bool = False, ): super().__init__(app, parent=parent) self._message_localizable = 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.setWindowModality(Qt.WindowModality.WindowModal) central_widget = QWidget() self._central_layout = QVBoxLayout() central_widget.setLayout(self._central_layout) self.setCentralWidget(central_widget) self._message = Text() self._central_layout.addWidget(self._message) self._controls = QHBoxLayout() self._central_layout.addLayout(self._controls) self._dismiss = QPushButton() self._dismiss.released.connect(self.close) self._controls.addWidget(self._dismiss)
@override @property def window_title(self) -> Localizable: return Str.plain("{error} - Betty", error=Str._("Error")) @override def _set_translatables(self) -> None: super()._set_translatables() self._message.setText(self._message_localizable.localize(self._app.localizer)) self._dismiss.setText(self._app.localizer._("Close"))
[docs] @override def closeEvent(self, a0: QCloseEvent | None) -> None: if self._close_parent: parent = self.parent() if isinstance(parent, QWidget): parent.close() super().closeEvent(a0)
_ErrorT = TypeVar("_ErrorT", bound=Error)
[docs] class ExceptionError(Error): """ An error window for a specific exception. """
[docs] def __init__( self, app: App, message: Localizable, error_type: type[BaseException], *, parent: QObject, close_parent: bool = False, ): super().__init__(app, message, parent=parent, close_parent=close_parent) self.error_type = error_type
_ExceptionErrorT = TypeVar("_ExceptionErrorT", bound=ExceptionError) class _UnexpectedExceptionError(ExceptionError): """ An error window for a specific unexpected exception. """ def __init__( self, app: App, error_type: type[Exception], error_message: str, error_traceback: str, *, parent: QObject, close_parent: bool = False, ): super().__init__( app, Str._( '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.', report_url="https://github.com/bartfeenstra/betty/issues", ), error_type, parent=parent, close_parent=close_parent, ) if error_message: self._exception_message = Code(error_message) self._central_layout.addWidget(self._exception_message) self._exception_details = QScrollArea() self._exception_details.setFrameShape(QFrame.Shape.NoFrame) self._exception_details.setWidget( Code(error_traceback + error_traceback + error_traceback + error_traceback) ) self._exception_details.setWidgetResizable(True) self._central_layout.addWidget(self._exception_details)