Compare commits

...

3 Commits

Author SHA1 Message Date
Talley Lambert
41ea4e8907 docs: document signals blocked (#186) 2023-08-17 09:40:06 -04:00
Talley Lambert
39b6a0596f fix: fix parameter inspection on ensure_thread decorators (alternate) (#185)
* fix: use different approach

* test: apply fixes

* back to signature

* fix get_max_args

* IMPORT THE FUTURE

* try or return None

* check for callable

* Update test_utils.py

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

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

---------

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-17 09:20:11 -04:00
Talley Lambert
9ff01e757b build: misc updates to repo (#180) 2023-08-16 12:08:13 -04:00
15 changed files with 227 additions and 72 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
# Signal Utilities
::: superqt.utils.signals_blocked

View File

@@ -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",
]

View File

@@ -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",
],
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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())

View File

@@ -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])

View File

@@ -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)

View File

@@ -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