mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-08-31 14:50:06 +02:00
Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
5ab72a0c48 | ||
|
06da62811b | ||
|
bb538cda2a | ||
|
c8a40ba051 | ||
|
ac1d8403fd | ||
|
ba20665d57 | ||
|
939c5222af | ||
|
22beed7608 | ||
|
9a72d9d474 | ||
|
5202aba6a8 |
26
.github/workflows/test_and_deploy.yml
vendored
26
.github/workflows/test_and_deploy.yml
vendored
@@ -60,20 +60,26 @@ jobs:
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-18.04
|
||||
backend: pyside2
|
||||
- python-version: 3.6
|
||||
platform: ubuntu-16.04
|
||||
backend: pyqt5
|
||||
- python-version: 3.6
|
||||
platform: windows-2016
|
||||
backend: pyqt5
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyside511
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt511
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyside511
|
||||
backend: pyqt512
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt513
|
||||
- python-version: 3.7
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt514
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -83,12 +89,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Linux libraries
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
|
||||
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
|
||||
libxcb-xinerama0 libxcb-xfixes0
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
|
||||
- name: Linux opengl
|
||||
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
|
||||
@@ -100,7 +101,9 @@ jobs:
|
||||
pip install setuptools tox tox-gh-actions
|
||||
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: tox
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
@@ -128,7 +131,6 @@ jobs:
|
||||
name: screenshots ${{ runner.os }}
|
||||
path: screenshots
|
||||
|
||||
|
||||
deploy:
|
||||
# this will run when you have tagged a commit, starting with "v*"
|
||||
# and requires that you have put your twine API key in your
|
||||
|
8
.github_changelog_generator
Normal file
8
.github_changelog_generator
Normal 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"]}}
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -79,3 +79,5 @@ target/
|
||||
*/_version.py
|
||||
.vscode/settings.json
|
||||
screenshots
|
||||
|
||||
.mypy_cache
|
||||
|
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal 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)*
|
@@ -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]
|
||||
|
63
docs/combobox.md
Normal file
63
docs/combobox.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ComboBox
|
||||
|
||||
|
||||
## Enum Combo Box
|
||||
|
||||
`QEnumComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html)
|
||||
that populates the items in the combobox based on a python `Enum` class. In addition to all
|
||||
the methods provided by `QComboBox`, this subclass adds the methods
|
||||
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by the combobox,
|
||||
and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` member in the combobox.
|
||||
There is also a new signal `currentEnumChanged(enum)` analogous to `currentIndexChanged` and `currentTextChanged`.
|
||||
|
||||
Method like `insertItem` and `addItem` are blocked and try of its usage will end with `RuntimeError`
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
```
|
||||
|
||||
other option is to use optional `enum_class` argument of constructor and change
|
||||
```python
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
```
|
||||
to
|
||||
```python
|
||||
combo = QEnumComboBox(enum_class=SampleEnum)
|
||||
```
|
||||
|
||||
|
||||
### Allow `None`
|
||||
`QEnumComboBox` allow using Optional type annotation:
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum, allow_none=True)
|
||||
```
|
||||
|
||||
In this case there is added option `----` and `currentEnum` will return `None` for it.
|
86
docs/decorators.md
Normal file
86
docs/decorators.md
Normal 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
|
||||
|
||||
```
|
@@ -95,8 +95,7 @@ then you can also target it directly in your style sheet. The one "special"
|
||||
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
|
||||
bar between the handles.
|
||||
|
||||
> The code for these example widgets is [here](examples/demo_widget.py)
|
||||
|
||||
> The code for these example widgets is [here](../examples/demo_widget.py)
|
||||
<details>
|
||||
|
||||
<summary><em>See style sheet used for this example</em></summary>
|
||||
|
12
examples/eliding_label.py
Normal file
12
examples/eliding_label.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from superqt import QElidingLabel
|
||||
from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
widget = QElidingLabel(
|
||||
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||
)
|
||||
widget.setWordWrap(True)
|
||||
widget.show()
|
||||
app.exec_()
|
@@ -66,6 +66,9 @@ testing =
|
||||
tox
|
||||
tox-conda
|
||||
|
||||
[options.package_data]
|
||||
superqt = py.typed
|
||||
|
||||
[flake8]
|
||||
exclude = _version.py,.eggs,examples
|
||||
docstring-convention = numpy
|
||||
|
@@ -5,6 +5,8 @@ except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
|
||||
from ._eliding_label import QElidingLabel
|
||||
from .combobox import QEnumComboBox
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
@@ -15,14 +17,20 @@ 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",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QMessageHandler",
|
||||
"QRangeSlider",
|
||||
"QEnumComboBox",
|
||||
]
|
||||
|
110
superqt/_eliding_label.py
Normal file
110
superqt/_eliding_label.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import List
|
||||
|
||||
from superqt.qtcompat.QtCore import QPoint, QRect, QSize, Qt
|
||||
from superqt.qtcompat.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
|
||||
from superqt.qtcompat.QtWidgets import QLabel
|
||||
|
||||
|
||||
class QElidingLabel(QLabel):
|
||||
"""A QLabel variant that will elide text (add '…') to fit width.
|
||||
|
||||
QElidingLabel()
|
||||
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
|
||||
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
|
||||
|
||||
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
|
||||
will wrap to fit the width, and only the last line will be elided.
|
||||
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
|
||||
the full text.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._elide_mode = Qt.TextElideMode.ElideRight
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setText(args[0] if args and isinstance(args[0], str) else "")
|
||||
|
||||
# New Public methods
|
||||
|
||||
def elideMode(self) -> Qt.TextElideMode:
|
||||
"""The current Qt.TextElideMode."""
|
||||
return self._elide_mode
|
||||
|
||||
def setElideMode(self, mode: Qt.TextElideMode):
|
||||
"""Set the elide mode to a Qt.TextElideMode."""
|
||||
self._elide_mode = Qt.TextElideMode(mode)
|
||||
super().setText(self._elidedText())
|
||||
|
||||
@staticmethod
|
||||
def wrapText(text, width, font=None) -> List[str]:
|
||||
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
|
||||
|
||||
Static method.
|
||||
"""
|
||||
tl = QTextLayout(text, font or QFont())
|
||||
tl.beginLayout()
|
||||
lines = []
|
||||
while True:
|
||||
ln = tl.createLine()
|
||||
if not ln.isValid():
|
||||
break
|
||||
ln.setLineWidth(width)
|
||||
start = ln.textStart()
|
||||
lines.append(text[start : start + ln.textLength()])
|
||||
tl.endLayout()
|
||||
return lines
|
||||
|
||||
# Reimplemented QT methods
|
||||
|
||||
def text(self) -> str:
|
||||
"""This property holds the label's text.
|
||||
|
||||
If no text has been set this will return an empty string.
|
||||
"""
|
||||
return self._text
|
||||
|
||||
def setText(self, txt: str):
|
||||
"""Set the label's text.
|
||||
|
||||
Setting the text clears any previous content.
|
||||
NOTE: we set the QLabel private text to the elided version
|
||||
"""
|
||||
self._text = txt
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def resizeEvent(self, ev: QResizeEvent) -> None:
|
||||
ev.accept()
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def setWordWrap(self, wrap: bool) -> None:
|
||||
super().setWordWrap(wrap)
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
if not self.wordWrap():
|
||||
return super().sizeHint()
|
||||
fm = QFontMetrics(self.font())
|
||||
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
|
||||
return QSize(self.width(), r.height())
|
||||
|
||||
# private implementation methods
|
||||
|
||||
def _elidedText(self) -> str:
|
||||
"""Return `self._text` elided to `width`"""
|
||||
fm = QFontMetrics(self.font())
|
||||
# the 2 is a magic number that prevents the ellipses from going missing
|
||||
# in certain cases (?)
|
||||
width = self.width() - 2
|
||||
if not self.wordWrap():
|
||||
return fm.elidedText(self._text, self._elide_mode, width)
|
||||
|
||||
# get number of lines we can fit without eliding
|
||||
nlines = self.height() // fm.height() - 1
|
||||
# get the last line (elided)
|
||||
text = self._wrappedText()
|
||||
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
|
||||
# join them
|
||||
return "".join(text[:nlines] + [last_line])
|
||||
|
||||
def _wrappedText(self) -> List[str]:
|
||||
return QElidingLabel.wrapText(self._text, self.width(), self.font())
|
68
superqt/_tests/test_eliding_label.py
Normal file
68
superqt/_tests/test_eliding_label.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from superqt import QElidingLabel
|
||||
from superqt.qtcompat.QtCore import QSize, Qt
|
||||
from superqt.qtcompat.QtGui import QResizeEvent
|
||||
|
||||
TEXT = (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad "
|
||||
"minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip "
|
||||
"ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate "
|
||||
"velit esse cillum dolore eu fugiat nullapariatur."
|
||||
)
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
def test_eliding_label(qtbot):
|
||||
wdg = QElidingLabel(TEXT)
|
||||
qtbot.addWidget(wdg)
|
||||
assert wdg._elidedText().endswith(ELLIPSIS)
|
||||
oldsize = wdg.size()
|
||||
newsize = QSize(200, 20)
|
||||
wdg.resize(newsize)
|
||||
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
|
||||
assert wdg.text() == TEXT
|
||||
|
||||
|
||||
def test_wrapped_eliding_label(qtbot):
|
||||
wdg = QElidingLabel(TEXT)
|
||||
qtbot.addWidget(wdg)
|
||||
assert not wdg.wordWrap()
|
||||
assert 630 < wdg.sizeHint().width() < 635
|
||||
assert wdg._elidedText() == (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et d…"
|
||||
)
|
||||
wdg.resize(QSize(200, 100))
|
||||
assert wdg.text() == TEXT
|
||||
assert wdg._elidedText() == "Lorem ipsum dolor sit amet, co…"
|
||||
wdg.setWordWrap(True)
|
||||
assert wdg.wordWrap()
|
||||
assert wdg.text() == TEXT
|
||||
assert wdg._elidedText() == (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua. "
|
||||
"Ut enim ad minim ven iam, quis nostrud exercitation ullamco la…"
|
||||
)
|
||||
assert wdg.sizeHint() == QSize(200, 176)
|
||||
wdg.resize(wdg.sizeHint())
|
||||
assert wdg._elidedText() == TEXT
|
||||
|
||||
|
||||
def test_shorter_eliding_label(qtbot):
|
||||
short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s"
|
||||
wdg = QElidingLabel()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.setText(short)
|
||||
assert not wdg._elidedText().endswith(ELLIPSIS)
|
||||
wdg.resize(100, 20)
|
||||
assert wdg._elidedText().endswith(ELLIPSIS)
|
||||
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
|
||||
assert wdg._elidedText().startswith(ELLIPSIS)
|
||||
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
|
||||
|
||||
|
||||
def test_wrap_text():
|
||||
wrap = QElidingLabel.wrapText(TEXT, 200)
|
||||
assert isinstance(wrap, list)
|
||||
assert all(isinstance(x, str) for x in wrap)
|
||||
assert len(wrap) == 11
|
3
superqt/combobox/__init__.py
Normal file
3
superqt/combobox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
|
||||
__all__ = ("QEnumComboBox",)
|
112
superqt/combobox/_enum_combobox.py
Normal file
112
superqt/combobox/_enum_combobox.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from enum import Enum, EnumMeta
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from ..qtcompat.QtCore import Signal
|
||||
from ..qtcompat.QtWidgets import QComboBox
|
||||
|
||||
EnumType = TypeVar("EnumType", bound=Enum)
|
||||
|
||||
|
||||
NONE_STRING = "----"
|
||||
|
||||
|
||||
def _get_name(enum_value: Enum):
|
||||
"""Create human readable name if user does not provide own implementation of __str__"""
|
||||
if enum_value.__str__.__module__ != "enum":
|
||||
# check if function was overloaded
|
||||
name = str(enum_value)
|
||||
else:
|
||||
name = enum_value.name.replace("_", " ")
|
||||
return name
|
||||
|
||||
|
||||
class QEnumComboBox(QComboBox):
|
||||
"""
|
||||
ComboBox presenting options from a python Enum.
|
||||
|
||||
If the Enum class does not implement `__str__` then a human readable name
|
||||
is created from the name of the enum member, replacing underscores with spaces.
|
||||
"""
|
||||
|
||||
currentEnumChanged = Signal(object)
|
||||
|
||||
def __init__(
|
||||
self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._enum_class = None
|
||||
self._allow_none = False
|
||||
if enum_class is not None:
|
||||
self.setEnumClass(enum_class, allow_none)
|
||||
self.currentIndexChanged.connect(self._emit_signal)
|
||||
|
||||
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
|
||||
"""
|
||||
Set enum class from which members value should be selected
|
||||
"""
|
||||
self.clear()
|
||||
self._enum_class = enum
|
||||
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())))
|
||||
|
||||
def enumClass(self) -> Optional[EnumMeta]:
|
||||
"""return current Enum class"""
|
||||
return self._enum_class
|
||||
|
||||
def isOptional(self) -> bool:
|
||||
"""return if current enum is with optional annotation"""
|
||||
return self._allow_none
|
||||
|
||||
def clear(self):
|
||||
self._enum_class = None
|
||||
self._allow_none = False
|
||||
super().clear()
|
||||
|
||||
def currentEnum(self) -> Optional[EnumType]:
|
||||
"""current value as Enum member"""
|
||||
if self._enum_class is not None:
|
||||
if self._allow_none:
|
||||
if self.currentText() == NONE_STRING:
|
||||
return None
|
||||
else:
|
||||
return list(self._enum_class.__members__.values())[
|
||||
self.currentIndex() - 1
|
||||
]
|
||||
return list(self._enum_class.__members__.values())[self.currentIndex()]
|
||||
return None
|
||||
|
||||
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
|
||||
"""Set value with Enum."""
|
||||
if self._enum_class is None:
|
||||
raise RuntimeError(
|
||||
"Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`."
|
||||
)
|
||||
if value is None and self._allow_none:
|
||||
self.setCurrentIndex(0)
|
||||
return
|
||||
if not isinstance(value, self._enum_class):
|
||||
raise TypeError(
|
||||
f"setValue(self, Enum): argument 1 has unexpected type {type(value).__name__!r}"
|
||||
)
|
||||
self.setCurrentText(_get_name(value))
|
||||
|
||||
def _emit_signal(self):
|
||||
if self._enum_class is not None:
|
||||
self.currentEnumChanged.emit(self.currentEnum())
|
||||
|
||||
def insertItems(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert items")
|
||||
|
||||
def insertItem(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert item")
|
||||
|
||||
def addItems(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to add items")
|
||||
|
||||
def addItem(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to add item")
|
||||
|
||||
def setInsertPolicy(self, policy):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert item")
|
129
superqt/combobox/_tests/test_enum_comb_box.py
Normal file
129
superqt/combobox/_tests/test_enum_comb_box.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
|
||||
from superqt.combobox import QEnumComboBox
|
||||
from superqt.combobox._enum_combobox import NONE_STRING
|
||||
|
||||
|
||||
class Enum1(Enum):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
|
||||
class Enum2(Enum):
|
||||
d = 1
|
||||
e = 2
|
||||
f = 3
|
||||
g = 4
|
||||
|
||||
|
||||
class Enum3(Enum):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
def __str__(self):
|
||||
return self.name + "1"
|
||||
|
||||
|
||||
class Enum4(Enum):
|
||||
a_1 = 1
|
||||
b_2 = 2
|
||||
c_3 = 3
|
||||
|
||||
|
||||
def test_simple_create(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_simple_create2(qtbot):
|
||||
enum = QEnumComboBox()
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 0
|
||||
enum.setEnumClass(Enum1)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert enum.enumClass() == Enum1
|
||||
assert isinstance(enum.currentEnum(), Enum1)
|
||||
enum.setEnumClass(Enum2)
|
||||
assert enum.enumClass() == Enum2
|
||||
assert isinstance(enum.currentEnum(), Enum2)
|
||||
assert enum.count() == 4
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["d", "e", "f", "g"]
|
||||
|
||||
|
||||
def test_str_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum3)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a1", "b1", "c1"]
|
||||
|
||||
|
||||
def test_underscore_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum4)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a 1", "b 2", "c 3"]
|
||||
|
||||
|
||||
def test_change_value(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.currentEnum() == Enum1.a
|
||||
with qtbot.waitSignal(
|
||||
enum.currentEnumChanged, check_params_cb=lambda x: isinstance(x, Enum)
|
||||
):
|
||||
enum.setCurrentEnum(Enum1.c)
|
||||
assert enum.currentEnum() == Enum1.c
|
||||
|
||||
|
||||
def test_no_enum(qtbot):
|
||||
enum = QEnumComboBox()
|
||||
assert enum.enumClass() is None
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.currentEnum() is None
|
||||
|
||||
|
||||
def test_prohibited_methods(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.addItem("aaa")
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.addItems(["aaa", "bbb"])
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.insertItem(0, "aaa")
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.insertItems(0, ["aaa", "bbb"])
|
||||
assert enum.count() == 3
|
||||
|
||||
|
||||
def test_optional(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1, allow_none=True)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == [
|
||||
NONE_STRING,
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]
|
||||
assert enum.currentText() == NONE_STRING
|
||||
assert enum.currentEnum() is None
|
||||
enum.setCurrentEnum(Enum1.a)
|
||||
assert enum.currentText() == "a"
|
||||
assert enum.currentEnum() == Enum1.a
|
||||
assert enum.enumClass() is Enum1
|
||||
enum.setCurrentEnum(None)
|
||||
assert enum.currentText() == NONE_STRING
|
||||
assert enum.currentEnum() is None
|
0
superqt/py.typed
Normal file
0
superqt/py.typed
Normal file
12
superqt/sliders/__init__.pyi
Normal file
12
superqt/sliders/__init__.pyi
Normal file
@@ -0,0 +1,12 @@
|
||||
from superqt.qtcompat.QtWidgets import QSlider
|
||||
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
from ._generic_slider import _GenericSlider
|
||||
|
||||
class QDoubleRangeSlider(_GenericRangeSlider): ...
|
||||
class QDoubleSlider(_GenericSlider): ...
|
||||
class QRangeSlider(_GenericRangeSlider): ...
|
||||
class QLabeledSlider(QSlider): ...
|
||||
class QLabeledDoubleSlider(QDoubleSlider): ...
|
||||
class QLabeledRangeSlider(QRangeSlider): ...
|
||||
class QLabeledDoubleRangeSlider(QDoubleRangeSlider): ...
|
@@ -483,6 +483,5 @@ def _sliderValueFromPosition(
|
||||
return max if upsideDown else min
|
||||
if position >= span:
|
||||
return min if upsideDown else max
|
||||
range = max - min
|
||||
tmp = min + position * range / span
|
||||
return max - tmp if upsideDown else tmp + min
|
||||
tmp = (max - min) * (position / span)
|
||||
return (max - tmp) if upsideDown else tmp + min
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from ..qtcompat.QtGui import QFontMetrics, QValidator
|
||||
@@ -86,6 +87,9 @@ class _SliderProxy:
|
||||
def setTickPosition(self, pos) -> None:
|
||||
self._slider.setTickPosition(pos)
|
||||
|
||||
def __getattr__(self, name) -> Any:
|
||||
return getattr(self._slider, name)
|
||||
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
parent = None
|
||||
|
@@ -5,7 +5,7 @@ import pytest
|
||||
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from superqt.qtcompat.QtGui import QHoverEvent
|
||||
from superqt.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
|
||||
from superqt.sliders._generic_slider import _GenericSlider
|
||||
from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition
|
||||
|
||||
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
|
||||
|
||||
@@ -155,3 +155,28 @@ def test_slider_extremes(gslider: _GenericSlider, mag, qtbot):
|
||||
gslider.setValue(i)
|
||||
assert math.isclose(gslider.value(), i, rel_tol=1e-8)
|
||||
gslider.initStyleOption(QStyleOptionSlider())
|
||||
|
||||
|
||||
# args are (min: float, max: float, position: int, span: int, upsideDown: bool)
|
||||
@pytest.mark.parametrize(
|
||||
"args, result",
|
||||
[
|
||||
# (min, max, pos, span[, inverted]), expectation
|
||||
# data range (1, 2)
|
||||
((1, 2, 50, 100), 1.5),
|
||||
((1, 2, 70, 100), 1.7),
|
||||
((1, 2, 70, 100, True), 1.3), # inverted appearance
|
||||
((1, 2, 170, 100), 2),
|
||||
((1, 2, 100, 100), 2),
|
||||
((1, 2, -30, 100), 1),
|
||||
# data range (-2, 2)
|
||||
((-2, 2, 50, 100), 0),
|
||||
((-2, 2, 75, 100), 1),
|
||||
((-2, 2, 75, 100, True), -1), # inverted appearance
|
||||
((-2, 2, 170, 100), 2),
|
||||
((-2, 2, 100, 100), 2),
|
||||
((-2, 2, -30, 100), -2),
|
||||
],
|
||||
)
|
||||
def test_slider_value_from_position(args, result):
|
||||
assert math.isclose(_sliderValueFromPosition(*args), result)
|
||||
|
11
superqt/sliders/_tests/test_labeled_slider.py
Normal file
11
superqt/sliders/_tests/test_labeled_slider.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from superqt import QLabeledRangeSlider
|
||||
|
||||
|
||||
def test_labeled_slider_api(qtbot):
|
||||
slider = QLabeledRangeSlider()
|
||||
qtbot.addWidget(slider)
|
||||
slider.hideBar()
|
||||
slider.showBar()
|
||||
slider.setBarVisible()
|
||||
slider.setBarMovesAllHandles()
|
||||
slider.setBarIsRigid()
|
4
superqt/utils/__init__.py
Normal file
4
superqt/utils/__init__.py
Normal 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
|
122
superqt/utils/_ensure_thread.py
Normal file
122
superqt/utils/_ensure_thread.py
Normal 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
|
52
superqt/utils/_ensure_thread.pyi
Normal file
52
superqt/utils/_ensure_thread.pyi
Normal 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]]: ...
|
98
superqt/utils/_message_handler.py
Normal file
98
superqt/utils/_message_handler.py
Normal 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)
|
186
superqt/utils/_tests/test_ensure_thread.py
Normal file
186
superqt/utils/_tests/test_ensure_thread.py
Normal 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
|
35
superqt/utils/_tests/test_qmessage_handler.py
Normal file
35
superqt/utils/_tests/test_qmessage_handler.py
Normal 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
|
16
tox.ini
16
tox.ini
@@ -1,5 +1,5 @@
|
||||
[tox]
|
||||
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-{linux,macos,windows}-{pyqt511,pyside511}
|
||||
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-linux-{pyqt511,pyside511,pyqt512,pyqt513,pyqt514}
|
||||
toxworkdir=/tmp/.tox
|
||||
|
||||
[coverage:report]
|
||||
@@ -35,8 +35,11 @@ BACKEND =
|
||||
pyside2: pyside2
|
||||
pyqt6: pyqt6
|
||||
pyside6: pyside6
|
||||
pyqt511: pyqt511
|
||||
pyside511: pyside511
|
||||
pyqt511: pyqt511
|
||||
pyqt512: pyqt512
|
||||
pyqt513: pyqt513
|
||||
pyqt514: pyqt514
|
||||
|
||||
[testenv]
|
||||
platform =
|
||||
@@ -45,9 +48,14 @@ platform =
|
||||
windows: win32
|
||||
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
|
||||
deps =
|
||||
pytest-xvfb ; sys_platform == 'linux'
|
||||
pyqt511: pyqt5==5.11.*
|
||||
pyside511: pyside2==5.11.*
|
||||
pyqt512: pyqt5==5.12.*
|
||||
pyside512: pyside2==5.12.*
|
||||
pyqt513: pyqt5==5.13.*
|
||||
pyside513: pyside2==5.13.*
|
||||
pyqt514: pyqt5==5.14.*
|
||||
pyside514: pyside2==5.14.*
|
||||
extras =
|
||||
testing
|
||||
pyqt5: pyqt5
|
||||
@@ -56,4 +64,4 @@ extras =
|
||||
pyside6: pyside6
|
||||
commands_pre =
|
||||
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
|
||||
commands = pytest --color=yes --cov=superqt --cov-report=xml {posargs}
|
||||
commands = pytest --color=yes --cov=superqt --cov-report=xml --pyargs superqt {posargs}
|
||||
|
Reference in New Issue
Block a user