Skip to content

Commit

Permalink
add --disable-debugger-detection flag
Browse files Browse the repository at this point in the history
  • Loading branch information
Michael Peters authored and flub committed Feb 3, 2023
1 parent 7d4c413 commit 389a669
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 19 deletions.
4 changes: 4 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ check to see if the module it belongs to is present in a set of known
debugging frameworks modules OR if pytest itself drops you into a pdb
session using ``--pdb`` or similar.

This functionality can be disabled with the ``--disable-debugger-detection`` flag
or the corresponding ``timeout_disable_debugger_detection`` ini setting / environment
variable.


Extending pytest-timeout with plugins
=====================================
Expand Down
94 changes: 75 additions & 19 deletions pytest_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,17 @@
function body, ignoring the time it takes when evaluating any fixtures
used in the test.
""".strip()
DISABLE_DEBUGGER_DETECTION_DESC = """
When specified, disables debugger detection. breakpoint(), pdb.set_trace(), etc.
will be interrupted.
""".strip()

# bdb covers pdb, ipdb, and possibly others
# pydevd covers PyCharm, VSCode, and possibly others
KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb", "pydevd_frame_evaluator"}
Settings = namedtuple("Settings", ["timeout", "method", "func_only"])
Settings = namedtuple(
"Settings", ["timeout", "method", "func_only", "disable_debugger_detection"]
)


@pytest.hookimpl
Expand All @@ -68,9 +74,21 @@ def pytest_addoption(parser):
choices=["signal", "thread"],
help=METHOD_DESC,
)
group.addoption(
"--disable-debugger-detection",
dest="timeout_disable_debugger_detection",
action="store_true",
help=DISABLE_DEBUGGER_DETECTION_DESC,
)
parser.addini("timeout", TIMEOUT_DESC)
parser.addini("timeout_method", METHOD_DESC)
parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool", default=False)
parser.addini(
"timeout_disable_debugger_detection",
DISABLE_DEBUGGER_DETECTION_DESC,
type="bool",
default=False,
)


class TimeoutHooks:
Expand Down Expand Up @@ -107,19 +125,24 @@ def pytest_configure(config):
"""Register the marker so it shows up in --markers output."""
config.addinivalue_line(
"markers",
"timeout(timeout, method=None, func_only=False): Set a timeout, timeout "
"timeout(timeout, method=None, func_only=False, "
"disable_debugger_detection=False): Set a timeout, timeout "
"method and func_only evaluation on just one test item. The first "
"argument, *timeout*, is the timeout in seconds while the keyword, "
"*method*, takes the same values as the --timeout_method option. The "
"*method*, takes the same values as the --timeout-method option. The "
"*func_only* keyword, when set to True, defers the timeout evaluation "
"to only the test function body, ignoring the time it takes when "
"evaluating any fixtures used in the test.",
"evaluating any fixtures used in the test. The "
"*disable_debugger_detection* keyword, when set to True, disables "
"debugger detection, allowing breakpoint(), pdb.set_trace(), etc. "
"to be interrupted",
)

settings = get_env_settings(config)
config._env_timeout = settings.timeout
config._env_timeout_method = settings.method
config._env_timeout_func_only = settings.func_only
config._env_timeout_disable_debugger_detection = settings.disable_debugger_detection


@pytest.hookimpl(hookwrapper=True)
Expand Down Expand Up @@ -238,7 +261,7 @@ def pytest_timeout_set_timer(item, settings):

def handler(signum, frame):
__tracebackhide__ = True
timeout_sigalrm(item, settings.timeout)
timeout_sigalrm(item, settings)

def cancel():
signal.setitimer(signal.ITIMER_REAL, 0)
Expand All @@ -248,9 +271,7 @@ def cancel():
signal.signal(signal.SIGALRM, handler)
signal.setitimer(signal.ITIMER_REAL, settings.timeout)
elif timeout_method == "thread":
timer = threading.Timer(
settings.timeout, timeout_timer, (item, settings.timeout)
)
timer = threading.Timer(settings.timeout, timeout_timer, (item, settings))
timer.name = "%s %s" % (__name__, item.nodeid)

def cancel():
Expand Down Expand Up @@ -299,26 +320,40 @@ def get_env_settings(config):
method = DEFAULT_METHOD

func_only = config.getini("timeout_func_only")
return Settings(timeout, method, func_only)

disable_debugger_detection = config.getvalue("timeout_disable_debugger_detection")
if disable_debugger_detection is None:
ini = config.getini("timeout_disable_debugger_detection")
if ini:
disable_debugger_detection = _validate_disable_debugger_detection(
ini, "config file"
)

return Settings(timeout, method, func_only, disable_debugger_detection)


def _get_item_settings(item, marker=None):
"""Return (timeout, method) for an item."""
timeout = method = func_only = None
timeout = method = func_only = disable_debugger_detection = None
if not marker:
marker = item.get_closest_marker("timeout")
if marker is not None:
settings = _parse_marker(item.get_closest_marker(name="timeout"))
timeout = _validate_timeout(settings.timeout, "marker")
method = _validate_method(settings.method, "marker")
func_only = _validate_func_only(settings.func_only, "marker")
disable_debugger_detection = _validate_disable_debugger_detection(
settings.disable_debugger_detection, "marker"
)
if timeout is None:
timeout = item.config._env_timeout
if method is None:
method = item.config._env_timeout_method
if func_only is None:
func_only = item.config._env_timeout_func_only
return Settings(timeout, method, func_only)
if disable_debugger_detection is None:
disable_debugger_detection = item.config._env_timeout_disable_debugger_detection
return Settings(timeout, method, func_only, disable_debugger_detection)


def _parse_marker(marker):
Expand All @@ -329,14 +364,16 @@ def _parse_marker(marker):
"""
if not marker.args and not marker.kwargs:
raise TypeError("Timeout marker must have at least one argument")
timeout = method = func_only = NOTSET = object()
timeout = method = func_only = disable_debugger_detection = NOTSET = object()
for kw, val in marker.kwargs.items():
if kw == "timeout":
timeout = val
elif kw == "method":
method = val
elif kw == "func_only":
func_only = val
elif kw == "disable_debugger_detection":
disable_debugger_detection = val
else:
raise TypeError("Invalid keyword argument for timeout marker: %s" % kw)
if len(marker.args) >= 1 and timeout is not NOTSET:
Expand All @@ -347,15 +384,23 @@ def _parse_marker(marker):
raise TypeError("Multiple values for method argument of timeout marker")
elif len(marker.args) >= 2:
method = marker.args[1]
if len(marker.args) > 2:
if len(marker.args) >= 3 and disable_debugger_detection is not NOTSET:
raise TypeError(
"Multiple values for disable_debugger_detection argument of timeout marker"
)
elif len(marker.args) >= 3:
disable_debugger_detection = marker.args[2]
if len(marker.args) > 3:
raise TypeError("Too many arguments for timeout marker")
if timeout is NOTSET:
timeout = None
if method is NOTSET:
method = None
if func_only is NOTSET:
func_only = None
return Settings(timeout, method, func_only)
if disable_debugger_detection is NOTSET:
disable_debugger_detection = None
return Settings(timeout, method, func_only, disable_debugger_detection)


