Skip to content

Commit 389a669

Browse files
Michael Petersflub
authored andcommitted
add --disable-debugger-detection flag
1 parent 7d4c413 commit 389a669

File tree

3 files changed

+167
-19
lines changed

3 files changed

+167
-19
lines changed

README.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ check to see if the module it belongs to is present in a set of known
241241
debugging frameworks modules OR if pytest itself drops you into a pdb
242242
session using ``--pdb`` or similar.
243243

244+
This functionality can be disabled with the ``--disable-debugger-detection`` flag
245+
or the corresponding ``timeout_disable_debugger_detection`` ini setting / environment
246+
variable.
247+
244248

245249
Extending pytest-timeout with plugins
246250
=====================================

pytest_timeout.py

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,17 @@
4040
function body, ignoring the time it takes when evaluating any fixtures
4141
used in the test.
4242
""".strip()
43+
DISABLE_DEBUGGER_DETECTION_DESC = """
44+
When specified, disables debugger detection. breakpoint(), pdb.set_trace(), etc.
45+
will be interrupted.
46+
""".strip()
4347

4448
# bdb covers pdb, ipdb, and possibly others
4549
# pydevd covers PyCharm, VSCode, and possibly others
4650
KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb", "pydevd_frame_evaluator"}
47-
Settings = namedtuple("Settings", ["timeout", "method", "func_only"])
51+
Settings = namedtuple(
52+
"Settings", ["timeout", "method", "func_only", "disable_debugger_detection"]
53+
)
4854

4955

5056
@pytest.hookimpl
@@ -68,9 +74,21 @@ def pytest_addoption(parser):
6874
choices=["signal", "thread"],
6975
help=METHOD_DESC,
7076
)
77+
group.addoption(
78+
"--disable-debugger-detection",
79+
dest="timeout_disable_debugger_detection",
80+
action="store_true",
81+
help=DISABLE_DEBUGGER_DETECTION_DESC,
82+
)
7183
parser.addini("timeout", TIMEOUT_DESC)
7284
parser.addini("timeout_method", METHOD_DESC)
7385
parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool", default=False)
86+
parser.addini(
87+
"timeout_disable_debugger_detection",
88+
DISABLE_DEBUGGER_DETECTION_DESC,
89+
type="bool",
90+
default=False,
91+
)
7492

7593

7694
class TimeoutHooks:
@@ -107,19 +125,24 @@ def pytest_configure(config):
107125
"""Register the marker so it shows up in --markers output."""
108126
config.addinivalue_line(
109127
"markers",
110-
"timeout(timeout, method=None, func_only=False): Set a timeout, timeout "
128+
"timeout(timeout, method=None, func_only=False, "
129+
"disable_debugger_detection=False): Set a timeout, timeout "
111130
"method and func_only evaluation on just one test item. The first "
112131
"argument, *timeout*, is the timeout in seconds while the keyword, "
113-
"*method*, takes the same values as the --timeout_method option. The "
132+
"*method*, takes the same values as the --timeout-method option. The "
114133
"*func_only* keyword, when set to True, defers the timeout evaluation "
115134
"to only the test function body, ignoring the time it takes when "
116-
"evaluating any fixtures used in the test.",
135+
"evaluating any fixtures used in the test. The "
136+
"*disable_debugger_detection* keyword, when set to True, disables "
137+
"debugger detection, allowing breakpoint(), pdb.set_trace(), etc. "
138+
"to be interrupted",
117139
)
118140

119141
settings = get_env_settings(config)
120142
config._env_timeout = settings.timeout
121143
config._env_timeout_method = settings.method
122144
config._env_timeout_func_only = settings.func_only
145+
config._env_timeout_disable_debugger_detection = settings.disable_debugger_detection
123146

124147

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

239262
def handler(signum, frame):
240263
__tracebackhide__ = True
241-
timeout_sigalrm(item, settings.timeout)
264+
timeout_sigalrm(item, settings)
242265

243266
def cancel():
244267
signal.setitimer(signal.ITIMER_REAL, 0)
@@ -248,9 +271,7 @@ def cancel():
248271
signal.signal(signal.SIGALRM, handler)
249272
signal.setitimer(signal.ITIMER_REAL, settings.timeout)
250273
elif timeout_method == "thread":
251-
timer = threading.Timer(
252-
settings.timeout, timeout_timer, (item, settings.timeout)
253-
)
274+
timer = threading.Timer(settings.timeout, timeout_timer, (item, settings))
254275
timer.name = "%s %s" % (__name__, item.nodeid)
255276

256277
def cancel():
@@ -299,26 +320,40 @@ def get_env_settings(config):
299320
method = DEFAULT_METHOD
300321

301322
func_only = config.getini("timeout_func_only")
302-
return Settings(timeout, method, func_only)
323+
324+
disable_debugger_detection = config.getvalue("timeout_disable_debugger_detection")
325+
if disable_debugger_detection is None:
326+
ini = config.getini("timeout_disable_debugger_detection")
327+
if ini:
328+
disable_debugger_detection = _validate_disable_debugger_detection(
329+
ini, "config file"
330+
)
331+
332+
return Settings(timeout, method, func_only, disable_debugger_detection)
303333

304334

305335
def _get_item_settings(item, marker=None):
306336
"""Return (timeout, method) for an item."""
307-
timeout = method = func_only = None
337+
timeout = method = func_only = disable_debugger_detection = None
308338
if not marker:
309339
marker = item.get_closest_marker("timeout")
310340
if marker is not None:
311341
settings = _parse_marker(item.get_closest_marker(name="timeout"))
312342
timeout = _validate_timeout(settings.timeout, "marker")
313343
method = _validate_method(settings.method, "marker")
314344
func_only = _validate_func_only(settings.func_only, "marker")
345+
disable_debugger_detection = _validate_disable_debugger_detection(
346+
settings.disable_debugger_detection, "marker"
347+
)
315348
if timeout is None:
316349
timeout = item.config._env_timeout
317350
if method is None:
318351
method = item.config._env_timeout_method
319352
if func_only is None:
320353
func_only = item.config._env_timeout_func_only
321-
return Settings(timeout, method, func_only)
354+
if disable_debugger_detection is None:
355+
disable_debugger_detection = item.config._env_timeout_disable_debugger_detection
356+
return Settings(timeout, method, func_only, disable_debugger_detection)
322357

323358

324359
def _parse_marker(marker):
@@ -329,14 +364,16 @@ def _parse_marker(marker):
329364
"""
330365
if not marker.args and not marker.kwargs:
331366
raise TypeError("Timeout marker must have at least one argument")
332-
timeout = method = func_only = NOTSET = object()
367+
timeout = method = func_only = disable_debugger_detection = NOTSET = object()
333368
for kw, val in marker.kwargs.items():
334369
if kw == "timeout":
335370
timeout = val
336371
elif kw == "method":
337372
method = val
338373
elif kw == "func_only":
339374
func_only = val
375+
elif kw == "disable_debugger_detection":
376+
disable_debugger_detection = val
340377
else:
341378
raise TypeError("Invalid keyword argument for timeout marker: %s" % kw)
342379
if len(marker.args) >= 1 and timeout is not NOTSET:
@@ -347,15 +384,23 @@ def _parse_marker(marker):
347384
raise TypeError("Multiple values for method argument of timeout marker")
348385
elif len(marker.args) >= 2:
349386
method = marker.args[1]
350-
if len(marker.args) > 2:
387+
if len(marker.args) >= 3 and disable_debugger_detection is not NOTSET:
388+
raise TypeError(
389+
"Multiple values for disable_debugger_detection argument of timeout marker"
390+
)
391+
elif len(marker.args) >= 3:
392+
disable_debugger_detection = marker.args[2]
393+
if len(marker.args) > 3:
351394
raise TypeError("Too many arguments for timeout marker")
352395
if timeout is NOTSET:
353396
timeout = None
354397
if method is NOTSET:
355398
method = None
356399
if func_only is NOTSET:
357400
func_only = None
358-
return Settings(timeout, method, func_only)
401+
if disable_debugger_detection is NOTSET:
402+
disable_debugger_detection = None
403+
return Settings(timeout, method, func_only, disable_debugger_detection)
359404

