Introduce new parameter raise_timeout_exceptions
to prepare for the introduction of raising exceptions in timeout
conditions by default in a future release. Forward compatible code sets
this parameter to True.
Markus Gerstel
2 years ago
7 | 7 | import sys |
8 | 8 | import time |
9 | 9 | import timeit |
10 | import warnings | |
10 | 11 | from multiprocessing import Pipe |
11 | 12 | from threading import Thread |
12 | 13 | |
345 | 346 | environment_override=None, |
346 | 347 | win32resolve=True, |
347 | 348 | working_directory=None, |
349 | raise_timeout_exception=False, | |
348 | 350 | ): |
349 | 351 | """ |
350 | 352 | Run an external process. |
371 | 373 | extension. |
372 | 374 | :param string working_directory: If specified, run the executable from |
373 | 375 | within this working directory. |
376 | :param boolean raise_timeout_exception: Forward compatibility flag. If set | |
377 | then a subprocess.TimeoutExpired exception is raised | |
378 | instead of returning an object that can be checked | |
379 | for a timeout condition. Defaults to False, will be | |
380 | changed to True in a future release. | |
374 | 381 | :return: A ReturnObject() containing the executed command, stdout and stderr |
375 | 382 | (both as bytestrings), and the exitcode. Further values such as |
376 | 383 | process runtime can be accessed as dictionary values. |
388 | 395 | start_time = timeit.default_timer() |
389 | 396 | if timeout is not None: |
390 | 397 | max_time = start_time + timeout |
398 | if not raise_timeout_exception: | |
399 | warnings.warn( | |
400 | "Using procrunner with timeout and without raise_timeout_exception set is deprecated", | |
401 | DeprecationWarning, | |
402 | stacklevel=2, | |
403 | ) | |
391 | 404 | |
392 | 405 | if environment is not None: |
393 | 406 | env = {key: _path_resolve(environment[key]) for key in environment} |
518 | 531 | |
519 | 532 | stdout = stdout.get_output() |
520 | 533 | stderr = stderr.get_output() |
534 | ||
535 | if timeout_encountered and raise_timeout_exception: | |
536 | raise subprocess.TimeoutExpired( | |
537 | cmd=command, timeout=timeout, output=stdout, stderr=stderr | |
538 | ) | |
539 | ||
521 | 540 | time_end = time.strftime("%Y-%m-%d %H:%M:%S GMT", time.gmtime()) |
522 | ||
523 | 541 | result = ReturnObject( |
524 | 542 | { |
525 | 543 | "exitcode": p.returncode, |
3 | 3 | import procrunner |
4 | 4 | import pytest |
5 | 5 | import sys |
6 | ||
7 | ||
8 | @mock.patch("procrunner._NonBlockingStreamReader") | |
9 | @mock.patch("procrunner.time") | |
10 | @mock.patch("procrunner.subprocess") | |
11 | @mock.patch("procrunner.Pipe") | |
12 | def test_run_command_aborts_after_timeout_legacy( | |
13 | mock_pipe, mock_subprocess, mock_time, mock_streamreader | |
14 | ): | |
15 | mock_pipe.return_value = mock.Mock(), mock.Mock() | |
16 | mock_process = mock.Mock() | |
17 | mock_process.returncode = None | |
18 | mock_subprocess.Popen.return_value = mock_process | |
19 | task = ["___"] | |
20 | ||
21 | with pytest.raises(RuntimeError): | |
22 | with pytest.warns(DeprecationWarning, match="timeout"): | |
23 | procrunner.run(task, -1, False) | |
24 | ||
25 | assert mock_subprocess.Popen.called | |
26 | assert mock_process.terminate.called | |
27 | assert mock_process.kill.called | |
6 | 28 | |
7 | 29 | |
8 | 30 | @mock.patch("procrunner._NonBlockingStreamReader") |
19 | 41 | task = ["___"] |
20 | 42 | |
21 | 43 | with pytest.raises(RuntimeError): |
22 | procrunner.run(task, -1, False) | |
44 | procrunner.run(task, -1, False, raise_timeout_exception=True) | |
23 | 45 | |
24 | 46 | assert mock_subprocess.Popen.called |
25 | 47 | assert mock_process.terminate.called |
65 | 87 | callback_stdout=mock.sentinel.callback_stdout, |
66 | 88 | callback_stderr=mock.sentinel.callback_stderr, |
67 | 89 | working_directory=mock.sentinel.cwd, |
90 | raise_timeout_exception=True, | |
68 | 91 | ) |
69 | 92 | |
70 | 93 | assert mock_subprocess.Popen.called |
103 | 126 | def test_default_process_environment_is_parent_environment(mock_subprocess): |
104 | 127 | mock_subprocess.Popen.side_effect = NotImplementedError() # cut calls short |
105 | 128 | with pytest.raises(NotImplementedError): |
106 | procrunner.run([mock.Mock()], -1, False) | |
129 | procrunner.run([mock.Mock()], -1, False, raise_timeout_exception=True) | |
107 | 130 | assert mock_subprocess.Popen.call_args[1]["env"] == os.environ |
108 | 131 | |
109 | 132 | |
113 | 136 | mock_env = {"key": mock.sentinel.key} |
114 | 137 | # Pass an environment dictionary |
115 | 138 | with pytest.raises(NotImplementedError): |
116 | procrunner.run([mock.Mock()], -1, False, environment=copy.copy(mock_env)) | |
139 | procrunner.run( | |
140 | [mock.Mock()], | |
141 | -1, | |
142 | False, | |
143 | environment=copy.copy(mock_env), | |
144 | raise_timeout_exception=True, | |
145 | ) | |
117 | 146 | assert mock_subprocess.Popen.call_args[1]["env"] == mock_env |
118 | 147 | |
119 | 148 | |
130 | 159 | False, |
131 | 160 | environment=copy.copy(mock_env1), |
132 | 161 | environment_override=copy.copy(mock_env2), |
162 | raise_timeout_exception=True, | |
133 | 163 | ) |
134 | 164 | mock_env_sum = copy.copy(mock_env1) |
135 | 165 | mock_env_sum.update({key: str(mock_env2[key]) for key in mock_env2}) |
142 | 172 | mock_env2 = {"keyB": str(mock.sentinel.keyB)} |
143 | 173 | with pytest.raises(NotImplementedError): |
144 | 174 | procrunner.run( |
145 | [mock.Mock()], -1, False, environment_override=copy.copy(mock_env2) | |
175 | [mock.Mock()], | |
176 | -1, | |
177 | False, | |
178 | environment_override=copy.copy(mock_env2), | |
179 | raise_timeout_exception=True, | |
146 | 180 | ) |
147 | 181 | random_environment_variable = list(os.environ)[0] |
148 | 182 | if random_environment_variable == list(mock_env2)[0]: |
173 | 207 | environment_override={ |
174 | 208 | random_environment_variable: "X" + random_environment_value |
175 | 209 | }, |
210 | raise_timeout_exception=True, | |
176 | 211 | ) |
177 | 212 | assert ( |
178 | 213 | mock_subprocess.Popen.call_args[1]["env"][random_environment_variable] |
0 | 0 | import os |
1 | import subprocess | |
1 | 2 | import sys |
3 | import timeit | |
2 | 4 | |
3 | 5 | import procrunner |
4 | 6 | import pytest |
67 | 69 | assert ( |
68 | 70 | "LEAK_DETECTOR" not in os.environ |
69 | 71 | ), "overridden environment variable leaked into parent process" |
72 | ||
73 | ||
74 | def test_timeout_behaviour_legacy(tmp_path): | |
75 | start = timeit.default_timer() | |
76 | with pytest.warns(DeprecationWarning, match="timeout"): | |
77 | result = procrunner.run( | |
78 | [sys.executable, "-c", "import time; time.sleep(5)"], | |
79 | timeout=0.1, | |
80 | working_directory=tmp_path, | |
81 | raise_timeout_exception=False, | |
82 | ) | |
83 | runtime = timeit.default_timer() - start | |
84 | if hasattr(result, "timeout"): | |
85 | with pytest.warns(DeprecationWarning, match="\\.timeout"): | |
86 | assert result.timeout | |
87 | else: | |
88 | assert result["timeout"] | |
89 | assert runtime < 3 | |
90 | assert not result.stdout | |
91 | assert not result.stderr | |
92 | assert result.returncode | |
93 | ||
94 | ||
95 | def test_timeout_behaviour(tmp_path): | |
96 | command = (sys.executable, "-c", "import time; time.sleep(5)") | |
97 | start = timeit.default_timer() | |
98 | with pytest.raises(subprocess.TimeoutExpired) as te: | |
99 | procrunner.run( | |
100 | command, | |
101 | timeout=0.1, | |
102 | working_directory=tmp_path, | |
103 | raise_timeout_exception=True, | |
104 | ) | |
105 | runtime = timeit.default_timer() - start | |
106 | assert runtime < 3 | |
107 | assert te.value.stdout == b"" | |
108 | assert te.value.stderr == b"" | |
109 | assert te.value.timeout == 0.1 | |
110 | assert te.value.cmd == command |