Compare commits

...

4 Commits

Author SHA1 Message Date
Talley Lambert
5ab72a0c48 add changelog for 0.2.4 (#25) 2021-09-13 13:25:30 -04:00
Talley Lambert
06da62811b Add type stubs for ensure_thread decorator (#23)
* types

* udpate manifest

* remove unused
2021-09-11 07:59:24 -04:00
Grzegorz Bokota
bb538cda2a Add ensure_main_tread and ensure_object_thread (#22)
* initial implementation

* add tests

* add test for property

* add doc part 1

* same behavior for direct and indirect call

* allow use decorator without braces

* add documentation

* Update docs/decorators.md

* update docs

* update docs

* simplify

* remove obsolete timeout

* update docs for future

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2021-09-10 21:21:33 -04:00
Talley Lambert
c8a40ba051 add mesage handler (#21) 2021-09-02 22:49:55 -04:00
14 changed files with 647 additions and 3 deletions

View File

@@ -0,0 +1,8 @@
# run this with:
# export CHANGELOG_GITHUB_TOKEN=......
# github_changelog_generator --future-release vX.Y.Z
user=napari
project=superqt
issues=false
since-tag=v0.2.0
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]}}

45
CHANGELOG.md Normal file
View File

@@ -0,0 +1,45 @@
# Changelog
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.1)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View File

@@ -1,5 +1,7 @@
include LICENSE
include README.md
include superqt/py.typed
recursive-include superqt *.py
recursive-include superqt *.pyi
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

86
docs/decorators.md Normal file
View File

