From 7f0e51a4acc05835f16272d1b29542b73f383108 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 20 Aug 2016 23:48:17 +0200 Subject: [PATCH 01/14] Add qtbot.waitCallback Fixes #156 --- pytestqt/qtbot.py | 51 +++++++++++++++++++++ pytestqt/wait_signal.py | 94 +++++++++++++++++++++++++++++++++++++++ tests/test_wait_signal.py | 92 ++++++++++++++++++++++++++++++++++---- 3 files changed, 228 insertions(+), 9 deletions(-) diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index 6cde4f40..66845eea 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -9,6 +9,8 @@ MultiSignalBlocker, SignalEmittedSpy, SignalEmittedError, + CallbackBlocker, + CallbackTimeoutError, ) @@ -524,6 +526,54 @@ def timed_out(): wait_until = waitUntil # pep-8 alias + def waitCallback(self, timeout=1000, raising=None): + """ + .. versionadded:: 3.1 + + Stops current test until a callback is called. + + Used to stop the control flow of a test until the returned callback is + called, or a number of milliseconds, specified by ``timeout``, has + elapsed. + + Best used as a context manager:: + + with qtbot.waitCallback() as callback: + function_taking_a_callback(callback) + assert callback.args == [True] + + Also, you can use the :class:`CallbackBlocker` directly if the + context manager form is not convenient:: + + blocker = qtbot.waitCallback(timeout=1000) + function_calling_a_callback(blocker) + blocker.wait() + + + :param int timeout: + How many milliseconds to wait before resuming control flow. + :param bool raising: + If :class:`QtBot.CallbackTimeoutError ` + should be raised if a timeout occurred. + This defaults to ``True`` unless ``qt_wait_signal_raising = false`` + is set in the config. + :returns: + A ``CallbackBlocker`` object which can be used directly as a + callback as it implements ``__call__``. + + .. note:: This method is also available as ``wait_callback`` (pep-8 alias) + """ + if raising is None: + raising_val = self._request.config.getini('qt_wait_signal_raising') + if not raising_val: + raising = True + else: + raising = _parse_ini_boolean(raising_val) + blocker = CallbackBlocker(timeout=timeout, raising=raising) + return blocker + + wait_callback = waitCallback # pep-8 alias + @contextlib.contextmanager def captureExceptions(self): """ @@ -594,6 +644,7 @@ def result(*args, **kwargs): QtBot.SignalTimeoutError = SignalTimeoutError QtBot.SignalEmittedError = SignalEmittedError QtBot.TimeoutError = TimeoutError +QtBot.CallbackTimeoutError = CallbackTimeoutError def _add_widget(item, widget): diff --git a/pytestqt/wait_signal.py b/pytestqt/wait_signal.py index 901e6f50..00725023 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -605,6 +605,90 @@ def assert_not_emitted(self): ) +class CallbackBlocker(object): + + """ + .. versionadded:: 3.1 + + An object which checks if the returned callback gets called. + + Intended to be used as a context manager. + + :ivar int timeout: maximum time to wait for the callback to be called. + + :ivar bool raising: + If :class:`CallbackTimeoutError` should be raised if a timeout occured. + + .. note:: contrary to the parameter of same name in + :meth:`pytestqt.qtbot.QtBot.waitCallback`, this parameter does not + consider the :ref:`qt_wait_signal_raising` option. + + :ivar list args: + The arguments with which the callback was called, or None if the + callback wasn't called at all. + + :ivar dict kwargs: + The keyword arguments with which the callback was called, or None if + the callback wasn't called at all. + """ + + def __init__(self, timeout=1000, raising=True): + self.timeout = timeout + self.raising = raising + self.args = None + self.kwargs = None + self.called = False + self._loop = qt_api.QtCore.QEventLoop() + self._timer = qt_api.QtCore.QTimer(self._loop) + self._timer.setSingleShot(True) + self._timer.setInterval(timeout) + + def _quit_loop_by_timeout(self): + try: + self._cleanup() + finally: + self._loop.quit() + + def _cleanup(self): + if self._timer is not None: + self._timer.stop() + self._timer = None + + def wait(self): + """ + Waits until either the returned callback is called or timeout is + reached. + """ + __tracebackhide__ = True + if self.called: + return + if self._timer is not None: + self._timer.timeout.connect(self._quit_loop_by_timeout) + self._timer.start() + self._loop.exec_() + if not self.called and self.raising: + raise CallbackTimeoutError("Callback wasn't called after %sms." % + self.timeout) + + def __call__(self, *args, **kwargs): + try: + self.args = list(args) + self.kwargs = kwargs + self.called = True + self._cleanup() + finally: + self._loop.quit() + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + __tracebackhide__ = True + if value is None: + # only wait if no exception happened inside the "with" block + self.wait() + + class SignalEmittedError(Exception): """ .. versionadded:: 1.11 @@ -616,6 +700,16 @@ class SignalEmittedError(Exception): pass +class CallbackTimeoutError(Exception): + """ + .. versionadded:: 2.1 + + The exception thrown by :meth:`pytestqt.qtbot.QtBot.waitCallback` if there + was a timeout and raising was not turned off. + """ + pass + + def _silent_disconnect(signal, slot): """Disconnects a signal from a slot, ignoring errors. Sometimes Qt might disconnect a signal automatically for unknown reasons. diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index bcd55c5b..a0fbeab9 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -281,25 +281,66 @@ class Signaller(qt_api.QtCore.QObject): return Signaller() -@pytest.mark.parametrize("multiple", [True, False]) -@pytest.mark.parametrize("raising", [True, False]) -def test_wait_signals_handles_exceptions(qtbot, multiple, raising, signaller): +@pytest.yield_fixture +def timer(): """ - Make sure waitSignal handles exceptions correctly. + Fixture that provides a callback with signature: (signal, delay) that + triggers that signal once after the given delay in ms. + + The fixture is responsible for cleaning up after the timers. + """ + + class Timer(qt_api.QtCore.QObject): + def __init__(self): + qt_api.QtCore.QObject.__init__(self) + self.timers_and_slots = [] + + def shutdown(self): + for t, slot in self.timers_and_slots: + t.stop() + t.timeout.disconnect(slot) + self.timers_and_slots[:] = [] + + def single_shot(self, signal, delay): + t = qt_api.QtCore.QTimer(self) + t.setSingleShot(True) + slot = functools.partial(self._emit, signal) + t.timeout.connect(slot) + t.start(delay) + self.timers_and_slots.append((t, slot)) + + def _emit(self, signal): + signal.emit() + + timer = Timer() + yield timer + timer.shutdown() + + +@pytest.mark.parametrize('blocker', ['single', 'multiple', 'callback']) +@pytest.mark.parametrize('raising', [True, False]) +def test_blockers_handle_exceptions(qtbot, blocker, raising, signaller): + """ + Make sure blockers handle exceptions correctly. """ class TestException(Exception): pass - if multiple: + if blocker == 'multiple': func = qtbot.waitSignals - arg = [signaller.signal, signaller.signal_2] - else: + args = [[signaller.signal, signaller.signal_2]] + elif blocker == 'single': func = qtbot.waitSignal - arg = signaller.signal + args = [signaller.signal] + elif blocker == 'callback': + func = qtbot.waitCallback + args = [] + else: + assert False with pytest.raises(TestException): - with func(arg, timeout=10, raising=raising): + with func(*args, timeout=10, raising=raising): raise TestException @@ -1314,3 +1355,36 @@ def test_disconnected(self, qtbot, signaller): with qtbot.assertNotEmitted(signaller.signal): pass signaller.signal.emit() + + +class TestWaitCallback: + + def test_immediate(self, qtbot): + with qtbot.waitCallback() as callback: + assert not callback.called + callback() + assert callback.called + + def test_later(self, qtbot): + t = qt_api.QtCore.QTimer() + t.setSingleShot(True) + t.setInterval(50) + with qtbot.waitCallback() as callback: + t.timeout.connect(callback) + t.start() + assert callback.called + + def test_args(self, qtbot): + with qtbot.waitCallback() as callback: + callback(23, answer=42) + assert callback.args == [23] + assert callback.kwargs == {'answer': 42} + + def test_explicit(self, qtbot): + blocker = qtbot.waitCallback() + assert not blocker.called + blocker() + blocker.wait() + assert blocker.called + + # FIXME tests for timeouts From 97d2937be29851bbec1604cef2459dab5f841e3f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 11 Sep 2018 21:14:28 +0200 Subject: [PATCH 02/14] Get rid of CallbackTimeoutError --- pytestqt/qtbot.py | 4 +--- pytestqt/wait_signal.py | 15 ++------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index 66845eea..de5bc914 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -10,7 +10,6 @@ SignalEmittedSpy, SignalEmittedError, CallbackBlocker, - CallbackTimeoutError, ) @@ -553,7 +552,7 @@ def waitCallback(self, timeout=1000, raising=None): :param int timeout: How many milliseconds to wait before resuming control flow. :param bool raising: - If :class:`QtBot.CallbackTimeoutError ` + If :class:`QtBot.TimeoutError ` should be raised if a timeout occurred. This defaults to ``True`` unless ``qt_wait_signal_raising = false`` is set in the config. @@ -644,7 +643,6 @@ def result(*args, **kwargs): QtBot.SignalTimeoutError = SignalTimeoutError QtBot.SignalEmittedError = SignalEmittedError QtBot.TimeoutError = TimeoutError -QtBot.CallbackTimeoutError = CallbackTimeoutError def _add_widget(item, widget): diff --git a/pytestqt/wait_signal.py b/pytestqt/wait_signal.py index 00725023..191aa21f 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -617,7 +617,7 @@ class CallbackBlocker(object): :ivar int timeout: maximum time to wait for the callback to be called. :ivar bool raising: - If :class:`CallbackTimeoutError` should be raised if a timeout occured. + If :class:`TimeoutError` should be raised if a timeout occured. .. note:: contrary to the parameter of same name in :meth:`pytestqt.qtbot.QtBot.waitCallback`, this parameter does not @@ -667,8 +667,7 @@ def wait(self): self._timer.start() self._loop.exec_() if not self.called and self.raising: - raise CallbackTimeoutError("Callback wasn't called after %sms." % - self.timeout) + raise TimeoutError("Callback wasn't called after %sms." % self.timeout) def __call__(self, *args, **kwargs): try: @@ -700,16 +699,6 @@ class SignalEmittedError(Exception): pass -class CallbackTimeoutError(Exception): - """ - .. versionadded:: 2.1 - - The exception thrown by :meth:`pytestqt.qtbot.QtBot.waitCallback` if there - was a timeout and raising was not turned off. - """ - pass - - def _silent_disconnect(signal, slot): """Disconnects a signal from a slot, ignoring errors. Sometimes Qt might disconnect a signal automatically for unknown reasons. From 1911059b02d80137f70e27acecbcadc29a6e723c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 11 Sep 2018 22:34:28 +0200 Subject: [PATCH 03/14] Rename qt_wait_signal_raising to qt_default_raising --- docs/signals.rst | 12 +++++++----- pytestqt/plugin.py | 12 +++++++++++- pytestqt/qtbot.py | 36 +++++++++++++++++++----------------- pytestqt/wait_signal.py | 4 ++-- tests/test_wait_signal.py | 27 +++++++++++++++++++++------ 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/docs/signals.rst b/docs/signals.rst index 5683be21..34d24dde 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -50,25 +50,27 @@ reached before the signal is triggered: # check whether the signal was triggered: assert blocker.signal_triggered, "process timed-out" -.. _qt_wait_signal_raising: +.. _qt_default_raising: -qt_wait_signal_raising ini option ---------------------------------- +qt_default_raising ini option +----------------------------- .. versionadded:: 1.11 .. versionchanged:: 2.0 +.. versionchanged:: 3.1 -The ``qt_wait_signal_raising`` ini option can be used to override the default +The ``qt_default_raising`` ini option can be used to override the default value of the ``raising`` parameter of the ``qtbot.waitSignal`` and ``qtbot.waitSignals`` functions when omitted: .. code-block:: ini [pytest] - qt_wait_signal_raising = false + qt_default_raising = false Calls which explicitly pass the ``raising`` parameter are not affected. +This option was called ``qt_wait_signal_raising`` before 3.1.0. check_params_cb parameter ------------------------- diff --git a/pytestqt/plugin.py b/pytestqt/plugin.py index 65fb0320..38ba648f 100644 --- a/pytestqt/plugin.py +++ b/pytestqt/plugin.py @@ -1,3 +1,5 @@ +import warnings + import pytest from pytestqt.exceptions import ( @@ -101,9 +103,13 @@ def pytest_addoption(parser): "qt_api", 'Qt api version to use: "pyside", "pyqt4", "pyqt4v2", "pyqt5"' ) parser.addini("qt_no_exception_capture", "disable automatic exception capture") + parser.addini( + "qt_default_raising", + "Default value for the raising parameter of qtbot.waitSignal/waitCallback", + ) parser.addini( "qt_wait_signal_raising", - "Default value for the raising parameter of qtbot.waitSignal", + "Default value for the raising parameter of qtbot.waitSignal (legacy alias)", ) default_log_fail = QtLoggingPlugin.LOG_FAIL_OPTIONS[0] @@ -211,6 +217,10 @@ def pytest_configure(config): qt_api.set_qt_api(config.getini("qt_api")) + if config.getini("qt_wait_signal_raising"): + warnings.warn("qt_wait_signal_raising is deprecated, use qt_default_raising instead.", + DeprecationWarning) + from .qtbot import QtBot QtBot._inject_qtest_methods() diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index de5bc914..2f9d4602 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -138,6 +138,19 @@ class QtBot(object): def __init__(self, request): self._request = request + def _should_raise(self, raising_arg): + ini_val = self._request.config.getini("qt_default_raising") + legacy_ini_val = self._request.config.getini("qt_wait_signal_raising") + + if raising_arg is not None: + return raising_arg + elif legacy_ini_val: + return _parse_ini_boolean(legacy_ini_val) + elif ini_val: + return _parse_ini_boolean(ini_val) + else: + return True + def addWidget(self, widget): """ Adds a widget to be tracked by this bot. This is not required, but will ensure that the @@ -299,7 +312,7 @@ def waitSignal(self, signal=None, timeout=1000, raising=None, check_params_cb=No :param bool raising: If :class:`QtBot.TimeoutError ` should be raised if a timeout occurred. - This defaults to ``True`` unless ``qt_wait_signal_raising = false`` + This defaults to ``True`` unless ``qt_default_raising = false`` is set in the config. :param Callable check_params_cb: Optional ``callable`` that compares the provided signal parameters to some expected parameters. @@ -315,12 +328,7 @@ def waitSignal(self, signal=None, timeout=1000, raising=None, check_params_cb=No .. note:: This method is also available as ``wait_signal`` (pep-8 alias) """ - if raising is None: - raising_val = self._request.config.getini("qt_wait_signal_raising") - if not raising_val: - raising = True - else: - raising = _parse_ini_boolean(raising_val) + raising = self._should_raise(raising) blocker = SignalBlocker( timeout=timeout, raising=raising, check_params_cb=check_params_cb ) @@ -368,7 +376,7 @@ def waitSignals( :param bool raising: If :class:`QtBot.TimeoutError ` should be raised if a timeout occurred. - This defaults to ``True`` unless ``qt_wait_signal_raising = false`` + This defaults to ``True`` unless ``qt_default_raising = false`` is set in the config. :param list check_params_cbs: optional list of callables that compare the provided signal parameters to some expected parameters. @@ -400,8 +408,7 @@ def waitSignals( if order not in ["none", "simple", "strict"]: raise ValueError("order has to be set to 'none', 'simple' or 'strict'") - if raising is None: - raising = self._request.config.getini("qt_wait_signal_raising") + raising = self._should_raise(raising) if check_params_cbs: if len(check_params_cbs) != len(signals): @@ -554,7 +561,7 @@ def waitCallback(self, timeout=1000, raising=None): :param bool raising: If :class:`QtBot.TimeoutError ` should be raised if a timeout occurred. - This defaults to ``True`` unless ``qt_wait_signal_raising = false`` + This defaults to ``True`` unless ``qt_default_raising = false`` is set in the config. :returns: A ``CallbackBlocker`` object which can be used directly as a @@ -562,12 +569,7 @@ def waitCallback(self, timeout=1000, raising=None): .. note:: This method is also available as ``wait_callback`` (pep-8 alias) """ - if raising is None: - raising_val = self._request.config.getini('qt_wait_signal_raising') - if not raising_val: - raising = True - else: - raising = _parse_ini_boolean(raising_val) + raising = self._should_raise(raising) blocker = CallbackBlocker(timeout=timeout, raising=raising) return blocker diff --git a/pytestqt/wait_signal.py b/pytestqt/wait_signal.py index 191aa21f..76b14c18 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -165,7 +165,7 @@ class SignalBlocker(_AbstractSignalBlocker): .. note:: contrary to the parameter of same name in :meth:`pytestqt.qtbot.QtBot.waitSignal`, this parameter does not - consider the :ref:`qt_wait_signal_raising`. + consider the :ref:`qt_default_raising` option. :ivar list args: The arguments which were emitted by the signal, or None if the signal @@ -621,7 +621,7 @@ class CallbackBlocker(object): .. note:: contrary to the parameter of same name in :meth:`pytestqt.qtbot.QtBot.waitCallback`, this parameter does not - consider the :ref:`qt_wait_signal_raising` option. + consider the :ref:`qt_default_raising` option. :ivar list args: The arguments with which the callback was called, or None if the diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index a0fbeab9..f95a8dec 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -114,13 +114,17 @@ def test_signal_triggered( @pytest.mark.parametrize( "configval, raises", [("false", False), ("true", True), (None, True)] ) -def test_raising(qtbot, testdir, configval, raises): +@pytest.mark.parametrize( + "configkey", ["qt_wait_signal_raising", "qt_default_raising"] +) +def test_raising(qtbot, testdir, configkey, configval, raises): if configval is not None: testdir.makeini( """ [pytest] - qt_wait_signal_raising = {} + {} = {} """.format( + configkey, configval ) ) @@ -140,7 +144,12 @@ def test_foo(qtbot): pass """ ) - res = testdir.runpytest() + + if configkey == 'qt_wait_signal_raising' and configval is not None: + with pytest.warns(DeprecationWarning): + res = testdir.runpytest() + else: + res = testdir.runpytest() if raises: res.stdout.fnmatch_lines(["*1 failed*"]) @@ -148,12 +157,18 @@ def test_foo(qtbot): res.stdout.fnmatch_lines(["*1 passed*"]) -def test_raising_by_default_overridden(qtbot, testdir): +@pytest.mark.filterwarnings('ignore:qt_wait_signal_raising is deprecated') +@pytest.mark.parametrize( + "configkey", ["qt_wait_signal_raising", "qt_default_raising"] +) +def test_raising_by_default_overridden(qtbot, testdir, configkey): testdir.makeini( """ [pytest] - qt_wait_signal_raising = false - """ + {} = false + """.format( + configkey + ) ) testdir.makepyfile( From c9d2efe178a3b0df37e6415c7c1b4584cd40e6b5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 01:02:42 +0200 Subject: [PATCH 04/14] Update CallbackBlocker based on AbstractBlocker --- pytestqt/wait_signal.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pytestqt/wait_signal.py b/pytestqt/wait_signal.py index 76b14c18..0ea6b932 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -639,20 +639,12 @@ def __init__(self, timeout=1000, raising=True): self.kwargs = None self.called = False self._loop = qt_api.QtCore.QEventLoop() - self._timer = qt_api.QtCore.QTimer(self._loop) - self._timer.setSingleShot(True) - self._timer.setInterval(timeout) - - def _quit_loop_by_timeout(self): - try: - self._cleanup() - finally: - self._loop.quit() - - def _cleanup(self): - if self._timer is not None: - self._timer.stop() + if timeout is None: self._timer = None + else: + self._timer = qt_api.QtCore.QTimer(self._loop) + self._timer.setSingleShot(True) + self._timer.setInterval(timeout) def wait(self): """ @@ -669,6 +661,18 @@ def wait(self): if not self.called and self.raising: raise TimeoutError("Callback wasn't called after %sms." % self.timeout) + def _quit_loop_by_timeout(self): + try: + self._cleanup() + finally: + self._loop.quit() + + def _cleanup(self): + if self._timer is not None: + _silent_disconnect(self._timer.timeout, self._quit_loop_by_timeout) + self._timer.stop() + self._timer = None + def __call__(self, *args, **kwargs): try: self.args = list(args) From 9c2bc60f64b9498aa382188c02245d20c8b52f79 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 15:05:59 +0200 Subject: [PATCH 05/14] callback blocker: Show error when callback was called twice --- pytestqt/qtbot.py | 2 ++ pytestqt/wait_signal.py | 15 +++++++++++++++ tests/test_wait_signal.py | 8 ++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index 2f9d4602..57cb6578 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -10,6 +10,7 @@ SignalEmittedSpy, SignalEmittedError, CallbackBlocker, + CallbackCalledTwiceError, ) @@ -645,6 +646,7 @@ def result(*args, **kwargs): QtBot.SignalTimeoutError = SignalTimeoutError QtBot.SignalEmittedError = SignalEmittedError QtBot.TimeoutError = TimeoutError +QtBot.CallbackCalledTwiceError = CallbackCalledTwiceError def _add_widget(item, widget): diff --git a/pytestqt/wait_signal.py b/pytestqt/wait_signal.py index 0ea6b932..082bd027 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -674,6 +674,10 @@ def _cleanup(self): self._timer = None def __call__(self, *args, **kwargs): + # Not inside the try: block, as if self.called is True, we did quit the + # loop already. + if self.called: + raise CallbackCalledTwiceError("Callback called twice") try: self.args = list(args) self.kwargs = kwargs @@ -703,6 +707,17 @@ class SignalEmittedError(Exception): pass +class CallbackCalledTwiceError(Exception): + """ + .. versionadded:: 3.1 + + The exception thrown by :meth:`pytestqt.qtbot.QtBot.waitCallback` if a + callback was called twice. + """ + + pass + + def _silent_disconnect(signal, slot): """Disconnects a signal from a slot, ignoring errors. Sometimes Qt might disconnect a signal automatically for unknown reasons. diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index f95a8dec..f2ab7667 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -5,7 +5,7 @@ import sys from pytestqt.qt_compat import qt_api -from pytestqt.wait_signal import SignalEmittedError, TimeoutError, SignalAndArgs +from pytestqt.wait_signal import SignalEmittedError, TimeoutError, SignalAndArgs, CallbackCalledTwiceError def test_signal_blocker_exception(qtbot): @@ -1402,4 +1402,8 @@ def test_explicit(self, qtbot): blocker.wait() assert blocker.called - # FIXME tests for timeouts + def test_called_twice(self, qtbot): + with pytest.raises(CallbackCalledTwiceError): + with qtbot.waitCallback() as callback: + callback() + callback() From a1cd4030de1afcc3972154bd825968c61927780a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 15:06:19 +0200 Subject: [PATCH 06/14] callback blocker: Add tests for timeout --- tests/test_wait_signal.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index f2ab7667..b3243052 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -1407,3 +1407,16 @@ def test_called_twice(self, qtbot): with qtbot.waitCallback() as callback: callback() callback() + + def test_timeout_raising(self, qtbot): + with pytest.raises(TimeoutError): + with qtbot.waitCallback(timeout=10): + pass + + def test_timeout_not_raising(self, qtbot): + with qtbot.waitCallback(timeout=10, raising=False) as callback: + pass + + assert not callback.called + assert callback.args is None + assert callback.kwargs is None From 03bc7a4d5cb2daf0fd01d511aaabe84656c6b476 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 15:51:40 +0200 Subject: [PATCH 07/14] Add docs for waitCallback --- docs/index.rst | 1 + docs/wait_callback.rst | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 docs/wait_callback.rst diff --git a/docs/index.rst b/docs/index.rst index 66191e78..bdf3c2a8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ pytest-qt logging signals wait_until + wait_callback virtual_methods modeltester app_exit diff --git a/docs/wait_callback.rst b/docs/wait_callback.rst new file mode 100644 index 00000000..8c61b202 --- /dev/null +++ b/docs/wait_callback.rst @@ -0,0 +1,50 @@ +waitCallback: Waiting for methods taking a callback +=================================================== + +.. versionadded:: 3.1 + +Some methods in Qt (especially ``QtWebEngine``) take a callback as argument, +which gets called by Qt once a given operation is done. + +To test such code, you can use :meth:`qtbot.waitCallback ` +which waits until the callback has been called or a timeout is reached. + +The ``qtbot.waitCallback()`` method returns a callback which is callable +directly. + +For example: + +.. code-block:: python + + def test_js(qtbot): + page = QWebEnginePage() + with qtbot.waitCallback() as cb: + page.runJavaScript("1 + 1", cb) + # After callback + +Anything following the ``with`` block will be run only after the callback has been called. + +If the callback doesn't get called during the given timeout, +:class:`qtbot.TimeoutError ` is raised. If it is called more than once, +:class:`qtbot.CallbackCalledTwiceError ` is raised. + +raising parameter +----------------- + +Similarly to ``qtbot.waitSignal``, you can pass a ``raising=False`` parameter +(or set the ``qt_default_raising`` ini option) to avoid raising an exception on +timeouts. See :doc:`signals` for details. + +Getting arguments the callback was called with +---------------------------------------------- + +After the callback is called, the arguments and keyword arguments passed to it +are available via ``.args`` (as a list) and ``.kwargs`` (as a dict), +respectively. + +In the example above, we could check the result via: + +.. code-block:: python + + assert cb.args == [2] + assert cb.kwargs == {} From 5eb82ed577c2533e1fa65cc1290e62bf4fb55185 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 15:55:37 +0200 Subject: [PATCH 08/14] Don't refer to deprecated SignalTimeoutError in docs --- docs/signals.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/signals.rst b/docs/signals.rst index 34d24dde..4b10cf0b 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -21,7 +21,7 @@ ensuring the results are correct: app.worker.start() # Test will block at this point until either the "finished" or the # "failed" signal is emitted. If 10 seconds passed without a signal, - # SignalTimeoutError will be raised. + # TimeoutError will be raised. assert_application_results(app) @@ -34,7 +34,7 @@ raising parameter .. versionchanged:: 2.0 You can pass ``raising=False`` to avoid raising a -:class:`qtbot.SignalTimeoutError ` if the timeout is +:class:`qtbot.TimeoutError ` if the timeout is reached before the signal is triggered: .. code-block:: python @@ -46,7 +46,7 @@ reached before the signal is triggered: assert_application_results(app) - # qtbot.SignalTimeoutError is not raised, but you can still manually + # qtbot.TimeoutError is not raised, but you can still manually # check whether the signal was triggered: assert blocker.signal_triggered, "process timed-out" @@ -149,7 +149,7 @@ the ``raising`` parameter:: w.start() # this will be reached after all workers emit their "finished" - # signal or a qtbot.SignalTimeoutError will be raised + # signal or a qtbot.TimeoutError will be raised assert_application_results(app) check_params_cbs parameter From 34f0f1d6151a864e250d936a675374a6da3d10fc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 16:01:05 +0200 Subject: [PATCH 09/14] Run black --- docs/wait_callback.rst | 2 +- pytestqt/plugin.py | 6 ++++-- pytestqt/qtbot.py | 2 +- tests/test_wait_signal.py | 35 +++++++++++++++++------------------ 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/wait_callback.rst b/docs/wait_callback.rst index 8c61b202..f0c6331c 100644 --- a/docs/wait_callback.rst +++ b/docs/wait_callback.rst @@ -25,7 +25,7 @@ For example: Anything following the ``with`` block will be run only after the callback has been called. If the callback doesn't get called during the given timeout, -:class:`qtbot.TimeoutError ` is raised. If it is called more than once, +:class:`qtbot.TimeoutError ` is raised. If it is called more than once, :class:`qtbot.CallbackCalledTwiceError ` is raised. raising parameter diff --git a/pytestqt/plugin.py b/pytestqt/plugin.py index 38ba648f..e2e0797f 100644 --- a/pytestqt/plugin.py +++ b/pytestqt/plugin.py @@ -218,8 +218,10 @@ def pytest_configure(config): qt_api.set_qt_api(config.getini("qt_api")) if config.getini("qt_wait_signal_raising"): - warnings.warn("qt_wait_signal_raising is deprecated, use qt_default_raising instead.", - DeprecationWarning) + warnings.warn( + "qt_wait_signal_raising is deprecated, use qt_default_raising instead.", + DeprecationWarning, + ) from .qtbot import QtBot diff --git a/pytestqt/qtbot.py b/pytestqt/qtbot.py index 57cb6578..0ed5fc45 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -140,7 +140,7 @@ def __init__(self, request): self._request = request def _should_raise(self, raising_arg): - ini_val = self._request.config.getini("qt_default_raising") + ini_val = self._request.config.getini("qt_default_raising") legacy_ini_val = self._request.config.getini("qt_wait_signal_raising") if raising_arg is not None: diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index b3243052..a58d6b65 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -5,7 +5,12 @@ import sys from pytestqt.qt_compat import qt_api -from pytestqt.wait_signal import SignalEmittedError, TimeoutError, SignalAndArgs, CallbackCalledTwiceError +from pytestqt.wait_signal import ( + SignalEmittedError, + TimeoutError, + SignalAndArgs, + CallbackCalledTwiceError, +) def test_signal_blocker_exception(qtbot): @@ -114,9 +119,7 @@ def test_signal_triggered( @pytest.mark.parametrize( "configval, raises", [("false", False), ("true", True), (None, True)] ) -@pytest.mark.parametrize( - "configkey", ["qt_wait_signal_raising", "qt_default_raising"] -) +@pytest.mark.parametrize("configkey", ["qt_wait_signal_raising", "qt_default_raising"]) def test_raising(qtbot, testdir, configkey, configval, raises): if configval is not None: testdir.makeini( @@ -124,8 +127,7 @@ def test_raising(qtbot, testdir, configkey, configval, raises): [pytest] {} = {} """.format( - configkey, - configval + configkey, configval ) ) @@ -145,7 +147,7 @@ def test_foo(qtbot): """ ) - if configkey == 'qt_wait_signal_raising' and configval is not None: + if configkey == "qt_wait_signal_raising" and configval is not None: with pytest.warns(DeprecationWarning): res = testdir.runpytest() else: @@ -157,10 +159,8 @@ def test_foo(qtbot): res.stdout.fnmatch_lines(["*1 passed*"]) -@pytest.mark.filterwarnings('ignore:qt_wait_signal_raising is deprecated') -@pytest.mark.parametrize( - "configkey", ["qt_wait_signal_raising", "qt_default_raising"] -) +@pytest.mark.filterwarnings("ignore:qt_wait_signal_raising is deprecated") +@pytest.mark.parametrize("configkey", ["qt_wait_signal_raising", "qt_default_raising"]) def test_raising_by_default_overridden(qtbot, testdir, configkey): testdir.makeini( """ @@ -332,8 +332,8 @@ def _emit(self, signal): timer.shutdown() -@pytest.mark.parametrize('blocker', ['single', 'multiple', 'callback']) -@pytest.mark.parametrize('raising', [True, False]) +@pytest.mark.parametrize("blocker", ["single", "multiple", "callback"]) +@pytest.mark.parametrize("raising", [True, False]) def test_blockers_handle_exceptions(qtbot, blocker, raising, signaller): """ Make sure blockers handle exceptions correctly. @@ -342,13 +342,13 @@ def test_blockers_handle_exceptions(qtbot, blocker, raising, signaller): class TestException(Exception): pass - if blocker == 'multiple': + if blocker == "multiple": func = qtbot.waitSignals args = [[signaller.signal, signaller.signal_2]] - elif blocker == 'single': + elif blocker == "single": func = qtbot.waitSignal args = [signaller.signal] - elif blocker == 'callback': + elif blocker == "callback": func = qtbot.waitCallback args = [] else: @@ -1373,7 +1373,6 @@ def test_disconnected(self, qtbot, signaller): class TestWaitCallback: - def test_immediate(self, qtbot): with qtbot.waitCallback() as callback: assert not callback.called @@ -1393,7 +1392,7 @@ def test_args(self, qtbot): with qtbot.waitCallback() as callback: callback(23, answer=42) assert callback.args == [23] - assert callback.kwargs == {'answer': 42} + assert callback.kwargs == {"answer": 42} def test_explicit(self, qtbot): blocker = qtbot.waitCallback() From 488c2aaa71f2abe029644f999aa8cd6e45767317 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 12 Sep 2018 16:04:56 +0200 Subject: [PATCH 10/14] Remove accidentally readded timer fixture --- tests/test_wait_signal.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index a58d6b65..66e1aa78 100644 --- a/tests/test_wait_signal.py +++ b/tests/test_wait_signal.py @@ -296,42 +296,6 @@ class Signaller(qt_api.QtCore.QObject): return Signaller() -@pytest.yield_fixture -def timer(): - """ - Fixture that provides a callback with signature: (signal, delay) that - triggers that signal once after the given delay in ms. - - The fixture is responsible for cleaning up after the timers. - """ - - class Timer(qt_api.QtCore.QObject): - def __init__(self): - qt_api.QtCore.QObject.__init__(self) - self.timers_and_slots = [] - - def shutdown(self): - for t, slot in self.timers_and_slots: - t.stop() - t.timeout.disconnect(slot) - self.timers_and_slots[:] = [] - - def single_shot(self, signal, delay): - t = qt_api.QtCore.QTimer(self) - t.setSingleShot(True) - slot = functools.partial(self._emit, signal) - t.timeout.connect(slot) - t.start(delay) - self.timers_and_slots.append((t, slot)) - - def _emit(self, signal): - signal.emit() - - timer = Timer() - yield timer - timer.shutdown() - - @pytest.mark.parametrize("blocker", ["single", "multiple", "callback"]) @pytest.mark.parametrize("raising", [True, False]) def test_blockers_handle_exceptions(qtbot, blocker, raising, signaller): From e9ea2f89fc1e837a43321c7542804c349602e4a8 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 13 Sep 2018 17:58:24 +0200 Subject: [PATCH 11/14] Update changelog --- CHANGELOG.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 363c3f9f..eb7348f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,13 @@ +3.1.0 (unreleased) +------------------ + +- The ``qt_wait_signal_raising`` option was renamed to ``qt_default_raising``. + The old name continues to work, but is deprecated. +- New ``qtbot.waitCallback()`` method that returns a ``CallbackBlocker``, which + can be used to wait for a callback to be called. +- The docs still referred to ``SignalTimeoutError`` in some places, despite it + being renamed to ``TimeoutError`` in the 2.1 release. This is now corrected. + 3.0.3 (unreleased) ------------------ From 2a5277bd6a03641a09eb6b4abe4f3fcd48782540 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 13 Sep 2018 13:48:10 -0300 Subject: [PATCH 12/14] Require pytest>=3.8 filterwarnings was not a registered marker until 3.7.2 --- .travis.yml | 2 +- appveyor.yml | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index c91dc20b..f0a6d7ca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ dist: trusty env: global: # used by ci-helpers - - DEPS="pytest tox coveralls six" + - DEPS="pytest>=3.8 tox coveralls six" - MINICONDA_VERSION=latest - DISPLAY=":99.0" diff --git a/appveyor.yml b/appveyor.yml index ddac24c9..dcf86ed5 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -13,37 +13,37 @@ environment: matrix: - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyqt4" - CONDA_DEPENDENCIES: "pytest pyqt=4.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyqt4v2" - CONDA_DEPENDENCIES: "pytest pyqt=4.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyside" - CONDA_DEPENDENCIES: "pytest pyside=1.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyside=1.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyqt4" - CONDA_DEPENDENCIES: "pytest pyqt=4.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyqt4v2" - CONDA_DEPENDENCIES: "pytest pyqt=4.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyside" - CONDA_DEPENDENCIES: "pytest pyside=1.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyside=1.*" - PYTHON_VERSION: "3.5" PYTEST_QT_API: "pyqt5" - CONDA_DEPENDENCIES: "pytest pyqt=5.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=5.*" - PYTHON_VERSION: "3.5" PYTEST_QT_API: "pyside2" - CONDA_DEPENDENCIES: "pytest pyside2=2.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyside2=2.*" - PYTHON_VERSION: "3.6" PYTEST_QT_API: "pyqt5" - CONDA_DEPENDENCIES: "pytest pyqt=5.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=5.*" - PYTHON_VERSION: "3.6" PYTEST_QT_API: "pyside2" - CONDA_DEPENDENCIES: "pytest pyside2=2.*" + CONDA_DEPENDENCIES: "pytest>=3.8 pyside2=2.*" matrix: exclude: @@ -51,7 +51,7 @@ matrix: - PYTEST_QT_API: "pyside2" platform: - -x64 + - x64 install: # If there is a newer build queued for the same PR, cancel this one. @@ -70,7 +70,7 @@ install: - "pip install -e ." # Not a .NET project, we build in the install step instead -build: false +build: off test_script: - "%CMD_IN_ENV% python -m pytest -v tests/" From 1108eeafa54399cb3ededd920dfe76bd27ebc4fb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 13 Sep 2018 20:15:28 +0200 Subject: [PATCH 13/14] Revert "Require pytest>=3.8" This reverts commit 2a5277bd6a03641a09eb6b4abe4f3fcd48782540. --- .travis.yml | 2 +- appveyor.yml | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index f0a6d7ca..c91dc20b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ dist: trusty env: global: # used by ci-helpers - - DEPS="pytest>=3.8 tox coveralls six" + - DEPS="pytest tox coveralls six" - MINICONDA_VERSION=latest - DISPLAY=":99.0" diff --git a/appveyor.yml b/appveyor.yml index dcf86ed5..ddac24c9 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -13,37 +13,37 @@ environment: matrix: - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyqt4" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" + CONDA_DEPENDENCIES: "pytest pyqt=4.*" - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyqt4v2" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" + CONDA_DEPENDENCIES: "pytest pyqt=4.*" - PYTHON_VERSION: "2.7" PYTEST_QT_API: "pyside" - CONDA_DEPENDENCIES: "pytest>=3.8 pyside=1.*" + CONDA_DEPENDENCIES: "pytest pyside=1.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyqt4" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" + CONDA_DEPENDENCIES: "pytest pyqt=4.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyqt4v2" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=4.*" + CONDA_DEPENDENCIES: "pytest pyqt=4.*" - PYTHON_VERSION: "3.4" PYTEST_QT_API: "pyside" - CONDA_DEPENDENCIES: "pytest>=3.8 pyside=1.*" + CONDA_DEPENDENCIES: "pytest pyside=1.*" - PYTHON_VERSION: "3.5" PYTEST_QT_API: "pyqt5" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=5.*" + CONDA_DEPENDENCIES: "pytest pyqt=5.*" - PYTHON_VERSION: "3.5" PYTEST_QT_API: "pyside2" - CONDA_DEPENDENCIES: "pytest>=3.8 pyside2=2.*" + CONDA_DEPENDENCIES: "pytest pyside2=2.*" - PYTHON_VERSION: "3.6" PYTEST_QT_API: "pyqt5" - CONDA_DEPENDENCIES: "pytest>=3.8 pyqt=5.*" + CONDA_DEPENDENCIES: "pytest pyqt=5.*" - PYTHON_VERSION: "3.6" PYTEST_QT_API: "pyside2" - CONDA_DEPENDENCIES: "pytest>=3.8 pyside2=2.*" + CONDA_DEPENDENCIES: "pytest pyside2=2.*" matrix: exclude: @@ -51,7 +51,7 @@ matrix: - PYTEST_QT_API: "pyside2" platform: - - x64 + -x64 install: # If there is a newer build queued for the same PR, cancel this one. @@ -70,7 +70,7 @@ install: - "pip install -e ." # Not a .NET project, we build in the install step instead -build: off +build: false test_script: - "%CMD_IN_ENV% python -m pytest -v tests/" From 95fee8b20ccd20cde728dc4e05a488797fd19b93 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 13 Sep 2018 20:16:11 +0200 Subject: [PATCH 14/14] Force registering @pytest.filterwarnings --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index 2f680978..13326bd9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,3 +5,5 @@ universal = 1 testpaths = tests addopts = --strict xfail_strict = true +markers = + filterwarnings: pytest's filterwarnings marker