"""
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)