diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c0b88079..f4b09df5 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) ------------------ 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/signals.rst b/docs/signals.rst index 13b37347..db691c8d 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,29 +46,31 @@ 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" -.. _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 ------------------------- @@ -158,7 +160,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 diff --git a/docs/wait_callback.rst b/docs/wait_callback.rst new file mode 100644 index 00000000..f0c6331c --- /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 == {} diff --git a/pytestqt/plugin.py b/pytestqt/plugin.py index 65fb0320..e2e0797f 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,12 @@ 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 3d0eeb62..3922aa5d 100644 --- a/pytestqt/qtbot.py +++ b/pytestqt/qtbot.py @@ -9,6 +9,8 @@ MultiSignalBlocker, SignalEmittedSpy, SignalEmittedError, + CallbackBlocker, + CallbackCalledTwiceError, ) @@ -137,6 +139,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 @@ -298,7 +313,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. @@ -314,12 +329,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 ) @@ -367,7 +377,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. @@ -399,8 +409,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): @@ -529,6 +538,49 @@ 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.TimeoutError ` + should be raised if a timeout occurred. + 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 + callback as it implements ``__call__``. + + .. note:: This method is also available as ``wait_callback`` (pep-8 alias) + """ + raising = self._should_raise(raising) + blocker = CallbackBlocker(timeout=timeout, raising=raising) + return blocker + + wait_callback = waitCallback # pep-8 alias + @contextlib.contextmanager def captureExceptions(self): """ @@ -599,6 +651,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 2b016d65..171baa91 100644 --- a/pytestqt/wait_signal.py +++ b/pytestqt/wait_signal.py @@ -168,7 +168,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 @@ -608,6 +608,97 @@ 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:`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 + consider the :ref:`qt_default_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() + 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): + """ + 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 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): + # 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 + 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 @@ -619,6 +710,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/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 diff --git a/tests/test_wait_signal.py b/tests/test_wait_signal.py index 9270b575..fc604852 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 +from pytestqt.wait_signal import ( + SignalEmittedError, + TimeoutError, + SignalAndArgs, + CallbackCalledTwiceError, +) def test_signal_blocker_exception(qtbot): @@ -129,14 +134,15 @@ def test_zero_timeout(qtbot, timer, delayed, signaller): @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( - configval + configkey, configval ) ) @@ -155,7 +161,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*"]) @@ -163,12 +174,16 @@ 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( @@ -296,25 +311,30 @@ class Signaller(qt_api.QtCore.QObject): return Signaller() -@pytest.mark.parametrize("multiple", [True, False]) +@pytest.mark.parametrize("blocker", ["single", "multiple", "callback"]) @pytest.mark.parametrize("raising", [True, False]) -def test_wait_signals_handles_exceptions(qtbot, multiple, raising, signaller): +def test_blockers_handle_exceptions(qtbot, blocker, raising, signaller): """ - Make sure waitSignal handles exceptions correctly. + 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 @@ -1343,3 +1363,52 @@ def test_continues_when_emitted(self, qtbot, signaller, stop_watch): signaller.signal.emit() stop_watch.check(4000) + + +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 + + def test_called_twice(self, qtbot): + with pytest.raises(CallbackCalledTwiceError): + 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