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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest_subproc = pytest_subproc
@@ -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,8 @@
1
+ {
2
+ "tag": "0.1.0",
3
+ "distance": 0,
4
+ "node": "g7b27017b072970cd709b8b7467a66f5816796c84",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-11"
8
+ }
@@ -0,0 +1 @@
1
+ pytest_subproc