def _validate_timeout(timeout, where):
Expand Down Expand Up @@ -383,14 +428,25 @@ def _validate_func_only(func_only, where):
return func_only


def timeout_sigalrm(item, timeout):
def _validate_disable_debugger_detection(disable_debugger_detection, where):
if disable_debugger_detection is None:
return None
if not isinstance(disable_debugger_detection, bool):
raise ValueError(
"Invalid disable_debugger_detection value %s from %s"
% (disable_debugger_detection, where)
)
return disable_debugger_detection


def timeout_sigalrm(item, settings):
"""Dump stack of threads and raise an exception.
This will output the stacks of any threads other then the
current to stderr and then raise an AssertionError, thus
terminating the test.
"""
if is_debugging():
if not settings.disable_debugger_detection and is_debugging():
return
__tracebackhide__ = True
nthreads = len(threading.enumerate())
Expand All @@ -399,16 +455,16 @@ def timeout_sigalrm(item, timeout):
dump_stacks()
if nthreads > 1:
write_title("Timeout", sep="+")
pytest.fail("Timeout >%ss" % timeout)
pytest.fail("Timeout >%ss" % settings.timeout)


def timeout_timer(item, timeout):
def timeout_timer(item, settings):
"""Dump stack of threads and call os._exit().
This disables the capturemanager and dumps stdout and stderr.
Then the stacks are dumped and os._exit(1) is called.
"""
if is_debugging():
if not settings.disable_debugger_detection and is_debugging():
return
try:
capman = item.config.pluginmanager.getplugin("capturemanager")
Expand Down
88 changes: 88 additions & 0 deletions test_pytest_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,94 @@ def test_foo():
assert "fail" not in result


@pytest.mark.parametrize(
["debugging_module", "debugging_set_trace"],
[
("pdb", "set_trace()"),
pytest.param(
"ipdb",
"set_trace()",
marks=pytest.mark.xfail(
reason="waiting on https://github.com/pytest-dev/pytest/pull/7207"
" to allow proper testing"
),
),
pytest.param(
"pydevd",
"settrace(port=4678)",
marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"),
),
],
)
@have_spawn
def test_disable_debugger_detection_flag(
testdir, debugging_module, debugging_set_trace
):
p1 = testdir.makepyfile(
"""
import pytest, {debugging_module}
@pytest.mark.timeout(1)
def test_foo():
{debugging_module}.{debugging_set_trace}
""".format(
debugging_module=debugging_module, debugging_set_trace=debugging_set_trace
)
)
child = testdir.spawn_pytest(f"{p1} --disable-debugger-detection")
child.expect("test_foo")
time.sleep(1.2)
result = child.read().decode().lower()
if child.isalive():
child.terminate(force=True)
assert "timeout >1.0s" in result
assert "fail" in result


@pytest.mark.parametrize(
["debugging_module", "debugging_set_trace"],
[
("pdb", "set_trace()"),
pytest.param(
"ipdb",
"set_trace()",
marks=pytest.mark.xfail(
reason="waiting on https://github.com/pytest-dev/pytest/pull/7207"
" to allow proper testing"
),
),
pytest.param(
"pydevd",
"settrace(port=4678)",
marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"),
),
],
)
@have_spawn
def test_disable_debugger_detection_marker(
testdir, debugging_module, debugging_set_trace
):
p1 = testdir.makepyfile(
"""
import pytest, {debugging_module}
@pytest.mark.timeout(1, disable_debugger_detection=True)
def test_foo():
{debugging_module}.{debugging_set_trace}
""".format(
debugging_module=debugging_module, debugging_set_trace=debugging_set_trace
)
)
child = testdir.spawn_pytest(str(p1))
child.expect("test_foo")
time.sleep(1.2)
result = child.read().decode().lower()
if child.isalive():
child.terminate(force=True)
assert "timeout >1.0s" in result
assert "fail" in result


def test_is_debugging(monkeypatch):
import pytest_timeout

Expand Down

0 comments on commit 389a669

Please sign in to comment.