From 60cf2302b28049895f72f0dff08db3a4122cd9f3 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 13:58:44 +0200 Subject: [PATCH 1/9] Enable mypy strict mode with per-module exemptions Turn on ``strict = True`` so new code must be fully typed, and list the modules that do not pass yet in per-module overrides. Each list should shrink as annotations are added, until ``py.typed`` can drop ``partial``. Also fix the few errors that ``disallow_incomplete_defs``, ``warn_return_any`` and ``no_implicit_reexport`` found, and set ``mypy_path = src`` so single files resolve ``pytestqt``. Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 29 +++++++++++++++++++++++++---- src/pytestqt/qtbot.py | 4 ++-- tests/test_qtbot_pep8_aliases.py | 6 +++--- tests/test_wait_signal.py | 2 +- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/mypy.ini b/mypy.ini index b4dc48dd..c75095cc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,8 +1,29 @@ [mypy] exclude = ^docs/ +mypy_path = src pretty = True show_error_codes = True -strict_equality = True -warn_redundant_casts = True -warn_unused_configs = True -warn_unused_ignores = True +strict = True + +# The modules below do not pass strict mode yet. Remove a module from +# these lists once it is fully annotated, so it cannot regress. + +[mypy-pytestqt.plugin,pytestqt.qt_compat,pytestqt.qtbot,pytestqt.utils] +disallow_untyped_calls = False +disallow_untyped_defs = False + +[mypy-pytestqt.exceptions,pytestqt.logging,pytestqt.modeltest,pytestqt.wait_signal] +check_untyped_defs = False +disallow_untyped_calls = False +disallow_untyped_defs = False + +[mypy-test_logging,test_qtbot_pep8_aliases,test_qtest_proxies,test_screenshot,test_wait_until] +disallow_untyped_calls = False +disallow_untyped_defs = False + +[mypy-conftest,test_basics,test_exceptions,test_modeltest,test_wait_signal] +check_untyped_defs = False +disallow_untyped_calls = False +disallow_untyped_defs = False +# Qt classes are ``Any`` when no Qt binding is installed (as in pre-commit). +disallow_subclassing_any = False diff --git a/src/pytestqt/qtbot.py b/src/pytestqt/qtbot.py index 9426a828..fa24d3e0 100644 --- a/src/pytestqt/qtbot.py +++ b/src/pytestqt/qtbot.py @@ -308,7 +308,7 @@ def waitForWindowShown(self, widget: QWidget) -> bool: "instead.", DeprecationWarning, ) - return qt_api.QtTest.QTest.qWaitForWindowExposed(widget) + return bool(qt_api.QtTest.QTest.qWaitForWindowExposed(widget)) def stop(self) -> None: """ @@ -696,7 +696,7 @@ def screenshot( if pixmap.isNull(): raise ScreenshotError("Got null pixmap from Qt") - tmp_path = self._request.getfixturevalue("tmp_path") + tmp_path: Path = self._request.getfixturevalue("tmp_path") parts = ["screenshot", widget.__class__.__name__] name = widget.objectName() diff --git a/tests/test_qtbot_pep8_aliases.py b/tests/test_qtbot_pep8_aliases.py index e633c46f..c1ea56e0 100644 --- a/tests/test_qtbot_pep8_aliases.py +++ b/tests/test_qtbot_pep8_aliases.py @@ -31,7 +31,7 @@ def _format_pep_8(camel_case_name: str) -> str: ("wait_callback", "waitCallback"), ], ) -def test_format_pep8(expected: str, camel_case_input: str): +def test_format_pep8(expected: str, camel_case_input: str) -> None: assert _format_pep_8(camel_case_input) == expected @@ -76,8 +76,8 @@ def generate_test_cases_for_test_subclass_of_qtbot_has_overwritten_pep8_aliases( list(generate_test_cases_for_test_subclass_of_qtbot_has_overwritten_pep8_aliases()), ) def test_subclass_of_qtbot_has_overwritten_pep8_aliases( - qtbot_subclass, method_name: str, pep8_name: str -): + qtbot_subclass: type[QtBot], method_name: str, pep8_name: str +) -> None: """ Test that subclassing QtBot does not create surprises, by checking that the PEP8 aliases follow overwritten diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index eafa7b49..f498a764 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -5,9 +5,9 @@ import sys from pytestqt.qt_compat import qt_api +from pytestqt.exceptions import TimeoutError from pytestqt.wait_signal import ( SignalEmittedError, - TimeoutError, SignalAndArgs, CallbackCalledTwiceError, ) From e0f971e2ad7ac10a625c4c5283c19db56c891b35 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:34:24 +0200 Subject: [PATCH 2/9] Type-annotate pytestqt.utils and check it with mypy strict Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- src/pytestqt/utils.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mypy.ini b/mypy.ini index c75095cc..a14f8146 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,7 +8,7 @@ strict = True # The modules below do not pass strict mode yet. Remove a module from # these lists once it is fully annotated, so it cannot regress. -[mypy-pytestqt.plugin,pytestqt.qt_compat,pytestqt.qtbot,pytestqt.utils] +[mypy-pytestqt.plugin,pytestqt.qt_compat,pytestqt.qtbot] disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/src/pytestqt/utils.py b/src/pytestqt/utils.py index 79703c02..b9e0a371 100644 --- a/src/pytestqt/utils.py +++ b/src/pytestqt/utils.py @@ -1,4 +1,9 @@ -def get_marker(item, name): +from typing import Optional + +import pytest + + +def get_marker(item: pytest.Item, name: str) -> Optional["pytest.Mark"]: """Get a marker from a pytest item. This is here in order to stay compatible with pytest < 3.6 and not produce @@ -8,4 +13,4 @@ def get_marker(item, name): return item.get_closest_marker(name) except AttributeError: # pytest < 3.6 - return item.get_marker(name) + return item.get_marker(name) # type: ignore[attr-defined,no-any-return] From 9a0d0f4716292e25798e608f9d900150aee98541 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:34:40 +0200 Subject: [PATCH 3/9] Type-annotate test_qtest_proxies and check it with mypy strict Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- tests/test_qtest_proxies.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mypy.ini b/mypy.ini index a14f8146..2f47a0ce 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,7 @@ check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-test_logging,test_qtbot_pep8_aliases,test_qtest_proxies,test_screenshot,test_wait_until] +[mypy-test_logging,test_qtbot_pep8_aliases,test_screenshot,test_wait_until] disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/tests/test_qtest_proxies.py b/tests/test_qtest_proxies.py index 81860c73..bae015c1 100644 --- a/tests/test_qtest_proxies.py +++ b/tests/test_qtest_proxies.py @@ -1,6 +1,7 @@ import pytest from pytestqt.qt_compat import qt_api +from pytestqt.qtbot import QtBot @pytest.mark.parametrize( @@ -21,7 +22,7 @@ "mouseRelease", ], ) -def test_expected_qtest_proxies(qtbot, expected_method): +def test_expected_qtest_proxies(qtbot: QtBot, expected_method: str) -> None: """ This test originates from the implementation where QTest API methods were exported on runtime. @@ -31,7 +32,7 @@ def test_expected_qtest_proxies(qtbot, expected_method): @pytest.mark.skipif(qt_api.is_pyside, reason="PyQt test only") -def test_keyToAscii_not_available_on_pyqt(pytester): +def test_keyToAscii_not_available_on_pyqt(pytester: pytest.Pytester) -> None: """ Test that qtbot.keyToAscii() is not available on PyQt5 and calling the method raises a NotImplementedError. From 5b4a6ad63be0e25b11c5f34823a709b9c6400571 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:35:49 +0200 Subject: [PATCH 4/9] Type-annotate test_qtbot_pep8_aliases and check it with mypy strict Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- tests/test_qtbot_pep8_aliases.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mypy.ini b/mypy.ini index 2f47a0ce..4c9045a7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,7 @@ check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-test_logging,test_qtbot_pep8_aliases,test_screenshot,test_wait_until] +[mypy-test_logging,test_screenshot,test_wait_until] disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/tests/test_qtbot_pep8_aliases.py b/tests/test_qtbot_pep8_aliases.py index c1ea56e0..e208a9d2 100644 --- a/tests/test_qtbot_pep8_aliases.py +++ b/tests/test_qtbot_pep8_aliases.py @@ -1,4 +1,5 @@ import inspect +from collections.abc import Iterator from unittest.mock import MagicMock import pytest @@ -35,7 +36,7 @@ def test_format_pep8(expected: str, camel_case_input: str) -> None: assert _format_pep_8(camel_case_input) == expected -def test_pep8_aliases(qtbot): +def test_pep8_aliases(qtbot: QtBot) -> None: """ Test that defined PEP8 aliases actually refer to the correct implementation. Only check methods that have such an alias defined. @@ -50,7 +51,9 @@ def test_pep8_aliases(qtbot): ) -def generate_test_cases_for_test_subclass_of_qtbot_has_overwritten_pep8_aliases(): +def generate_test_cases_for_test_subclass_of_qtbot_has_overwritten_pep8_aliases() -> ( + Iterator[tuple[type[QtBot], str, str]] +): """ For each PEP8 alias found in QtBot, yields a test case consisting of a QtBot subclass that has the alias pair’s camelCase implementation From f13701e6cdcc92a6f9f4733a8d847e16d6a8ff57 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:36:05 +0200 Subject: [PATCH 5/9] Type-annotate test_screenshot and check it with mypy strict Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- tests/test_screenshot.py | 31 ++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/mypy.ini b/mypy.ini index 4c9045a7..684a7fa4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,7 @@ check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-test_logging,test_screenshot,test_wait_until] +[mypy-test_logging,test_wait_until] disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 6fba04bd..4032873d 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -3,10 +3,11 @@ import pytest from pytestqt.qt_compat import qt_api +from pytestqt.qtbot import QtBot, QWidget @pytest.fixture -def widget(qtbot): +def widget(qtbot: QtBot) -> QWidget: w = qt_api.QtWidgets.QWidget() qtbot.addWidget(w) w.setAttribute(qt_api.QtCore.Qt.WidgetAttribute.WA_StyledBackground) @@ -14,7 +15,7 @@ def widget(qtbot): return w -def test_basic(qtbot, widget): +def test_basic(qtbot: QtBot, widget: QWidget) -> None: path = qtbot.screenshot(widget) assert path.exists() @@ -26,7 +27,7 @@ def test_basic(qtbot, widget): assert (color.red(), color.green(), color.blue()) == (255, 0, 255) -def test_region(qtbot, widget): +def test_region(qtbot: QtBot, widget: QWidget) -> None: region = qt_api.QtCore.QRect(0, 0, 25, 25) path = qtbot.screenshot(widget, region=region) @@ -35,40 +36,42 @@ def test_region(qtbot, widget): assert pixmap.rect() == region -def test_filename_class(qtbot, widget): +def test_filename_class(qtbot: QtBot, widget: QWidget) -> None: path = qtbot.screenshot(widget) assert path.name == "screenshot_QWidget.png" -def test_filename_objectname(qtbot, widget): +def test_filename_objectname(qtbot: QtBot, widget: QWidget) -> None: widget.setObjectName("shotgun") path = qtbot.screenshot(widget) assert path.name == "screenshot_QWidget_shotgun.png" -def test_filename_suffix(qtbot, widget): +def test_filename_suffix(qtbot: QtBot, widget: QWidget) -> None: path = qtbot.screenshot(widget, suffix="before") assert path.name == "screenshot_QWidget_before.png" -def test_filename_both(qtbot, widget): +def test_filename_both(qtbot: QtBot, widget: QWidget) -> None: widget.setObjectName("shotgun") path = qtbot.screenshot(widget, suffix="before") assert path.name == "screenshot_QWidget_shotgun_before.png" -def test_filename_endless(qtbot, widget, monkeypatch): +def test_filename_endless( + qtbot: QtBot, widget: QWidget, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setattr(pathlib.Path, "exists", lambda _self: True) with pytest.raises(qtbot.ScreenshotError, match="Failed to find unique filename"): qtbot.screenshot(widget, suffix="before") -def test_filename_invalid(qtbot, widget): +def test_filename_invalid(qtbot: QtBot, widget: QWidget) -> None: with pytest.raises(qtbot.ScreenshotError, match="Saving to .* failed"): qtbot.screenshot(widget, suffix=r"invalid/path\everywhere") -def test_folder(qtbot, tmp_path, widget): +def test_folder(qtbot: QtBot, tmp_path: pathlib.Path, widget: QWidget) -> None: path = qtbot.screenshot(widget) assert path.parent == tmp_path @@ -82,7 +85,13 @@ def test_folder(qtbot, tmp_path, widget): (["QWidget", "QWidget_2"], "QWidget_3"), ], ) -def test_filename_dedup(qtbot, widget, tmp_path, existing, expected): +def test_filename_dedup( + qtbot: QtBot, + widget: QWidget, + tmp_path: pathlib.Path, + existing: list[str], + expected: str, +) -> None: for name in existing: path = tmp_path / f"screenshot_{name}.png" path.touch() From d4d6f568ae4135c7b0d54c4fe070cfc21b952c49 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:36:52 +0200 Subject: [PATCH 6/9] Type-annotate test_wait_until and check it with mypy strict Move the Counter helper to module level so tests can use it as a type. Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- tests/test_wait_until.py | 71 +++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/mypy.ini b/mypy.ini index 684a7fa4..2f4746b6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,7 @@ check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-test_logging,test_wait_until] +[mypy-test_logging] disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/tests/test_wait_until.py b/tests/test_wait_until.py index 451513cc..9884ffad 100644 --- a/tests/test_wait_until.py +++ b/tests/test_wait_until.py @@ -1,30 +1,67 @@ +from collections.abc import Callable, Iterator +from typing import Optional + import pytest +from pytestqt.qt_compat import qt_api +from pytestqt.qtbot import QtBot + + +class Counter: + """ + Counts timer "ticks" periodically. + """ + + def __init__(self) -> None: + self._ticks = 0 + self.timer = qt_api.QtCore.QTimer() + self.timer.timeout.connect(self._tick) + + def start(self, ms: int) -> None: + self.timer.start(ms) + + def _tick(self) -> None: + self._ticks += 1 -def test_wait_until(qtbot, wait_4_ticks_callback, tick_counter): + @property + def ticks(self) -> int: + return self._ticks + + +def test_wait_until( + qtbot: QtBot, + wait_4_ticks_callback: Callable[[], Optional[bool]], + tick_counter: Counter, +) -> None: tick_counter.start(100) qtbot.waitUntil(wait_4_ticks_callback, timeout=1000) assert tick_counter.ticks >= 4 -def test_wait_until_timeout(qtbot, wait_4_ticks_callback, tick_counter): +def test_wait_until_timeout( + qtbot: QtBot, + wait_4_ticks_callback: Callable[[], Optional[bool]], + tick_counter: Counter, +) -> None: tick_counter.start(200) with pytest.raises(qtbot.TimeoutError): qtbot.waitUntil(wait_4_ticks_callback, timeout=100) assert tick_counter.ticks < 4 -def test_invalid_callback_return_value(qtbot): +def test_invalid_callback_return_value(qtbot: QtBot) -> None: with pytest.raises(ValueError): - qtbot.waitUntil(lambda: []) + qtbot.waitUntil(lambda: []) # type: ignore[arg-type,return-value] -def test_pep8_alias(qtbot): +def test_pep8_alias(qtbot: QtBot) -> None: qtbot.wait_until @pytest.fixture(params=["predicate", "assert"]) -def wait_4_ticks_callback(request, tick_counter): +def wait_4_ticks_callback( + request: pytest.FixtureRequest, tick_counter: Counter +) -> Callable[[], Optional[bool]]: """Parametrized fixture which returns the two possible callback methods that can be passed to ``waitUntil``: predicate and assertion. """ @@ -32,35 +69,17 @@ def wait_4_ticks_callback(request, tick_counter): return lambda: tick_counter.ticks >= 4 else: - def check_ticks(): + def check_ticks() -> None: assert tick_counter.ticks >= 4 return check_ticks @pytest.fixture -def tick_counter(): +def tick_counter() -> Iterator[Counter]: """ Returns an object which counts timer "ticks" periodically. """ - from pytestqt.qt_compat import qt_api - - class Counter: - def __init__(self): - self._ticks = 0 - self.timer = qt_api.QtCore.QTimer() - self.timer.timeout.connect(self._tick) - - def start(self, ms): - self.timer.start(ms) - - def _tick(self): - self._ticks += 1 - - @property - def ticks(self): - return self._ticks - counter = Counter() yield counter counter.timer.stop() From 2bf946b57c05a8aae0c605148fa1c7636118bb15 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:38:14 +0200 Subject: [PATCH 7/9] Type-annotate test_logging and check it with mypy strict The annotations replace the ':type pytester:' docstring lines. Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 4 --- tests/test_logging.py | 77 +++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 51 deletions(-) diff --git a/mypy.ini b/mypy.ini index 2f4746b6..07c32c89 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,10 +17,6 @@ check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-test_logging] -disallow_untyped_calls = False -disallow_untyped_defs = False - [mypy-conftest,test_basics,test_exceptions,test_modeltest,test_wait_signal] check_untyped_defs = False disallow_untyped_calls = False diff --git a/tests/test_logging.py b/tests/test_logging.py index a804f2d6..4a772ce4 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -2,6 +2,7 @@ import pytest +from pytestqt.logging import _QtMessageCapture from pytestqt.qt_compat import qt_api # qInfo is not exposed by PySide6 < 6.8.2 (#225) @@ -10,11 +11,11 @@ @pytest.mark.parametrize("test_succeeds", [True, False]) @pytest.mark.parametrize("qt_log", [True, False]) -def test_basic_logging(pytester, test_succeeds, qt_log): +def test_basic_logging( + pytester: pytest.Pytester, test_succeeds: bool, qt_log: bool +) -> None: """ Test Qt logging capture output. - - :type pytester: pytest.Pytester """ pytester.makepyfile(f""" import sys @@ -62,7 +63,7 @@ def test_types(): ) -def test_qtlog_fixture(qtlog): +def test_qtlog_fixture(qtlog: _QtMessageCapture) -> None: """ Test qtlog fixture. """ @@ -85,16 +86,14 @@ def test_qtlog_fixture(qtlog): assert records == expected # `records` attribute is read-only with pytest.raises(AttributeError): - qtlog.records = [] + qtlog.records = [] # type: ignore[misc] @pytest.mark.parametrize("arg", ["--no-qt-log", "--capture=no", "-s"]) -def test_fixture_with_logging_disabled(pytester, arg): +def test_fixture_with_logging_disabled(pytester: pytest.Pytester, arg: str) -> None: """ Test that qtlog fixture doesn't capture anything if logging is disabled in the command line. - - :type pytester: pytest.Pytester """ pytester.makepyfile(""" from pytestqt.qt_compat import qt_api @@ -108,11 +107,11 @@ def test_types(qtlog): @pytest.mark.parametrize("use_context_manager", [True, False]) -def test_disable_qtlog_context_manager(pytester, use_context_manager): +def test_disable_qtlog_context_manager( + pytester: pytest.Pytester, use_context_manager: bool +) -> None: """ Test qtlog.disabled() context manager. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -138,11 +137,9 @@ def test_1(qtlog): @pytest.mark.parametrize("use_mark", [True, False]) -def test_disable_qtlog_mark(pytester, use_mark): +def test_disable_qtlog_mark(pytester: pytest.Pytester, use_mark: bool) -> None: """ Test mark which disables logging capture for a test. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -164,11 +161,9 @@ def test_1(): res.assertoutcome(passed=passed, failed=int(not passed)) -def test_logging_formatting(pytester): +def test_logging_formatting(pytester: pytest.Pytester) -> None: """ Test custom formatting for logging messages. - - :type pytester: pytest.Pytester """ pytester.makepyfile(""" from pytestqt.qt_compat import qt_api @@ -190,11 +185,11 @@ def test_types(): @pytest.mark.parametrize( "level, expect_passes", [("DEBUG", 1), ("WARNING", 2), ("CRITICAL", 3), ("NO", 4)] ) -def test_logging_fails_tests(pytester, level, expect_passes): +def test_logging_fails_tests( + pytester: pytest.Pytester, level: str, expect_passes: int +) -> None: """ Test qt_log_level_fail ini option. - - :type pytester: pytest.Pytester """ pytester.makeini( """ @@ -228,11 +223,9 @@ def test_4(): res.stdout.fnmatch_lines(lines) -def test_logging_fails_tests_mark(pytester): +def test_logging_fails_tests_mark(pytester: pytest.Pytester) -> None: """ Test mark overrides what's configured in the ini file. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -249,11 +242,9 @@ def test_1(): res.assertoutcome(failed=1) -def test_logging_fails_ignore(pytester): +def test_logging_fails_ignore(pytester: pytest.Pytester) -> None: """ Test qt_log_ignore config option. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -303,11 +294,11 @@ def test4(): @pytest.mark.parametrize("message", ["match-global", "match-mark"]) @pytest.mark.parametrize("marker_args", ["'match-mark', extend=True", "'match-mark'"]) -def test_logging_mark_with_extend(pytester, message, marker_args): +def test_logging_mark_with_extend( + pytester: pytest.Pytester, message: str, marker_args: str +) -> None: """ Test qt_log_ignore mark with extend=True. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -331,11 +322,11 @@ def test1(): @pytest.mark.parametrize( "message, error_expected", [("match-global", True), ("match-mark", False)] ) -def test_logging_mark_without_extend(pytester, message, error_expected): +def test_logging_mark_without_extend( + pytester: pytest.Pytester, message: str, error_expected: bool +) -> None: """ Test qt_log_ignore mark with extend=False. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -360,11 +351,9 @@ def test1(): res.assertoutcome(passed=1, failed=0) -def test_logging_mark_with_invalid_argument(pytester): +def test_logging_mark_with_invalid_argument(pytester: pytest.Pytester) -> None: """ Test qt_log_ignore mark with invalid keyword argument. - - :type pytester: pytest.Pytester """ pytester.makepyfile(""" import pytest @@ -386,11 +375,11 @@ def test1(): @pytest.mark.parametrize("apply_mark", [True, False]) -def test_logging_fails_ignore_mark_multiple(pytester, apply_mark): +def test_logging_fails_ignore_mark_multiple( + pytester: pytest.Pytester, apply_mark: bool +) -> None: """ Make sure qt_log_ignore mark supports multiple arguments. - - :type pytester: pytest.Pytester """ if apply_mark: mark = '@pytest.mark.qt_log_ignore("WM_DESTROY", "WM_PAINT")' @@ -411,12 +400,10 @@ def test1(): res.assertoutcome(passed=passed, failed=int(not passed)) -def test_lineno_failure(pytester): +def test_lineno_failure(pytester: pytest.Pytester) -> None: """ Test that tests when failing because log messages were emitted report the correct line number. - - :type pytester: pytest.Pytester """ pytester.makeini(""" [pytest] @@ -448,15 +435,13 @@ def foo(): ) -def test_context_none(pytester): +def test_context_none(pytester: pytest.Pytester) -> None: """ Sometimes PyQt will emit a context with some/all attributes set as None instead of appropriate file, function and line number. Test that when this happens the plugin doesn't break, and it filters out the context information. - - :type pytester: pytest.Pytester """ pytester.makepyfile(""" from pytestqt.qt_compat import qt_api @@ -473,13 +458,11 @@ def test_foo(request): res.stdout.fnmatch_lines(["QtWarningMsg: WARNING message"]) -def test_logging_broken_makereport(pytester): +def test_logging_broken_makereport(pytester: pytest.Pytester) -> None: """ Make sure logging's makereport hookwrapper doesn't hide exceptions. See https://github.com/pytest-dev/pytest-qt/issues/98 - - :type pytester: pytest.Pytester """ pytester.makepyfile( conftest=""" From e7e761f61ae1a13ad29055f372ca40325163ee7a Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:39:38 +0200 Subject: [PATCH 8/9] Type-annotate pytestqt.exceptions and check it with mypy strict The traceback in CapturedException is now Optional, matching what sys.excepthook actually receives. Refs #595 Co-Authored-By: Claude Opus 5.5 --- mypy.ini | 2 +- src/pytestqt/exceptions.py | 35 +++++++++++++++++++++++------------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/mypy.ini b/mypy.ini index 07c32c89..bd15daee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,7 +12,7 @@ strict = True disallow_untyped_calls = False disallow_untyped_defs = False -[mypy-pytestqt.exceptions,pytestqt.logging,pytestqt.modeltest,pytestqt.wait_signal] +[mypy-pytestqt.logging,pytestqt.modeltest,pytestqt.wait_signal] check_untyped_defs = False disallow_untyped_calls = False disallow_untyped_defs = False diff --git a/src/pytestqt/exceptions.py b/src/pytestqt/exceptions.py index ef3c7da7..69cb86ff 100644 --- a/src/pytestqt/exceptions.py +++ b/src/pytestqt/exceptions.py @@ -1,18 +1,20 @@ import functools import sys import traceback +from collections.abc import Callable, Iterator from contextlib import contextmanager from types import TracebackType +from typing import Optional import pytest from pytestqt.utils import get_marker -CapturedException = tuple[type[BaseException], BaseException, TracebackType] +CapturedException = tuple[type[BaseException], BaseException, Optional[TracebackType]] CapturedExceptions = list[CapturedException] @contextmanager -def capture_exceptions(): +def capture_exceptions() -> Iterator[CapturedExceptions]: """ Context manager that captures exceptions that happen insides its context, and returns them as a list of (type, value, traceback) after the @@ -26,7 +28,12 @@ def capture_exceptions(): manager.finish() -def _except_hook(type_, value, tback, exceptions=None): +def _except_hook( + type_: type[BaseException], + value: BaseException, + tback: Optional[TracebackType], + exceptions: CapturedExceptions, +) -> None: """Hook functions installed by _QtExceptionCaptureManager""" exceptions.append((type_, value, tback)) sys.stderr.write(format_captured_exceptions([(type_, value, tback)])) @@ -37,18 +44,22 @@ class _QtExceptionCaptureManager: Manages exception capture context. """ - def __init__(self): - self.old_hook = None - self.exceptions = [] + def __init__(self) -> None: + self.old_hook: Optional[ + Callable[ + [type[BaseException], BaseException, Optional[TracebackType]], object + ] + ] = None + self.exceptions: CapturedExceptions = [] - def start(self): + def start(self) -> None: """Start exception capturing by installing a hook into sys.excepthook that records exceptions received into ``self.exceptions``. """ self.old_hook = sys.excepthook sys.excepthook = functools.partial(_except_hook, exceptions=self.exceptions) - def finish(self): + def finish(self) -> None: """Stop exception capturing, restoring the original hook. Can be called multiple times. @@ -57,7 +68,7 @@ def finish(self): sys.excepthook = self.old_hook self.old_hook = None - def fail_if_exceptions_occurred(self, when): + def fail_if_exceptions_occurred(self, when: str) -> None: """calls pytest.fail() with an informative message if exceptions have been captured so far. Before pytest.fail() is called, also finish capturing. @@ -70,11 +81,11 @@ def fail_if_exceptions_occurred(self, when): msg = prefix + format_captured_exceptions(exceptions) del exceptions[:] # Don't keep exceptions alive longer. if hasattr(sys, "last_exc"): - sys.last_exc = None + sys.last_exc = None # type: ignore[assignment] pytest.fail(msg, pytrace=False) -def format_captured_exceptions(exceptions): +def format_captured_exceptions(exceptions: CapturedExceptions) -> str: """ Formats exceptions given as (type, value, traceback) into a string suitable to display as a test failure. @@ -91,7 +102,7 @@ def format_captured_exceptions(exceptions): return stream.getvalue() -def _is_exception_capture_enabled(item): +def _is_exception_capture_enabled(item: pytest.Item) -> bool: """returns if exception capture is disabled for the given test item.""" disabled = get_marker(item, "qt_no_exception_capture") or item.config.getini( "qt_no_exception_capture" From c53926ac2f66fe6a28a158c5279c8b03746ccfdf Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 29 Sep 2026 14:55:04 +0200 Subject: [PATCH 9/9] Name the excepthook type in pytestqt.exceptions Reuse CapturedException for its parameters with Unpack instead of spelling the signature out inline. Refs #595 Co-Authored-By: Claude Opus 5.5 --- src/pytestqt/exceptions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pytestqt/exceptions.py b/src/pytestqt/exceptions.py index 69cb86ff..90b7f7c7 100644 --- a/src/pytestqt/exceptions.py +++ b/src/pytestqt/exceptions.py @@ -7,10 +7,12 @@ from typing import Optional import pytest +from typing_extensions import Unpack from pytestqt.utils import get_marker CapturedException = tuple[type[BaseException], BaseException, Optional[TracebackType]] CapturedExceptions = list[CapturedException] +ExceptHook = Callable[[Unpack[CapturedException]], object] @contextmanager @@ -45,11 +47,7 @@ class _QtExceptionCaptureManager: """ def __init__(self) -> None: - self.old_hook: Optional[ - Callable[ - [type[BaseException], BaseException, Optional[TracebackType]], object - ] - ] = None + self.old_hook: Optional[ExceptHook] = None self.exceptions: CapturedExceptions = [] def start(self) -> None: