diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb2b142..b03291a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,3 +46,9 @@ repos: files: ^(HOWTORELEASE.rst|README.rst)$ language: python additional_dependencies: [pygments, restructuredtext_lint] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.16.0' + hooks: + - id: mypy + additional_dependencies: [pytest] + exclude: ^docs/ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88aeb9b..963a433 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ UNRELEASED - Added official support for Python 3.13. - Dropped support for EOL Python 3.8. - Dropped support for EOL PySide 2. +- Type annotations are now provided. Note that because the Qt library used is defined at runtime, Qt classes are currently annotated as ``Any``. - Fixed PySide6 exceptions / warnings about being unable to disconnect signals with ``qtbot.waitSignal`` (`#552`_, `#558`_). - Reduced the likelyhood of trouble when using ``qtbot.waitSignal(s)`` and diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..b4dc48d --- /dev/null +++ b/mypy.ini @@ -0,0 +1,8 @@ +[mypy] +exclude = ^docs/ +pretty = True +show_error_codes = True +strict_equality = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unused_ignores = True diff --git a/setup.py b/setup.py index cc64bf2..a84a288 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ packages=find_packages(where="src"), package_dir={"": "src"}, entry_points={"pytest11": ["pytest-qt = pytestqt.plugin"]}, - install_requires=["pytest", "pluggy>=1.1"], + install_requires=["pytest", "pluggy>=1.1", "typing_extensions"], extras_require={ "doc": ["sphinx", "sphinx_rtd_theme"], "dev": ["pre-commit", "tox"], diff --git a/src/pytestqt/__init__.py b/src/pytestqt/__init__.py index 7c6237c..66b5a5f 100644 --- a/src/pytestqt/__init__.py +++ b/src/pytestqt/__init__.py @@ -1,4 +1,4 @@ # _version is automatically generated by setuptools_scm from pytestqt._version import version -__version__ = version +__version__: str = version diff --git a/src/pytestqt/exceptions.py b/src/pytestqt/exceptions.py index d342876..ef3c7da 100644 --- a/src/pytestqt/exceptions.py +++ b/src/pytestqt/exceptions.py @@ -2,10 +2,14 @@ import sys import traceback from contextlib import contextmanager +from types import TracebackType import pytest from pytestqt.utils import get_marker +CapturedException = tuple[type[BaseException], BaseException, TracebackType] +CapturedExceptions = list[CapturedException] + @contextmanager def capture_exceptions(): diff --git a/src/pytestqt/py.typed b/src/pytestqt/py.typed new file mode 100644 index 0000000..b648ac9 --- /dev/null +++ b/src/pytestqt/py.typed @@ -0,0 +1 @@ +partial diff --git a/src/pytestqt/qt_compat.py b/src/pytestqt/qt_compat.py index e66f369..49c4e80 100644 --- a/src/pytestqt/qt_compat.py +++ b/src/pytestqt/qt_compat.py @@ -162,7 +162,7 @@ def exec(self, obj, *args, **kwargs): def get_versions(self): if self.pytest_qt_api == "pyside6": - import PySide6 + import PySide6 # type: ignore[import-not-found,unused-ignore] version = PySide6.__version__ diff --git a/src/pytestqt/qtbot.py b/src/pytestqt/qtbot.py index 321b2a3..a8cfdfb 100644 --- a/src/pytestqt/qtbot.py +++ b/src/pytestqt/qtbot.py @@ -1,6 +1,20 @@ +from collections.abc import Callable import contextlib +from types import TracebackType import weakref import warnings +from typing import ( + TYPE_CHECKING, + Generator, + Iterator, + Literal, + Optional, + Any, + cast, +) +from pathlib import Path +import pytest +from typing_extensions import Self, TypeAlias from pytestqt.exceptions import TimeoutError, ScreenshotError from pytestqt.qt_compat import qt_api @@ -11,14 +25,31 @@ SignalEmittedError, CallbackBlocker, CallbackCalledTwiceError, + CheckParamsCb, ) +from pytest import FixtureRequest + +# Type hint objects until figuring out how to import across qt +# versions possibly using 'qtpy' library. +QWidget: TypeAlias = Any +SignalInstance: TypeAlias = Any +QRect: TypeAlias = Any +QKeySequence: TypeAlias = Any + +if TYPE_CHECKING: + # Keep local import behavior the same. + from pytestqt.exceptions import CapturedExceptions + +BeforeCloseFunc = Callable[[QWidget], None] +WaitSignalsOrder = Literal["none", "simple", "strict"] -def _parse_ini_boolean(value): + +def _parse_ini_boolean(value: Any) -> bool: if value in (True, False): - return value + return cast("bool", value) try: - return {"true": True, "false": False}[value.lower()] + return {"true": True, "false": False}[str(value).lower()] except KeyError: raise ValueError("unknown string for bool: %r" % value) @@ -146,7 +177,7 @@ class QtBot: """ - def __init__(self, request): + def __init__(self, request: FixtureRequest) -> None: self._request = request # pep8 aliases. Set here to automatically use implementations defined in sub-classes for alias creation self.add_widget = self.addWidget @@ -160,7 +191,7 @@ def __init__(self, request): self.wait_until = self.waitUntil self.wait_callback = self.waitCallback - def _should_raise(self, raising_arg): + def _should_raise(self, raising_arg: Optional[bool]) -> bool: ini_val = self._request.config.getini("qt_default_raising") if raising_arg is not None: @@ -170,7 +201,9 @@ def _should_raise(self, raising_arg): else: return True - def addWidget(self, widget, *, before_close_func=None): + def addWidget( + self, widget: QWidget, *, before_close_func: Optional[BeforeCloseFunc] = None + ) -> None: """ Adds a widget to be tracked by this bot. This is not required, but will ensure that the widget gets closed by the end of the test, so it is highly recommended. @@ -188,7 +221,9 @@ def addWidget(self, widget, *, before_close_func=None): raise TypeError(f"Need to pass a QWidget to addWidget: {widget!r}") _add_widget(self._request.node, widget, before_close_func=before_close_func) - def waitActive(self, widget, *, timeout=5000): + def waitActive( + self, widget: QWidget, *, timeout: int = 5000 + ) -> "_WaitWidgetContextManager": """ Context manager that waits for ``timeout`` milliseconds or until the window is active. If window is not exposed within ``timeout`` milliseconds, raise @@ -215,7 +250,9 @@ def waitActive(self, widget, *, timeout=5000): "qWaitForWindowActive", "activated", widget, timeout ) - def waitExposed(self, widget, *, timeout=5000): + def waitExposed( + self, widget: QWidget, *, timeout: int = 5000 + ) -> "_WaitWidgetContextManager": """ Context manager that waits for ``timeout`` milliseconds or until the window is exposed. If the window is not exposed within ``timeout`` milliseconds, raise @@ -242,7 +279,7 @@ def waitExposed(self, widget, *, timeout=5000): "qWaitForWindowExposed", "exposed", widget, timeout ) - def waitForWindowShown(self, widget): + def waitForWindowShown(self, widget: QWidget) -> bool: """ Waits until the window is shown in the screen. This is mainly useful for asynchronous systems like X11, where a window will be mapped to screen some time after being asked to @@ -274,7 +311,7 @@ def waitForWindowShown(self, widget): ) return qt_api.QtTest.QTest.qWaitForWindowExposed(widget) - def stop(self): + def stop(self) -> None: """ Stops the current test flow, letting the user interact with any visible widget. @@ -295,7 +332,14 @@ def stop(self): for widget, visible in widget_and_visibility: widget.setVisible(visible) - def waitSignal(self, signal, *, timeout=5000, raising=None, check_params_cb=None): + def waitSignal( + self, + signal: SignalInstance, + *, + timeout: int = 5000, + raising: Optional[bool] = None, + check_params_cb: Optional[CheckParamsCb] = None, + ) -> "SignalBlocker": """ .. versionadded:: 1.2 @@ -358,13 +402,13 @@ def waitSignal(self, signal, *, timeout=5000, raising=None, check_params_cb=None def waitSignals( self, - signals, + signals: list[SignalInstance], *, - timeout=5000, - raising=None, - check_params_cbs=None, - order="none", - ): + timeout: int = 5000, + raising: Optional[bool] = None, + check_params_cbs: Optional[list[CheckParamsCb]] = None, + order: WaitSignalsOrder = "none", + ) -> "MultiSignalBlocker": """ .. versionadded:: 1.4 @@ -446,7 +490,7 @@ def waitSignals( blocker.add_signals(signals) return blocker - def wait(self, ms): + def wait(self, ms: int) -> None: """ .. versionadded:: 1.9 @@ -459,7 +503,9 @@ def wait(self, ms): blocker.wait() @contextlib.contextmanager - def assertNotEmitted(self, signal, *, wait=0): + def assertNotEmitted( + self, signal: SignalInstance, *, wait: int = 0 + ) -> Generator[None, None, None]: """ .. versionadded:: 1.11 @@ -480,7 +526,9 @@ def assertNotEmitted(self, signal, *, wait=0): yield spy.assert_not_emitted() - def waitUntil(self, callback, *, timeout=5000): + def waitUntil( + self, callback: Callable[[], Optional[bool]], *, timeout: int = 5000 + ) -> None: """ .. versionadded:: 2.0 @@ -551,7 +599,9 @@ def timed_out(): raise TimeoutError(timeout_msg) self.wait(10) - def waitCallback(self, *, timeout=5000, raising=None): + def waitCallback( + self, *, timeout: int = 5000, raising: Optional[bool] = None + ) -> "CallbackBlocker": """ .. versionadded:: 3.1 @@ -593,7 +643,7 @@ def waitCallback(self, *, timeout=5000, raising=None): return blocker @contextlib.contextmanager - def captureExceptions(self): + def captureExceptions(self) -> Iterator["CapturedExceptions"]: """ .. versionadded:: 2.1 @@ -617,9 +667,9 @@ def captureExceptions(self): with capture_exceptions() as exceptions: yield exceptions - capture_exceptions = captureExceptions - - def screenshot(self, widget, suffix="", region=None): + def screenshot( + self, widget: QWidget, suffix: str = "", region: Optional[QRect] = None + ) -> Path: """ .. versionadded:: 4.1 @@ -692,13 +742,13 @@ def keyRelease(*args, **kwargs): qt_api.QtTest.QTest.keyRelease(*args, **kwargs) @staticmethod - def keySequence(widget, key_sequence): + def keySequence(widget: QWidget, key_sequence: QKeySequence) -> None: if not hasattr(qt_api.QtTest.QTest, "keySequence"): raise NotImplementedError("This method is available from Qt 5.10 upwards.") qt_api.QtTest.QTest.keySequence(widget, key_sequence) @staticmethod - def keyToAscii(key): + def keyToAscii(key: Any) -> None: if not hasattr(qt_api.QtTest.QTest, "keyToAscii"): raise NotImplementedError("This method isn't available on PyQt5.") qt_api.QtTest.QTest.keyToAscii(key) @@ -723,40 +773,44 @@ def mousePress(*args, **kwargs): def mouseRelease(*args, **kwargs): qt_api.QtTest.QTest.mouseRelease(*args, **kwargs) - -# provide easy access to exceptions to qtbot fixtures -QtBot.SignalEmittedError = SignalEmittedError -QtBot.TimeoutError = TimeoutError -QtBot.ScreenshotError = ScreenshotError -QtBot.CallbackCalledTwiceError = CallbackCalledTwiceError + # provide easy access to exceptions to qtbot fixtures + SignalEmittedError = SignalEmittedError + TimeoutError = TimeoutError + ScreenshotError = ScreenshotError + CallbackCalledTwiceError = CallbackCalledTwiceError -def _add_widget(item, widget, *, before_close_func=None): +def _add_widget( + item: pytest.Item, + widget: QWidget, + *, + before_close_func: Optional[BeforeCloseFunc] = None, +) -> None: """ Register a widget into the given pytest item for later closing. """ qt_widgets = getattr(item, "qt_widgets", []) qt_widgets.append((weakref.ref(widget), before_close_func)) - item.qt_widgets = qt_widgets + item.qt_widgets = qt_widgets # type: ignore[attr-defined] -def _close_widgets(item): +def _close_widgets(item: pytest.Item) -> None: """ Close all widgets registered in the pytest item. """ widgets = getattr(item, "qt_widgets", None) if widgets: - for w, before_close_func in item.qt_widgets: + for w, before_close_func in item.qt_widgets: # type: ignore[attr-defined] w = w() if w is not None: if before_close_func is not None: before_close_func(w) w.close() w.deleteLater() - del item.qt_widgets + del item.qt_widgets # type: ignore[attr-defined] -def _iter_widgets(item): +def _iter_widgets(item: pytest.Item) -> Iterator[weakref.ReferenceType[QWidget]]: """ Iterates over widgets registered in the given pytest item. """ @@ -764,12 +818,21 @@ def _iter_widgets(item): return (w for (w, _) in qt_widgets) +WaitAdjectiveName = Literal["activated", "exposed"] + + class _WaitWidgetContextManager: """ Context manager implementation used by ``waitActive`` and ``waitExposed`` methods. """ - def __init__(self, method_name, adjective_name, widget, timeout): + def __init__( + self, + method_name: str, + adjective_name: WaitAdjectiveName, + widget: QWidget, + timeout: int, + ) -> None: """ :param str method_name: name to the ``QtTest`` method to call to check if widget is active/exposed. :param str adjective_name: "activated" or "exposed". @@ -781,11 +844,16 @@ def __init__(self, method_name, adjective_name, widget, timeout): self._widget = widget self._timeout = timeout - def __enter__(self): + def __enter__(self) -> Self: __tracebackhide__ = True return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: __tracebackhide__ = True try: if exc_type is None: diff --git a/src/pytestqt/wait_signal.py b/src/pytestqt/wait_signal.py index 359e744..da98228 100644 --- a/src/pytestqt/wait_signal.py +++ b/src/pytestqt/wait_signal.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import functools import dataclasses from typing import Any @@ -5,6 +6,8 @@ from pytestqt.exceptions import TimeoutError from pytestqt.qt_compat import qt_api +CheckParamsCb = Callable[..., bool] + class _AbstractSignalBlocker: """ diff --git a/tests/test_modeltest.py b/tests/test_modeltest.py index c427a26..7ae2a9d 100644 --- a/tests/test_modeltest.py +++ b/tests/test_modeltest.py @@ -6,7 +6,7 @@ pytestmark = pytest.mark.usefixtures("qtbot") -class BasicModel(qt_api.QtCore.QAbstractItemModel): +class BasicModel(qt_api.QtCore.QAbstractItemModel): # type: ignore[name-defined] def data(self, index, role=qt_api.QtCore.Qt.ItemDataRole.DisplayRole): return None