mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
41ea4e8907 | ||
|
39b6a0596f | ||
|
9ff01e757b |
19
.github/workflows/test_and_deploy.yml
vendored
19
.github/workflows/test_and_deploy.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: Test
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -43,14 +47,6 @@ jobs:
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
|
||||
# python 3.7
|
||||
- python-version: 3.7
|
||||
platform: macos-latest
|
||||
backend: pyqt5
|
||||
- python-version: 3.7
|
||||
platform: windows-latest
|
||||
backend: pyside2
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-latest
|
||||
@@ -63,11 +59,6 @@ jobs:
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -167,6 +158,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
|
@@ -26,7 +26,7 @@ pytest
|
||||
|
||||
All widgets must be well-tested, and should work on:
|
||||
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
- macOS, Windows, & Linux
|
||||
|
@@ -15,7 +15,7 @@ that are not provided in the native QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
@@ -10,7 +10,7 @@ QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
3
docs/utilities/signal_utils.md
Normal file
3
docs/utilities/signal_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Signal Utilities
|
||||
|
||||
::: superqt.utils.signals_blocked
|
@@ -8,9 +8,9 @@ build-backend = "hatchling.build"
|
||||
name = "superqt"
|
||||
description = "Missing widgets and components for PyQt/PySide"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.7"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "BSD 3-Clause License" }
|
||||
authors = [{ email = "talley.lambert@gmail.com" }, { name = "Talley Lambert" }]
|
||||
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
|
||||
keywords = [
|
||||
"qt",
|
||||
"pyqt",
|
||||
@@ -28,7 +28,6 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -93,19 +92,21 @@ src_paths = ["src/superqt", "tests"]
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py37"
|
||||
target-version = "py38"
|
||||
src = ["src", "tests"]
|
||||
select = [
|
||||
"E", # style errors
|
||||
"F", # flakes
|
||||
"W", # flakes
|
||||
"D", # pydocstyle
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"S", # bandit
|
||||
"C", # flake8-comprehensions
|
||||
"C4", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TID", # tidy imports
|
||||
]
|
||||
ignore = [
|
||||
"D100", # Missing docstring in public module
|
||||
@@ -118,7 +119,6 @@ ignore = [
|
||||
"D401", # First line should be in imperative mood
|
||||
"D413", # Missing blank line after last section
|
||||
"D416", # Section name should end with a colon
|
||||
"C901", # Function is too complex
|
||||
]
|
||||
|
||||
|
||||
@@ -180,5 +180,4 @@ ignore = [
|
||||
"CONTRIBUTING.md",
|
||||
"codecov.yml",
|
||||
".ruff_cache/**/*",
|
||||
"setup.py",
|
||||
]
|
||||
|
29
setup.py
29
setup.py
@@ -1,29 +0,0 @@
|
||||
import sys
|
||||
|
||||
sys.stderr.write(
|
||||
"""
|
||||
===============================
|
||||
Unsupported installation method
|
||||
===============================
|
||||
superqt does not support installation with `python setup.py install`.
|
||||
Please use `python -m pip install .` instead.
|
||||
"""
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# The below code will never execute, however GitHub is particularly
|
||||
# picky about where it finds Python packaging metadata.
|
||||
# See: https://github.com/github/feedback/discussions/6456
|
||||
#
|
||||
# To be removed once GitHub catches up.
|
||||
|
||||
setup( # noqa: F821
|
||||
name="superqt",
|
||||
install_requires=[
|
||||
"packaging",
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions",
|
||||
],
|
||||
)
|
@@ -1,9 +1,10 @@
|
||||
"""superqt is a collection of Qt components for python."""
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = version("superqt")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, ClassVar, overload
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
|
||||
|
||||
from qtpy.QtCore import (
|
||||
QCoreApplication,
|
||||
@@ -15,6 +15,8 @@ from qtpy.QtCore import (
|
||||
Slot,
|
||||
)
|
||||
|
||||
from ._util import get_max_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypeVar
|
||||
|
||||
@@ -28,7 +30,7 @@ class CallCallable(QObject):
|
||||
finished = Signal(object)
|
||||
instances: ClassVar[list[CallCallable]] = []
|
||||
|
||||
def __init__(self, callable, *args, **kwargs):
|
||||
def __init__(self, callable: Callable, args: tuple, kwargs: dict):
|
||||
super().__init__()
|
||||
self._callable = callable
|
||||
self._args = args
|
||||
@@ -88,15 +90,17 @@ def ensure_main_thread(
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
max_args = get_max_args(func_)
|
||||
|
||||
@wraps(func_)
|
||||
def _func(*args, **kwargs):
|
||||
def _func(*args, _max_args_=max_args, **kwargs):
|
||||
return _run_in_thread(
|
||||
func_,
|
||||
QCoreApplication.instance().thread(),
|
||||
await_return,
|
||||
timeout,
|
||||
*args,
|
||||
**kwargs,
|
||||
args[:_max_args_],
|
||||
kwargs,
|
||||
)
|
||||
|
||||
return _func
|
||||
@@ -150,10 +154,13 @@ def ensure_object_thread(
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
max_args = get_max_args(func_)
|
||||
|
||||
@wraps(func_)
|
||||
def _func(self, *args, **kwargs):
|
||||
def _func(*args, _max_args_=max_args, **kwargs):
|
||||
thread = args[0].thread() # self
|
||||
return _run_in_thread(
|
||||
func_, self.thread(), await_return, timeout, self, *args, **kwargs
|
||||
func_, thread, await_return, timeout, args[:_max_args_], kwargs
|
||||
)
|
||||
|
||||
return _func
|
||||
@@ -166,9 +173,9 @@ def _run_in_thread(
|
||||
thread: QThread,
|
||||
await_return: bool,
|
||||
timeout: int,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
args: tuple,
|
||||
kwargs: dict,
|
||||
) -> Any:
|
||||
future = Future() # type: ignore
|
||||
if thread is QThread.currentThread():
|
||||
result = func(*args, **kwargs)
|
||||
@@ -176,7 +183,8 @@ def _run_in_thread(
|
||||
future.set_result(result)
|
||||
return future
|
||||
return result
|
||||
f = CallCallable(func, *args, **kwargs)
|
||||
|
||||
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 # noqa
|
||||
|
@@ -7,7 +7,24 @@ if TYPE_CHECKING:
|
||||
|
||||
@contextmanager
|
||||
def signals_blocked(obj: "QObject") -> Iterator[None]:
|
||||
"""Context manager to temporarily block signals emitted by QObject: `obj`."""
|
||||
"""Context manager to temporarily block signals emitted by QObject: `obj`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
obj : QObject
|
||||
The QObject whose signals should be blocked.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtWidgets import QSpinBox
|
||||
from superqt import signals_blocked
|
||||
|
||||
spinbox = QSpinBox()
|
||||
with signals_blocked(spinbox):
|
||||
spinbox.setValue(10)
|
||||
```
|
||||
"""
|
||||
previous = obj.blockSignals(True)
|
||||
try:
|
||||
yield
|
||||
|
23
src/superqt/utils/_util.py
Normal file
23
src/superqt/utils/_util.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import signature
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def get_max_args(func: Callable) -> int | None:
|
||||
"""Return the maximum number of positional arguments that func can accept."""
|
||||
if not callable(func):
|
||||
raise TypeError(f"{func!r} is not callable")
|
||||
|
||||
try:
|
||||
sig = signature(func)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
max_args = 0
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == param.VAR_POSITIONAL:
|
||||
return None
|
||||
if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
|
||||
max_args += 1
|
||||
return max_args
|
@@ -1,7 +1,10 @@
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, TimeoutError
|
||||
from functools import wraps
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal
|
||||
@@ -217,3 +220,80 @@ def test_object_thread(qtbot):
|
||||
assert ob.thread() is thread
|
||||
with qtbot.waitSignal(thread.finished):
|
||||
thread.quit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["method", "func", "wrapped"])
|
||||
@pytest.mark.parametrize("deco", [ensure_main_thread, ensure_object_thread])
|
||||
def test_ensure_thread_sig_inspection(deco, mode):
|
||||
class Emitter(QObject):
|
||||
sig = Signal(int, int, int)
|
||||
|
||||
obj = Emitter()
|
||||
mock = Mock()
|
||||
|
||||
if mode == "method":
|
||||
|
||||
class Receiver(QObject):
|
||||
@deco
|
||||
def func(self, a: int, b: int):
|
||||
mock(a, b)
|
||||
|
||||
r = Receiver()
|
||||
obj.sig.connect(r.func)
|
||||
elif deco == ensure_object_thread:
|
||||
return # not compatible with function types
|
||||
|
||||
elif mode == "wrapped":
|
||||
|
||||
def wr(fun):
|
||||
@wraps(fun)
|
||||
def wr2(*args):
|
||||
mock(*args)
|
||||
return fun(*args) * 2
|
||||
|
||||
return wr2
|
||||
|
||||
@deco
|
||||
@wr
|
||||
def wrapped_func(a, b):
|
||||
return a + b
|
||||
|
||||
obj.sig.connect(wrapped_func)
|
||||
|
||||
elif mode == "func":
|
||||
|
||||
@deco
|
||||
def func(a: int, b: int) -> None:
|
||||
mock(a, b)
|
||||
|
||||
obj.sig.connect(func)
|
||||
|
||||
# this is the crux of the test...
|
||||
# we emit 3 args, but the function only takes 2
|
||||
# this should normally work fine in Qt.
|
||||
# testing here that the decorator doesn't break it.
|
||||
obj.sig.emit(1, 2, 3)
|
||||
mock.assert_called_once_with(1, 2)
|
||||
|
||||
|
||||
def test_main_thread_function(qtbot):
|
||||
"""Testing decorator on a function rather than QObject method."""
|
||||
|
||||
mock = Mock()
|
||||
|
||||
class Emitter(QObject):
|
||||
sig = Signal(int, int, int)
|
||||
|
||||
@ensure_main_thread
|
||||
def func(x: int) -> None:
|
||||
mock(x, QThread.currentThread())
|
||||
|
||||
e = Emitter()
|
||||
e.sig.connect(func)
|
||||
|
||||
with qtbot.waitSignal(e.sig):
|
||||
thread = threading.Thread(target=e.sig.emit, args=(1, 2, 3))
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
mock.assert_called_once_with(1, QCoreApplication.instance().thread())
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -26,8 +25,7 @@ def test_slider_connect_works(qtbot):
|
||||
|
||||
def _assert_types(args: Iterable[Any], type_: type):
|
||||
# sourcery skip: comprehension-to-generator
|
||||
if sys.version_info >= (3, 8):
|
||||
assert all(isinstance(v, type_) for v in args), "invalid type"
|
||||
assert all(isinstance(v, type_) for v in args), "invalid type"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider])
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import math
|
||||
import sys
|
||||
from itertools import product
|
||||
from typing import Any, Iterable
|
||||
from unittest.mock import Mock
|
||||
@@ -218,8 +217,7 @@ def test_wheel(cls, orientation, qtbot):
|
||||
|
||||
def _assert_types(args: Iterable[Any], type_: type):
|
||||
# sourcery skip: comprehension-to-generator
|
||||
if sys.version_info >= (3, 8):
|
||||
assert all(isinstance(v, type_) for v in args), "invalid type"
|
||||
assert all(isinstance(v, type_) for v in args), "invalid type"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
|
||||
|
@@ -3,6 +3,7 @@ from unittest.mock import Mock
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
from superqt.utils._util import get_max_args
|
||||
|
||||
|
||||
def test_signal_blocker(qtbot):
|
||||
@@ -27,3 +28,66 @@ def test_signal_blocker(qtbot):
|
||||
qtbot.wait(10)
|
||||
|
||||
receiver.assert_not_called()
|
||||
|
||||
|
||||
def test_get_max_args_simple():
|
||||
def fun1():
|
||||
pass
|
||||
|
||||
assert get_max_args(fun1) == 0
|
||||
|
||||
def fun2(a):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun2) == 1
|
||||
|
||||
def fun3(a, b=1):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun3) == 2
|
||||
|
||||
def fun4(a, *, b=2):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun4) == 1
|
||||
|
||||
def fun5(a, *b):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun5) is None
|
||||
|
||||
assert get_max_args(print) is None
|
||||
|
||||
|
||||
def test_get_max_args_wrapped():
|
||||
from functools import partial, wraps
|
||||
|
||||
def fun1(a, b):
|
||||
pass
|
||||
|
||||
assert get_max_args(partial(fun1, 1)) == 1
|
||||
|
||||
def dec(fun):
|
||||
@wraps(fun)
|
||||
def wrapper(*args, **kwargs):
|
||||
return fun(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
assert get_max_args(dec(fun1)) == 2
|
||||
|
||||
|
||||
def test_get_max_args_methods():
|
||||
class A:
|
||||
def fun1(self):
|
||||
pass
|
||||
|
||||
def fun2(self, a):
|
||||
pass
|
||||
|
||||
def __call__(self, a, b=1):
|
||||
pass
|
||||
|
||||
assert get_max_args(A().fun1) == 0
|
||||
assert get_max_args(A().fun2) == 1
|
||||
assert get_max_args(A()) == 2
|
||||
|
Reference in New Issue
Block a user