360405

361406
def _validate_timeout(timeout, where):
@@ -383,14 +428,25 @@ def _validate_func_only(func_only, where):
383428
return func_only
384429

385430

386-
def timeout_sigalrm(item, timeout):
431+
def _validate_disable_debugger_detection(disable_debugger_detection, where):
432+
if disable_debugger_detection is None:
433+
return None
434+
if not isinstance(disable_debugger_detection, bool):
435+
raise ValueError(
436+
"Invalid disable_debugger_detection value %s from %s"
437+
% (disable_debugger_detection, where)
438+
)
439+
return disable_debugger_detection
440+
441+
442+
def timeout_sigalrm(item, settings):
387443
"""Dump stack of threads and raise an exception.
388444
389445
This will output the stacks of any threads other then the
390446
current to stderr and then raise an AssertionError, thus
391447
terminating the test.
392448
"""
393-
if is_debugging():
449+
if not settings.disable_debugger_detection and is_debugging():
394450
return
395451
__tracebackhide__ = True
396452
nthreads = len(threading.enumerate())
@@ -399,16 +455,16 @@ def timeout_sigalrm(item, timeout):
399455
dump_stacks()
400456
if nthreads > 1:
401457
write_title("Timeout", sep="+")
402-
pytest.fail("Timeout >%ss" % timeout)
458+
pytest.fail("Timeout >%ss" % settings.timeout)
403459

