From 5b381c144bae2e70d9c08005bdb9fb5276e3b15c Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 12 Jan 2022 12:24:23 +0200 Subject: [PATCH 1/8] Add custom hooks specifications for overriding setup_timeout and teardown_timeout methods --- pytest_timeout.py | 49 +++++++++++++++++++++++++++++++++++------- test_pytest_timeout.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/pytest_timeout.py b/pytest_timeout.py index 682f597..e103e8c 100644 --- a/pytest_timeout.py +++ b/pytest_timeout.py @@ -70,6 +70,32 @@ def pytest_addoption(parser): parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool") +class TimeoutHooks: + @pytest.hookspec(firstresult=True) + def pytest_timeout_setup(item): + """Called at timeout setup. + + 'item' is a pytest node to setup timeout for. + + Can be overridden by plugins for alternative timeout implementation strategies. + + """ + + @pytest.hookspec(firstresult=True) + def pytest_timeout_teardown(item): + """Called at timeout teardown. + + 'item' is a pytest node which was used for timeout setup. + + Can be overridden by plugins for alternative timeout implementation strategies. + + """ + + +def pytest_addhooks(pluginmanager): + pluginmanager.add_hookspecs(TimeoutHooks) + + @pytest.hookimpl def pytest_configure(config): """Register the marker so it shows up in --markers output.""" @@ -98,12 +124,13 @@ def pytest_runtest_protocol(item): teardown, then this hook installs the timeout. Otherwise pytest_runtest_call is used. """ + hooks = item.config.pluginmanager.hook func_only = get_func_only_setting(item) if func_only is False: - timeout_setup(item) + hooks.pytest_timeout_setup(item=item) yield if func_only is False: - timeout_teardown(item) + hooks.pytest_timeout_teardown(item=item) @pytest.hookimpl(hookwrapper=True) @@ -113,12 +140,13 @@ def pytest_runtest_call(item): If the timeout is set on only the test function this hook installs the timeout, otherwise pytest_runtest_protocol is used. """ + hooks = item.config.pluginmanager.hook func_only = get_func_only_setting(item) if func_only is True: - timeout_setup(item) + hooks.pytest_timeout_setup(item=item) yield if func_only is True: - timeout_teardown(item) + hooks.pytest_timeout_teardown(item=item) @pytest.hookimpl(tryfirst=True) @@ -138,7 +166,8 @@ def pytest_report_header(config): @pytest.hookimpl(tryfirst=True) def pytest_exception_interact(node): """Stop the timeout when pytest enters pdb in post-mortem mode.""" - timeout_teardown(node) + hooks = node.config.pluginmanager.hook + hooks.pytest_timeout_teardown(item=node) @pytest.hookimpl @@ -187,11 +216,12 @@ def is_debugging(trace_func=None): SUPPRESS_TIMEOUT = False -def timeout_setup(item): +@pytest.hookimpl(trylast=True) +def pytest_timeout_setup(item): """Setup up a timeout trigger and handler.""" params = get_params(item) if params.timeout is None or params.timeout <= 0: - return + return True timeout_method = params.method if ( @@ -223,9 +253,11 @@ def cancel(): item.cancel_timeout = cancel timer.start() + return True -def timeout_teardown(item): +@pytest.hookimpl(trylast=True) +def pytest_timeout_teardown(item): """Cancel the timeout trigger if it was set.""" # When skipping is raised from a pytest_runtest_setup function # (as is the case when using the pytest.mark.skipif marker) we @@ -234,6 +266,7 @@ def timeout_teardown(item): cancel = getattr(item, "cancel_timeout", None) if cancel: cancel() + return True def get_env_settings(config): diff --git a/test_pytest_timeout.py b/test_pytest_timeout.py index 770737a..9ac9b5a 100644 --- a/test_pytest_timeout.py +++ b/test_pytest_timeout.py @@ -506,3 +506,40 @@ def test_x(): pass result.stdout.fnmatch_lines( ["timeout: 1.0s", "timeout method:*", "timeout func_only:*"] ) + + +def test_plugin_is_debugging(request): + config = request.config + plugin = config.pluginmanager.get_plugin("timeout") + assert not plugin.is_debugging() + + +def test_plugin_interface(testdir): + testdir.makeconftest( + """ + import pytest + + @pytest.mark.tryfirst + def pytest_timeout_setup(item): + print() + print("pytest_timeout_setup") + return True + + @pytest.mark.tryfirst + def pytest_timeout_teardown(item): + print() + print("pytest_timeout_teardown") + return True + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.mark.timeout(1) + def test_foo(): + pass + """ + ) + result = testdir.runpytest("-s") + result.stdout.fnmatch_lines(["pytest_timeout_setup", "pytest_timeout_teardown"]) From e046eae598dc5bc4daa194625c69aa543091398c Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 12 Jan 2022 12:54:28 +0200 Subject: [PATCH 2/8] Add required docstrings --- pytest_timeout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pytest_timeout.py b/pytest_timeout.py index e103e8c..a313a82 100644 --- a/pytest_timeout.py +++ b/pytest_timeout.py @@ -71,6 +71,8 @@ def pytest_addoption(parser): class TimeoutHooks: + """Timeout specific hooks""" + @pytest.hookspec(firstresult=True) def pytest_timeout_setup(item): """Called at timeout setup. @@ -93,6 +95,7 @@ def pytest_timeout_teardown(item): def pytest_addhooks(pluginmanager): + """Register timeout-specific hooks""" pluginmanager.add_hookspecs(TimeoutHooks) From 314e0bc870626d7e2ff9290be51054a653e246ae Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Wed, 12 Jan 2022 12:58:59 +0200 Subject: [PATCH 3/8] Fix linter --- pytest_timeout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_timeout.py b/pytest_timeout.py index a313a82..65bd3f6 100644 --- a/pytest_timeout.py +++ b/pytest_timeout.py @@ -71,7 +71,7 @@ def pytest_addoption(parser): class TimeoutHooks: - """Timeout specific hooks""" + """Timeout specific hooks.""" @pytest.hookspec(firstresult=True) def pytest_timeout_setup(item): @@ -95,7 +95,7 @@ def pytest_timeout_teardown(item): def pytest_addhooks(pluginmanager): - """Register timeout-specific hooks""" + """Register timeout-specific hooks.""" pluginmanager.add_hookspecs(TimeoutHooks) From 146a07e1d65958b5516752996dab7b718eb3d8c3 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sat, 15 Jan 2022 16:49:05 +0200 Subject: [PATCH 4/8] Reflect notes --- README.rst | 44 ++++++++++++++++++++++++++++++++++++++++++ pytest_timeout.py | 21 +++++++++++--------- test_pytest_timeout.py | 21 ++++++++++---------- 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/README.rst b/README.rst index 42b32fe..d9c7f2f 100644 --- a/README.rst +++ b/README.rst @@ -237,6 +237,50 @@ debugging frameworks modules OR if pytest itself drops you into a pdb session using ``--pdb`` or similar. +Extending pytest-timeout with plugings +====================================== + +``pytest-timeout`` provides two hooks that can be used for extending the tool. There +hooks are used for for setting the timeout timer and cancelling it it the timeout is not +reached. + +For example, ``pytest-asyncio`` can provide asyncio-specific code that generates better +traceback and points on timed out ``await`` instead of the running loop ieration. + +See `pytest hooks documentation +`_ for more info +regarding to use custom hooks. + +``pytest_timeout_set_timer`` +---------------------------- + + @pytest.hookspec(firstresult=True) + def pytest_timeout_set_timer(item): + """Called at timeout setup. + + 'item' is a pytest node to setup timeout for. + + Can be overridden by plugins for alternative timeout implementation strategies. + + """ + + +``pytest_timeout_cancel_timer`` +------------------------------- + + + @pytest.hookspec(firstresult=True) + def pytest_timeout_cancel_timer(item): + """Called at timeout teardown. + + 'item' is a pytest node which was used for timeout setup. + + Can be overridden by plugins for alternative timeout implementation strategies. + + """ + + + Changelog ========= diff --git a/pytest_timeout.py b/pytest_timeout.py index 2ecdb2b..3d3c41c 100644 --- a/pytest_timeout.py +++ b/pytest_timeout.py @@ -18,6 +18,9 @@ import pytest +__all__ = ("is_debugging",) + + HAVE_SIGALRM = hasattr(signal, "SIGALRM") if HAVE_SIGALRM: DEFAULT_METHOD = "signal" @@ -74,7 +77,7 @@ class TimeoutHooks: """Timeout specific hooks.""" @pytest.hookspec(firstresult=True) - def pytest_timeout_setup(item): + def pytest_timeout_set_timer(item): """Called at timeout setup. 'item' is a pytest node to setup timeout for. @@ -84,7 +87,7 @@ def pytest_timeout_setup(item): """ @pytest.hookspec(firstresult=True) - def pytest_timeout_teardown(item): + def pytest_timeout_cancel_timer(item): """Called at timeout teardown. 'item' is a pytest node which was used for timeout setup. @@ -130,10 +133,10 @@ def pytest_runtest_protocol(item): hooks = item.config.pluginmanager.hook func_only = get_func_only_setting(item) if func_only is False: - hooks.pytest_timeout_setup(item=item) + hooks.pytest_timeout_set_timer(item=item) yield if func_only is False: - hooks.pytest_timeout_teardown(item=item) + hooks.pytest_timeout_cancel_timer(item=item) @pytest.hookimpl(hookwrapper=True) @@ -146,10 +149,10 @@ def pytest_runtest_call(item): hooks = item.config.pluginmanager.hook func_only = get_func_only_setting(item) if func_only is True: - hooks.pytest_timeout_setup(item=item) + hooks.pytest_timeout_set_timer(item=item) yield if func_only is True: - hooks.pytest_timeout_teardown(item=item) + hooks.pytest_timeout_cancel_timer(item=item) @pytest.hookimpl(tryfirst=True) @@ -170,7 +173,7 @@ def pytest_report_header(config): def pytest_exception_interact(node): """Stop the timeout when pytest enters pdb in post-mortem mode.""" hooks = node.config.pluginmanager.hook - hooks.pytest_timeout_teardown(item=node) + hooks.pytest_timeout_cancel_timer(item=node) @pytest.hookimpl @@ -220,7 +223,7 @@ def is_debugging(trace_func=None): @pytest.hookimpl(trylast=True) -def pytest_timeout_setup(item): +def pytest_timeout_set_timer(item): """Setup up a timeout trigger and handler.""" params = get_params(item) if params.timeout is None or params.timeout <= 0: @@ -260,7 +263,7 @@ def cancel(): @pytest.hookimpl(trylast=True) -def pytest_timeout_teardown(item): +def pytest_timeout_cancel_timer(item): """Cancel the timeout trigger if it was set.""" # When skipping is raised from a pytest_runtest_setup function # (as is the case when using the pytest.mark.skipif marker) we diff --git a/test_pytest_timeout.py b/test_pytest_timeout.py index 9ac9b5a..a7dcd20 100644 --- a/test_pytest_timeout.py +++ b/test_pytest_timeout.py @@ -508,27 +508,21 @@ def test_x(): pass ) -def test_plugin_is_debugging(request): - config = request.config - plugin = config.pluginmanager.get_plugin("timeout") - assert not plugin.is_debugging() - - def test_plugin_interface(testdir): testdir.makeconftest( """ import pytest @pytest.mark.tryfirst - def pytest_timeout_setup(item): + def pytest_timeout_set_timer(item): print() - print("pytest_timeout_setup") + print("pytest_timeout_set_timer") return True @pytest.mark.tryfirst - def pytest_timeout_teardown(item): + def pytest_timeout_cancel_timer(item): print() - print("pytest_timeout_teardown") + print("pytest_timeout_cancel_timer") return True """ ) @@ -542,4 +536,9 @@ def test_foo(): """ ) result = testdir.runpytest("-s") - result.stdout.fnmatch_lines(["pytest_timeout_setup", "pytest_timeout_teardown"]) + result.stdout.fnmatch_lines( + [ + "pytest_timeout_set_timer", + "pytest_timeout_cancel_timer", + ] + ) From d05e0e9451485447490f7aceb39f311f4b2b032a Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sat, 15 Jan 2022 18:00:26 +0200 Subject: [PATCH 5/8] Fix notes --- README.rst | 37 +++++++++++++++++++++++++++- pytest_timeout.py | 56 +++++++++++++++++------------------------- test_pytest_timeout.py | 2 +- 3 files changed, 59 insertions(+), 36 deletions(-) diff --git a/README.rst b/README.rst index d9c7f2f..6be6b7e 100644 --- a/README.rst +++ b/README.rst @@ -255,16 +255,37 @@ regarding to use custom hooks. ---------------------------- @pytest.hookspec(firstresult=True) - def pytest_timeout_set_timer(item): + def pytest_timeout_set_timer(item, settings): """Called at timeout setup. 'item' is a pytest node to setup timeout for. + 'settings' is Settings namedtuple (described below). + Can be overridden by plugins for alternative timeout implementation strategies. """ +``Settings`` +------------ + +When ``pytest_timeout_set_timer`` is called, ``settings`` argument is passed. + +The argument has ``Settings`` namedtuple type with the following fields: + ++-----------+-------+--------------------------------------------------------+ +|Attribute | Index | Value | ++===========+=======+========================================================+ +| timeout | 0 | timeout in seconds or ``None`` for no timeout | ++-----------+-------+--------------------------------------------------------+ +| method | 1 | Method mechanism, | +| | | ``'signal'`` and ``'thread'`` are supported by default | ++-----------+-------+--------------------------------------------------------+ +| func_only | 2 | Apply timeout to test function only if ``True``, | +| | | wrap all test function and its fixtures otherwise | ++-----------+-------+--------------------------------------------------------+ + ``pytest_timeout_cancel_timer`` ------------------------------- @@ -279,6 +300,20 @@ regarding to use custom hooks. """ +``is_debugging`` +---------------- + +The the timeout occurs, user can open the debugger session. In this case, the timeout +should be discarded. A custom hook can check this case by calling ``is_debugging()`` +function:: + + import pytest + import pytest_timeout + + def on_timeout(): + if pytest_timeout.is_debugging(): + return + pytest.fail("+++ Timeout +++") Changelog diff --git a/pytest_timeout.py b/pytest_timeout.py index 3d3c41c..da02c0d 100644 --- a/pytest_timeout.py +++ b/pytest_timeout.py @@ -18,7 +18,7 @@ import pytest -__all__ = ("is_debugging",) +__all__ = ("is_debugging", "Settings") HAVE_SIGALRM = hasattr(signal, "SIGALRM") @@ -77,7 +77,7 @@ class TimeoutHooks: """Timeout specific hooks.""" @pytest.hookspec(firstresult=True) - def pytest_timeout_set_timer(item): + def pytest_timeout_set_timer(item, settings): """Called at timeout setup. 'item' is a pytest node to setup timeout for. @@ -131,11 +131,12 @@ def pytest_runtest_protocol(item): pytest_runtest_call is used. """ hooks = item.config.pluginmanager.hook - func_only = get_func_only_setting(item) - if func_only is False: - hooks.pytest_timeout_set_timer(item=item) + settings = _get_item_settings(item) + is_timeout = settings.timeout is not None and settings.timeout > 0 + if is_timeout and settings.func_only is False: + hooks.pytest_timeout_set_timer(item=item, settings=settings) yield - if func_only is False: + if is_timeout and settings.func_only is False: hooks.pytest_timeout_cancel_timer(item=item) @@ -147,11 +148,12 @@ def pytest_runtest_call(item): the timeout, otherwise pytest_runtest_protocol is used. """ hooks = item.config.pluginmanager.hook - func_only = get_func_only_setting(item) - if func_only is True: - hooks.pytest_timeout_set_timer(item=item) + settings = _get_item_settings(item) + is_timeout = settings.timeout is not None and settings.timeout > 0 + if is_timeout and settings.func_only is True: + hooks.pytest_timeout_set_timer(item=item, settings=settings) yield - if func_only is True: + if is_timeout and settings.func_only is True: hooks.pytest_timeout_cancel_timer(item=item) @@ -223,13 +225,9 @@ def is_debugging(trace_func=None): @pytest.hookimpl(trylast=True) -def pytest_timeout_set_timer(item): +def pytest_timeout_set_timer(item, settings): """Setup up a timeout trigger and handler.""" - params = get_params(item) - if params.timeout is None or params.timeout <= 0: - return True - - timeout_method = params.method + timeout_method = settings.method if ( timeout_method == "signal" and threading.current_thread() is not threading.main_thread() @@ -240,7 +238,7 @@ def pytest_timeout_set_timer(item): def handler(signum, frame): __tracebackhide__ = True - timeout_sigalrm(item, params.timeout) + timeout_sigalrm(item, settings.timeout) def cancel(): signal.setitimer(signal.ITIMER_REAL, 0) @@ -248,9 +246,11 @@ def cancel(): item.cancel_timeout = cancel signal.signal(signal.SIGALRM, handler) - signal.setitimer(signal.ITIMER_REAL, params.timeout) + signal.setitimer(signal.ITIMER_REAL, settings.timeout) elif timeout_method == "thread": - timer = threading.Timer(params.timeout, timeout_timer, (item, params.timeout)) + timer = threading.Timer( + settings.timeout, timeout_timer, (item, settings.timeout) + ) timer.name = "%s %s" % (__name__, item.nodeid) def cancel(): @@ -307,21 +307,7 @@ def get_env_settings(config): return Settings(timeout, method, func_only or False) -def get_func_only_setting(item): - """Return the func_only setting for an item.""" - func_only = None - marker = item.get_closest_marker("timeout") - if marker: - settings = get_params(item, marker=marker) - func_only = _validate_func_only(settings.func_only, "marker") - if func_only is None: - func_only = item.config._env_timeout_func_only - if func_only is None: - func_only = False - return func_only - - -def get_params(item, marker=None): +def _get_item_settings(item, marker=None): """Return (timeout, method) for an item.""" timeout = method = func_only = None if not marker: @@ -337,6 +323,8 @@ def get_params(item, marker=None): method = item.config._env_timeout_method if func_only is None: func_only = item.config._env_timeout_func_only + if func_only is None: + func_only = False return Settings(timeout, method, func_only) diff --git a/test_pytest_timeout.py b/test_pytest_timeout.py index a7dcd20..1ff2213 100644 --- a/test_pytest_timeout.py +++ b/test_pytest_timeout.py @@ -514,7 +514,7 @@ def test_plugin_interface(testdir): import pytest @pytest.mark.tryfirst - def pytest_timeout_set_timer(item): + def pytest_timeout_set_timer(item, settings): print() print("pytest_timeout_set_timer") return True From 20adb8f941539463346dae11547bb5bc9a1da6e9 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 16 Jan 2022 13:53:50 +0200 Subject: [PATCH 6/8] Update README.rst Co-authored-by: Floris Bruynooghe --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6be6b7e..22562fc 100644 --- a/README.rst +++ b/README.rst @@ -240,7 +240,7 @@ session using ``--pdb`` or similar. Extending pytest-timeout with plugings ====================================== -``pytest-timeout`` provides two hooks that can be used for extending the tool. There +``pytest-timeout`` provides two hooks that can be used for extending the tool. These hooks are used for for setting the timeout timer and cancelling it it the timeout is not reached. From 6d1ab1c1a36f97fa17226986d251572af205bced Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 16 Jan 2022 13:54:06 +0200 Subject: [PATCH 7/8] Update README.rst Co-authored-by: Floris Bruynooghe --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 22562fc..bcdca87 100644 --- a/README.rst +++ b/README.rst @@ -303,7 +303,7 @@ The argument has ``Settings`` namedtuple type with the following fields: ``is_debugging`` ---------------- -The the timeout occurs, user can open the debugger session. In this case, the timeout +When the timeout occurs, user can open the debugger session. In this case, the timeout should be discarded. A custom hook can check this case by calling ``is_debugging()`` function:: From 3c8729681809b8b0a2bf480a8d9c8cd65a1f5f6e Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Sun, 16 Jan 2022 14:05:18 +0200 Subject: [PATCH 8/8] Extend readme --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index bcdca87..6cc2925 100644 --- a/README.rst +++ b/README.rst @@ -324,6 +324,8 @@ Unreleased - Get terminal width from shlib instead of deprecated py, thanks Andrew Svetlov. +- Add an API for extending ``pytest-timeout`` functionality + with third-party plugins, thanks Andrew Svetlov. 2.0.2 -----