Compare commits

...

8 Commits

Author SHA1 Message Date
Talley Lambert
8525efd98c chore: changelog v0.5.4 2023-08-31 09:56:01 -04:00
Talley Lambert
f676d7e171 fix: fix mysterious segfault (#192) 2023-08-31 09:54:39 -04:00
Talley Lambert
599dff7d02 chore: changelog v0.5.3 2023-08-21 17:14:13 -04:00
Talley Lambert
ed960f4994 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>
2023-08-21 17:12:39 -04:00
Talley Lambert
7fcba7a485 fix: remove dupes/aliases in QEnumCombo (#190)
* fix: remove dupes/aliases in QEnumCombo

* test: add test
2023-08-20 09:52:14 -04:00
Talley Lambert
619daae13f chore: changelog v0.5.2 2023-08-18 15:00:16 -04:00
Grzegorz Bokota
462eeada93 fix: Add descriptive exception when fail to add instance to weakref dictionary (#189)
* add weakref information and test

* more information

* Update src/superqt/utils/_throttler.py

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-08-18 14:20:11 -04:00
Grzegorz Bokota
8457563f49 Implement throttling of methods (#188)
* Implement throttling of methods

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

* fix line length

* chek if object instance is Qt object

* handle `self._name` being None or empty string

* fix throttling method

* handle staticmethod

* use descriptor

* try fix staticmethods

* move descriptor to a separate class

* move __set_name__

* simplify code and restore timer information

* inspire tlamber suggestions

* clean code

* add weakref dict as fallback

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-18 13:30:03 -04:00
11 changed files with 439 additions and 14 deletions

View File

@@ -1,8 +1,40 @@
# Changelog
## [Unreleased](https://github.com/pyapp-kit/superqt/tree/HEAD)
## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...HEAD)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
**Fixed bugs:**
- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03))
## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3)
**Implemented enhancements:**
- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03))
## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2)
**Implemented enhancements:**
- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki))
## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1)
**Fixed bugs:**

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

@@ -49,7 +49,9 @@ class QEnumComboBox(QComboBox):
self._allow_none = allow_none and enum is not None
if allow_none:
super().addItem(NONE_STRING)
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
names = map(_get_name, self._enum_class.__members__.values())
_names = dict.fromkeys(names) # remove duplicates/aliases, keep order
super().addItems(list(_names))
def enumClass(self) -> Optional[EnumMeta]:
"""Return current Enum class."""

View File

@@ -135,8 +135,8 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy)
self.setFocusPolicy(Qt.FocusPolicy(fp))
self._slider = self._slider_class()
self._label = SliderLabel(self._slider, connect=self._setValue)
self._slider = self._slider_class(parent=self)
self._label = SliderLabel(self._slider, connect=self._setValue, parent=self)
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
self._rename_signals()
@@ -145,12 +145,15 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self._slider.sliderMoved.connect(self.sliderMoved.emit)
self._slider.sliderPressed.connect(self.sliderPressed.emit)
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.valueChanged.connect(self._label.setValue)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.valueChanged.connect(self._on_slider_value_changed)
self._label.editingFinished.connect(self.editingFinished)
self.setOrientation(orientation)
def _on_slider_value_changed(self, v):
self._label.setValue(v)
self.valueChanged.emit(v)
def _setValue(self, value: float):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))

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

