"""
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 types import TracebackType
from typing import TypeVar, Generic, ParamSpec
from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject
from PyQt6.QtGui import QCloseEvent
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QScrollArea, QFrame
from betty.app import App
from betty.error import UserFacingError
from betty.gui.text import Code, Text
from betty.gui.window import BettyMainWindow
from betty.locale import Str, Localizable
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,
)
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):
window_height = 300
window_width = 500
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)
@property
def window_title(self) -> Localizable:
return Str.plain('{error} - Betty', error=Str._('Error'))
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]
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):
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
class _UnexpectedExceptionError(ExceptionError):
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)