Skip to content

Commit dd9d608

Browse files
authored
Add custom hooks specifications for overriding setup_timeout and teardown_timeout methods (#117)
It seems pytest-asyncio is interested in this. See e.g. pytest-dev/pytest-asyncio#216
1 parent ed8ecd6 commit dd9d608

File tree

3 files changed

+180
-36
lines changed

3 files changed

+180
-36
lines changed

README.rst

+81
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,85 @@ debugging frameworks modules OR if pytest itself drops you into a pdb
237237
session using ``--pdb`` or similar.
238238

239239

240+
Extending pytest-timeout with plugings
241+
======================================
242+
243+
``pytest-timeout`` provides two hooks that can be used for extending the tool. These
244+
hooks are used for for setting the timeout timer and cancelling it it the timeout is not
245+
reached.
246+
247+
For example, ``pytest-asyncio`` can provide asyncio-specific code that generates better
248+
traceback and points on timed out ``await`` instead of the running loop ieration.
249+
250+
See `pytest hooks documentation
251+
<https://docs.pytest.org/en/latest/how-to/writing_hook_functions.html>`_ for more info
252+
regarding to use custom hooks.
253+
254+
``pytest_timeout_set_timer``
255+
----------------------------
256+
257+
@pytest.hookspec(firstresult=True)
258+
def pytest_timeout_set_timer(item, settings):
259+
"""Called at timeout setup.
260+
261+
'item' is a pytest node to setup timeout for.
262+
263+
'settings' is Settings namedtuple (described below).
264+
265+
Can be overridden by plugins for alternative timeout implementation strategies.
266+
267+
"""
268+
269+
270+
``Settings``
271+
------------
272+
273+
When ``pytest_timeout_set_timer`` is called, ``settings`` argument is passed.
274+
275+
The argument has ``Settings`` namedtuple type with the following fields:
276+
277+
+-----------+-------+--------------------------------------------------------+
278+
|Attribute | Index | Value |
279+
+===========+=======+========================================================+
280+
| timeout | 0 | timeout in seconds or ``None`` for no timeout |
281+
+-----------+-------+--------------------------------------------------------+
282+
| method | 1 | Method mechanism, |
283+
| | | ``'signal'`` and ``'thread'`` are supported by default |
284+
+-----------+-------+--------------------------------------------------------+
285+
| func_only | 2 | Apply timeout to test function only if ``True``, |
286+
| | | wrap all test function and its fixtures otherwise |
287+
+-----------+-------+--------------------------------------------------------+
288+
289+
``pytest_timeout_cancel_timer``
290+
-------------------------------
291+
292+
293+
@pytest.hookspec(firstresult=True)
294+
def pytest_timeout_cancel_timer(item):
295+
"""Called at timeout teardown.
296+
297+
'item' is a pytest node which was used for timeout setup.
298+
299+
Can be overridden by plugins for alternative timeout implementation strategies.
300+
301+
"""
302+
303+
``is_debugging``
304+
----------------
305+
306+
When the timeout occurs, user can open the debugger session. In this case, the timeout
307+
should be discarded. A custom hook can check this case by calling ``is_debugging()``
308+
function::
309+
310+
import pytest
311+
import pytest_timeout
312+
313+
def on_timeout():
314+
if pytest_timeout.is_debugging():
315+
return
316+
pytest.fail("+++ Timeout +++")
317+
318+
240319
Changelog
241320
=========
242321

@@ -245,6 +324,8 @@ Unreleased
245324

246325
- Get terminal width from shutil instead of deprecated py, thanks
247326
Andrew Svetlov.
327+
- Add an API for extending ``pytest-timeout`` functionality
328+
with third-party plugins, thanks Andrew Svetlov.
248329

249330
2.0.2
250331
-----

pytest_timeout.py

+63-36
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import pytest
1919

2020

21+
__all__ = ("is_debugging", "Settings")
22+
23+
2124
HAVE_SIGALRM = hasattr(signal, "SIGALRM")
2225
if HAVE_SIGALRM:
2326
DEFAULT_METHOD = "signal"
@@ -70,6 +73,35 @@ def pytest_addoption(parser):
7073
parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool")
7174

7275

76+
class TimeoutHooks:
77+
"""Timeout specific hooks."""
78+
79+
@pytest.hookspec(firstresult=True)
80+
def pytest_timeout_set_timer(item, settings):
81+
"""Called at timeout setup.
82+
83+
'item' is a pytest node to setup timeout for.
84+
85+
Can be overridden by plugins for alternative timeout implementation strategies.
86+
87+
"""
88+
89+
@pytest.hookspec(firstresult=True)
90+
def pytest_timeout_cancel_timer(item):
91+
"""Called at timeout teardown.
92+
93+
'item' is a pytest node which was used for timeout setup.
94+
95+
Can be overridden by plugins for alternative timeout implementation strategies.
96+
97+
"""
98+
99+
100+
def pytest_addhooks(pluginmanager):
101+
"""Register timeout-specific hooks."""
102+
pluginmanager.add_hookspecs(TimeoutHooks)
103+
104+
73105
@pytest.hookimpl
74106
def pytest_configure(config):
75107
"""Register the marker so it shows up in --markers output."""
@@ -98,12 +130,14 @@ def pytest_runtest_protocol(item):
98130
teardown, then this hook installs the timeout. Otherwise
99131
pytest_runtest_call is used.
100132
"""
101-
func_only = get_func_only_setting(item)
102-
if func_only is False:
103-
timeout_setup(item)
133+
hooks = item.config.pluginmanager.hook
134+
settings = _get_item_settings(item)
135+
is_timeout = settings.timeout is not None and settings.timeout > 0
136+
if is_timeout and settings.func_only is False:
137+
hooks.pytest_timeout_set_timer(item=item, settings=settings)
104138
yield
105-
if func_only is False:
106-
timeout_teardown(item)
139+
if is_timeout and settings.func_only is False:
140+
hooks.pytest_timeout_cancel_timer(item=item)
107141

108142

109143
@pytest.hookimpl(hookwrapper=True)
@@ -113,12 +147,14 @@ def pytest_runtest_call(item):
113147
If the timeout is set on only the test function this hook installs
114148
the timeout, otherwise pytest_runtest_protocol is used.
115149
"""
116-
func_only = get_func_only_setting(item)
117-
if func_only is True:
118-
timeout_setup(item)
150+
hooks = item.config.pluginmanager.hook
151+
settings = _get_item_settings(item)
152+
is_timeout = settings.timeout is not None and settings.timeout > 0
153+
if is_timeout and settings.func_only is True:
154+
hooks.pytest_timeout_set_timer(item=item, settings=settings)
119155
yield
120-
if func_only is True:
121-
timeout_teardown(item)
156+
if is_timeout and settings.func_only is True:
157+
hooks.pytest_timeout_cancel_timer(item=item)
122158

123159

124160
@pytest.hookimpl(tryfirst=True)
@@ -138,7 +174,8 @@ def pytest_report_header(config):
138174
@pytest.hookimpl(tryfirst=True)
139175
def pytest_exception_interact(node):
140176
"""Stop the timeout when pytest enters pdb in post-mortem mode."""
141-
timeout_teardown(node)
177+
hooks = node.config.pluginmanager.hook
178+
hooks.pytest_timeout_cancel_timer(item=node)
142179

143180

144181
@pytest.hookimpl
@@ -187,13 +224,10 @@ def is_debugging(trace_func=None):
187224
SUPPRESS_TIMEOUT = False
188225

189226

190-
def timeout_setup(item):
227+
@pytest.hookimpl(trylast=True)
228+
def pytest_timeout_set_timer(item, settings):
191229
"""Setup up a timeout trigger and handler."""
192-
params = get_params(item)
193-
if params.timeout is None or params.timeout <= 0:
194-
return
195-
196-
timeout_method = params.method
230+
timeout_method = settings.method
197231
if (
198232
timeout_method == "signal"
199233
and threading.current_thread() is not threading.main_thread()
@@ -204,17 +238,19 @@ def timeout_setup(item):
204238

205239
def handler(signum, frame):
206240
__tracebackhide__ = True
207-
timeout_sigalrm(item, params.timeout)
241+
timeout_sigalrm(item, settings.timeout)
208242

209243
def cancel():
210244
signal.setitimer(signal.ITIMER_REAL, 0)
211245
signal.signal(signal.SIGALRM, signal.SIG_DFL)
212246

213247
item.cancel_timeout = cancel
214248
signal.signal(signal.SIGALRM, handler)
215-
signal.setitimer(signal.ITIMER_REAL, params.timeout)
249+
signal.setitimer(signal.ITIMER_REAL, settings.timeout)
216250
elif timeout_method == "thread":
217-
timer = threading.Timer(params.timeout, timeout_timer, (item, params.timeout))
251+
timer = threading.Timer(
252+
settings.timeout, timeout_timer, (item, settings.timeout)
253+
)
218254
timer.name = "%s %s" % (__name__, item.nodeid)
219255

220256
def cancel():
@@ -223,9 +259,11 @@ def cancel():
223259

224260
item.cancel_timeout = cancel
225261
timer.start()
262+
return True
226263

227264

228-
def timeout_teardown(item):
265+
@pytest.hookimpl(trylast=True)
266+
def pytest_timeout_cancel_timer(item):
229267
"""Cancel the timeout trigger if it was set."""
230268
# When skipping is raised from a pytest_runtest_setup function
231269
# (as is the case when using the pytest.mark.skipif marker) we
@@ -234,6 +272,7 @@ def timeout_teardown(item):
234272
cancel = getattr(item, "cancel_timeout", None)
235273
if cancel:
236274
cancel()
275+
return True
237276

238277

239278
def get_env_settings(config):
@@ -268,21 +307,7 @@ def get_env_settings(config):
268307
return Settings(timeout, method, func_only or False)
269308

270309

271-
def get_func_only_setting(item):
272-
"""Return the func_only setting for an item."""
273-
func_only = None
274-
marker = item.get_closest_marker("timeout")
275-
if marker:
276-
settings = get_params(item, marker=marker)
277-
func_only = _validate_func_only(settings.func_only, "marker")
278-
if func_only is None:
279-
func_only = item.config._env_timeout_func_only
280-
if func_only is None:
281-
func_only = False
282-
return func_only
283-
284-
285-
def get_params(item, marker=None):
310+
def _get_item_settings(item, marker=None):
286311
"""Return (timeout, method) for an item."""
287312
timeout = method = func_only = None
288313
if not marker:
@@ -298,6 +323,8 @@ def get_params(item, marker=None):
298323
method = item.config._env_timeout_method
299324
if func_only is None:
300325
func_only = item.config._env_timeout_func_only
326+
if func_only is None:
327+
func_only = False
301328
return Settings(timeout, method, func_only)
302329

303330

test_pytest_timeout.py

+36
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,39 @@ def test_x(): pass
506506
result.stdout.fnmatch_lines(
507507
["timeout: 1.0s", "timeout method:*", "timeout func_only:*"]
508508
)
509+
510+
511+
def test_plugin_interface(testdir):
512+
testdir.makeconftest(
513+
"""
514+
import pytest
515+
516+
@pytest.mark.tryfirst
517+
def pytest_timeout_set_timer(item, settings):
518+
print()
519+
print("pytest_timeout_set_timer")
520+
return True
521+
522+
@pytest.mark.tryfirst
523+
def pytest_timeout_cancel_timer(item):
524+
print()
525+
print("pytest_timeout_cancel_timer")
526+
return True
527+
"""
528+
)
529+
testdir.makepyfile(
530+
"""
531+
import pytest
532+
533+
@pytest.mark.timeout(1)
534+
def test_foo():
535+
pass
536+
"""
537+
)
538+
result = testdir.runpytest("-s")
539+
result.stdout.fnmatch_lines(
540+
[
541+
"pytest_timeout_set_timer",
542+
"pytest_timeout_cancel_timer",
543+
]
544+
)

0 commit comments

Comments
 (0)