Merge pull request #4078 from altendky/4073-altendky-subprocessing_timeout

Add timeout for Testdir.runpytest_subprocess() and Testdir.run()
This commit is contained in:
Kyle Altendorf 2018-10-07 18:07:06 -04:00 committed by GitHub
commit a6fb4c8268
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 91 additions and 3 deletions

View File

@ -121,6 +121,7 @@ Katerina Koukiou
Kevin Cox Kevin Cox
Kodi B. Arfer Kodi B. Arfer
Kostis Anagnostopoulos Kostis Anagnostopoulos
Kyle Altendorf
Lawrence Mitchell Lawrence Mitchell
Lee Kamentsky Lee Kamentsky
Lev Maximov Lev Maximov

View File

@ -0,0 +1 @@
Allow specification of timeout for ``Testdir.runpytest_subprocess()`` and ``Testdir.run()``.

View File

@ -61,6 +61,11 @@ def pytest_configure(config):
config.pluginmanager.register(checker) config.pluginmanager.register(checker)
def raise_on_kwargs(kwargs):
if kwargs:
raise TypeError("Unexpected arguments: {}".format(", ".join(sorted(kwargs))))
class LsofFdLeakChecker(object): class LsofFdLeakChecker(object):
def get_open_files(self): def get_open_files(self):
out = self._exec_lsof() out = self._exec_lsof()
@ -482,6 +487,9 @@ class Testdir(object):
""" """
class TimeoutExpired(Exception):
pass
def __init__(self, request, tmpdir_factory): def __init__(self, request, tmpdir_factory):
self.request = request self.request = request
self._mod_collections = WeakKeyDictionary() self._mod_collections = WeakKeyDictionary()
@ -1039,14 +1047,23 @@ class Testdir(object):
return popen return popen
def run(self, *cmdargs): def run(self, *cmdargs, **kwargs):
"""Run a command with arguments. """Run a command with arguments.
Run a process using subprocess.Popen saving the stdout and stderr. Run a process using subprocess.Popen saving the stdout and stderr.
:param args: the sequence of arguments to pass to `subprocess.Popen()`
:param timeout: the period in seconds after which to timeout and raise
:py:class:`Testdir.TimeoutExpired`
Returns a :py:class:`RunResult`. Returns a :py:class:`RunResult`.
""" """
__tracebackhide__ = True
timeout = kwargs.pop("timeout", None)
raise_on_kwargs(kwargs)
cmdargs = [ cmdargs = [
str(arg) if isinstance(arg, py.path.local) else arg for arg in cmdargs str(arg) if isinstance(arg, py.path.local) else arg for arg in cmdargs
] ]
@ -1061,7 +1078,40 @@ class Testdir(object):
popen = self.popen( popen = self.popen(
cmdargs, stdout=f1, stderr=f2, close_fds=(sys.platform != "win32") cmdargs, stdout=f1, stderr=f2, close_fds=(sys.platform != "win32")
) )
def handle_timeout():
__tracebackhide__ = True
timeout_message = (
"{seconds} second timeout expired running:"
" {command}".format(seconds=timeout, command=cmdargs)
)
popen.kill()
popen.wait()
raise self.TimeoutExpired(timeout_message)
if timeout is None:
ret = popen.wait() ret = popen.wait()
elif six.PY3:
try:
ret = popen.wait(timeout)
except subprocess.TimeoutExpired:
handle_timeout()
else:
end = time.time() + timeout
resolution = min(0.1, timeout / 10)
while True:
ret = popen.poll()
if ret is not None:
break
if time.time() > end:
handle_timeout()
time.sleep(resolution)
finally: finally:
f1.close() f1.close()
f2.close() f2.close()
@ -1108,9 +1158,15 @@ class Testdir(object):
with "runpytest-" so they do not conflict with the normal numbered with "runpytest-" so they do not conflict with the normal numbered
pytest location for temporary files and directories. pytest location for temporary files and directories.
:param args: the sequence of arguments to pass to the pytest subprocess
:param timeout: the period in seconds after which to timeout and raise
:py:class:`Testdir.TimeoutExpired`
Returns a :py:class:`RunResult`. Returns a :py:class:`RunResult`.
""" """
__tracebackhide__ = True
p = py.path.local.make_numbered_dir( p = py.path.local.make_numbered_dir(
prefix="runpytest-", keep=None, rootdir=self.tmpdir prefix="runpytest-", keep=None, rootdir=self.tmpdir
) )
@ -1119,7 +1175,7 @@ class Testdir(object):
if plugins: if plugins:
args = ("-p", plugins[0]) + args args = ("-p", plugins[0]) + args
args = self._getpytestargs() + args args = self._getpytestargs() + args
return self.run(*args) return self.run(*args, timeout=kwargs.get("timeout"))
def spawn_pytest(self, string, expect_timeout=10.0): def spawn_pytest(self, string, expect_timeout=10.0):
"""Run pytest using pexpect. """Run pytest using pexpect.

View File

@ -4,6 +4,7 @@ import os
import py.path import py.path
import pytest import pytest
import sys import sys
import time
import _pytest.pytester as pytester import _pytest.pytester as pytester
from _pytest.pytester import HookRecorder from _pytest.pytester import HookRecorder
from _pytest.pytester import CwdSnapshot, SysModulesSnapshot, SysPathsSnapshot from _pytest.pytester import CwdSnapshot, SysModulesSnapshot, SysPathsSnapshot
@ -401,3 +402,32 @@ def test_testdir_subprocess(testdir):
def test_unicode_args(testdir): def test_unicode_args(testdir):
result = testdir.runpytest("-k", u"💩") result = testdir.runpytest("-k", u"💩")
assert result.ret == EXIT_NOTESTSCOLLECTED assert result.ret == EXIT_NOTESTSCOLLECTED
def test_testdir_run_no_timeout(testdir):
testfile = testdir.makepyfile("def test_no_timeout(): pass")
assert testdir.runpytest_subprocess(testfile).ret == EXIT_OK
def test_testdir_run_with_timeout(testdir):
testfile = testdir.makepyfile("def test_no_timeout(): pass")
start = time.time()
result = testdir.runpytest_subprocess(testfile, timeout=120)
end = time.time()
duration = end - start
assert result.ret == EXIT_OK
assert duration < 5
def test_testdir_run_timeout_expires(testdir):
testfile = testdir.makepyfile(
"""
import time
def test_timeout():
time.sleep(10)"""
)
with pytest.raises(testdir.TimeoutExpired):
testdir.runpytest_subprocess(testfile, timeout=1)