├── pyproject.toml ├── MANIFEST.in ├── setup.py ├── TODO ├── tox.ini ├── failure_demo.py ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── main.yml ├── setup.cfg ├── .pre-commit-config.yaml ├── test_pytest_timeout.py ├── pytest_timeout.py └── README.rst /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include LICENSE 3 | include test_pytest_timeout.py 4 | include failure_demo.py 5 | include tox.ini 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setuptools install script for pytest-timeout.""" 2 | 3 | from setuptools import setup 4 | 5 | if __name__ == "__main__": 6 | setup() 7 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | * Consider checking for an existing signal handler 5 | If it exists maybe fall back to the threading based solution. 6 | 7 | * Add support for eventlet and gevent 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 8.0 3 | addopts = -ra 4 | 5 | [tox] 6 | envlist = py310,py311,py312,py313,py314,pypy3 7 | 8 | [testenv] 9 | deps = pytest 10 | pexpect 11 | ipdb 12 | pytest-cov 13 | pytest-github-actions-annotate-failures 14 | commands = pytest {posargs} 15 | 16 | [testenv:linting] 17 | skip_install = True 18 | basepython = python3 19 | deps = pre-commit>=4.0.0 20 | commands = pre-commit run --all-files --show-diff-on-failure 21 | 22 | 23 | [flake8] 24 | disable-noqa = True 25 | max-line-length = 88 26 | -------------------------------------------------------------------------------- /failure_demo.py: -------------------------------------------------------------------------------- 1 | """Demonstration of timeout failures using pytest_timeout. 2 | 3 | To use this demo, invoke pytest on it:: 4 | 5 | pytest failure_demo.py 6 | """ 7 | 8 | import threading 9 | import time 10 | 11 | import pytest 12 | 13 | 14 | def sleep(s): 15 | """Sleep for a while, possibly triggering a timeout. 16 | 17 | Also adds another function on the stack showing off the stack. 18 | """ 19 | # Separate function to demonstrate nested calls 20 | time.sleep(s) 21 | 22 | 23 | @pytest.mark.timeout(1) 24 | def test_simple(): 25 | """Basic timeout demonstration.""" 26 | sleep(2) 27 | 28 | 29 | def _run(): 30 | sleep(2) 31 | 32 | 33 | @pytest.mark.timeout(1) 34 | def test_thread(): 35 | """Timeout when multiple threads are running.""" 36 | t = threading.Thread(target=_run) 37 | t.start() 38 | sleep(2) 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg* 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | .pytest_cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # Virtual Envs 57 | .env* 58 | venv 59 | 60 | # IDE 61 | .idea 62 | .vscode 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (C) 2012, 2014 Floris Bruynooghe 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | "on": [push, pull_request] 4 | jobs: 5 | build: 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | python: 11 | - v: "3.10" 12 | tox_env: "py310" 13 | - v: "3.11" 14 | tox_env: "py311" 15 | - v: "3.12" 16 | tox_env: "py312" 17 | - v: "3.13" 18 | tox_env: "py313" 19 | - v: "3.14" 20 | tox_env: "py314" 21 | os: [ubuntu-latest, windows-latest] 22 | steps: 23 | - name: Set Git to use LF 24 | run: | 25 | git config --global core.autocrlf false 26 | git config --global core.eol lf 27 | - uses: actions/checkout@v5 28 | - name: Set up Python 29 | uses: actions/setup-python@v6 30 | with: 31 | python-version: ${{ matrix.python.v }} 32 | - name: Install tox 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -U setuptools tox 36 | - name: Test 37 | run: | 38 | tox -e ${{ matrix.python.tox_env }} ${{ matrix.python.pre_releases }} 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pytest-timeout 3 | description = pytest plugin to abort hanging tests 4 | long_description = file: README.rst 5 | long_description_content_type = text/x-rst 6 | version = 2.3.1 7 | author = Floris Bruynooghe 8 | author_email = flub@devork.be 9 | url = https://github.com/pytest-dev/pytest-timeout 10 | license = MIT 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Environment :: Console 14 | Environment :: Plugins 15 | Intended Audience :: Developers 16 | License :: DFSG approved 17 | License :: OSI Approved :: MIT License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: Implementation :: PyPy 21 | Programming Language :: Python :: Implementation :: CPython 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.10 25 | Programming Language :: Python :: 3.11 26 | Programming Language :: Python :: 3.12 27 | Programming Language :: Python :: 3.13 28 | Programming Language :: Python :: 3.14 29 | Topic :: Software Development :: Testing 30 | Framework :: Pytest 31 | 32 | [options] 33 | py_modules = pytest_timeout 34 | install_requires = 35 | pytest>=8.0.0 36 | python_requires = >=3.10 37 | 38 | [options.entry_points] 39 | pytest11 = 40 | timeout = pytest_timeout 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/psf/black 4 | rev: 25.11.0 5 | hooks: 6 | - id: black 7 | args: [--safe, --quiet, --target-version, py310] 8 | - repo: https://github.com/asottile/blacken-docs 9 | rev: 1.20.0 10 | hooks: 11 | - id: blacken-docs 12 | additional_dependencies: [black==25.11.0] 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v6.0.0 15 | hooks: 16 | - id: check-ast 17 | - id: check-case-conflict 18 | - id: check-json 19 | - id: check-merge-conflict 20 | - id: check-toml 21 | - id: check-yaml 22 | - id: check-illegal-windows-names 23 | - id: check-symlinks 24 | - id: check-added-large-files 25 | args: ['--maxkb=500'] 26 | - id: debug-statements 27 | - id: destroyed-symlinks 28 | - id: end-of-file-fixer 29 | - id: mixed-line-ending 30 | args: ["--fix=lf"] 31 | - id: trailing-whitespace 32 | - id: name-tests-test 33 | args: 34 | - --pytest-test-first 35 | language_version: python3 36 | - repo: https://github.com/PyCQA/flake8 37 | rev: 7.0.0 38 | hooks: 39 | - id: flake8 40 | language_version: python3 41 | additional_dependencies: [flake8-typing-imports==1.17.0] 42 | - repo: https://github.com/pycqa/isort 43 | rev: 7.0.0 44 | hooks: 45 | - id: isort 46 | - repo: https://github.com/asottile/pyupgrade 47 | rev: v3.21.1 48 | hooks: 49 | - id: pyupgrade 50 | args: [--keep-percent-format, --py310-plus] 51 | - repo: https://github.com/pre-commit/pygrep-hooks 52 | rev: v1.10.0 53 | hooks: 54 | - id: rst-backticks 55 | - repo: https://github.com/adrienverge/yamllint.git 56 | rev: v1.37.1 57 | hooks: 58 | - id: yamllint 59 | -------------------------------------------------------------------------------- /test_pytest_timeout.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import signal 3 | import sys 4 | import time 5 | 6 | import pexpect 7 | import pytest 8 | 9 | from pytest_timeout import PYTEST_FAILURE_MESSAGE 10 | 11 | MATCH_FAILURE_MESSAGE = f"*Failed: {PYTEST_FAILURE_MESSAGE}*" 12 | pytest_plugins = "pytester" 13 | 14 | have_sigalrm = pytest.mark.skipif( 15 | not hasattr(signal, "SIGALRM"), reason="OS does not have SIGALRM" 16 | ) 17 | have_spawn = pytest.mark.skipif( 18 | not hasattr(pexpect, "spawn"), reason="pexpect does not have spawn" 19 | ) 20 | 21 | 22 | def test_header(pytester): 23 | pytester.makepyfile( 24 | """ 25 | def test_x(): pass 26 | """ 27 | ) 28 | result = pytester.runpytest_subprocess("--timeout=1", "--session-timeout=2") 29 | result.stdout.fnmatch_lines( 30 | [ 31 | "timeout: 1.0s", 32 | "timeout method:*", 33 | "timeout func_only:*", 34 | "session timeout: 2.0s", 35 | ] 36 | ) 37 | 38 | 39 | @have_sigalrm 40 | def test_sigalrm(pytester): 41 | pytester.makepyfile( 42 | """ 43 | import time 44 | 45 | def test_foo(): 46 | time.sleep(2) 47 | """ 48 | ) 49 | result = pytester.runpytest_subprocess("--timeout=1") 50 | result.stdout.fnmatch_lines([MATCH_FAILURE_MESSAGE % "1.0"]) 51 | 52 | 53 | def test_thread(pytester): 54 | pytester.makepyfile( 55 | """ 56 | import time 57 | 58 | def test_foo(): 59 | time.sleep(2) 60 | """ 61 | ) 62 | result = pytester.runpytest_subprocess("--timeout=1", "--timeout-method=thread") 63 | result.stdout.fnmatch_lines( 64 | [ 65 | "*++ Timeout ++*", 66 | "*~~ Stack of MainThread* ~~*", 67 | "*File *, line *, in *", 68 | "*++ Timeout ++*", 69 | ] 70 | ) 71 | assert "++ Timeout ++" in result.stdout.lines[-1] 72 | 73 | 74 | @pytest.mark.skipif( 75 | hasattr(sys, "pypy_version_info"), reason="pypy coverage seems broken currently" 76 | ) 77 | def test_cov(pytester): 78 | # This test requires pytest-cov 79 | pytest.importorskip("pytest_cov") 80 | pytester.makepyfile( 81 | """ 82 | import time 83 | 84 | def test_foo(): 85 | time.sleep(2) 86 | """ 87 | ) 88 | result = pytester.runpytest_subprocess( 89 | "--timeout=1", "--cov=test_cov", "--timeout-method=thread" 90 | ) 91 | result.stdout.fnmatch_lines( 92 | [ 93 | "*++ Timeout ++*", 94 | "*~~ Stack of MainThread* ~~*", 95 | "*File *, line *, in *", 96 | "*++ Timeout ++*", 97 | ] 98 | ) 99 | assert "++ Timeout ++" in result.stdout.lines[-1] 100 | 101 | 102 | def test_timeout_env(pytester, monkeypatch): 103 | pytester.makepyfile( 104 | """ 105 | import time 106 | 107 | def test_foo(): 108 | time.sleep(2) 109 | """ 110 | ) 111 | monkeypatch.setitem(os.environ, "PYTEST_TIMEOUT", "1") 112 | result = pytester.runpytest_subprocess() 113 | assert result.ret > 0 114 | 115 | 116 | # @pytest.mark.parametrize('meth', [have_sigalrm('signal'), 'thread']) 117 | # def test_func_fix(meth, pytester): 118 | # pytester.makepyfile(""" 119 | # import time, pytest 120 | 121 | # @pytest.fixture(scope='function') 122 | # def fix(): 123 | # time.sleep(2) 124 | 125 | # def test_foo(fix): 126 | # pass 127 | # """) 128 | # result = pytester.runpytest_subprocess('--timeout=1', 129 | # '--timeout-method={0}'.format(meth)) 130 | # assert result.ret > 0 131 | # assert 'Timeout' in result.stdout.str() + result.stderr.str() 132 | 133 | 134 | @pytest.mark.parametrize("meth", [pytest.param("signal", marks=have_sigalrm), "thread"]) 135 | @pytest.mark.parametrize("scope", ["function", "class", "module", "session"]) 136 | def test_fix_setup(meth, scope, pytester): 137 | pytester.makepyfile( 138 | f""" 139 | import time, pytest 140 | 141 | class TestFoo: 142 | 143 | @pytest.fixture(scope='{scope}') 144 | def fix(self): 145 | time.sleep(2) 146 | 147 | def test_foo(self, fix): 148 | pass 149 | """ 150 | ) 151 | result = pytester.runpytest_subprocess("--timeout=1", f"--timeout-method={meth}") 152 | assert result.ret > 0 153 | assert "Timeout" in result.stdout.str() + result.stderr.str() 154 | 155 | 156 | def test_fix_setup_func_only(pytester): 157 | pytester.makepyfile( 158 | """ 159 | import time, pytest 160 | 161 | class TestFoo: 162 | 163 | @pytest.fixture 164 | def fix(self): 165 | time.sleep(0.1) 166 | 167 | @pytest.mark.timeout(func_only=True) 168 | def test_foo(self, fix): 169 | pass 170 | """ 171 | ) 172 | result = pytester.runpytest_subprocess("--timeout=1") 173 | assert result.ret == 0 174 | assert "Timeout" not in result.stdout.str() + result.stderr.str() 175 | 176 | 177 | @pytest.mark.parametrize("meth", [pytest.param("signal", marks=have_sigalrm), "thread"]) 178 | @pytest.mark.parametrize("scope", ["function", "class", "module", "session"]) 179 | def test_fix_finalizer(meth, scope, pytester): 180 | pytester.makepyfile( 181 | """ 182 | import time, pytest 183 | 184 | class TestFoo: 185 | 186 | @pytest.fixture 187 | def fix(self, request): 188 | print('fix setup') 189 | def fin(): 190 | print('fix finaliser') 191 | time.sleep(2) 192 | request.addfinalizer(fin) 193 | 194 | def test_foo(self, fix): 195 | pass 196 | """ 197 | ) 198 | result = pytester.runpytest_subprocess( 199 | "--timeout=1", "-s", f"--timeout-method={meth}" 200 | ) 201 | assert result.ret > 0 202 | assert "Timeout" in result.stdout.str() + result.stderr.str() 203 | 204 | 205 | def test_fix_finalizer_func_only(pytester): 206 | pytester.makepyfile( 207 | """ 208 | import time, pytest 209 | 210 | class TestFoo: 211 | 212 | @pytest.fixture 213 | def fix(self, request): 214 | print('fix setup') 215 | def fin(): 216 | print('fix finaliser') 217 | time.sleep(0.1) 218 | request.addfinalizer(fin) 219 | 220 | @pytest.mark.timeout(func_only=True) 221 | def test_foo(self, fix): 222 | pass 223 | """ 224 | ) 225 | result = pytester.runpytest_subprocess("--timeout=1", "-s") 226 | assert result.ret == 0 227 | assert "Timeout" not in result.stdout.str() + result.stderr.str() 228 | 229 | 230 | @have_sigalrm 231 | def test_timeout_mark_sigalrm(pytester): 232 | pytester.makepyfile( 233 | """ 234 | import time, pytest 235 | 236 | @pytest.mark.timeout(1) 237 | def test_foo(): 238 | time.sleep(2) 239 | assert False 240 | """ 241 | ) 242 | result = pytester.runpytest_subprocess() 243 | result.stdout.fnmatch_lines([MATCH_FAILURE_MESSAGE % "1.0"]) 244 | 245 | 246 | def test_timeout_mark_timer(pytester): 247 | pytester.makepyfile( 248 | """ 249 | import time, pytest 250 | 251 | @pytest.mark.timeout(1) 252 | def test_foo(): 253 | time.sleep(2) 254 | """ 255 | ) 256 | result = pytester.runpytest_subprocess("--timeout-method=thread") 257 | result.stdout.fnmatch_lines(["*++ Timeout ++*"]) 258 | 259 | 260 | def test_timeout_mark_non_int(pytester): 261 | pytester.makepyfile( 262 | """ 263 | import time, pytest 264 | 265 | @pytest.mark.timeout(0.01) 266 | def test_foo(): 267 | time.sleep(1) 268 | """ 269 | ) 270 | result = pytester.runpytest_subprocess("--timeout-method=thread") 271 | result.stdout.fnmatch_lines(["*++ Timeout ++*"]) 272 | 273 | 274 | def test_timeout_mark_non_number(pytester): 275 | pytester.makepyfile( 276 | """ 277 | import pytest 278 | 279 | @pytest.mark.timeout('foo') 280 | def test_foo(): 281 | pass 282 | """ 283 | ) 284 | result = pytester.runpytest_subprocess() 285 | result.stdout.fnmatch_lines(["*ValueError*"]) 286 | 287 | 288 | def test_timeout_mark_args(pytester): 289 | pytester.makepyfile( 290 | """ 291 | import pytest 292 | 293 | @pytest.mark.timeout(1, 2) 294 | def test_foo(): 295 | pass 296 | """ 297 | ) 298 | result = pytester.runpytest_subprocess() 299 | result.stdout.fnmatch_lines(["*ValueError*"]) 300 | 301 | 302 | def test_timeout_mark_method_nokw(pytester): 303 | pytester.makepyfile( 304 | """ 305 | import time, pytest 306 | 307 | @pytest.mark.timeout(1, 'thread') 308 | def test_foo(): 309 | time.sleep(2) 310 | """ 311 | ) 312 | result = pytester.runpytest_subprocess() 313 | result.stdout.fnmatch_lines(["*+ Timeout +*"]) 314 | 315 | 316 | def test_timeout_mark_noargs(pytester): 317 | pytester.makepyfile( 318 | """ 319 | import pytest 320 | 321 | @pytest.mark.timeout 322 | def test_foo(): 323 | pass 324 | """ 325 | ) 326 | result = pytester.runpytest_subprocess() 327 | result.stdout.fnmatch_lines(["*TypeError*"]) 328 | 329 | 330 | def test_ini_timeout(pytester): 331 | pytester.makepyfile( 332 | """ 333 | import time 334 | 335 | def test_foo(): 336 | time.sleep(2) 337 | """ 338 | ) 339 | pytester.makeini( 340 | """ 341 | [pytest] 342 | timeout = 1 343 | """ 344 | ) 345 | result = pytester.runpytest_subprocess() 346 | assert result.ret 347 | 348 | 349 | def test_ini_timeout_func_only(pytester): 350 | pytester.makepyfile( 351 | """ 352 | import time, pytest 353 | 354 | @pytest.fixture 355 | def slow(): 356 | time.sleep(2) 357 | def test_foo(slow): 358 | pass 359 | """ 360 | ) 361 | pytester.makeini( 362 | """ 363 | [pytest] 364 | timeout = 1 365 | timeout_func_only = true 366 | """ 367 | ) 368 | result = pytester.runpytest_subprocess() 369 | assert result.ret == 0 370 | 371 | 372 | def test_ini_timeout_func_only_marker_override(pytester): 373 | pytester.makepyfile( 374 | """ 375 | import time, pytest 376 | 377 | @pytest.fixture 378 | def slow(): 379 | time.sleep(2) 380 | @pytest.mark.timeout(1.5) 381 | def test_foo(slow): 382 | pass 383 | """ 384 | ) 385 | pytester.makeini( 386 | """ 387 | [pytest] 388 | timeout = 1 389 | timeout_func_only = true 390 | """ 391 | ) 392 | result = pytester.runpytest_subprocess() 393 | assert result.ret == 0 394 | 395 | 396 | def test_ini_method(pytester): 397 | pytester.makepyfile( 398 | """ 399 | import time 400 | 401 | def test_foo(): 402 | time.sleep(2) 403 | """ 404 | ) 405 | pytester.makeini( 406 | """ 407 | [pytest] 408 | timeout = 1 409 | timeout_method = thread 410 | """ 411 | ) 412 | result = pytester.runpytest_subprocess() 413 | assert "=== 1 failed in " not in result.outlines[-1] 414 | 415 | 416 | def test_timeout_marker_inheritance(pytester): 417 | pytester.makepyfile( 418 | """ 419 | import time, pytest 420 | 421 | @pytest.mark.timeout(timeout=2) 422 | class TestFoo: 423 | 424 | @pytest.mark.timeout(timeout=3) 425 | def test_foo_2(self): 426 | time.sleep(2) 427 | 428 | def test_foo_1(self): 429 | time.sleep(1) 430 | """ 431 | ) 432 | result = pytester.runpytest_subprocess("--timeout=1", "-s") 433 | assert result.ret == 0 434 | assert "Timeout" not in result.stdout.str() + result.stderr.str() 435 | 436 | 437 | def test_marker_help(pytester): 438 | result = pytester.runpytest_subprocess("--markers") 439 | result.stdout.fnmatch_lines(["@pytest.mark.timeout(*"]) 440 | 441 | 442 | @pytest.mark.parametrize( 443 | ["debugging_module", "debugging_set_trace"], 444 | [ 445 | ("pdb", "set_trace()"), 446 | pytest.param( 447 | "ipdb", 448 | "set_trace()", 449 | marks=pytest.mark.xfail( 450 | reason="waiting on https://github.com/pytest-dev/pytest/pull/7207" 451 | " to allow proper testing" 452 | ), 453 | ), 454 | pytest.param( 455 | "pydevd", 456 | "settrace(port=4678)", 457 | marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"), 458 | ), 459 | ], 460 | ) 461 | @have_spawn 462 | def test_suppresses_timeout_when_debugger_is_entered( 463 | pytester, debugging_module, debugging_set_trace 464 | ): 465 | p1 = pytester.makepyfile( 466 | f""" 467 | import pytest, {debugging_module} 468 | 469 | @pytest.mark.timeout(1) 470 | def test_foo(): 471 | {debugging_module}.{debugging_set_trace} 472 | """ 473 | ) 474 | child = pytester.spawn_pytest(str(p1)) 475 | child.expect("test_foo") 476 | time.sleep(0.2) 477 | child.send("c\n") 478 | child.sendeof() 479 | result = child.read().decode().lower() 480 | if child.isalive(): 481 | child.terminate(force=True) 482 | assert "timeout (>1.0s)" not in result 483 | assert "fail" not in result 484 | 485 | 486 | @pytest.mark.parametrize( 487 | ["debugging_module", "debugging_set_trace"], 488 | [ 489 | ("pdb", "set_trace()"), 490 | pytest.param( 491 | "ipdb", 492 | "set_trace()", 493 | marks=pytest.mark.xfail( 494 | reason="waiting on https://github.com/pytest-dev/pytest/pull/7207" 495 | " to allow proper testing" 496 | ), 497 | ), 498 | pytest.param( 499 | "pydevd", 500 | "settrace(port=4678)", 501 | marks=pytest.mark.xfail(reason="in need of way to setup pydevd server"), 502 | ), 503 | ], 504 | ) 505 | @have_spawn 506 | def test_disable_debugger_detection_flag( 507 | pytester, debugging_module, debugging_set_trace 508 | ): 509 | p1 = pytester.makepyfile( 510 | f""" 511 | import pytest, {debugging_module} 512 | 513 | @pytest.mark.timeout(1) 514 | def test_foo(): 515 | {debugging_module}.{debugging_set_trace} 516 | """ 517 | ) 518 | child = pytester.spawn_pytest(f"{p1} --timeout-disable-debugger-detection") 519 | child.expect("test_foo") 520 | time.sleep(1.2) 521 | result = child.read().decode().lower() 522 | if child.isalive(): 523 | child.terminate(force=True) 524 | assert "timeout (>1.0s)" in result 525 | assert "fail" in result 526 | 527 | 528 | def test_is_debugging(monkeypatch): 529 | import pytest_timeout 530 | 531 | assert not pytest_timeout.is_debugging() 532 | 533 | # create a fake module named "custom.pydevd" with a trace function on it 534 | from types import ModuleType 535 | 536 | module_name = "custom.pydevd" 537 | module = ModuleType(module_name) 538 | monkeypatch.setitem(sys.modules, module_name, module) 539 | 540 | def custom_trace(*args): 541 | pass 542 | 543 | custom_trace.__module__ = module_name 544 | module.custom_trace = custom_trace 545 | 546 | assert pytest_timeout.is_debugging(custom_trace) 547 | 548 | 549 | def test_not_main_thread(pytester): 550 | pytest.skip("The 'pytest_timeout.timeout_setup' function no longer exists") 551 | pytester.makepyfile( 552 | """ 553 | import threading 554 | import pytest_timeout 555 | 556 | current_timeout_setup = pytest_timeout.timeout_setup 557 | 558 | def new_timeout_setup(item): 559 | threading.Thread( 560 | target=current_timeout_setup, args=(item), 561 | ).join() 562 | 563 | pytest_timeout.timeout_setup = new_timeout_setup 564 | 565 | def test_x(): pass 566 | """ 567 | ) 568 | result = pytester.runpytest_subprocess("--timeout=1") 569 | result.stdout.fnmatch_lines( 570 | ["timeout: 1.0s", "timeout method:*", "timeout func_only:*"] 571 | ) 572 | 573 | 574 | def test_plugin_interface(pytester): 575 | pytester.makeconftest( 576 | """ 577 | import pytest 578 | 579 | @pytest.mark.tryfirst 580 | def pytest_timeout_set_timer(item, settings): 581 | print() 582 | print("pytest_timeout_set_timer") 583 | return True 584 | 585 | @pytest.mark.tryfirst 586 | def pytest_timeout_cancel_timer(item): 587 | print() 588 | print("pytest_timeout_cancel_timer") 589 | return True 590 | """ 591 | ) 592 | pytester.makepyfile( 593 | """ 594 | import pytest 595 | 596 | @pytest.mark.timeout(1) 597 | def test_foo(): 598 | pass 599 | """ 600 | ) 601 | result = pytester.runpytest_subprocess("-s") 602 | result.stdout.fnmatch_lines( 603 | [ 604 | "pytest_timeout_set_timer", 605 | "pytest_timeout_cancel_timer", 606 | ] 607 | ) 608 | 609 | 610 | def test_session_timeout(pytester): 611 | # This is designed to timeout during the first test to ensure 612 | # - the first test still runs to completion 613 | # - the second test is not started 614 | pytester.makepyfile( 615 | """ 616 | import time, pytest 617 | 618 | @pytest.fixture() 619 | def slow_setup_and_teardown(): 620 | time.sleep(1) 621 | yield 622 | time.sleep(1) 623 | 624 | def test_one(slow_setup_and_teardown): 625 | time.sleep(1) 626 | 627 | def test_two(slow_setup_and_teardown): 628 | time.sleep(1) 629 | """ 630 | ) 631 | result = pytester.runpytest_subprocess("--session-timeout", "2") 632 | result.stdout.fnmatch_lines(["*!! session-timeout: 2.0 sec exceeded !!!*"]) 633 | # This would be 2 passed if the second test was allowed to run 634 | result.assert_outcomes(passed=1) 635 | 636 | 637 | def test_ini_session_timeout(pytester): 638 | pytester.makepyfile( 639 | """ 640 | import time 641 | 642 | def test_one(): 643 | time.sleep(2) 644 | 645 | def test_two(): 646 | time.sleep(2) 647 | """ 648 | ) 649 | pytester.makeini( 650 | """ 651 | [pytest] 652 | session_timeout = 1 653 | """ 654 | ) 655 | result = pytester.runpytest_subprocess() 656 | result.stdout.fnmatch_lines(["*!! session-timeout: 1.0 sec exceeded !!!*"]) 657 | result.assert_outcomes(passed=1) 658 | -------------------------------------------------------------------------------- /pytest_timeout.py: -------------------------------------------------------------------------------- 1 | """Timeout for tests to stop hanging testruns. 2 | 3 | This plugin will dump the stack and terminate the test. This can be 4 | useful when running tests on a continuous integration server. 5 | 6 | If the platform supports SIGALRM this is used to raise an exception in 7 | the test, otherwise os._exit(1) is used. 8 | """ 9 | 10 | import inspect 11 | import os 12 | import signal 13 | import sys 14 | import threading 15 | import time 16 | import traceback 17 | from collections import namedtuple 18 | 19 | import pytest 20 | 21 | __all__ = ("Settings", "is_debugging") 22 | SESSION_TIMEOUT_KEY = pytest.StashKey[float]() 23 | SESSION_EXPIRE_KEY = pytest.StashKey[float]() 24 | PYTEST_FAILURE_MESSAGE = "Timeout (>%ss) from pytest-timeout." 25 | 26 | HAVE_SIGALRM = hasattr(signal, "SIGALRM") 27 | DEFAULT_METHOD = "signal" if HAVE_SIGALRM else "thread" 28 | TIMEOUT_DESC = """ 29 | Timeout in seconds before dumping the stacks. Default is 0 which 30 | means no timeout. 31 | """.strip() 32 | METHOD_DESC = """ 33 | Timeout mechanism to use. 'signal' uses SIGALRM, 'thread' uses a timer 34 | thread. If unspecified 'signal' is used on platforms which support 35 | SIGALRM, otherwise 'thread' is used. 36 | """.strip() 37 | FUNC_ONLY_DESC = """ 38 | When set to True, defers the timeout evaluation to only the test 39 | function body, ignoring the time it takes when evaluating any fixtures 40 | used in the test. 41 | """.strip() 42 | DISABLE_DEBUGGER_DETECTION_DESC = """ 43 | When specified, disables debugger detection. breakpoint(), pdb.set_trace(), etc. 44 | will be interrupted by the timeout. 45 | """.strip() 46 | SESSION_TIMEOUT_DESC = """ 47 | Timeout in seconds for entire session. Default is None which 48 | means no timeout. Timeout is checked between tests, and will not interrupt a test 49 | in progress. 50 | """.strip() 51 | 52 | # bdb covers pdb, ipdb, and possibly others 53 | # pydevd covers PyCharm, VSCode, and possibly others 54 | KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb", "pydevd_frame_evaluator"} 55 | Settings = namedtuple( 56 | "Settings", ["timeout", "method", "func_only", "disable_debugger_detection"] 57 | ) 58 | 59 | 60 | @pytest.hookimpl 61 | def pytest_addoption(parser): 62 | """Add options to control the timeout plugin.""" 63 | group = parser.getgroup( 64 | "timeout", 65 | "Interrupt test run and dump stacks of all threads after a test times out", 66 | ) 67 | group.addoption("--timeout", type=float, help=TIMEOUT_DESC) 68 | group.addoption( 69 | "--timeout_method", 70 | action="store", 71 | choices=["signal", "thread"], 72 | help="Deprecated, use --timeout-method", 73 | ) 74 | group.addoption( 75 | "--timeout-method", 76 | dest="timeout_method", 77 | action="store", 78 | choices=["signal", "thread"], 79 | help=METHOD_DESC, 80 | ) 81 | group.addoption( 82 | "--timeout-disable-debugger-detection", 83 | dest="timeout_disable_debugger_detection", 84 | action="store_true", 85 | help=DISABLE_DEBUGGER_DETECTION_DESC, 86 | ) 87 | group.addoption( 88 | "--session-timeout", 89 | action="store", 90 | dest="session_timeout", 91 | default=None, 92 | type=float, 93 | metavar="SECONDS", 94 | help=SESSION_TIMEOUT_DESC, 95 | ) 96 | parser.addini("timeout", TIMEOUT_DESC) 97 | parser.addini("timeout_method", METHOD_DESC) 98 | parser.addini("timeout_func_only", FUNC_ONLY_DESC, type="bool", default=False) 99 | parser.addini( 100 | "timeout_disable_debugger_detection", 101 | DISABLE_DEBUGGER_DETECTION_DESC, 102 | type="bool", 103 | default=False, 104 | ) 105 | parser.addini("session_timeout", SESSION_TIMEOUT_DESC) 106 | 107 | 108 | class TimeoutHooks: 109 | """Timeout specific hooks.""" 110 | 111 | @pytest.hookspec(firstresult=True) 112 | def pytest_timeout_set_timer(self, item, settings): 113 | """Called at timeout setup. 114 | 115 | 'item' is a pytest node to setup timeout for. 116 | 117 | Can be overridden by plugins for alternative timeout implementation strategies. 118 | 119 | """ 120 | 121 | @pytest.hookspec(firstresult=True) 122 | def pytest_timeout_cancel_timer(self, item): 123 | """Called at timeout teardown. 124 | 125 | 'item' is a pytest node which was used for timeout setup. 126 | 127 | Can be overridden by plugins for alternative timeout implementation strategies. 128 | 129 | """ 130 | 131 | 132 | def pytest_addhooks(pluginmanager): 133 | """Register timeout-specific hooks.""" 134 | pluginmanager.add_hookspecs(TimeoutHooks) 135 | 136 | 137 | @pytest.hookimpl 138 | def pytest_configure(config): 139 | """Register the marker so it shows up in --markers output.""" 140 | config.addinivalue_line( 141 | "markers", 142 | "timeout(timeout, method=None, func_only=False, " 143 | "disable_debugger_detection=False): Set a timeout, timeout " 144 | "method and func_only evaluation on just one test item. The first " 145 | "argument, *timeout*, is the timeout in seconds while the keyword, " 146 | "*method*, takes the same values as the --timeout-method option. The " 147 | "*func_only* keyword, when set to True, defers the timeout evaluation " 148 | "to only the test function body, ignoring the time it takes when " 149 | "evaluating any fixtures used in the test. The " 150 | "*disable_debugger_detection* keyword, when set to True, disables " 151 | "debugger detection, allowing breakpoint(), pdb.set_trace(), etc. " 152 | "to be interrupted", 153 | ) 154 | 155 | settings = get_env_settings(config) 156 | config._env_timeout = settings.timeout 157 | config._env_timeout_method = settings.method 158 | config._env_timeout_func_only = settings.func_only 159 | config._env_timeout_disable_debugger_detection = settings.disable_debugger_detection 160 | 161 | timeout = config.getoption("session_timeout") 162 | if timeout is None: 163 | ini = config.getini("session_timeout") 164 | if ini: 165 | timeout = _validate_timeout(config.getini("session_timeout"), "config file") 166 | if timeout is not None: 167 | expire_time = time.time() + timeout 168 | else: 169 | expire_time = 0 170 | timeout = 0 171 | config.stash[SESSION_TIMEOUT_KEY] = timeout 172 | config.stash[SESSION_EXPIRE_KEY] = expire_time 173 | 174 | 175 | @pytest.hookimpl(hookwrapper=True) 176 | def pytest_runtest_protocol(item): 177 | """Hook in timeouts to the runtest protocol. 178 | 179 | If the timeout is set on the entire test, including setup and 180 | teardown, then this hook installs the timeout. Otherwise 181 | pytest_runtest_call is used. 182 | """ 183 | hooks = item.config.pluginmanager.hook 184 | settings = _get_item_settings(item) 185 | is_timeout = settings.timeout is not None and settings.timeout > 0 186 | if is_timeout and settings.func_only is False: 187 | hooks.pytest_timeout_set_timer(item=item, settings=settings) 188 | yield 189 | if is_timeout and settings.func_only is False: 190 | hooks.pytest_timeout_cancel_timer(item=item) 191 | 192 | # check session timeout 193 | expire_time = item.session.config.stash[SESSION_EXPIRE_KEY] 194 | if expire_time and (expire_time < time.time()): 195 | timeout = item.session.config.stash[SESSION_TIMEOUT_KEY] 196 | item.session.shouldfail = f"session-timeout: {timeout} sec exceeded" 197 | 198 | 199 | @pytest.hookimpl(hookwrapper=True) 200 | def pytest_runtest_call(item): 201 | """Hook in timeouts to the test function call only. 202 | 203 | If the timeout is set on only the test function this hook installs 204 | the timeout, otherwise pytest_runtest_protocol is used. 205 | """ 206 | hooks = item.config.pluginmanager.hook 207 | settings = _get_item_settings(item) 208 | is_timeout = settings.timeout is not None and settings.timeout > 0 209 | if is_timeout and settings.func_only is True: 210 | hooks.pytest_timeout_set_timer(item=item, settings=settings) 211 | yield 212 | if is_timeout and settings.func_only is True: 213 | hooks.pytest_timeout_cancel_timer(item=item) 214 | 215 | 216 | @pytest.hookimpl(tryfirst=True) 217 | def pytest_report_header(config): 218 | """Add timeout config to pytest header.""" 219 | timeout_header = [] 220 | 221 | if config._env_timeout: 222 | timeout_header.append( 223 | f"timeout: {config._env_timeout}s\n" 224 | f"timeout method: {config._env_timeout_method}\n" 225 | f"timeout func_only: {config._env_timeout_func_only}" 226 | ) 227 | 228 | session_timeout = config.getoption("session_timeout") 229 | if session_timeout: 230 | timeout_header.append("session timeout: %ss" % session_timeout) 231 | if timeout_header: 232 | return timeout_header 233 | return None 234 | 235 | 236 | @pytest.hookimpl(tryfirst=True) 237 | def pytest_exception_interact(node): 238 | """Stop the timeout when pytest enters pdb in post-mortem mode.""" 239 | hooks = node.config.pluginmanager.hook 240 | hooks.pytest_timeout_cancel_timer(item=node) 241 | 242 | 243 | @pytest.hookimpl 244 | def pytest_enter_pdb(): 245 | """Stop the timeouts when we entered pdb. 246 | 247 | This stops timeouts from triggering when pytest's builtin pdb 248 | support notices we entered pdb. 249 | """ 250 | # Since pdb.set_trace happens outside any pytest control, we don't have 251 | # any pytest ``item`` here, so we cannot use timeout_teardown. Thus, we 252 | # need another way to signify that the timeout should not be performed. 253 | global SUPPRESS_TIMEOUT 254 | SUPPRESS_TIMEOUT = True 255 | 256 | 257 | def is_debugging(trace_func=None): 258 | """Detect if a debugging session is in progress. 259 | 260 | This looks at both pytest's builtin pdb support as well as 261 | externally installed debuggers using some heuristics. 262 | 263 | This is done by checking if either of the following conditions is 264 | true: 265 | 266 | 1. Examines the trace function to see if the module it originates 267 | from is in KNOWN_DEBUGGING_MODULES. 268 | 2. Check is SUPPRESS_TIMEOUT is set to True. 269 | 270 | :param trace_func: the current trace function, if not given will use 271 | sys.gettrace(). Used to unit-test this function. 272 | """ 273 | global SUPPRESS_TIMEOUT, KNOWN_DEBUGGING_MODULES 274 | if SUPPRESS_TIMEOUT: 275 | return True 276 | if trace_func is None: 277 | trace_func = sys.gettrace() 278 | trace_module = None 279 | if trace_func: 280 | trace_module = inspect.getmodule(trace_func) or inspect.getmodule( 281 | trace_func.__class__ 282 | ) 283 | if trace_module: 284 | parts = trace_module.__name__.split(".") 285 | for name in KNOWN_DEBUGGING_MODULES: 286 | if any(part.startswith(name) for part in parts): 287 | return True 288 | 289 | # For 3.12, sys.monitoring is used for tracing. 290 | # Check if any debugger has been registered. 291 | if hasattr(sys, "monitoring"): 292 | return sys.monitoring.get_tool(sys.monitoring.DEBUGGER_ID) is not None 293 | return False 294 | 295 | 296 | SUPPRESS_TIMEOUT = False 297 | 298 | 299 | @pytest.hookimpl(trylast=True) 300 | def pytest_timeout_set_timer(item, settings): 301 | """Setup up a timeout trigger and handler.""" 302 | timeout_method = settings.method 303 | if ( 304 | timeout_method == "signal" 305 | and threading.current_thread() is not threading.main_thread() 306 | ): 307 | timeout_method = "thread" 308 | 309 | if timeout_method == "signal": 310 | 311 | def handler(signum, frame): 312 | __tracebackhide__ = True 313 | timeout_sigalrm(item, settings) 314 | 315 | def cancel(): 316 | signal.setitimer(signal.ITIMER_REAL, 0) 317 | signal.signal(signal.SIGALRM, signal.SIG_DFL) 318 | 319 | item.cancel_timeout = cancel 320 | signal.signal(signal.SIGALRM, handler) 321 | signal.setitimer(signal.ITIMER_REAL, settings.timeout) 322 | elif timeout_method == "thread": 323 | timer = threading.Timer(settings.timeout, timeout_timer, (item, settings)) 324 | timer.name = f"{__name__} {item.nodeid}" 325 | 326 | def cancel(): 327 | timer.cancel() 328 | timer.join() 329 | 330 | item.cancel_timeout = cancel 331 | timer.start() 332 | return True 333 | 334 | 335 | @pytest.hookimpl(trylast=True) 336 | def pytest_timeout_cancel_timer(item): 337 | """Cancel the timeout trigger if it was set.""" 338 | # When skipping is raised from a pytest_runtest_setup function 339 | # (as is the case when using the pytest.mark.skipif marker) we 340 | # may be called without our setup counterpart having been 341 | # called. 342 | cancel = getattr(item, "cancel_timeout", None) 343 | if cancel: 344 | cancel() 345 | return True 346 | 347 | 348 | def get_env_settings(config): 349 | """Return the configured timeout settings. 350 | 351 | This looks up the settings in the environment and config file. 352 | """ 353 | timeout = config.getvalue("timeout") 354 | if timeout is None: 355 | timeout = _validate_timeout( 356 | os.environ.get("PYTEST_TIMEOUT"), "PYTEST_TIMEOUT environment variable" 357 | ) 358 | if timeout is None: 359 | ini = config.getini("timeout") 360 | if ini: 361 | timeout = _validate_timeout(ini, "config file") 362 | 363 | method = config.getvalue("timeout_method") 364 | if method is None: 365 | ini = config.getini("timeout_method") 366 | if ini: 367 | method = _validate_method(ini, "config file") 368 | if method is None: 369 | method = DEFAULT_METHOD 370 | 371 | func_only = config.getini("timeout_func_only") 372 | 373 | disable_debugger_detection = config.getvalue("timeout_disable_debugger_detection") 374 | if disable_debugger_detection is None: 375 | ini = config.getini("timeout_disable_debugger_detection") 376 | if ini: 377 | disable_debugger_detection = _validate_disable_debugger_detection( 378 | ini, "config file" 379 | ) 380 | 381 | return Settings(timeout, method, func_only, disable_debugger_detection) 382 | 383 | 384 | def _get_item_settings(item, marker=None): 385 | """Return (timeout, method) for an item.""" 386 | timeout = method = func_only = disable_debugger_detection = None 387 | if not marker: 388 | marker = item.get_closest_marker("timeout") 389 | if marker is not None: 390 | settings = _parse_marker(item.get_closest_marker(name="timeout")) 391 | timeout = _validate_timeout(settings.timeout, "marker") 392 | method = _validate_method(settings.method, "marker") 393 | func_only = _validate_func_only(settings.func_only, "marker") 394 | disable_debugger_detection = _validate_disable_debugger_detection( 395 | settings.disable_debugger_detection, "marker" 396 | ) 397 | if timeout is None: 398 | timeout = item.config._env_timeout 399 | if method is None: 400 | method = item.config._env_timeout_method 401 | if func_only is None: 402 | func_only = item.config._env_timeout_func_only 403 | if disable_debugger_detection is None: 404 | disable_debugger_detection = item.config._env_timeout_disable_debugger_detection 405 | return Settings(timeout, method, func_only, disable_debugger_detection) 406 | 407 | 408 | def _parse_marker(marker): 409 | """Return (timeout, method) tuple from marker. 410 | 411 | Either could be None. The values are not interpreted, so 412 | could still be bogus and even the wrong type. 413 | """ 414 | if not marker.args and not marker.kwargs: 415 | raise TypeError("Timeout marker must have at least one argument") 416 | timeout = method = func_only = NOTSET = object() 417 | for kw, val in marker.kwargs.items(): 418 | if kw == "timeout": 419 | timeout = val 420 | elif kw == "method": 421 | method = val 422 | elif kw == "func_only": 423 | func_only = val 424 | else: 425 | msg = f"Invalid keyword argument for timeout marker: {kw}" 426 | raise TypeError(msg) 427 | if len(marker.args) >= 1 and timeout is not NOTSET: 428 | raise TypeError("Multiple values for timeout argument of timeout marker") 429 | if len(marker.args) >= 1: 430 | timeout = marker.args[0] 431 | if len(marker.args) >= 2 and method is not NOTSET: 432 | raise TypeError("Multiple values for method argument of timeout marker") 433 | if len(marker.args) >= 2: 434 | method = marker.args[1] 435 | if len(marker.args) > 2: 436 | raise TypeError("Too many arguments for timeout marker") 437 | if timeout is NOTSET: 438 | timeout = None 439 | if method is NOTSET: 440 | method = None 441 | if func_only is NOTSET: 442 | func_only = None 443 | return Settings(timeout, method, func_only, None) 444 | 445 | 446 | def _validate_timeout(timeout, where): 447 | if timeout is None: 448 | return None 449 | try: 450 | return float(timeout) 451 | except ValueError: 452 | msg = f"Invalid timeout {timeout} from {where}" 453 | raise ValueError(msg) 454 | 455 | 456 | def _validate_method(method, where): 457 | if method is None: 458 | return None 459 | if method not in ["signal", "thread"]: 460 | msg = f"Invalid method {method} from {where}" 461 | raise ValueError(msg) 462 | return method 463 | 464 | 465 | def _validate_func_only(func_only, where): 466 | if func_only is None: 467 | return None 468 | if not isinstance(func_only, bool): 469 | msg = f"Invalid func_only value {func_only} from {where}" 470 | raise ValueError(msg) 471 | return func_only 472 | 473 | 474 | def _validate_disable_debugger_detection(disable_debugger_detection, where): 475 | if disable_debugger_detection is None: 476 | return None 477 | if not isinstance(disable_debugger_detection, bool): 478 | raise ValueError( 479 | "Invalid disable_debugger_detection value %s from %s" 480 | % (disable_debugger_detection, where) 481 | ) 482 | return disable_debugger_detection 483 | 484 | 485 | def timeout_sigalrm(item, settings): 486 | """Dump stack of threads and raise an exception. 487 | 488 | This will output the stacks of any threads other than the 489 | current to stderr and then raise an AssertionError, thus 490 | terminating the test. 491 | """ 492 | if not settings.disable_debugger_detection and is_debugging(): 493 | return 494 | __tracebackhide__ = True 495 | nthreads = len(threading.enumerate()) 496 | terminal = item.config.get_terminal_writer() 497 | if nthreads > 1: 498 | terminal.sep("+", title="Timeout") 499 | dump_stacks(terminal) 500 | if nthreads > 1: 501 | terminal.sep("+", title="Timeout") 502 | pytest.fail(PYTEST_FAILURE_MESSAGE % settings.timeout) 503 | 504 | 505 | def timeout_timer(item, settings): 506 | """Dump stack of threads and call os._exit(). 507 | 508 | This disables the capturemanager and dumps stdout and stderr. 509 | Then the stacks are dumped and os._exit(1) is called. 510 | """ 511 | if not settings.disable_debugger_detection and is_debugging(): 512 | return 513 | terminal = item.config.get_terminal_writer() 514 | try: 515 | capman = item.config.pluginmanager.getplugin("capturemanager") 516 | if capman: 517 | capman.suspend_global_capture(item) 518 | stdout, stderr = capman.read_global_capture() 519 | else: 520 | stdout, stderr = None, None 521 | terminal.sep("+", title="Timeout") 522 | caplog = item.config.pluginmanager.getplugin("_capturelog") 523 | if caplog and hasattr(item, "capturelog_handler"): 524 | log = item.capturelog_handler.stream.getvalue() 525 | if log: 526 | terminal.sep("~", title="Captured log") 527 | terminal.write(log) 528 | if stdout: 529 | terminal.sep("~", title="Captured stdout") 530 | terminal.write(stdout) 531 | if stderr: 532 | terminal.sep("~", title="Captured stderr") 533 | terminal.write(stderr) 534 | dump_stacks(terminal) 535 | terminal.sep("+", title="Timeout") 536 | except Exception: 537 | traceback.print_exc() 538 | finally: 539 | terminal.flush() 540 | sys.stdout.flush() 541 | sys.stderr.flush() 542 | os._exit(1) 543 | 544 | 545 | def dump_stacks(terminal): 546 | """Dump the stacks of all threads except the current thread.""" 547 | current_ident = threading.current_thread().ident 548 | for thread_ident, frame in sys._current_frames().items(): 549 | if thread_ident == current_ident: 550 | continue 551 | for t in threading.enumerate(): 552 | if t.ident == thread_ident: 553 | thread_name = t.name 554 | break 555 | else: 556 | thread_name = "" 557 | terminal.sep("~", title=f"Stack of {thread_name} ({thread_ident})") 558 | terminal.write("".join(traceback.format_stack(frame))) 559 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | pytest-timeout 3 | ============== 4 | 5 | |python| |version| |anaconda| |ci| |pre-commit| 6 | 7 | .. |version| image:: https://img.shields.io/pypi/v/pytest-timeout.svg 8 | :target: https://pypi.python.org/pypi/pytest-timeout 9 | 10 | .. |anaconda| image:: https://img.shields.io/conda/vn/conda-forge/pytest-timeout.svg 11 | :target: https://anaconda.org/conda-forge/pytest-timeout 12 | 13 | .. |ci| image:: https://github.com/pytest-dev/pytest-timeout/workflows/build/badge.svg 14 | :target: https://github.com/pytest-dev/pytest-timeout/actions 15 | 16 | .. |python| image:: https://img.shields.io/pypi/pyversions/pytest-timeout.svg 17 | :target: https://pypi.python.org/pypi/pytest-timeout/ 18 | 19 | .. |pre-commit| image:: https://results.pre-commit.ci/badge/github/pytest-dev/pytest-timeout/master.svg 20 | :target: https://results.pre-commit.ci/latest/github/pytest-dev/pytest-timeout/master 21 | 22 | 23 | .. warning:: 24 | 25 | Please read this README carefully and only use this plugin if you 26 | understand the consequences. This plugin is designed to catch 27 | excessively long test durations like deadlocked or hanging tests, 28 | it is not designed for precise timings or performance regressions. 29 | Remember your test suite should aim to be **fast**, with timeouts 30 | being a last resort, not an expected failure mode. 31 | 32 | This plugin will time each test and terminate it when it takes too 33 | long. Termination may or may not be graceful, please see below, but 34 | when aborting it will show a stack dump of all thread running at the 35 | time. This is useful when running tests under a continuous 36 | integration server or simply if you don't know why the test suite 37 | hangs. 38 | 39 | .. note:: 40 | 41 | While by default on POSIX systems pytest will continue to execute 42 | the tests after a test has timed out this is not always possible. 43 | Often the only sure way to interrupt a hanging test is by 44 | terminating the entire process. As this is a hard termination 45 | (``os._exit()``) it will result in no teardown, JUnit XML output 46 | etc. But the plugin will ensure you will have the debugging output 47 | on stderr nevertheless, which is the most important part at this 48 | stage. See below for detailed information on the timeout methods 49 | and their side-effects. 50 | 51 | The pytest-timeout plugin has been tested on Python 3.7 and higher, 52 | including PyPy3. See tox.ini for currently tested versions. 53 | 54 | 55 | Usage 56 | ===== 57 | 58 | Install is as simple as e.g.:: 59 | 60 | pip install pytest-timeout 61 | 62 | Now you can run the test suite while setting a timeout in seconds, any 63 | individual test which takes longer than the given duration will be 64 | terminated:: 65 | 66 | pytest --timeout=300 67 | 68 | Furthermore you can also use a decorator to set the timeout for an 69 | individual test. If combined with the ``--timeout`` flag this will 70 | override the timeout for this individual test: 71 | 72 | .. code:: python 73 | 74 | @pytest.mark.timeout(60) 75 | def test_foo(): 76 | pass 77 | 78 | By default the plugin will not time out any tests, you must specify a 79 | valid timeout for the plugin to interrupt long-running tests. A 80 | timeout is always specified as a number of seconds, and can be 81 | defined in a number of ways, from low to high priority: 82 | 83 | 1. You can set a global timeout in the `pytest configuration file`__ 84 | using the ``timeout`` option. E.g.: 85 | 86 | .. code:: ini 87 | 88 | [pytest] 89 | timeout = 300 90 | 91 | 2. The ``PYTEST_TIMEOUT`` environment variable sets a global timeout 92 | overriding a possible value in the configuration file. 93 | 94 | 3. The ``--timeout`` command line option sets a global timeout 95 | overriding both the environment variable and configuration option. 96 | 97 | 4. Using the ``timeout`` marker_ on test items you can specify 98 | timeouts on a per-item basis: 99 | 100 | .. code:: python 101 | 102 | @pytest.mark.timeout(300) 103 | def test_foo(): 104 | pass 105 | 106 | __ https://docs.pytest.org/en/latest/reference.html#ini-options-ref 107 | 108 | .. _marker: https://docs.pytest.org/en/latest/mark.html 109 | 110 | Setting a timeout to 0 seconds disables the timeout, so if you have a 111 | global timeout set you can still disable the timeout by using the 112 | mark. 113 | 114 | Timeout Methods 115 | =============== 116 | 117 | Interrupting tests which hang is not always as simple and can be 118 | platform dependent. Furthermore some methods of terminating a test 119 | might conflict with the code under test itself. The pytest-timeout 120 | plugin tries to pick the most suitable method based on your platform, 121 | but occasionally you may need to specify a specific timeout method 122 | explicitly. 123 | 124 | If a timeout method does not work your safest bet is to use the 125 | *thread* method. 126 | 127 | thread 128 | ------ 129 | 130 | This is the surest and most portable method. It is also the default 131 | on systems not supporting the *signal* method. For each test item the 132 | pytest-timeout plugin starts a timer thread which will terminate the 133 | whole process after the specified timeout. When a test item finishes 134 | this timer thread is cancelled and the test run continues. 135 | 136 | The downsides of this method are that there is a relatively large 137 | overhead for running each test and that test runs are not completed. 138 | This means that other pytest features, like e.g. JUnit XML output or 139 | fixture teardown, will not function normally. The second issue might 140 | be alleviated by using the ``--forked`` option of the pytest-forked_ 141 | plugin. 142 | 143 | .. _pytest-forked: https://pypi.org/project/pytest-forked/ 144 | 145 | The benefit of this method is that it will always work. Furthermore 146 | it will still provide you debugging information by printing the stacks 147 | of all the threads in the application to stderr. 148 | 149 | signal 150 | ------ 151 | 152 | If the system supports the SIGALRM signal the *signal* method will be 153 | used by default. This method schedules an alarm when the test item 154 | starts and cancels the alarm when the test finishes. If the alarm expires 155 | during the test the signal handler will dump the stack of any other threads 156 | running to stderr and use ``pytest.fail()`` to interrupt the test. 157 | 158 | The benefit of this method is that the pytest process is not 159 | terminated and the test run can complete normally. 160 | 161 | The main issue to look out for with this method is that it may 162 | interfere with the code under test. If the code under test uses 163 | SIGALRM itself things will go wrong and you will have to choose the 164 | *thread* method. 165 | 166 | Specifying the Timeout Method 167 | ----------------------------- 168 | 169 | The timeout method can be specified by using the ``timeout_method`` 170 | option in the `pytest configuration file`__, the ``--timeout_method`` 171 | command line parameter or the ``timeout`` marker_. Simply set their 172 | value to the string ``thread`` or ``signal`` to override the default 173 | method. On a marker this is done using the ``method`` keyword: 174 | 175 | .. code:: python 176 | 177 | @pytest.mark.timeout(method="thread") 178 | def test_foo(): 179 | pass 180 | 181 | __ https://docs.pytest.org/en/latest/reference.html#ini-options-ref 182 | 183 | .. _marker: https://docs.pytest.org/en/latest/mark.html 184 | 185 | The ``timeout`` Marker API 186 | ========================== 187 | 188 | The full signature of the timeout marker is: 189 | 190 | .. code:: python 191 | 192 | pytest.mark.timeout(timeout=0, method=DEFAULT_METHOD) 193 | 194 | You can use either positional or keyword arguments for both the 195 | timeout and the method. Neither needs to be present. 196 | 197 | See the marker api documentation_ and examples_ for the various ways 198 | markers can be applied to test items. 199 | 200 | .. _documentation: https://docs.pytest.org/en/latest/mark.html 201 | 202 | .. _examples: https://docs.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules 203 | 204 | 205 | Timeouts in Fixture Teardown 206 | ============================ 207 | 208 | The plugin will happily terminate timeouts in the finalisers of 209 | fixtures. The timeout specified applies to the entire process of 210 | setting up fixtures, running the tests and finalising the fixtures. 211 | However when a timeout occurs in a fixture finaliser and the test 212 | suite continues, i.e. the signal method is used, it must be realised 213 | that subsequent fixtures which need to be finalised might not have 214 | been executed, which could result in a broken test-suite anyway. In 215 | case of doubt the thread method which terminates the entire process 216 | might result in clearer output. 217 | 218 | Avoiding timeouts in Fixtures 219 | ============================= 220 | 221 | The timeout applies to the entire test including any fixtures which 222 | may need to be setup or torn down for the test (the exact affected 223 | fixtures depends on which scope they are and whether other tests will 224 | still use the same fixture). If the timeouts really are too short to 225 | include fixture durations, firstly make the timeouts larger ;). If 226 | this really isn't an option a ``timeout_func_only`` boolean setting 227 | exists which can be set in the pytest ini configuration file, as 228 | documented in ``pytest --help``. 229 | 230 | For the decorated function, a decorator will override 231 | ``timeout_func_only = true`` in the pytest ini file to the default 232 | value. If you need to keep this option for a decorated test, you 233 | must specify the option explicitly again: 234 | 235 | .. code:: python 236 | 237 | @pytest.mark.timeout(60, func_only=True) 238 | def test_foo(): 239 | pass 240 | 241 | 242 | Debugger Detection 243 | ================== 244 | 245 | This plugin tries to avoid triggering the timeout when a debugger is 246 | detected. This is mostly a convenience so you do not need to remember 247 | to disable the timeout when interactively debugging. 248 | 249 | The way this plugin detects whether or not a debugging session is 250 | active is by checking if a trace function is set and if one is, it 251 | check to see if the module it belongs to is present in a set of known 252 | debugging frameworks modules OR if pytest itself drops you into a pdb 253 | session using ``--pdb`` or similar. 254 | 255 | This functionality can be disabled with the ``--disable-debugger-detection`` flag 256 | or the corresponding ``timeout_disable_debugger_detection`` ini setting / environment 257 | variable. 258 | 259 | 260 | Extending pytest-timeout with plugins 261 | ===================================== 262 | 263 | ``pytest-timeout`` provides two hooks that can be used for extending the tool. These 264 | hooks are used for setting the timeout timer and cancelling it if the timeout is not 265 | reached. 266 | 267 | For example, ``pytest-asyncio`` can provide asyncio-specific code that generates better 268 | traceback and points on timed out ``await`` instead of the running loop iteration. 269 | 270 | See `pytest hooks documentation 271 | `_ for more info 272 | regarding to use custom hooks. 273 | 274 | ``pytest_timeout_set_timer`` 275 | ---------------------------- 276 | 277 | .. code:: python 278 | 279 | @pytest.hookspec(firstresult=True) 280 | def pytest_timeout_set_timer(item, settings): 281 | """Called at timeout setup. 282 | 283 | 'item' is a pytest node to setup timeout for. 284 | 285 | 'settings' is Settings namedtuple (described below). 286 | 287 | Can be overridden by plugins for alternative timeout implementation strategies. 288 | """ 289 | 290 | 291 | ``Settings`` 292 | ------------ 293 | 294 | When ``pytest_timeout_set_timer`` is called, ``settings`` argument is passed. 295 | 296 | The argument has ``Settings`` namedtuple type with the following fields: 297 | 298 | +-----------+-------+--------------------------------------------------------+ 299 | |Attribute | Index | Value | 300 | +===========+=======+========================================================+ 301 | | timeout | 0 | timeout in seconds or ``None`` for no timeout | 302 | +-----------+-------+--------------------------------------------------------+ 303 | | method | 1 | Method mechanism, | 304 | | | | ``'signal'`` and ``'thread'`` are supported by default | 305 | +-----------+-------+--------------------------------------------------------+ 306 | | func_only | 2 | Apply timeout to test function only if ``True``, | 307 | | | | wrap all test function and its fixtures otherwise | 308 | +-----------+-------+--------------------------------------------------------+ 309 | 310 | ``pytest_timeout_cancel_timer`` 311 | ------------------------------- 312 | 313 | .. code:: python 314 | 315 | @pytest.hookspec(firstresult=True) 316 | def pytest_timeout_cancel_timer(item): 317 | """Called at timeout teardown. 318 | 319 | 'item' is a pytest node which was used for timeout setup. 320 | 321 | Can be overridden by plugins for alternative timeout implementation strategies. 322 | """ 323 | 324 | ``is_debugging`` 325 | ---------------- 326 | 327 | When the timeout occurs, user can open the debugger session. In this case, the timeout 328 | should be discarded. A custom hook can check this case by calling ``is_debugging()`` 329 | function: 330 | 331 | .. code:: python 332 | 333 | import pytest 334 | import pytest_timeout 335 | 336 | 337 | def on_timeout(): 338 | if pytest_timeout.is_debugging(): 339 | return 340 | pytest.fail("+++ Timeout +++") 341 | 342 | 343 | 344 | Session Timeout 345 | =============== 346 | 347 | The above mentioned timeouts are all per test function. 348 | The "per test function" timeouts will stop an individual test 349 | from taking too long. We may also want to limit the time of the entire 350 | set of tests running in one session. A session all of the tests 351 | that will be run with one invocation of pytest. 352 | 353 | A session timeout is set with ``--session-timeout`` and is in seconds. 354 | 355 | The following example shows a session timeout of 10 minutes (600 seconds):: 356 | 357 | pytest --session-timeout=600 358 | 359 | You can also set the session timeout the pytest configuration file using the ``session_timeout`` option: 360 | 361 | .. code:: ini 362 | 363 | [pytest] 364 | session_timeout = 600 365 | 366 | Cooperative timeouts 367 | -------------------- 368 | 369 | Session timeouts are cooperative timeouts. pytest-timeout checks the 370 | session time at the end of each test function, and stops further tests 371 | from running if the session timeout is exceeded. The session will 372 | results in a test failure if this occurs. 373 | 374 | In particular this means if a test does not finish of itself, it will 375 | only be interrupted if there is also a function timeout set. A 376 | session timeout is not enough to ensure that a test-suite is 377 | guaranteed to finish. 378 | 379 | Combining session and function timeouts 380 | --------------------------------------- 381 | 382 | It works fine to combine both session and function timeouts. In fact 383 | when using a session timeout it is recommended to also provide a 384 | function timeout. 385 | 386 | For example, to limit test functions to 5 seconds and the full session 387 | to 100 seconds:: 388 | 389 | pytest --timeout=5 --session-timeout=100 390 | 391 | 392 | Changelog 393 | ========= 394 | 395 | x.y.z 396 | ----- 397 | - Minimum support Python3.10 and pytest=8.0. Thanks Vladimir Roshchin. 398 | - Add support Python3.13 and Python3.14. Thanks Vladimir Roshchin. 399 | - Detect debuggers registered with sys.monitoring. Thanks Rich Chiodo. 400 | 401 | 2.3.1 402 | ----- 403 | 404 | - Fixup some build errors, mostly README syntax which stopped twine 405 | from uploading. 406 | 407 | 2.3.0 408 | ----- 409 | 410 | - Fix debugger detection for recent VSCode, this compiles pydevd using 411 | cython which is now correctly detected. Thanks Adrian Gielniewski. 412 | - Switched to using Pytest's ``TerminalReporter`` instead of writing 413 | directly to ``sys.{stdout,stderr}``. 414 | This change also switches all output from ``sys.stderr`` to ``sys.stdout``. 415 | Thanks Pedro Algarvio. 416 | - Pytest 7.0.0 is now the minimum supported version. Thanks Pedro Algarvio. 417 | - Add ``--session-timeout`` option and ``session_timeout`` setting. 418 | Thanks Brian Okken. 419 | 420 | 2.2.0 421 | ----- 422 | 423 | - Add ``--timeout-disable-debugger-detection`` flag, thanks 424 | Michael Peters 425 | 426 | 2.1.0 427 | ----- 428 | 429 | - Get terminal width from shutil instead of deprecated py, thanks 430 | Andrew Svetlov. 431 | - Add an API for extending ``pytest-timeout`` functionality 432 | with third-party plugins, thanks Andrew Svetlov. 433 | 434 | 2.0.2 435 | ----- 436 | 437 | - Fix debugger detection on OSX, thanks Alexander Pacha. 438 | 439 | 2.0.1 440 | ----- 441 | 442 | - Fix Python 2 removal, thanks Nicusor Picatureanu. 443 | 444 | 2.0.0 445 | ----- 446 | 447 | - Increase pytest requirement to >=5.0.0. Thanks Dominic Davis-Foster. 448 | - Use thread timeout method when plugin is not called from main 449 | thread to avoid crash. 450 | - Fix pycharm debugger detection so timeouts are not triggered during 451 | debugger usage. 452 | - Dropped support for Python 2, minimum pytest version supported is 5.0.0. 453 | 454 | 1.4.2 455 | ----- 456 | 457 | - Fix compatibility when run with pytest pre-releases, thanks 458 | Bruno Oliveira, 459 | - Fix detection of third-party debuggers, thanks Bruno Oliveira. 460 | 461 | 1.4.1 462 | ----- 463 | 464 | - Fix coverage compatibility which was broken by 1.4.0. 465 | 466 | 1.4.0 467 | ----- 468 | 469 | - Better detection of when we are debugging, thanks Mattwmaster58. 470 | 471 | 1.3.4 472 | ----- 473 | 474 | - Give the threads a name to help debugging, thanks Thomas Grainger. 475 | - Changed location to https://github.com/pytest-dev/pytest-timeout 476 | because bitbucket is dropping mercurial support. Thanks Thomas 477 | Grainger and Bruno Oliveira. 478 | 479 | 1.3.3 480 | ----- 481 | 482 | - Fix support for pytest >= 3.10. 483 | 484 | 1.3.2 485 | ----- 486 | 487 | - This changelog was omitted for the 1.3.2 release and was added 488 | afterwards. Apologies for the confusion. 489 | - Fix pytest 3.7.3 compatibility. The capture API had changed 490 | slightly and this needed fixing. Thanks Bruno Oliveira for the 491 | contribution. 492 | 493 | 1.3.1 494 | ----- 495 | 496 | - Fix deprecation warning on Python 3.6. Thanks Mickaël Schoentgen 497 | - Create a valid tag for the release. Somehow this didn't happen for 498 | 1.3.0, that tag points to a non-existing commit. 499 | 500 | 1.3.0 501 | ----- 502 | 503 | - Make it possible to only run the timeout timer on the test function 504 | and not the whole fixture setup + test + teardown duration. Thanks 505 | Pedro Algarvio for the work! 506 | - Use the new pytest marker API, Thanks Pedro Algarvio for the work! 507 | 508 | 1.2.1 509 | ----- 510 | 511 | - Fix for pytest 3.3, thanks Bruno Oliveira. 512 | - Update supported python versions: 513 | - Add CPython 3.6. 514 | - Drop CPython 2.6 (as did pytest 3.3) 515 | - Drop CPython 3.3 516 | - Drop CPython 3.4 517 | 518 | 1.2.0 519 | ----- 520 | 521 | * Allow using floats as timeout instead of only integers, thanks Tom 522 | Myers. 523 | 524 | 1.1.0 525 | ----- 526 | 527 | * Report (default) timeout duration in header, thanks Holger Krekel. 528 | 529 | 1.0.0 530 | ----- 531 | 532 | * Bump version to 1.0 to commit to semantic versioning. 533 | * Fix issue #12: Now compatible with pytest 2.8, thanks Holger Krekel. 534 | * No longer test with pexpect on py26 as it is no longer supported 535 | * Require pytest 2.8 and use new hookimpl decorator 536 | 537 | 0.5 538 | --- 539 | 540 | * Timeouts will no longer be triggered when inside an interactive pdb 541 | session started by ``pytest.set_trace()`` / ``pdb.set_trace()``. 542 | 543 | * Add pypy3 environment to tox.ini. 544 | 545 | * Transfer repository to pytest-dev team account. 546 | 547 | 0.4 548 | --- 549 | 550 | * Support timeouts happening in (session scoped) finalizers. 551 | 552 | * Change command line option --timeout_method into --timeout-method 553 | for consistency with pytest 554 | 555 | 0.3 556 | --- 557 | 558 | * Added the PYTEST_TIMEOUT environment variable as a way of specifying 559 | the timeout (closes issue #2). 560 | 561 | * More flexible marker argument parsing: you can now specify the 562 | method using a positional argument. 563 | 564 | * The plugin is now enabled by default. There is no longer a need to 565 | specify ``timeout=0`` in the configuration file or on the command 566 | line simply so that a marker would work. 567 | 568 | 569 | 0.2 570 | --- 571 | 572 | * Add a marker to modify the timeout delay using a @pytest.timeout(N) 573 | syntax, thanks to Laurant Brack for the initial code. 574 | 575 | * Allow the timeout marker to select the timeout method using the 576 | ``method`` keyword argument. 577 | 578 | * Rename the --nosigalrm option to --method=thread to future proof 579 | support for eventlet and gevent. Thanks to Ronny Pfannschmidt for 580 | the hint. 581 | 582 | * Add ``timeout`` and ``timeout_method`` items to the configuration 583 | file so you can enable and configure the plugin using the ini file. 584 | Thanks to Holger Krekel and Ronny Pfannschmidt for the hints. 585 | 586 | * Tested (and fixed) for python 2.6, 2.7 and 3.2. 587 | --------------------------------------------------------------------------------