Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify catching exceptions in the Graphical User Interface #1295

Merged
merged 1 commit into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions betty/extension/gramps/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions betty/extension/nginx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,11 +171,11 @@
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)

Check warning on line 178 in betty/extension/nginx/__init__.py

View check run for this annotation

Codecov / codecov/patch

betty/extension/nginx/__init__.py#L175-L178

Added lines #L175 - L178 were not covered by tests
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)
Expand Down
116 changes: 58 additions & 58 deletions betty/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,9 +125,9 @@
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'''

Check warning on line 130 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L129-L130

Added lines #L129 - L130 were not covered by tests
## Summary

## Steps to reproduce
Expand All @@ -139,83 +139,83 @@
{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({

Check warning on line 142 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L142

Added line #L142 was not covered by tests
'body': body,
'labels': 'bug',
}))

@catch_exceptions
def request_feature(self) -> None:
body = '''
with ExceptionCatcher(self):
body = '''

Check warning on line 149 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L148-L149

Added lines #L148 - L149 were not covered by tests
## 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({

Check warning on line 155 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L155

Added line #L155 was not covered by tests
'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()

Check warning on line 163 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L161-L163

Added lines #L161 - L163 were not covered by tests

@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

Check warning on line 181 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L181

Added line #L181 was not covered by tests
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

Check warning on line 189 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L188-L189

Added lines #L188 - L189 were not covered by tests

configuration_file_path_str, __ = QFileDialog.getSaveFileName(

Check warning on line 191 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L191

Added line #L191 was not covered by tests
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()

Check warning on line 203 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L197-L203

Added lines #L197 - L203 were not covered by tests

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

Check warning on line 218 in betty/gui/app.py

View check run for this annotation

Codecov / codecov/patch

betty/gui/app.py#L216-L218

Added lines #L216 - L218 were not covered by tests


class _WelcomeText(Text):
Expand Down
72 changes: 33 additions & 39 deletions betty/gui/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading