pytest-subproc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_subproc/__init__.py +508 -0
- pytest_subproc/_subproc_runner.py +118 -0
- pytest_subproc-0.1.0.dist-info/METADATA +162 -0
- pytest_subproc-0.1.0.dist-info/RECORD +10 -0
- pytest_subproc-0.1.0.dist-info/WHEEL +5 -0
- pytest_subproc-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_subproc-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytest_subproc-0.1.0.dist-info/scm_file_list.json +17 -0
- pytest_subproc-0.1.0.dist-info/scm_version.json +8 -0
- pytest_subproc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import pickle
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_INI_FORWARD_NAMES = [
|
|
13
|
+
"asyncio_mode",
|
|
14
|
+
"asyncio_default_fixture_loop_scope",
|
|
15
|
+
"xfail_strict",
|
|
16
|
+
"log_cli",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _forward_ini_overrides(config):
|
|
21
|
+
overrides = {}
|
|
22
|
+
for name in _INI_FORWARD_NAMES:
|
|
23
|
+
try:
|
|
24
|
+
val = config.getini(name)
|
|
25
|
+
if val is not None and val != "":
|
|
26
|
+
if isinstance(val, (list, tuple)):
|
|
27
|
+
parts = [str(v) for v in val if v]
|
|
28
|
+
val = " ".join(parts) if parts else None
|
|
29
|
+
elif hasattr(val, "value"):
|
|
30
|
+
val = val.value
|
|
31
|
+
else:
|
|
32
|
+
val = str(val)
|
|
33
|
+
if val is not None and val != "":
|
|
34
|
+
overrides[name] = val
|
|
35
|
+
except (ValueError, KeyError):
|
|
36
|
+
pass
|
|
37
|
+
return overrides
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_skip(item):
|
|
41
|
+
for marker in item.iter_markers(name="skip"):
|
|
42
|
+
condition = marker.kwargs.get("condition", True)
|
|
43
|
+
if callable(condition):
|
|
44
|
+
if not condition():
|
|
45
|
+
continue
|
|
46
|
+
elif not condition:
|
|
47
|
+
continue
|
|
48
|
+
reason = marker.kwargs.get(
|
|
49
|
+
"reason",
|
|
50
|
+
marker.args[0] if marker.args else "",
|
|
51
|
+
)
|
|
52
|
+
return reason
|
|
53
|
+
for marker in item.iter_markers(name="skipif"):
|
|
54
|
+
args = marker.args
|
|
55
|
+
if not args:
|
|
56
|
+
continue
|
|
57
|
+
condition = args[0]
|
|
58
|
+
if callable(condition):
|
|
59
|
+
if not condition():
|
|
60
|
+
continue
|
|
61
|
+
elif not condition:
|
|
62
|
+
continue
|
|
63
|
+
return marker.kwargs.get("reason", "")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_xfail_info(item):
|
|
68
|
+
for marker in item.iter_markers(name="xfail"):
|
|
69
|
+
run = marker.kwargs.get("run", True)
|
|
70
|
+
strict = marker.kwargs.get("strict")
|
|
71
|
+
if strict is None:
|
|
72
|
+
strict = item.config.getini("xfail_strict")
|
|
73
|
+
if strict is None:
|
|
74
|
+
strict = item.config.getini("strict")
|
|
75
|
+
raises = marker.kwargs.get("raises", None)
|
|
76
|
+
if "condition" not in marker.kwargs:
|
|
77
|
+
conditions = marker.args
|
|
78
|
+
else:
|
|
79
|
+
conditions = (marker.kwargs["condition"],)
|
|
80
|
+
if not conditions:
|
|
81
|
+
reason = marker.kwargs.get("reason", "")
|
|
82
|
+
return {
|
|
83
|
+
"reason": reason,
|
|
84
|
+
"strict": strict,
|
|
85
|
+
"raises": raises,
|
|
86
|
+
"run": run,
|
|
87
|
+
}
|
|
88
|
+
for condition in conditions:
|
|
89
|
+
result = condition() if callable(condition) else condition
|
|
90
|
+
if result:
|
|
91
|
+
reason = marker.kwargs.get("reason", "")
|
|
92
|
+
return {
|
|
93
|
+
"reason": reason,
|
|
94
|
+
"strict": strict,
|
|
95
|
+
"raises": raises,
|
|
96
|
+
"run": run,
|
|
97
|
+
}
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.hookimpl(tryfirst=True)
|
|
102
|
+
def pytest_runtest_protocol(item, nextitem):
|
|
103
|
+
if not _should_spawn(item):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
reason = _check_skip(item)
|
|
107
|
+
if reason is not None:
|
|
108
|
+
item.ihook.pytest_runtest_logstart(
|
|
109
|
+
nodeid=item.nodeid,
|
|
110
|
+
location=item.location,
|
|
111
|
+
)
|
|
112
|
+
call = pytest.CallInfo.from_call(
|
|
113
|
+
lambda: pytest.skip(reason),
|
|
114
|
+
when="setup",
|
|
115
|
+
reraise=None,
|
|
116
|
+
)
|
|
117
|
+
rep = item.ihook.pytest_runtest_makereport(item=item, call=call)
|
|
118
|
+
item.ihook.pytest_runtest_logreport(report=rep)
|
|
119
|
+
item.ihook.pytest_runtest_logfinish(
|
|
120
|
+
nodeid=item.nodeid,
|
|
121
|
+
location=item.location,
|
|
122
|
+
)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
timeout = _resolve_timeout(item)
|
|
126
|
+
|
|
127
|
+
# Cancel pytest-timeout's timeout so its timer does not kill the
|
|
128
|
+
# parent while we wait for the subprocess.
|
|
129
|
+
if sys.platform == "win32":
|
|
130
|
+
_cancel_pytest_timeout(item)
|
|
131
|
+
|
|
132
|
+
item.ihook.pytest_runtest_logstart(
|
|
133
|
+
nodeid=item.nodeid,
|
|
134
|
+
location=item.location,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = run_subprocess_test(item, item.nodeid, timeout)
|
|
139
|
+
except TimeoutError as exc:
|
|
140
|
+
msg = str(exc)
|
|
141
|
+
call = pytest.CallInfo.from_call(
|
|
142
|
+
lambda: pytest.fail(msg),
|
|
143
|
+
when="call",
|
|
144
|
+
reraise=None,
|
|
145
|
+
)
|
|
146
|
+
rep = item.ihook.pytest_runtest_makereport(item=item, call=call)
|
|
147
|
+
item.ihook.pytest_runtest_logreport(report=rep)
|
|
148
|
+
item.ihook.pytest_runtest_logfinish(
|
|
149
|
+
nodeid=item.nodeid,
|
|
150
|
+
location=item.location,
|
|
151
|
+
)
|
|
152
|
+
return True
|
|
153
|
+
except (pytest.fail.Exception, KeyboardInterrupt) as exc:
|
|
154
|
+
item.ihook.pytest_runtest_logfinish(
|
|
155
|
+
nodeid=item.nodeid,
|
|
156
|
+
location=item.location,
|
|
157
|
+
)
|
|
158
|
+
msg = str(exc)
|
|
159
|
+
call = pytest.CallInfo.from_call(
|
|
160
|
+
lambda: pytest.fail(msg),
|
|
161
|
+
when="call",
|
|
162
|
+
reraise=None,
|
|
163
|
+
)
|
|
164
|
+
rep = item.ihook.pytest_runtest_makereport(item=item, call=call)
|
|
165
|
+
item.ihook.pytest_runtest_logreport(report=rep)
|
|
166
|
+
return True
|
|
167
|
+
except BaseException:
|
|
168
|
+
item.ihook.pytest_runtest_logfinish(
|
|
169
|
+
nodeid=item.nodeid,
|
|
170
|
+
location=item.location,
|
|
171
|
+
)
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
stdout = result.get("_stdout", "")
|
|
175
|
+
stderr = result.get("_stderr", "")
|
|
176
|
+
|
|
177
|
+
if result.get("passed"):
|
|
178
|
+
call = pytest.CallInfo.from_call(
|
|
179
|
+
lambda: None,
|
|
180
|
+
when="call",
|
|
181
|
+
reraise=None,
|
|
182
|
+
)
|
|
183
|
+
elif result.get("xfailed"):
|
|
184
|
+
exc = result["exception"]
|
|
185
|
+
|
|
186
|
+
def _raise_xfail():
|
|
187
|
+
raise exc
|
|
188
|
+
|
|
189
|
+
call = pytest.CallInfo.from_call(
|
|
190
|
+
_raise_xfail,
|
|
191
|
+
when="call",
|
|
192
|
+
reraise=None,
|
|
193
|
+
)
|
|
194
|
+
elif "exception" in result:
|
|
195
|
+
exc = result["exception"]
|
|
196
|
+
msg = _build_exception_message(exc, stdout, stderr)
|
|
197
|
+
if msg != str(exc):
|
|
198
|
+
try:
|
|
199
|
+
exc.args = (msg, *exc.args[1:])
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _raise():
|
|
204
|
+
raise exc
|
|
205
|
+
|
|
206
|
+
call = pytest.CallInfo.from_call(_raise, when="call", reraise=None)
|
|
207
|
+
else:
|
|
208
|
+
msg = result.get("message", "Test failed in subprocess")
|
|
209
|
+
msg = _build_exception_message(Exception(msg), stdout, stderr)
|
|
210
|
+
|
|
211
|
+
def _fail():
|
|
212
|
+
pytest.fail(msg)
|
|
213
|
+
|
|
214
|
+
call = pytest.CallInfo.from_call(
|
|
215
|
+
_fail,
|
|
216
|
+
when="call",
|
|
217
|
+
reraise=None,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
rep = item.ihook.pytest_runtest_makereport(item=item, call=call)
|
|
221
|
+
item.ihook.pytest_runtest_logreport(report=rep)
|
|
222
|
+
item.ihook.pytest_runtest_logfinish(
|
|
223
|
+
nodeid=item.nodeid,
|
|
224
|
+
location=item.location,
|
|
225
|
+
)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
230
|
+
def pytest_runtest_makereport(item, call):
|
|
231
|
+
outcome = yield
|
|
232
|
+
if call.when != "call":
|
|
233
|
+
return
|
|
234
|
+
rep = outcome.get_result()
|
|
235
|
+
if getattr(rep, "wasxfail", None) is not None:
|
|
236
|
+
return
|
|
237
|
+
xfail_info = _get_xfail_info(item)
|
|
238
|
+
if xfail_info is None or not xfail_info.get("run", True):
|
|
239
|
+
return
|
|
240
|
+
if call.excinfo:
|
|
241
|
+
raises = xfail_info["raises"]
|
|
242
|
+
if raises is None or isinstance(call.excinfo.value, raises):
|
|
243
|
+
rep.outcome = "skipped"
|
|
244
|
+
rep.wasxfail = xfail_info["reason"]
|
|
245
|
+
elif not rep.skipped:
|
|
246
|
+
if xfail_info["strict"]:
|
|
247
|
+
rep.outcome = "failed"
|
|
248
|
+
rep.longrepr = "[XPASS(strict)] " + xfail_info["reason"]
|
|
249
|
+
else:
|
|
250
|
+
rep.outcome = "passed"
|
|
251
|
+
rep.wasxfail = xfail_info["reason"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _get_pytest_timeout_marker(item):
|
|
255
|
+
"""Get timeout value from @pytest.mark.timeout marker, if any."""
|
|
256
|
+
for marker in item.iter_markers(name="timeout"):
|
|
257
|
+
if marker.args:
|
|
258
|
+
try:
|
|
259
|
+
return float(marker.args[0])
|
|
260
|
+
except (ValueError, TypeError):
|
|
261
|
+
pass
|
|
262
|
+
kwargs_timeout = marker.kwargs.get("timeout")
|
|
263
|
+
if kwargs_timeout is not None:
|
|
264
|
+
try:
|
|
265
|
+
return float(kwargs_timeout)
|
|
266
|
+
except (ValueError, TypeError):
|
|
267
|
+
pass
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_config_default_timeout = None
|
|
272
|
+
_config_global_enabled = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def config_default_timeout(value=None):
|
|
276
|
+
if value is not None:
|
|
277
|
+
global _config_default_timeout
|
|
278
|
+
_config_default_timeout = float(value)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def config_global_enabled(value=None):
|
|
282
|
+
if value is not None:
|
|
283
|
+
global _config_global_enabled
|
|
284
|
+
_config_global_enabled = value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _resolve_timeout(item):
|
|
288
|
+
candidates = []
|
|
289
|
+
|
|
290
|
+
marker = item.get_closest_marker("subproc")
|
|
291
|
+
if marker is not None:
|
|
292
|
+
t = marker.kwargs.get("timeout")
|
|
293
|
+
if t is not None:
|
|
294
|
+
candidates.append(("subproc marker", float(t)))
|
|
295
|
+
|
|
296
|
+
pt = _get_pytest_timeout_marker(item)
|
|
297
|
+
if pt is not None:
|
|
298
|
+
candidates.append(("pytest-timeout marker", pt))
|
|
299
|
+
|
|
300
|
+
if candidates:
|
|
301
|
+
candidates.sort(key=lambda x: x[1])
|
|
302
|
+
return candidates[0][1]
|
|
303
|
+
|
|
304
|
+
cli = item.config.getoption("subproc_default_timeout", default=None)
|
|
305
|
+
if cli is not None and cli != "":
|
|
306
|
+
try:
|
|
307
|
+
return float(cli)
|
|
308
|
+
except ValueError:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
ini = item.config.getini("subproc_default_timeout")
|
|
312
|
+
if ini is not None and ini != "":
|
|
313
|
+
try:
|
|
314
|
+
return float(ini)
|
|
315
|
+
except ValueError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
if _config_default_timeout is not None:
|
|
319
|
+
return _config_default_timeout
|
|
320
|
+
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _should_spawn(item):
|
|
325
|
+
marker = item.get_closest_marker("subproc")
|
|
326
|
+
if marker is None:
|
|
327
|
+
return False
|
|
328
|
+
if "condition" in marker.kwargs:
|
|
329
|
+
condition = marker.kwargs["condition"]
|
|
330
|
+
if callable(condition):
|
|
331
|
+
return condition()
|
|
332
|
+
return bool(condition)
|
|
333
|
+
if _config_global_enabled is not None:
|
|
334
|
+
if callable(_config_global_enabled):
|
|
335
|
+
return _config_global_enabled()
|
|
336
|
+
return _config_global_enabled
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _get_timeout(item):
|
|
341
|
+
return _resolve_timeout(item)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _cancel_pytest_timeout(item):
|
|
345
|
+
"""Cancel pytest-timeout's timeout for this test item.
|
|
346
|
+
|
|
347
|
+
pytest-timeout stores a ``cancel_timeout`` callable on the item
|
|
348
|
+
that stops the timer thread. Calling it prevents the timer from
|
|
349
|
+
killing the parent while we wait for the subprocess.
|
|
350
|
+
"""
|
|
351
|
+
try:
|
|
352
|
+
cancel = getattr(item, "cancel_timeout", None)
|
|
353
|
+
if cancel is not None:
|
|
354
|
+
cancel()
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _kill_process_tree(proc):
|
|
360
|
+
"""Kill the subprocess and its entire process group / tree."""
|
|
361
|
+
try:
|
|
362
|
+
if sys.platform == "win32":
|
|
363
|
+
subprocess.run(
|
|
364
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
365
|
+
capture_output=True,
|
|
366
|
+
timeout=5,
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
pgid = os.getpgid(proc.pid)
|
|
370
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
371
|
+
except (OSError, subprocess.TimeoutExpired, Exception):
|
|
372
|
+
try:
|
|
373
|
+
proc.kill()
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run_subprocess_test(item, nodeid, timeout):
|
|
379
|
+
runner_path = os.path.join(os.path.dirname(__file__), "_subproc_runner.py")
|
|
380
|
+
|
|
381
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
|
382
|
+
result_path = f.name
|
|
383
|
+
|
|
384
|
+
env = os.environ.copy()
|
|
385
|
+
env["PYTHONPATH"] = os.pathsep.join(sys.path)
|
|
386
|
+
|
|
387
|
+
rootdir = str(item.config.rootpath)
|
|
388
|
+
|
|
389
|
+
ini_overrides = _forward_ini_overrides(item.config)
|
|
390
|
+
|
|
391
|
+
args = [
|
|
392
|
+
sys.executable,
|
|
393
|
+
runner_path,
|
|
394
|
+
nodeid,
|
|
395
|
+
result_path,
|
|
396
|
+
rootdir,
|
|
397
|
+
json.dumps(ini_overrides),
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
popen_kwargs = dict(
|
|
401
|
+
args=args,
|
|
402
|
+
stdin=subprocess.PIPE,
|
|
403
|
+
stdout=subprocess.PIPE,
|
|
404
|
+
stderr=subprocess.PIPE,
|
|
405
|
+
env=env,
|
|
406
|
+
)
|
|
407
|
+
if sys.platform != "win32":
|
|
408
|
+
popen_kwargs["start_new_session"] = True
|
|
409
|
+
|
|
410
|
+
proc = subprocess.Popen(**popen_kwargs)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
414
|
+
except subprocess.TimeoutExpired:
|
|
415
|
+
_kill_process_tree(proc)
|
|
416
|
+
stdout, stderr = proc.communicate()
|
|
417
|
+
try:
|
|
418
|
+
os.unlink(result_path)
|
|
419
|
+
except OSError:
|
|
420
|
+
pass
|
|
421
|
+
raise TimeoutError(f"Test timed out after {timeout}s")
|
|
422
|
+
except BaseException:
|
|
423
|
+
_kill_process_tree(proc)
|
|
424
|
+
stdout, stderr = proc.communicate()
|
|
425
|
+
try:
|
|
426
|
+
os.unlink(result_path)
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
with open(result_path, "rb") as f:
|
|
433
|
+
data_bytes = f.read()
|
|
434
|
+
if data_bytes:
|
|
435
|
+
result = pickle.loads(data_bytes)
|
|
436
|
+
else:
|
|
437
|
+
result = {
|
|
438
|
+
"failed": True,
|
|
439
|
+
"message": f"Subprocess crashed (exit code {proc.returncode})",
|
|
440
|
+
}
|
|
441
|
+
except FileNotFoundError:
|
|
442
|
+
result = {
|
|
443
|
+
"failed": True,
|
|
444
|
+
"message": f"Subprocess crashed (exit code {proc.returncode})",
|
|
445
|
+
}
|
|
446
|
+
except (pickle.UnpicklingError, EOFError, Exception):
|
|
447
|
+
result = {
|
|
448
|
+
"failed": True,
|
|
449
|
+
"message": f"Subprocess crashed (exit code {proc.returncode})",
|
|
450
|
+
}
|
|
451
|
+
finally:
|
|
452
|
+
try:
|
|
453
|
+
os.unlink(result_path)
|
|
454
|
+
except OSError:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
result["_stdout"] = stdout.decode(errors="replace")
|
|
458
|
+
result["_stderr"] = stderr.decode(errors="replace")
|
|
459
|
+
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _build_exception_message(exc, stdout, stderr):
|
|
464
|
+
msg = str(exc) if str(exc) else ""
|
|
465
|
+
if stdout:
|
|
466
|
+
msg += f"\n[subproc stdout]\n{stdout}"
|
|
467
|
+
if stderr:
|
|
468
|
+
msg += f"\n[subproc stderr]\n{stderr}"
|
|
469
|
+
return msg
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def pytest_addoption(parser):
|
|
473
|
+
parser.addini(
|
|
474
|
+
"subproc_default_timeout",
|
|
475
|
+
type="string",
|
|
476
|
+
default=None,
|
|
477
|
+
help="Default timeout in seconds for subprocess tests (int or float)",
|
|
478
|
+
)
|
|
479
|
+
parser.addoption(
|
|
480
|
+
"--subprocess-timeout",
|
|
481
|
+
dest="subproc_default_timeout",
|
|
482
|
+
type=str,
|
|
483
|
+
default=None,
|
|
484
|
+
metavar="SECONDS",
|
|
485
|
+
help="Default timeout in seconds for @pytest.mark.subproc tests",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def pytest_configure(config):
|
|
490
|
+
config.addinivalue_line(
|
|
491
|
+
"markers",
|
|
492
|
+
"subproc(timeout=None, condition=None): "
|
|
493
|
+
"run the marked test in a subprocess. "
|
|
494
|
+
"timeout: timeout in seconds. "
|
|
495
|
+
"condition: bool or callable to decide whether to use subprocess.",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
raw = config.getini("subproc_default_timeout")
|
|
499
|
+
if raw is not None and raw != "":
|
|
500
|
+
try:
|
|
501
|
+
float(raw)
|
|
502
|
+
except (ValueError, TypeError):
|
|
503
|
+
warnings.warn(
|
|
504
|
+
f"subproc_default_timeout = {raw!r} is not a valid number; "
|
|
505
|
+
"ignoring. Set it to a float or int, e.g. "
|
|
506
|
+
"subproc_default_timeout = 30",
|
|
507
|
+
pytest.PytestConfigWarning,
|
|
508
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Subprocess runner for pytest-subproc.
|
|
2
|
+
|
|
3
|
+
Receives the test nodeid, result path, and rootdir from the parent
|
|
4
|
+
process, runs pytest.main() for that single test, captures the
|
|
5
|
+
outcome, and writes a pickled result to the temp file.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import pickle
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
# Enable coverage measurement in subprocess when the parent has requested
|
|
14
|
+
# it via COVERAGE_PROCESS_START / COVERAGE_PROCESS_CONFIG. The env var
|
|
15
|
+
# is inherited from the parent, which also resolves the config path to an
|
|
16
|
+
# absolute path so the subprocess can find it regardless of its cwd.
|
|
17
|
+
if (
|
|
18
|
+
"COVERAGE_PROCESS_START" in __import__("os").environ
|
|
19
|
+
or "COVERAGE_PROCESS_CONFIG" in __import__("os").environ
|
|
20
|
+
):
|
|
21
|
+
try:
|
|
22
|
+
import coverage as _coverage
|
|
23
|
+
|
|
24
|
+
_coverage.process_startup()
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _SubprocResultPlugin:
|
|
30
|
+
"""Captures the test result from the subprocess pytest run."""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.call_exception = None
|
|
34
|
+
self.setup_passed = True
|
|
35
|
+
self.xfailed = False
|
|
36
|
+
self.xpassed = False
|
|
37
|
+
self.skipped = False
|
|
38
|
+
|
|
39
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
40
|
+
def pytest_runtest_makereport(self, item, call):
|
|
41
|
+
outcome = yield
|
|
42
|
+
if call.when == "call":
|
|
43
|
+
report = outcome.get_result()
|
|
44
|
+
self.call_exception = call.excinfo.value if call.excinfo else None
|
|
45
|
+
if report.skipped:
|
|
46
|
+
if getattr(report, "wasxfail", None) is not None:
|
|
47
|
+
self.xfailed = True
|
|
48
|
+
else:
|
|
49
|
+
self.skipped = True
|
|
50
|
+
elif call.when == "setup":
|
|
51
|
+
if call.excinfo:
|
|
52
|
+
self.setup_passed = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
nodeid = sys.argv[1]
|
|
57
|
+
result_path = sys.argv[2]
|
|
58
|
+
rootdir = sys.argv[3]
|
|
59
|
+
ini_overrides = json.loads(sys.argv[4]) if len(sys.argv) > 4 else {}
|
|
60
|
+
|
|
61
|
+
plugin = _SubprocResultPlugin()
|
|
62
|
+
|
|
63
|
+
pytest_args = [
|
|
64
|
+
nodeid,
|
|
65
|
+
"--rootdir",
|
|
66
|
+
rootdir,
|
|
67
|
+
"-p",
|
|
68
|
+
"no:pytest_subproc",
|
|
69
|
+
"-p",
|
|
70
|
+
"no:cacheprovider",
|
|
71
|
+
"-p",
|
|
72
|
+
"no:flaky",
|
|
73
|
+
"-p",
|
|
74
|
+
"no:timeout",
|
|
75
|
+
"-s",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
for name, value in ini_overrides.items():
|
|
79
|
+
if value is not None and value != "":
|
|
80
|
+
pytest_args.append(f"--override-ini={name}={value}")
|
|
81
|
+
|
|
82
|
+
exit_code = pytest.main(pytest_args, plugins=[plugin])
|
|
83
|
+
|
|
84
|
+
result = {"exit_code": exit_code}
|
|
85
|
+
|
|
86
|
+
if plugin.xfailed:
|
|
87
|
+
result["xfailed"] = True
|
|
88
|
+
result["exception"] = plugin.call_exception
|
|
89
|
+
elif plugin.call_exception is not None:
|
|
90
|
+
result["failed"] = True
|
|
91
|
+
result["exception"] = plugin.call_exception
|
|
92
|
+
elif not plugin.setup_passed:
|
|
93
|
+
result["failed"] = True
|
|
94
|
+
result["message"] = "Test setup failed in subprocess"
|
|
95
|
+
elif plugin.skipped:
|
|
96
|
+
result["skipped"] = True
|
|
97
|
+
else:
|
|
98
|
+
result["passed"] = True
|
|
99
|
+
|
|
100
|
+
_write_result(result_path, result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write_result(path, result):
|
|
104
|
+
# Ensure all values in result are picklable
|
|
105
|
+
if "exception" in result:
|
|
106
|
+
exc = result["exception"]
|
|
107
|
+
try:
|
|
108
|
+
pickle.dumps(exc)
|
|
109
|
+
except Exception:
|
|
110
|
+
result["message"] = str(exc)
|
|
111
|
+
result["failed"] = True
|
|
112
|
+
del result["exception"]
|
|
113
|
+
with open(path, "wb") as f:
|
|
114
|
+
pickle.dump(result, f)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-subproc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pytest plugin to run marked tests in a subprocess
|
|
5
|
+
Requires-Python: >=3.7
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: pytest>=7.0.0
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
12
|
+
Requires-Dist: flaky>=3.8; extra == "test"
|
|
13
|
+
Requires-Dist: pytest-timeout>=1.4; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# pytest-subproc
|
|
17
|
+
|
|
18
|
+
Run marked pytest test functions in an isolated subprocess to protect the main process from crashes caused by C++ panics, segfaults, or hangs.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pytest-subproc
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Mark any test with `@pytest.mark.subproc` to run it in a subprocess:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
@pytest.mark.subproc
|
|
34
|
+
def test_isolated():
|
|
35
|
+
assert True
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Parameters
|
|
39
|
+
|
|
40
|
+
`@pytest.mark.subproc(timeout=None, condition=None)`
|
|
41
|
+
|
|
42
|
+
| Parameter | Type | Default | Description |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `timeout` | `float` | `None` | Timeout in seconds. Kills the subprocess if the test exceeds this limit. Falls back to `subproc_default_timeout` ini option. |
|
|
45
|
+
| `condition` | `bool` or `() -> bool` | `True` | When falsy, the test runs in the main process normally. Useful for conditional isolation (e.g., only in CI). |
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
@pytest.mark.subproc(timeout=30)
|
|
49
|
+
def test_with_timeout():
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@pytest.mark.subproc(condition=lambda: os.environ.get("CI") == "true")
|
|
53
|
+
def test_ci_only():
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
@pytest.mark.subproc(timeout=10, condition=False)
|
|
57
|
+
def test_never_subprocess():
|
|
58
|
+
...
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Configuration via `pyproject.toml` / `pytest.ini`
|
|
62
|
+
|
|
63
|
+
```ini
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
subproc_default_timeout = 30
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or on the command line:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pytest --subprocess-timeout=30
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Module-level configuration
|
|
75
|
+
|
|
76
|
+
Set defaults for all `@pytest.mark.subproc` tests in a directory tree by calling `pytest_subproc` methods in a `conftest.py`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import pytest_subproc
|
|
80
|
+
|
|
81
|
+
# Default timeout (lowest priority: marker > CLI > ini > this)
|
|
82
|
+
pytest_subproc.config_default_timeout(30)
|
|
83
|
+
|
|
84
|
+
# Default condition for spawning (lowest priority: marker > this)
|
|
85
|
+
pytest_subproc.config_global_enabled(True)
|
|
86
|
+
pytest_subproc.config_global_enabled(lambda: os.environ.get("CI") == "true") # callable
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
### `@pytest.mark.timeout` interaction
|
|
91
|
+
|
|
92
|
+
When a test has both `@pytest.mark.subproc(timeout=5)` and `@pytest.mark.timeout(3)`,
|
|
93
|
+
the shorter value (3s) is used as the subprocess timeout. This prevents
|
|
94
|
+
`pytest-timeout` from killing the main process while the subprocess is still
|
|
95
|
+
running — our plugin cancels `pytest-timeout`'s timer and enforces the
|
|
96
|
+
effective timeout on the subprocess itself.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@pytest.mark.subproc(timeout=5)
|
|
100
|
+
@pytest.mark.timeout(3) # ← effective timeout (shorter wins)
|
|
101
|
+
def test_obey_the_shorter():
|
|
102
|
+
...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## How It Works
|
|
107
|
+
|
|
108
|
+
1. **Interception** — `pytest_runtest_protocol` (tryfirst) takes over the
|
|
109
|
+
protocol for `subproc`-marked tests.
|
|
110
|
+
|
|
111
|
+
2. **Isolated run** — The main process spawns a child that runs
|
|
112
|
+
`pytest.main([nodeid, --rootdir, ...])` for that single test.
|
|
113
|
+
The **full lifecycle** (conftest loading, fixture resolution, setup,
|
|
114
|
+
call, teardown) happens inside the subprocess.
|
|
115
|
+
|
|
116
|
+
3. **Timeout & cleanup** — The child is created with `start_new_session`
|
|
117
|
+
so the **entire process group** (including test‑spawned children) is
|
|
118
|
+
killed when the timeout fires.
|
|
119
|
+
|
|
120
|
+
4. **Result** — A plugin inside the subprocess captures the outcome and
|
|
121
|
+
exception; these are pickled to a temp file. The parent re‑raises
|
|
122
|
+
the exception so `xfail`, `skip`, etc. work as expected.
|
|
123
|
+
|
|
124
|
+
5. **Config parity** — `asyncio_mode`, `xfail_strict` and other ini
|
|
125
|
+
settings are forwarded to the subprocess via `--override-ini`.
|
|
126
|
+
|
|
127
|
+
## Comparison with `pytest-forked`
|
|
128
|
+
|
|
129
|
+
| | `pytest-subproc` | `pytest-forked` |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Mechanism | Spawns a new Python process (`subprocess`) | Forks the existing process (`os.fork`) |
|
|
132
|
+
| Fixtures | Re‑evaluated in the child (full conftest + fixture resolution) | Inherited from parent via copy‑on‑write |
|
|
133
|
+
| Windows | ✅ Supported | ❌ Not available |
|
|
134
|
+
| Crash isolation | Full — the child has its own PID and memory space; a segfault cannot reach the parent | Partial — forked process shares file descriptors and some kernel state with the parent |
|
|
135
|
+
| Timeout | Built‑in `timeout` parameter on the marker, with process‑tree termination | Only via external `pytest-timeout` plugin |
|
|
136
|
+
| Process‑tree cleanup | Kills the entire process group on timeout (`os.killpg` / `taskkill /T`) | No automatic child‑process cleanup |
|
|
137
|
+
| Startup cost | Moderate — a new Python interpreter starts and runs `pytest.main` for one test | Low — fork is (mostly) copy‑on‑write |
|
|
138
|
+
| `pytest-xdist` | ✅ Compatible | ✅ Compatible |
|
|
139
|
+
|
|
140
|
+
## Feature Compatibility
|
|
141
|
+
|
|
142
|
+
| Feature | Status |
|
|
143
|
+
|---|---|
|
|
144
|
+
| Python >= 3.7 | ✅ |
|
|
145
|
+
| Function, session, and module-level fixtures | ✅ |
|
|
146
|
+
| stdout/stderr captured and shown on failure | ✅ |
|
|
147
|
+
| `pytest.mark.asyncio` + `asyncio_mode = "auto"` | ✅ |
|
|
148
|
+
| `@pytest.mark.parametrize` | ✅ |
|
|
149
|
+
| `@pytest.mark.xfail(raises=...)` | ✅ |
|
|
150
|
+
| Custom exception pickling / re-raising | ✅ |
|
|
151
|
+
| `flaky` retries | ✅ |
|
|
152
|
+
| `pytest-timeout` signal handling | ✅ |
|
|
153
|
+
| `pytest-cov` coverage passthrough | ✅ |
|
|
154
|
+
|
|
155
|
+
## Requirements
|
|
156
|
+
|
|
157
|
+
- Python >= 3.7
|
|
158
|
+
- pytest >= 7.0
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pytest_subproc/__init__.py,sha256=jMJMZzNXzbcSuMNbKOqwsbZWc-F_vtEhEREDCaSyhr4,14528
|
|
2
|
+
pytest_subproc/_subproc_runner.py,sha256=bqqPzhSNxQgQ9q00H4dNid469qoHFZxEh_Q91gNN_qI,3318
|
|
3
|
+
pytest_subproc-0.1.0.dist-info/licenses/LICENSE,sha256=OIa4XLW8XpGHVL5Bp9rpYVEF3E6QfwtucmHcp1UqElA,1062
|
|
4
|
+
pytest_subproc-0.1.0.dist-info/METADATA,sha256=WM5NeQe1-ctv-wIW_nGVjJTHXNzcx3SREM1lEVu-sJ8,5339
|
|
5
|
+
pytest_subproc-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
pytest_subproc-0.1.0.dist-info/entry_points.txt,sha256=ogni652iMJ7HpLtW5f_XXsbOTuYNxFfhPqBQr9TmMmk,43
|
|
7
|
+
pytest_subproc-0.1.0.dist-info/scm_file_list.json,sha256=sFi6tqxljsghZO1yG3Zxj42s6CkZMxWAxpTF7u4HKV4,365
|
|
8
|
+
pytest_subproc-0.1.0.dist-info/scm_version.json,sha256=R2FlwhSU_yy9Kn7_SNZ7lroeT67orp_DXHrJO1-Qhio,160
|
|
9
|
+
pytest_subproc-0.1.0.dist-info/top_level.txt,sha256=YenaTJgSRX6v80HvW_-16dLCnW0hZWbhf5ZbJTtJRCc,15
|
|
10
|
+
pytest_subproc-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 WJ SI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".pre-commit-config.yaml",
|
|
4
|
+
"README.md",
|
|
5
|
+
"LICENSE",
|
|
6
|
+
"pyproject.toml",
|
|
7
|
+
"AGENTS.md",
|
|
8
|
+
"MANIFEST.in",
|
|
9
|
+
".gitignore",
|
|
10
|
+
"src/pytest_subproc/__init__.py",
|
|
11
|
+
"src/pytest_subproc/_subproc_runner.py",
|
|
12
|
+
"tests/conftest.py",
|
|
13
|
+
"tests/test_subproc.py",
|
|
14
|
+
".github/workflows/release.yml",
|
|
15
|
+
".github/workflows/ci.yml"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_subproc
|