feat: add error exceptions_as_dialog context manager to catch and show Exceptions (#191)

* feat: add error messagebox context

* typing

* Update src/superqt/utils/_errormsg_context.py

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>

* add tests

* style: [pre-commit.ci] auto fixes [...]

* docs: add docs

* test button result

* format doc

* docs: update docs

* docs

* add dialog example

* pass flags

* skip mac ci pyside6

---------

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Talley Lambert
2023-08-21 17:12:39 -04:00
committed by GitHub
parent 7fcba7a485
commit ed960f4994
5 changed files with 221 additions and 2 deletions

View File

@@ -0,0 +1,3 @@
# Error message context manager
::: superqt.utils.exceptions_as_dialog

View File

@@ -25,6 +25,7 @@ theme:
# - navigation.tabs
- search.highlight
- search.suggest
- content.code.copy
markdown_extensions:
- admonition

View File

@@ -14,10 +14,12 @@ __all__ = (
"signals_blocked",
"thread_worker",
"WorkerBase",
"exceptions_as_dialog",
)
from ._code_syntax_highlight import CodeSyntaxHighlight
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._message_handler import QMessageHandler
from ._misc import signals_blocked
from ._qthreading import (

View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import traceback
from contextlib import AbstractContextManager
from typing import TYPE_CHECKING, cast
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QErrorMessage, QMessageBox, QWidget
if TYPE_CHECKING:
from types import TracebackType
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
class exceptions_as_dialog(AbstractContextManager):
"""Context manager that shows a dialog when an exception is raised.
See examples below for common usage patterns.
To determine whether an exception was raised or not, check the `exception`
attribute after the context manager has exited. If `use_error_message` is `False`
(the default), you can also access the `dialog` attribute to get/manipulate the
`QMessageBox` instance.
Parameters
----------
exceptions : type[BaseException] | tuple[type[BaseException], ...], optional
The exception(s) to catch, by default `Exception` (i.e. all exceptions).
icon : QMessageBox.Icon, optional
The icon to show in the QMessageBox, by default `QMessageBox.Icon.Critical`
title : str, optional
The title of the `QMessageBox`, by default `"An error occurred"`.
msg_template : str, optional
The message to show in the `QMessageBox`. The message will be formatted
using three variables:
- `exc_value`: the exception instance
- `exc_type`: the exception type
- `tb`: the traceback as a string
The default template is the content of the exception: `"{exc_value}"`
buttons : QMessageBox.StandardButton, optional
The buttons to show in the `QMessageBox`, by default
`QMessageBox.StandardButton.Ok`
parent : QWidget | None, optional
The parent widget of the `QMessageBox`, by default `None`
use_error_message : bool | QErrorMessage, optional
Whether to use a `QErrorMessage` instead of a `QMessageBox`. By default
`False`. `QErrorMessage` shows a checkbox that the user can check to
prevent seeing the message again (based on the text of the formatted
`msg_template`.) If `True`, the global `QMessageError.qtHandler()`
instance is used to maintain a history of dismissed messages. You may also pass
a `QErrorMessage` instance to use a specific instance. If `use_error_message` is
True, or if you pass your own `QErrorMessage` instance, the `parent` argument
is ignored.
Attributes
----------
dialog : QMessageBox | None
The `QMessageBox` instance that was created (if `use_error_message` was
`False`). This can be used, among other things, to determine the result of
the dialog (e.g. `dialog.result()`) or to manipulate the dialog (e.g.
`dialog.setDetailedText("some text")`).
exception : BaseException | None
Will hold the exception instance if an exception was raised and caught.
Examplez
-------
```python
from qtpy.QtWidgets import QApplication
from superqt.utils import exceptions_as_dialog
app = QApplication([])
with exceptions_as_dialog() as ctx:
raise Exception("This will be caught and shown in a QMessageBox")
# you can access the exception instance here
assert ctx.exception is not None
# with exceptions_as_dialog(ValueError):
# 1 / 0 # ZeroDivisionError is not caught, so this will raise
with exceptions_as_dialog(msg_template="Error: {exc_value}"):
raise Exception("This message will be inserted at 'exc_value'")
for _i in range(3):
with exceptions_as_dialog(AssertionError, use_error_message=True):
assert False, "Uncheck the checkbox to ignore this in the future"
# use ctx.dialog to get the result of the dialog
btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
with exceptions_as_dialog(buttons=btns) as ctx:
raise Exception("This will be caught and shown in a QMessageBox")
print(ctx.dialog.result()) # prints which button was clicked
app.exec() # needed only for the use_error_message example to show
```
"""
dialog: QMessageBox | None
exception: BaseException | None
exec_result: int | None = None
def __init__(
self,
exceptions: type[BaseException] | tuple[type[BaseException], ...] = Exception,
icon: QMessageBox.Icon = QMessageBox.Icon.Critical,
title: str = "An error occurred",
msg_template: str = "{exc_value}",
buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok,
parent: QWidget | None = None,
flags: Qt.WindowType = _DEFAULT_FLAGS,
use_error_message: bool | QErrorMessage = False,
):
self.exceptions = exceptions
self.msg_template = msg_template
self.exception = None
self.dialog = None
self._err_msg = use_error_message
if not use_error_message:
# the message will be overwritten in __exit__
self.dialog = QMessageBox(
icon, title, "An error occurred", buttons, parent, flags
)
def __enter__(self) -> exceptions_as_dialog:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
tb: TracebackType | None,
) -> bool:
if not (exc_value is not None and isinstance(exc_value, self.exceptions)):
return False # let it propagate
# save the exception for later
self.exception = exc_value
# format the message using the context variables
if "{tb}" in self.msg_template:
_tb = "\n".join(traceback.format_exception(exc_type, exc_value, tb))
else:
_tb = ""
text = self.msg_template.format(exc_value=exc_value, exc_type=exc_type, tb=_tb)
# show the dialog
if self._err_msg:
msg = (
self._err_msg
if isinstance(self._err_msg, QErrorMessage)
else QErrorMessage.qtHandler()
)
cast("QErrorMessage", msg).showMessage(text)
elif self.dialog is not None: # it won't be if use_error_message=False
self.dialog.setText(text)
self.dialog.exec()
return True # swallow the exception

View File

@@ -1,8 +1,13 @@
import os
import sys
from unittest.mock import Mock
from qtpy.QtCore import QObject, Signal
import pytest
import qtpy
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QApplication, QErrorMessage, QMessageBox
from superqt.utils import signals_blocked
from superqt.utils import exceptions_as_dialog, signals_blocked
from superqt.utils._util import get_max_args
@@ -91,3 +96,46 @@ def test_get_max_args_methods():
assert get_max_args(A().fun1) == 0
assert get_max_args(A().fun2) == 1
assert get_max_args(A()) == 2
MAC_CI_PYSIDE6 = bool(
sys.platform == "darwin" and os.getenv("CI") and qtpy.API_NAME == "PySide6"
)
@pytest.mark.skipif(MAC_CI_PYSIDE6, reason="still hangs on mac ci with pyside6")
def test_exception_context(qtbot, qapp: QApplication) -> None:
def accept():
for wdg in qapp.topLevelWidgets():
if isinstance(wdg, QMessageBox):
wdg.button(QMessageBox.StandardButton.Ok).click()
with exceptions_as_dialog():
QTimer.singleShot(0, accept)
raise Exception("This will be caught and shown in a QMessageBox")
with pytest.raises(ZeroDivisionError), exceptions_as_dialog(ValueError):
1 / 0 # noqa
with exceptions_as_dialog(msg_template="Error: {exc_value}"):
QTimer.singleShot(0, accept)
raise Exception("This message will be used as 'exc_value'")
err = QErrorMessage()
with exceptions_as_dialog(use_error_message=err):
QTimer.singleShot(0, err.accept)
raise AssertionError("Uncheck the checkbox to ignore this in the future")
# tb formatting smoke test, and return value checking
exc = ValueError("Bad Val")
with exceptions_as_dialog(
msg_template="{tb}",
buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
) as ctx:
qtbot.addWidget(ctx.dialog)
QTimer.singleShot(100, accept)
raise exc
assert isinstance(ctx.dialog, QMessageBox)
assert ctx.dialog.result() == QMessageBox.StandardButton.Ok
assert ctx.exception is exc