Skip to content

Type annotation infrastructure and initial typing for qtbot.py #605

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ repos:
files: ^(HOWTORELEASE.rst|README.rst)$
language: python
additional_dependencies: [pygments, restructuredtext_lint]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.15.0'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
rev: 'v1.15.0'
rev: 'v1.16.0'

hooks:
- id: mypy
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
# 'preamble': '',
}
} # type: dict[str, str]
Copy link
Member

@nicoddemus nicoddemus Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add a mypy.ini file and add some options:

[mypy]
files = src,tests
no_implicit_optional = True
pretty = True
show_error_codes = True
strict_equality = True
warn_redundant_casts = True
warn_unused_configs = True
warn_unused_ignores = True

This way we can remove this type annotation:

Suggested change
} # type: dict[str, str]
}


# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
Expand Down
6 changes: 4 additions & 2 deletions src/pytestqt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from typing import cast

# _version is automatically generated by setuptools_scm
from pytestqt._version import version
from pytestqt._version import version # type: ignore[import-not-found]

__version__ = version
__version__ = cast("str", version)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work?

Suggested change
__version__ = cast("str", version)
__version__: str = version

5 changes: 5 additions & 0 deletions src/pytestqt/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
import sys
import traceback
from contextlib import contextmanager
from types import TracebackType
from typing import List, Tuple, Type

import pytest
from pytestqt.utils import get_marker

CapturedException = Tuple[Type[BaseException], BaseException, TracebackType]
CapturedExceptions = List[CapturedException]


@contextmanager
def capture_exceptions():
Expand Down
1 change: 1 addition & 0 deletions src/pytestqt/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
partial
2 changes: 1 addition & 1 deletion src/pytestqt/qt_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

version = PySide6.__version__

Expand Down
141 changes: 102 additions & 39 deletions src/pytestqt/qtbot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import contextlib
from types import TracebackType
import weakref
import warnings
from typing import (
TYPE_CHECKING,
Callable,
Generator,
Iterator,
List,
Literal,
Optional,
Any,
Self,
Type,
cast,
)
from pathlib import Path

from pytestqt.exceptions import TimeoutError, ScreenshotError
from pytestqt.qt_compat import qt_api
Expand All @@ -11,14 +26,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 = Any
SignalInstance = Any
QRect = Any
QKeySequence = Any
Comment on lines +36 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
QWidget = Any
SignalInstance = Any
QRect = Any
QKeySequence = Any
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

def _parse_ini_boolean(value):
BeforeCloseFunc = Callable[[QWidget], None]
WaitSignalsOrder = Literal["none", "simple", "strict"]


def _parse_ini_boolean(value: Any) -> bool:
if value in (True, False):
return value
return cast("bool", value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return cast("bool", value)
return cast(bool, value)

try:
return {"true": True, "false": False}[value.lower()]
return {"true": True, "false": False}[cast("str", value).lower()]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return {"true": True, "false": False}[cast("str", value).lower()]
return {"true": True, "false": False}[cast(str, value).lower()]

Or:

Suggested change
return {"true": True, "false": False}[cast("str", value).lower()]
return {"true": True, "false": False}[str(value).lower()]

except KeyError:
raise ValueError("unknown string for bool: %r" % value)

Expand Down Expand Up @@ -146,7 +178,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
Expand All @@ -160,7 +192,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:
Expand All @@ -170,7 +202,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.
Expand All @@ -188,7 +222,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
Expand All @@ -215,7 +251,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
Expand All @@ -242,7 +280,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
Expand Down Expand Up @@ -274,7 +312,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.

Expand All @@ -295,7 +333,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

Expand Down Expand Up @@ -358,13 +403,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

Expand Down Expand Up @@ -446,7 +491,7 @@ def waitSignals(
blocker.add_signals(signals)
return blocker

def wait(self, ms):
def wait(self, ms: int) -> None:
"""
.. versionadded:: 1.9

Expand All @@ -459,7 +504,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

Expand All @@ -480,7 +527,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

Expand Down Expand Up @@ -551,7 +600,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

Expand Down Expand Up @@ -593,7 +644,7 @@ def waitCallback(self, *, timeout=5000, raising=None):
return blocker

@contextlib.contextmanager
def captureExceptions(self):
def captureExceptions(self) -> Generator["CapturedExceptions", None, None]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def captureExceptions(self) -> Generator["CapturedExceptions", None, None]:
def captureExceptions(self) -> Iterator["CapturedExceptions"]:

"""
.. versionadded:: 2.1

Expand All @@ -617,9 +668,9 @@ def captureExceptions(self):
with capture_exceptions() as exceptions:
yield exceptions

capture_exceptions = captureExceptions
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: removed this line becausemypy gave a warning about this also being assigned in the constructor here: https://github.com/pytest-dev/pytest-qt/blob/master/src/pytestqt/qtbot.py#L153


def screenshot(self, widget, suffix="", region=None):
def screenshot(
self, widget: QWidget, suffix: str = "", region: Optional[QRect] = None
) -> Path:
"""
.. versionadded:: 4.1

Expand Down Expand Up @@ -692,13 +743,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)
Expand All @@ -725,13 +776,18 @@ def mouseRelease(*args, **kwargs):


# provide easy access to exceptions to qtbot fixtures
QtBot.SignalEmittedError = SignalEmittedError
QtBot.TimeoutError = TimeoutError
QtBot.ScreenshotError = ScreenshotError
QtBot.CallbackCalledTwiceError = CallbackCalledTwiceError


def _add_widget(item, widget, *, before_close_func=None):
QtBot.SignalEmittedError = SignalEmittedError # type: ignore[attr-defined]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does using ignore[attr-defined] here means that the type checker does not know about QtBot.SignalEmittedError?

If so, we should move this to the class declaration:

class QtBot:
    ...
    SignalEmittedError: ClassVar[Type[SignalEmittedError]] = SignalEmittedError

(Same for the other exceptions of course)

QtBot.TimeoutError = TimeoutError # type: ignore[attr-defined]
QtBot.ScreenshotError = ScreenshotError # type: ignore[attr-defined]
QtBot.CallbackCalledTwiceError = CallbackCalledTwiceError # type: ignore[attr-defined]


def _add_widget(
item: Any,
widget: QWidget,
*,
before_close_func: Optional[BeforeCloseFunc] = None,
) -> None:
"""
Register a widget into the given pytest item for later closing.
"""
Expand All @@ -740,7 +796,7 @@ def _add_widget(item, widget, *, before_close_func=None):
item.qt_widgets = qt_widgets


def _close_widgets(item):
def _close_widgets(item: Any) -> None:
"""
Close all widgets registered in the pytest item.
"""
Expand All @@ -756,7 +812,7 @@ def _close_widgets(item):
del item.qt_widgets


def _iter_widgets(item):
def _iter_widgets(item: Any) -> Iterator[weakref.ReferenceType[QWidget]]:
"""
Iterates over widgets registered in the given pytest item.
"""
Expand All @@ -769,7 +825,9 @@ 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: str, 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".
Expand All @@ -781,11 +839,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:
Expand Down
4 changes: 3 additions & 1 deletion src/pytestqt/wait_signal.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import functools
import dataclasses
from typing import Any
from typing import Any, Callable

from pytestqt.exceptions import TimeoutError
from pytestqt.qt_compat import qt_api

CheckParamsCb = Callable[..., bool]


class _AbstractSignalBlocker:
"""
Expand Down
2 changes: 1 addition & 1 deletion tests/test_modeltest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading