Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add --session-timeout #165

Merged
merged 15 commits into from
Mar 7, 2024
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ docs/_build/

# Virtual Envs
.env*
venv

# IDE
.idea
Expand Down
51 changes: 51 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,55 @@ function:
pytest.fail("+++ Timeout +++")



Session Timeout
===============

The above mentioned timeouts are all per test function.
The "per test function" timeouts will stop an individual test
from taking too long. We may also want to limit the time of the entire
set of tests running in one session. A session all of the tests
that will be run with one invokation of pytest.

A session timeout is set with `--session-timeout` and is in seconds.

The following example shows a session timeout of 10 minutes (600 seconds)::

pytest --session-timeout=600

You can also set the session timeout the pytest configuration file using the ``session_timeout`` option:

.. code:: ini

[pytest]
session_timeout = 600

Cooperative timeouts
-----------------

Session timeouts are cooperative timeouts. pytest-timeout checks the
session time at the end of each test function, and stops further tests
from running if the session timeout is exceeded. The session will
results in a test failure if this occurs.

In particular this means if a test does not finish of itself, it will
only be interrupted if there is also a function timeout set. A
session timeout is not enough to ensure that a test-suite is
guaranteed to finish.

Combining session and function timeouts
---------------------------------------

It works fine to combine both session and function timeouts. In fact
when using a session timeout it is recommended to also provide a
function timeout.

For example, to limit test functions to 5 seconds and the full session
to 100 seconds::

pytest --timeout=5 --session-timeout=100


Changelog
=========

Expand All @@ -353,6 +402,8 @@ Unreleased
This change also switches all output from ``sys.stderr`` to ``sys.stdout``.
Thanks Pedro Algarvio.
- Pytest 7.0.0 is now the minimum supported version. Thanks Pedro Algarvio.
- Add ``--session-timeout`` option and ``session_timeout`` setting.
Thanks Brian Okken.

2.2.0
-----
Expand Down
49 changes: 47 additions & 2 deletions pytest_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
import signal
import sys
import threading
import time
import traceback
from collections import namedtuple

import pytest


__all__ = ("is_debugging", "Settings")
SESSION_TIMEOUT_KEY = pytest.StashKey[float]()
SESSION_EXPIRE_KEY = pytest.StashKey[float]()


HAVE_SIGALRM = hasattr(signal, "SIGALRM")
Expand All @@ -43,6 +46,11 @@
When specified, disables debugger detection. breakpoint(), pdb.set_trace(), etc.
will be interrupted by the timeout.
""".strip()
SESSION_TIMEOUT_DESC = """
Timeout in seconds for entire session. Default is None which
means no timeout. Timeout is checked between tests, and will not interrupt a test
in progress.
""".strip()

# bdb covers pdb, ipdb, and possibly others
# pydevd covers PyCharm, VSCode, and possibly others
Expand Down Expand Up @@ -79,6 +87,15 @@ def pytest_addoption(parser):
action="store_true",
help=DISABLE_DEBUGGER_DETECTION_DESC,
)
group.addoption(
"--session-timeout",
action="store",
dest="session_timeout",
default=None,
type=float,
metavar="SECONDS",
help=SESSION_TIMEOUT_DESC,
)
parser.addini("timeout", TIMEOUT_DESC)
parser.addini("timeout_method", METHOD_DESC)
parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool", default=False)
Expand All @@ -88,6 +105,7 @@ def pytest_addoption(parser):
type="bool",
default=False,
)
parser.addini("session_timeout", SESSION_TIMEOUT_DESC)


class TimeoutHooks:
Expand Down Expand Up @@ -143,6 +161,19 @@ def pytest_configure(config):
config._env_timeout_func_only = settings.func_only
config._env_timeout_disable_debugger_detection = settings.disable_debugger_detection

timeout = config.getoption("session_timeout")
if timeout is None:
ini = config.getini("session_timeout")
if ini:
timeout = _validate_timeout(config.getini("session_timeout"), "config file")
if timeout is not None:
expire_time = time.time() + timeout
else:
expire_time = 0
timeout = 0
config.stash[SESSION_TIMEOUT_KEY] = timeout
config.stash[SESSION_EXPIRE_KEY] = expire_time


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item):
Expand All @@ -161,6 +192,12 @@ def pytest_runtest_protocol(item):
if is_timeout and settings.func_only is False:
hooks.pytest_timeout_cancel_timer(item=item)

# check session timeout
expire_time = item.session.config.stash[SESSION_EXPIRE_KEY]
if expire_time and (expire_time < time.time()):
timeout = item.session.config.stash[SESSION_TIMEOUT_KEY]
item.session.shouldfail = f"session-timeout: {timeout} sec exceeded"


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
Expand All @@ -182,15 +219,23 @@ def pytest_runtest_call(item):
@pytest.hookimpl(tryfirst=True)
def pytest_report_header(config):
"""Add timeout config to pytest header."""
timeout_header = []

if config._env_timeout:
return [
timeout_header.append(
"timeout: %ss\ntimeout method: %s\ntimeout func_only: %s"
% (
config._env_timeout,
config._env_timeout_method,
config._env_timeout_func_only,
)
]
)

session_timeout = config.getoption("session_timeout")
if session_timeout:
timeout_header.append("session timeout: %ss" % session_timeout)
if timeout_header:
return timeout_header


@pytest.hookimpl(tryfirst=True)
Expand Down
59 changes: 57 additions & 2 deletions test_pytest_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ def test_header(pytester):
def test_x(): pass
"""
)
result = pytester.runpytest_subprocess("--timeout=1")
result = pytester.runpytest_subprocess("--timeout=1", "--session-timeout=2")
result.stdout.fnmatch_lines(
["timeout: 1.0s", "timeout method:*", "timeout func_only:*"]
[
"timeout: 1.0s",
"timeout method:*",
"timeout func_only:*",
"session timeout: 2.0s",
]
)


Expand Down Expand Up @@ -603,3 +608,53 @@ def test_foo():
"pytest_timeout_cancel_timer",
]
)


def test_session_timeout(pytester):
# This is designed to timeout during hte first test to ensure
# - the first test still runs to completion
# - the second test is not started
pytester.makepyfile(
"""
import time, pytest

@pytest.fixture()
def slow_setup_and_teardown():
time.sleep(1)
yield
time.sleep(1)

def test_one(slow_setup_and_teardown):
time.sleep(1)

def test_two(slow_setup_and_teardown):
time.sleep(1)
"""
)
result = pytester.runpytest_subprocess("--session-timeout", "2")
result.stdout.fnmatch_lines(["*!! session-timeout: 2.0 sec exceeded !!!*"])
# This would be 2 passed if the second test was allowed to run
result.assert_outcomes(passed=1)


def test_ini_session_timeout(pytester):
pytester.makepyfile(
"""
import time

def test_one():
time.sleep(2)

def test_two():
time.sleep(2)
"""
)
pytester.makeini(
"""
[pytest]
session_timeout = 1
"""
)
result = pytester.runpytest_subprocess()
result.stdout.fnmatch_lines(["*!! session-timeout: 1.0 sec exceeded !!!*"])
result.assert_outcomes(passed=1)
Loading