diff --git a/betty/extension/gramps/gui.py b/betty/extension/gramps/gui.py index 9e5a328f1..d295a3a76 100644 --- a/betty/extension/gramps/gui.py +++ b/betty/extension/gramps/gui.py @@ -15,7 +15,7 @@ from betty.extension import Gramps from betty.extension.gramps.config import FamilyTreeConfiguration from betty.gui import mark_valid, mark_invalid -from betty.gui.error import catch_exceptions +from betty.gui.error import ExceptionCatcher from betty.gui.locale import LocalizedObject from betty.gui.text import Text from betty.gui.window import BettyMainWindow @@ -116,15 +116,15 @@ def _update_configuration_file_path(file_path: str) -> None: file_path_layout = QHBoxLayout() file_path_layout.addWidget(self._file_path) - @catch_exceptions def find_family_tree_file_path() -> None: - found_family_tree_file_path, __ = QFileDialog.getOpenFileName( - self._widget, - self._app.localizer._('Load the family tree from...'), - directory=self._file_path.text(), - ) - if '' != found_family_tree_file_path: - self._file_path.setText(found_family_tree_file_path) + with ExceptionCatcher(self): + found_family_tree_file_path, __ = QFileDialog.getOpenFileName( + self._widget, + self._app.localizer._('Load the family tree from...'), + directory=self._file_path.text(), + ) + if '' != found_family_tree_file_path: + self._file_path.setText(found_family_tree_file_path) self._file_path_find = QPushButton('...') self._file_path_find.released.connect(find_family_tree_file_path) file_path_layout.addWidget(self._file_path_find) @@ -134,10 +134,10 @@ def find_family_tree_file_path() -> None: buttons_layout = QHBoxLayout() self._layout.addRow(buttons_layout) - @catch_exceptions def save_and_close_family_tree() -> None: - self._app.extensions[Gramps].configuration.family_trees.append(self._family_tree) - self.close() + with ExceptionCatcher(self): + self._app.extensions[Gramps].configuration.family_trees.append(self._family_tree) + self.close() self._save_and_close = QPushButton() self._save_and_close.setDisabled(True) self._save_and_close.released.connect(save_and_close_family_tree) diff --git a/betty/extension/nginx/__init__.py b/betty/extension/nginx/__init__.py index 89b431930..93a8af903 100644 --- a/betty/extension/nginx/__init__.py +++ b/betty/extension/nginx/__init__.py @@ -12,7 +12,7 @@ from betty.extension.nginx.artifact import generate_configuration_file, generate_dockerfile_file from betty.generate import Generator, GenerationContext from betty.gui import GuiBuilder -from betty.gui.error import catch_exceptions +from betty.gui.error import ExceptionCatcher from betty.locale import Str from betty.serde.dump import Dump, VoidableDump, minimize, Void, VoidableDictDump from betty.serde.load import Asserter, Fields, OptionalField, Assertions @@ -171,11 +171,11 @@ def _update_configuration_www_directory_path(www_directory_path: str) -> None: www_directory_path_layout = QHBoxLayout() www_directory_path_layout.addWidget(self._nginx_www_directory_path) - @catch_exceptions def find_www_directory_path() -> None: - found_www_directory_path = QFileDialog.getExistingDirectory(self, 'Serve your site from...', directory=self._nginx_www_directory_path.text()) - if '' != found_www_directory_path: - self._nginx_www_directory_path.setText(found_www_directory_path) + with ExceptionCatcher(self): + found_www_directory_path = QFileDialog.getExistingDirectory(self, 'Serve your site from...', directory=self._nginx_www_directory_path.text()) + if '' != found_www_directory_path: + self._nginx_www_directory_path.setText(found_www_directory_path) self._nginx_www_directory_path_find = QPushButton('...') self._nginx_www_directory_path_find.released.connect(find_www_directory_path) www_directory_path_layout.addWidget(self._nginx_www_directory_path_find) diff --git a/betty/gui/app.py b/betty/gui/app.py index 2fe1e383d..b863118f4 100644 --- a/betty/gui/app.py +++ b/betty/gui/app.py @@ -16,7 +16,7 @@ from betty.app import App from betty.asyncio import sync, wait from betty.gui import get_configuration_file_filter -from betty.gui.error import catch_exceptions +from betty.gui.error import ExceptionCatcher from betty.gui.locale import TranslationsLocaleCollector from betty.gui.serve import ServeDemoWindow, ServeDocsWindow from betty.gui.text import Text @@ -125,9 +125,9 @@ def _set_translatables(self) -> None: self._docs_action.setText(self._app.localizer._('View documentation')) self.about_action.setText(self._app.localizer._('About Betty')) - @catch_exceptions def report_bug(self) -> None: - body = f''' + with ExceptionCatcher(self): + body = f''' ## Summary ## Steps to reproduce @@ -139,83 +139,83 @@ def report_bug(self) -> None: {report()} ``` '''.strip() - webbrowser.open_new_tab('https://github.com/bartfeenstra/betty/issues/new?' + urlencode({ - 'body': body, - 'labels': 'bug', - })) + webbrowser.open_new_tab('https://github.com/bartfeenstra/betty/issues/new?' + urlencode({ + 'body': body, + 'labels': 'bug', + })) - @catch_exceptions def request_feature(self) -> None: - body = ''' + with ExceptionCatcher(self): + body = ''' ## Summary ## Expected behavior '''.strip() - webbrowser.open_new_tab('https://github.com/bartfeenstra/betty/issues/new?' + urlencode({ - 'body': body, - 'labels': 'enhancement', - })) + webbrowser.open_new_tab('https://github.com/bartfeenstra/betty/issues/new?' + urlencode({ + 'body': body, + 'labels': 'enhancement', + })) - @catch_exceptions def _docs(self) -> None: - serve_window = ServeDocsWindow(self._app, parent=self) - serve_window.show() + with ExceptionCatcher(self): + serve_window = ServeDocsWindow(self._app, parent=self) + serve_window.show() - @catch_exceptions def _about_betty(self) -> None: - about_window = _AboutBettyWindow(self._app, parent=self) - about_window.show() + with ExceptionCatcher(self): + about_window = _AboutBettyWindow(self._app, parent=self) + about_window.show() - @catch_exceptions def open_project(self) -> None: - from betty.gui.project import ProjectWindow + with ExceptionCatcher(self): + from betty.gui.project import ProjectWindow + + configuration_file_path_str, __ = QFileDialog.getOpenFileName( + self, + self._app.localizer._('Open your project from...'), + '', + get_configuration_file_filter().localize(self._app.localizer), + ) + if not configuration_file_path_str: + return + wait(self._app.project.configuration.read(Path(configuration_file_path_str))) + project_window = ProjectWindow(self._app) + project_window.show() + self.close() - configuration_file_path_str, __ = QFileDialog.getOpenFileName( - self, - self._app.localizer._('Open your project from...'), - '', - get_configuration_file_filter().localize(self._app.localizer), - ) - if not configuration_file_path_str: - return - wait(self._app.project.configuration.read(Path(configuration_file_path_str))) - project_window = ProjectWindow(self._app) - project_window.show() - self.close() - - @catch_exceptions def new_project(self) -> None: - from betty.gui.project import ProjectWindow + with ExceptionCatcher(self): + from betty.gui.project import ProjectWindow + + configuration_file_path_str, __ = QFileDialog.getSaveFileName( + self, + self._app.localizer._('Save your new project to...'), + '', + get_configuration_file_filter().localize(self._app.localizer), + ) + if not configuration_file_path_str: + return + configuration = ProjectConfiguration() + wait(configuration.write(Path(configuration_file_path_str))) + project_window = ProjectWindow(self._app) + project_window.show() + self.close() - configuration_file_path_str, __ = QFileDialog.getSaveFileName( - self, - self._app.localizer._('Save your new project to...'), - '', - get_configuration_file_filter().localize(self._app.localizer), - ) - if not configuration_file_path_str: - return - configuration = ProjectConfiguration() - wait(configuration.write(Path(configuration_file_path_str))) - project_window = ProjectWindow(self._app) - project_window.show() - self.close() - - @catch_exceptions def _demo(self) -> None: - serve_window = ServeDemoWindow(self._app, parent=self) - serve_window.show() + with ExceptionCatcher(self): + serve_window = ServeDemoWindow(self._app, parent=self) + serve_window.show() - @catch_exceptions @sync async def clear_caches(self) -> None: - await self._app.cache.clear() + async with ExceptionCatcher(self): + await self._app.cache.clear() - @catch_exceptions def open_application_configuration(self) -> None: - window = ApplicationConfiguration(self._app, parent=self) - window.show() + with ExceptionCatcher(self): + window = ApplicationConfiguration(self._app, parent=self) + window.show() class _WelcomeText(Text): diff --git a/betty/gui/error.py b/betty/gui/error.py index e4a785df1..5706d5cc5 100644 --- a/betty/gui/error.py +++ b/betty/gui/error.py @@ -3,10 +3,10 @@ """ from __future__ import annotations -import functools import traceback +from asyncio import CancelledError from types import TracebackType -from typing import Callable, Any, TypeVar, Generic, TYPE_CHECKING, ParamSpec +from typing import Any, TypeVar, Generic, ParamSpec from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject from PyQt6.QtGui import QCloseEvent, QIcon @@ -15,68 +15,62 @@ from betty.app import App from betty.gui.locale import LocalizedObject -if TYPE_CHECKING: - from betty.gui import QWidgetT - - T = TypeVar('T') P = ParamSpec('P') +BaseExceptionT = TypeVar('BaseExceptionT', bound=BaseException) + + +class ExceptionCatcher(Generic[P, T]): + """ + Catch any exception and show an error window instead. + """ + + _SUPPRESS_EXCEPTION_TYPES = ( + CancelledError, + ) -class _ExceptionCatcher(Generic[P, T]): def __init__( - self, - f: Callable[P, T] | None = None, - parent: QWidget | None = None, - close_parent: bool = False, - instance: QWidget | None = None, + self, + parent: QObject, + *, + close_parent: bool = False, ): - if f: - functools.update_wrapper(self, f) - self._f = f - if close_parent and not parent: - raise ValueError('No parent was given to close.') - self._parent = instance if parent is None else parent + self._parent = parent self._close_parent = close_parent - self._instance = instance - - def __get__(self, instance: QWidgetT | None, owner: type[QWidgetT] | None = None) -> Any: - if instance is None: - return self - assert isinstance(instance, QWidget) - return type(self)(self._f, instance, self._close_parent, instance) - - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: - if not self._f: - raise RuntimeError('This exception catcher is not callable, but you can use it as a context manager instead using a `with` statement.') - if self._instance is not None: - args = (self._instance, *args) # type: ignore[assignment] - with self: - return self._f(*args, **kwargs) 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 exc_val is None: + if exception_type is None or exception is None: + return None + + if isinstance(exception, self._SUPPRESS_EXCEPTION_TYPES): return None + QMetaObject.invokeMethod( BettyApplication.instance(), '_catch_exception', Qt.ConnectionType.QueuedConnection, - Q_ARG(Exception, exc_val), + Q_ARG(Exception, exception), Q_ARG(QObject, self._parent), Q_ARG(bool, self._close_parent), ) return True -# Alias the class so its original name follows the PEP code style, but the alias follows the decorator code style. -catch_exceptions = _ExceptionCatcher - - class Error(LocalizedObject, QMessageBox): def __init__( self, diff --git a/betty/gui/project.py b/betty/gui/project.py index 0c21071ec..cf1191e86 100644 --- a/betty/gui/project.py +++ b/betty/gui/project.py @@ -24,7 +24,7 @@ from betty.asyncio import sync, wait from betty.gui import get_configuration_file_filter, GuiBuilder, mark_invalid, mark_valid from betty.gui.app import BettyPrimaryWindow -from betty.gui.error import catch_exceptions +from betty.gui.error import ExceptionCatcher from betty.gui.locale import LocalizedObject from betty.gui.locale import TranslationsLocaleCollector from betty.gui.logging import LogRecordViewerHandler, LogRecordViewer @@ -348,21 +348,21 @@ def _set_translatables(self) -> None: def window_title(self) -> Localizable: return Str._('Add a locale') - @catch_exceptions def _save_and_close_locale(self) -> None: - locale = self._locale_collector.locale.currentData() - alias: str | None = self._alias.text().strip() - if alias == '': - alias = None - try: - self._app.project.configuration.locales.append(LocaleConfiguration( - locale, - alias=alias, - )) - except AssertionFailed as e: - mark_invalid(self._alias, str(e)) - return - self.close() + with ExceptionCatcher(self): + locale = self._locale_collector.locale.currentData() + alias: str | None = self._alias.text().strip() + if alias == '': + alias = None + try: + self._app.project.configuration.locales.append(LocaleConfiguration( + locale, + alias=alias, + )) + except AssertionFailed as e: + mark_invalid(self._alias, str(e)) + return + self.close() class _ExtensionPane(LocalizedObject, QWidget): @@ -380,22 +380,22 @@ def __init__(self, app: App, extension_type: type[UserFacingExtension]): self._extension_description = Text() enable_layout.addRow(self._extension_description) - @catch_exceptions def _update_enabled(enabled: bool) -> None: - if enabled: - self._app.project.configuration.extensions.enable(extension_type) - extension = self._app.extensions[extension_type] - if isinstance(extension, GuiBuilder): - layout.addWidget(extension.gui_build()) - else: - self._app.project.configuration.extensions.disable(extension_type) - extension_gui_item = layout.itemAt(1) - if extension_gui_item is not None: - extension_gui_widget = extension_gui_item.widget() - assert extension_gui_widget is not None - layout.removeWidget(extension_gui_widget) - extension_gui_widget.setParent(None) - del extension_gui_widget + with ExceptionCatcher(self): + if enabled: + self._app.project.configuration.extensions.enable(extension_type) + extension = self._app.extensions[extension_type] + if isinstance(extension, GuiBuilder): + layout.addWidget(extension.gui_build()) + else: + self._app.project.configuration.extensions.disable(extension_type) + extension_gui_item = layout.itemAt(1) + if extension_gui_item is not None: + extension_gui_widget = extension_gui_item.widget() + assert extension_gui_widget is not None + layout.removeWidget(extension_gui_widget) + extension_gui_widget.setParent(None) + del extension_gui_widget self._extension_enabled = QCheckBox() self._extension_enabled_caption = Caption() @@ -568,25 +568,25 @@ def _set_translatables(self) -> None: def _set_window_title(self) -> None: self.setWindowTitle('%s - Betty' % self._app.project.configuration.title) - @catch_exceptions def _save_project_as(self) -> None: - configuration_file_path_str, __ = QFileDialog.getSaveFileName( - self, - self._app.localizer._('Save your project to...'), - '', - get_configuration_file_filter().localize(self._app.localizer), - ) - wait(self._app.project.configuration.write(Path(configuration_file_path_str))) + with ExceptionCatcher(self): + configuration_file_path_str, __ = QFileDialog.getSaveFileName( + self, + self._app.localizer._('Save your project to...'), + '', + get_configuration_file_filter().localize(self._app.localizer), + ) + wait(self._app.project.configuration.write(Path(configuration_file_path_str))) - @catch_exceptions def _generate(self) -> None: - generate_window = _GenerateWindow(self._app, parent=self) - generate_window.show() + with ExceptionCatcher(self): + generate_window = _GenerateWindow(self._app, parent=self) + generate_window.show() - @catch_exceptions def _serve(self) -> None: - serve_window = ServeProjectWindow(self._app, parent=self) - serve_window.show() + with ExceptionCatcher(self): + serve_window = ServeProjectWindow(self._app, parent=self) + serve_window.show() class _GenerateThread(QThread): @@ -601,7 +601,7 @@ async def run(self) -> None: self._task = asyncio.create_task(self._generate()) async def _generate(self) -> None: - with catch_exceptions(parent=self._generate_window, close_parent=True): + with ExceptionCatcher(self._generate_window, close_parent=True): async with App(project=self._project) as app: await load.load(app) await generate.generate(app) @@ -654,10 +654,10 @@ def __init__( def window_title(self) -> Localizable: return Str._('Generating your site...') - @catch_exceptions def _serve(self) -> None: - serve_window = ServeProjectWindow(self._app, parent=self) - serve_window.show() + with ExceptionCatcher(self): + serve_window = ServeProjectWindow(self._app, parent=self) + serve_window.show() def show(self) -> None: super().show() diff --git a/betty/gui/serve.py b/betty/gui/serve.py index cd92c835c..b7fa327af 100644 --- a/betty/gui/serve.py +++ b/betty/gui/serve.py @@ -12,7 +12,7 @@ from betty.app import App from betty.asyncio import sync from betty.extension import demo -from betty.gui.error import catch_exceptions +from betty.gui.error import ExceptionCatcher from betty.gui.text import Text from betty.gui.window import BettyMainWindow from betty.locale import Str, Localizable @@ -36,7 +36,7 @@ def server(self) -> Server: @sync async def run(self) -> None: - with catch_exceptions(parent=self._serve_window, close_parent=True): + with ExceptionCatcher(self._serve_window, close_parent=True): async with App(project=self._project) as self._app: await self._server.start() self.server_started.emit()