@@ -0,0 +1,86 @@
# Decorators
## Move to thread decorators
`superqt` provides two decorators that help to ensure that given function is
running in the desired thread:
* `ensure_main_thread` - ensures that the decorated function/method runs in the main thread
* `ensure_object_thread` - ensures that a decorated bound method of a `QObject` runs in the
thread in which the instance lives ([qt
documentation](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
By default, functions are executed asynchronously (they return immediately with
an instance of
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
```python
from superqt.qtcompat.QtCore import QObject
from superqt import ensure_main_thread, ensure_object_thread
@ensure_main_thread
def sample_function():
print("This function will run in main thread")
class SampleObject(QObject):
def __init__(self):
super().__init__()
self._value = 1
@ensure_main_thread
def sample_method1(self):
print("This method will run in main thread")
@ensure_object_thread
def sample_method3(self):
import time
print("sleeping")
time.sleep(1)
print("This method will run in object thread")
@property
def value(self):
print("return value")
return self._value
@value.setter
@ensure_object_thread
def value(self, value):
print("this setter will run in object thread")
self._value = value
```
As can be seen in this example these decorators can also be used for setters.
These decorators should not be used as replacement of Qt Signals but rather to
interact with Qt objects from non Qt code.
### Synchronous mode
If you'd like for the program to block and wait for the result of your function
call, use the `await_return=True` parameter, and optionally specify a timeout.
> *Note: Using synchronous mode may significantly impact performance.*
```python
from superqt import ensure_main_thread
@ensure_main_thread
def sample_function1():
return 1
@ensure_main_thread(await_return=True)
def sample_function2():
return 2
assert sample_function1() is None
assert sample_function2() == 2
# optionally, specify a timeout
@ensure_main_thread(await_return=True, timeout=10000)
def sample_function():
return 1
```

View File

@@ -66,6 +66,9 @@ testing =
tox
tox-conda
[options.package_data]
superqt = py.typed
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy

View File

@@ -17,8 +17,11 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QDoubleSlider",
"QElidingLabel",
@@ -27,6 +30,7 @@ __all__ = [
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QRangeSlider",
"QEnumComboBox",
]

0
superqt/py.typed Normal file
View File

View File

@@ -1,8 +1,7 @@
from PyQt5.QtWidgets import QSlider
from superqt.qtcompat.QtWidgets import QSlider
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
from .qtcompat.QtWidgets import QSlider
class QDoubleRangeSlider(_GenericRangeSlider): ...
class QDoubleSlider(_GenericSlider): ...

View File

@@ -0,0 +1,4 @@
__all__ = ("QMessageHandler", "ensure_object_thread", "ensure_main_thread")
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._message_handler import QMessageHandler

View File

@@ -0,0 +1,122 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from concurrent.futures import Future
from typing import Callable, List, Optional
from superqt.qtcompat.QtCore import (
QCoreApplication,
QMetaObject,
QObject,
Qt,
QThread,
Signal,
Slot,
)
class CallCallable(QObject):
finished = Signal(object)
instances: List["CallCallable"] = []
def __init__(self, callable, *args, **kwargs):
super().__init__()
self._callable = callable
self._args = args
self._kwargs = kwargs
CallCallable.instances.append(self)
@Slot()
def call(self):
CallCallable.instances.remove(self)
res = self._callable(*self._args, **self._kwargs)
self.finished.emit(res)
def ensure_main_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a function is called in the main QApplication thread.
It can be applied to functions or methods.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _func(*args, **kwargs):
return _run_in_thread(
func,
QCoreApplication.instance().thread(),
await_return,
timeout,
*args,
**kwargs,
)
return _func
if func is None:
return _out_func
return _out_func(func)
def ensure_object_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a QObject method is called in the object's thread.
It must be applied to methods of QObjects subclasses.
Parameters
----------
func : callable
The method to decorate, must be a method on a QObject.
await_return : bool, optional
Whether to block and wait for the result of the function, or return immediately.
by default False
timeout : int, optional
If `await_return` is `True`, time (in milliseconds) to wait for the result
before raising a TimeoutError, by default 1000
"""
def _out_func(func):
def _func(self, *args, **kwargs):
return _run_in_thread(
func, self.thread(), await_return, timeout, self, *args, **kwargs
)
return _func
if func is None:
return _out_func
return _out_func(func)
def _run_in_thread(
func: Callable,
thread: QThread,
await_return: bool,
timeout: int,
*args,
**kwargs,
):
future = Future() # type: ignore
if thread is QThread.currentThread():
result = func(*args, **kwargs)
if not await_return:
future.set_result(result)
return future
return result
f = CallCallable(func, *args, **kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
return future.result(timeout=timeout / 1000) if await_return else future

View File

@@ -0,0 +1,52 @@
from concurrent.futures import Future
from typing import Callable, TypeVar, overload
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
@overload
def ensure_main_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
@overload
def ensure_object_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...

View File

@@ -0,0 +1,98 @@
import logging
from contextlib import suppress
from typing import List, NamedTuple, Optional
from superqt.qtcompat.QtCore import (
QMessageLogContext,
QtMsgType,
qInstallMessageHandler,
)
class Record(NamedTuple):
level: int
message: str
ctx: dict
class QMessageHandler:
"""A context manager to intercept messages from Qt.
Parameters
----------
logger : logging.Logger, optional
If provided, intercepted messages will be logged with `logger` at the
corresponding python log level, by default None
Attributes
----------
records: list of tuple
Captured messages. This is a 3-tuple of:
`(log_level: int, message: str, context: dict)`
Examples
--------
>>> handler = QMessageHandler()
>>> handler.install() # now all Qt output will be available at mh.records
>>> with QMessageHandler() as handler: # temporarily install
... ...
>>> logger = logging.getLogger(__name__)
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
... ...
"""
_qt2loggertype = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
QtMsgType.QtCriticalMsg: logging.ERROR, # note
QtMsgType.QtFatalMsg: logging.CRITICAL, # note
QtMsgType.QtSystemMsg: logging.CRITICAL,
}
def __init__(self, logger: Optional[logging.Logger] = None):
self.records: List[Record] = []
self._logger = logger
self._previous_handler: Optional[object] = "__uninstalled__"
def install(self):
"""Install this handler (override the current QtMessageHandler)."""
self._previous_handler = qInstallMessageHandler(self)
def uninstall(self):
"""Uninstall this handler, restoring the previous handler."""
if self._previous_handler != "__uninstalled__":
qInstallMessageHandler(self._previous_handler)
def __repr__(self):
n = type(self).__name__
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
def __enter__(self):
"""Enter a context with this handler installed"""
self.install()
return self
def __exit__(self, *args):
self.uninstall()
def __call__(self, msgtype: QtMsgType, context: QMessageLogContext, message: str):
level = self._qt2loggertype[msgtype]
# PyQt seems to throw an error if these are simply empty
ctx = dict.fromkeys(["category", "file", "function", "line"])
with suppress(UnicodeDecodeError):
ctx["category"] = context.category
with suppress(UnicodeDecodeError):
ctx["file"] = context.file
with suppress(UnicodeDecodeError):
ctx["function"] = context.function
with suppress(UnicodeDecodeError):
ctx["line"] = context.line
self.records.append(Record(level, message, ctx))
if self._logger is not None:
self._logger.log(level, message, extra=ctx)

View File

@@ -0,0 +1,186 @@
import time
from concurrent.futures import Future, TimeoutError
import pytest
from superqt.qtcompat.QtCore import QCoreApplication, QObject, QThread, Signal
from superqt.utils import ensure_main_thread, ensure_object_thread
class SampleObject(QObject):
assigment_done = Signal()
def __init__(self):
super().__init__()
self.main_thread_res = {}
self.object_thread_res = {}
self.main_thread_prop_val = None
self.sample_thread_prop_val = None
def long_wait(self):
time.sleep(1)
@property
def sample_main_thread_property(self):
return self.main_thread_prop_val
@sample_main_thread_property.setter # type: ignore
@ensure_main_thread()
def sample_main_thread_property(self, value):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_prop_val = value
self.assigment_done.emit()
@property
def sample_object_thread_property(self):
return self.sample_thread_prop_val
@sample_object_thread_property.setter # type: ignore
@ensure_object_thread()
def sample_object_thread_property(self, value):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.sample_thread_prop_val = value
self.assigment_done.emit()
@ensure_main_thread
def check_main_thread(self, a, *, b=1):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
self.main_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread
def check_object_thread(self, a, *, b=1):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
self.object_thread_res = {"a": a, "b": b}
self.assigment_done.emit()
@ensure_object_thread(await_return=True)
def check_object_thread_return(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
return a * 7
@ensure_object_thread(await_return=True, timeout=200)
def check_object_thread_return_timeout(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(1)
return a * 7
@ensure_object_thread(await_return=False)
def check_object_thread_return_future(self, a):
if QThread.currentThread() is not self.thread():
raise RuntimeError("Wrong thread")
time.sleep(0.4)
return a * 7
@ensure_main_thread(await_return=True)
def check_main_thread_return(self, a):
if QThread.currentThread() is not QCoreApplication.instance().thread():
raise RuntimeError("Wrong thread")
return a * 8
class LocalThread(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
self.ob.check_main_thread(5, b=8)
self.ob.main_thread_prop_val = "text2"
class LocalThread2(QThread):
def __init__(self, ob):
super().__init__()
self.ob = ob
self.executed = False
def run(self):
assert QThread.currentThread() is not QCoreApplication.instance().thread()
assert self.ob.check_main_thread_return(5) == 40
self.executed = True
def test_only_main_thread(qapp):
ob = SampleObject()
ob.check_main_thread(1, b=3)
assert ob.main_thread_res == {"a": 1, "b": 3}
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
ob.sample_main_thread_property = 5
assert ob.sample_main_thread_property == 5
ob.sample_object_thread_property = 7
assert ob.sample_object_thread_property == 7
def test_object_thread(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with qtbot.waitSignal(ob.assigment_done):
ob.check_object_thread(2, b=4)
assert ob.object_thread_res == {"a": 2, "b": 4}
with qtbot.waitSignal(ob.assigment_done):
ob.sample_object_thread_property = "text"
assert ob.sample_object_thread_property == "text"
assert ob.thread() is thread
thread.exit(0)
def test_main_thread(qtbot):
ob = SampleObject()
t = LocalThread(ob)
with qtbot.waitSignal(t.finished):
t.start()
assert ob.main_thread_res == {"a": 5, "b": 8}
assert ob.sample_main_thread_property == "text2"
def test_object_thread_return(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
assert ob.check_object_thread_return(2) == 14
assert ob.thread() is thread
thread.exit(0)
def test_object_thread_return_timeout(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
with pytest.raises(TimeoutError):
ob.check_object_thread_return_timeout(2)
thread.exit(0)
def test_object_thread_return_future(qtbot):
ob = SampleObject()
thread = QThread()
thread.start()
ob.moveToThread(thread)
future = ob.check_object_thread_return_future(2)
assert isinstance(future, Future)
assert future.result() == 14
thread.exit(0)
def test_main_thread_return(qtbot):
ob = SampleObject()
t = LocalThread2(ob)
with qtbot.wait_signal(t.finished):
t.start()
assert t.executed

View File

@@ -0,0 +1,35 @@
import logging
from superqt import QMessageHandler
from superqt.qtcompat import QtCore
def test_message_handler():
with QMessageHandler() as mh:
QtCore.qDebug("debug")
QtCore.qWarning("warning")
QtCore.qCritical("critical")
assert len(mh.records) == 3
assert mh.records[0].level == logging.DEBUG
assert mh.records[1].level == logging.WARNING
assert mh.records[2].level == logging.CRITICAL
assert "3 records" in repr(mh)
def test_message_handler_with_logger(caplog):
logger = logging.getLogger("test_logger")
caplog.set_level(logging.DEBUG, logger="test_logger")
with QMessageHandler(logger):
QtCore.qDebug("debug")
QtCore.qWarning("warning")
QtCore.qCritical("critical")
assert len(caplog.records) == 3
caplog.records[0].message == "debug"
caplog.records[0].levelno == logging.DEBUG
caplog.records[1].message == "warning"
caplog.records[1].levelno == logging.WARNING
caplog.records[2].message == "critical"
caplog.records[2].levelno == logging.CRITICAL