@@ -32,6 +32,7 @@ from concurrent.futures import Future
from enum import IntFlag, auto
from functools import wraps
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload
from weakref import WeakKeyDictionary
from qtpy.QtCore import QObject, Qt, QTimer, Signal
@@ -202,18 +203,26 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
super().__init__(kind, emissionPolicy, parent)
self._future: Future[R] = Future()
if isinstance(func, staticmethod):
self._func = func.__func__
else:
self._func = func
self.__wrapped__ = func
self._args: tuple = ()
self._kwargs: dict = {}
self.triggered.connect(self._set_future_result)
self._name = None
self._obj_dkt = WeakKeyDictionary()
# even if we were to compile __call__ with a signature matching that of func,
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
# so we do it ourselfs and limit the number of positional arguments
# that we pass to func
self._max_args: int | None = get_max_args(func)
self._max_args: int | None = get_max_args(self._func)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
if not self._future.done():
@@ -227,9 +236,53 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
return self._future
def _set_future_result(self):
result = self.__wrapped__(*self._args[: self._max_args], **self._kwargs)
result = self._func(*self._args[: self._max_args], **self._kwargs)
self._future.set_result(result)
def __set_name__(self, owner, name):
if not isinstance(self.__wrapped__, staticmethod):
self._name = name
def _get_throttler(self, instance, owner, parent, obj):
throttler = ThrottledCallable(
self.__wrapped__.__get__(instance, owner),
self._kind,
self._emissionPolicy,
parent=parent,
)
throttler.setTimerType(self.timerType())
throttler.setTimeout(self.timeout())
try:
setattr(
obj,
self._name,
throttler,
)
except AttributeError:
try:
self._obj_dkt[obj] = throttler
except TypeError as e:
raise TypeError(
"To use qthrottled or qdebounced as a method decorator, "
"objects must have `__dict__` or be weak referenceable. "
"Please either add `__weakref__` to `__slots__` or use"
"qthrottled/qdebounced as a function (not a decorator)."
) from e
return throttler
def __get__(self, instance, owner):
if instance is None or not self._name:
return self
if instance in self._obj_dkt:
return self._obj_dkt[instance]
parent = self.parent()
if parent is None and isinstance(instance, QObject):
parent = instance
return self._get_throttler(instance, owner, parent, instance)
@overload
def qthrottled(
@@ -237,6 +290,7 @@ def qthrottled(
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R]:
...
@@ -247,6 +301,7 @@ def qthrottled(
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
...
@@ -256,6 +311,7 @@ def qthrottled(
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a throttled function that invokes func at most once per timeout.
@@ -284,8 +340,11 @@ def qthrottled(
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler)
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent)
@overload
@@ -294,6 +353,7 @@ def qdebounced(
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R]:
...
@@ -304,6 +364,7 @@ def qdebounced(
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
...
@@ -313,6 +374,7 @@ def qdebounced(
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
"""Creates a debounced function that delays invoking `func`.
@@ -344,8 +406,11 @@ def qdebounced(
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
desired interval
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
parent: QObject or None
Parent object for timer. If using qthrottled as function it may be usefull
for cleaning data
"""
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer)
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent)
def _make_decorator(
@@ -354,10 +419,16 @@ def _make_decorator(
leading: bool,
timer_type: Qt.TimerType,
kind: Kind,
parent: QObject | None = None,
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]:
nonlocal parent
instance: object | None = getattr(func, "__self__", None)
if isinstance(instance, QObject) and parent is None:
parent = instance
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
obj = ThrottledCallable(func, kind, policy)
obj = ThrottledCallable(func, kind, policy, parent=parent)
obj.setTimerType(timer_type)
obj.setTimeout(timeout)
return wraps(func)(obj)

View File

@@ -11,6 +11,8 @@ class Enum1(Enum):
b = 2
c = 3
ALIAS = a
class Enum2(Enum):
d = 1

View File

@@ -4,6 +4,7 @@ import pytest
from qtpy.QtCore import QObject, Signal
from superqt.utils import qdebounced, qthrottled
from superqt.utils._throttler import ThrottledCallable
def test_debounced(qtbot):
@@ -26,6 +27,101 @@ def test_debounced(qtbot):
assert mock2.call_count == 10
def test_debouncer_method(qtbot):
class A(QObject):
def __init__(self):
super().__init__()
self.count = 0
def callback(self):
self.count += 1
a = A()
assert all(not isinstance(x, ThrottledCallable) for x in a.children())
b = qdebounced(a.callback, timeout=4)
assert any(isinstance(x, ThrottledCallable) for x in a.children())
for _ in range(10):
b()
qtbot.wait(5)
assert a.count == 1
def test_debouncer_method_definition(qtbot):
mock1 = Mock()
mock2 = Mock()
class A(QObject):
def __init__(self):
super().__init__()
self.count = 0
@qdebounced(timeout=4)
def callback(self):
self.count += 1
@qdebounced(timeout=4)
@staticmethod
def call1():
mock1()
@staticmethod
@qdebounced(timeout=4)
def call2():
mock2()
a = A()
assert all(not isinstance(x, ThrottledCallable) for x in a.children())
for _ in range(10):
a.callback(1)
A.call1(34)
a.call1(22)
a.call2(22)
A.call2(32)
qtbot.wait(5)
assert a.count == 1
mock1.assert_called_once()
mock2.assert_called_once()
def test_class_with_slots(qtbot):
class A:
__slots__ = ("count", "__weakref__")
def __init__(self):
self.count = 0
@qdebounced(timeout=4)
def callback(self):
self.count += 1
a = A()
for _ in range(10):
a.callback()
qtbot.wait(5)
assert a.count == 1
@pytest.mark.usefixtures("qapp")
def test_class_with_slots_except():
class A:
__slots__ = ("count",)
def __init__(self):
self.count = 0
@qdebounced(timeout=4)
def callback(self):
self.count += 1
with pytest.raises(TypeError, match="To use qthrottled or qdebounced"):
A().callback()
def test_throttled(qtbot):
mock1 = Mock()
mock2 = Mock()

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