Skip to content
Merged
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
25 changes: 21 additions & 4 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
[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]
disallow_untyped_calls = False
disallow_untyped_defs = False

[mypy-pytestqt.logging,pytestqt.modeltest,pytestqt.wait_signal]
check_untyped_defs = False
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
33 changes: 21 additions & 12 deletions src/pytestqt/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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 typing_extensions import Unpack
from pytestqt.utils import get_marker

CapturedException = tuple[type[BaseException], BaseException, TracebackType]
CapturedException = tuple[type[BaseException], BaseException, Optional[TracebackType]]
CapturedExceptions = list[CapturedException]
ExceptHook = Callable[[Unpack[CapturedException]], object]


@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
Expand All @@ -26,7 +30,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)]))
Expand All @@ -37,18 +46,18 @@ class _QtExceptionCaptureManager:
Manages exception capture context.
"""

def __init__(self):
self.old_hook = None
self.exceptions = []
def __init__(self) -> None:
self.old_hook: Optional[ExceptHook] = 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.
Expand All @@ -57,7 +66,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.
Expand All @@ -70,11 +79,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.
Expand All @@ -91,7 +100,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"
Expand Down
4 changes: 2 additions & 2 deletions src/pytestqt/qtbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions src/pytestqt/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
77 changes: 30 additions & 47 deletions tests/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -62,7 +63,7 @@ def test_types():
)


def test_qtlog_fixture(qtlog):
def test_qtlog_fixture(qtlog: _QtMessageCapture) -> None:
"""
Test qtlog fixture.
"""
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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(
"""
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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")'
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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="""
Expand Down
Loading
Loading