404460

405-
def timeout_timer(item, timeout):
461+
def timeout_timer(item, settings):
406462
"""Dump stack of threads and call os._exit().
407463
408464
This disables the capturemanager and dumps stdout and stderr.
409465
Then the stacks are dumped and os._exit(1) is called.
410466
"""
411-
if is_debugging():
467+
if not settings.disable_debugger_detection and is_debugging():
412468
return
413469
try:
414470
capman = item.config.pluginmanager.getplugin("capturemanager")

test_pytest_timeout.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,94 @@ def test_foo():
486486
assert "fail" not in result
487487

488488

489+
@pytest.mark.parametrize(
490+
["debugging_module", "debugging_set_trace"],
491+
[
492+
("pdb", "set_trace()"),
493+
pytest.param(
494+
"ipdb",
495+
"set_trace()",
496+
marks=pytest.mark.xfail(
497+
reason="waiting on https://github.com/pytest-dev/pytest/pull/7207"
498+
" to allow proper testing"
499+
),
500+
),
501+
pytest.param(
502+
"pydevd",
503+
"settrace(port=4678)",
504+
marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"),
505+
),
506+
],
507+
)
508+
@have_spawn
509+
def test_disable_debugger_detection_flag(
510+
testdir, debugging_module, debugging_set_trace
511+
):
512+
p1 = testdir.makepyfile(
513+
"""
514+
import pytest, {debugging_module}
515+
516+
@pytest.mark.timeout(1)
517+
def test_foo():
518+
{debugging_module}.{debugging_set_trace}
519+
""".format(
520+
debugging_module=debugging_module, debugging_set_trace=debugging_set_trace
521+
)
522+
)
523+
child = testdir.spawn_pytest(f"{p1} --disable-debugger-detection")
524+
child.expect("test_foo")
525+
time.sleep(1.2)
526+
result = child.read().decode().lower()
527+
if child.isalive():
528+
child.terminate(force=True)
529+
assert "timeout >1.0s" in result
530+
assert "fail" in result
531+
532+
533+
@pytest.mark.parametrize(
534+
["debugging_module", "debugging_set_trace"],
535+
[
536+
("pdb", "set_trace()"),
537+
pytest.param(
538+
"ipdb",
539+
"set_trace()",
540+
marks=pytest.mark.xfail(
541+
reason="waiting on https://github.com/pytest-dev/pytest/pull/7207"
542+
" to allow proper testing"
543+
),
544+
),
545+
pytest.param(
546+
"pydevd",
547+
"settrace(port=4678)",
548+
marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"),
549+
),
550+
],
551+
)
552+
@have_spawn
553+
def test_disable_debugger_detection_marker(
554+
testdir, debugging_module, debugging_set_trace
555+
):
556+
p1 = testdir.makepyfile(
557+
"""
558+
import pytest, {debugging_module}
559+
560+
@pytest.mark.timeout(1, disable_debugger_detection=True)
561+
def test_foo():
562+
{debugging_module}.{debugging_set_trace}
563+
""".format(
564+
debugging_module=debugging_module, debugging_set_trace=debugging_set_trace
565+
)
566+
)
567+
child = testdir.spawn_pytest(str(p1))
568+
child.expect("test_foo")
569+
time.sleep(1.2)
570+
result = child.read().decode().lower()
571+
if child.isalive():
572+
child.terminate(force=True)
573+
assert "timeout >1.0s" in result
574+
assert "fail" in result
575+
576+
489577
def test_is_debugging(monkeypatch):
490578
import pytest_timeout
491579

0 commit comments

Comments
 (0)