pytest-isolated 0.2.0__py3-none-any.whl → 0.3.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_isolated/__init__.py +1 -1
- pytest_isolated/plugin.py +118 -38
- {pytest_isolated-0.2.0.dist-info → pytest_isolated-0.3.0.dist-info}/METADATA +1 -1
- pytest_isolated-0.3.0.dist-info/RECORD +9 -0
- {pytest_isolated-0.2.0.dist-info → pytest_isolated-0.3.0.dist-info}/WHEEL +1 -1
- pytest_isolated-0.2.0.dist-info/RECORD +0 -9
- {pytest_isolated-0.2.0.dist-info → pytest_isolated-0.3.0.dist-info}/entry_points.txt +0 -0
- {pytest_isolated-0.2.0.dist-info → pytest_isolated-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {pytest_isolated-0.2.0.dist-info → pytest_isolated-0.3.0.dist-info}/top_level.txt +0 -0
pytest_isolated/__init__.py
CHANGED
pytest_isolated/plugin.py
CHANGED
|
@@ -9,19 +9,53 @@ import tempfile
|
|
|
9
9
|
import time
|
|
10
10
|
from collections import OrderedDict
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
12
|
+
from typing import Any, Final, Literal, TypedDict, cast
|
|
13
13
|
|
|
14
14
|
import pytest
|
|
15
15
|
|
|
16
16
|
# Guard to prevent infinite recursion (parent spawns child; child must not spawn again)
|
|
17
|
-
SUBPROC_ENV = "PYTEST_RUNNING_IN_SUBPROCESS"
|
|
17
|
+
SUBPROC_ENV: Final = "PYTEST_RUNNING_IN_SUBPROCESS"
|
|
18
18
|
|
|
19
19
|
# Parent tells child where to write JSONL records per test call
|
|
20
|
-
SUBPROC_REPORT_PATH = "PYTEST_SUBPROCESS_REPORT_PATH"
|
|
20
|
+
SUBPROC_REPORT_PATH: Final = "PYTEST_SUBPROCESS_REPORT_PATH"
|
|
21
|
+
|
|
22
|
+
# Arguments to exclude when forwarding options to subprocess
|
|
23
|
+
_EXCLUDED_ARG_PREFIXES: Final = (
|
|
24
|
+
"--junitxml=",
|
|
25
|
+
"--html=",
|
|
26
|
+
"--result-log=",
|
|
27
|
+
"--collect-only",
|
|
28
|
+
"--setup-only",
|
|
29
|
+
"--setup-plan",
|
|
30
|
+
"-x",
|
|
31
|
+
"--exitfirst",
|
|
32
|
+
"--maxfail=",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Plugin-specific options that take values and should not be forwarded
|
|
36
|
+
_PLUGIN_OPTIONS_WITH_VALUE: Final = ("--isolated-timeout",)
|
|
37
|
+
|
|
38
|
+
# Plugin-specific flag options that should not be forwarded
|
|
39
|
+
_PLUGIN_FLAGS: Final = ("--no-isolation",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _TestRecord(TypedDict, total=False):
|
|
43
|
+
"""Structure for test phase results from subprocess."""
|
|
44
|
+
|
|
45
|
+
nodeid: str
|
|
46
|
+
when: Literal["setup", "call", "teardown"]
|
|
47
|
+
outcome: Literal["passed", "failed", "skipped"]
|
|
48
|
+
longrepr: str
|
|
49
|
+
duration: float
|
|
50
|
+
stdout: str
|
|
51
|
+
stderr: str
|
|
52
|
+
keywords: list[str]
|
|
53
|
+
sections: list[tuple[str, str]]
|
|
54
|
+
user_properties: list[tuple[str, Any]]
|
|
55
|
+
wasxfail: bool
|
|
21
56
|
|
|
22
57
|
|
|
23
58
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
24
|
-
"""Add configuration options for subprocess isolation."""
|
|
25
59
|
group = parser.getgroup("isolated")
|
|
26
60
|
group.addoption(
|
|
27
61
|
"--isolated-timeout",
|
|
@@ -62,17 +96,13 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
62
96
|
# CHILD MODE: record results + captured output per test phase
|
|
63
97
|
# ----------------------------
|
|
64
98
|
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
|
|
65
|
-
"""
|
|
66
|
-
In the child process, write one JSON line per test phase (setup/call/teardown)
|
|
67
|
-
containing outcome, captured stdout/stderr, duration, and other metadata.
|
|
68
|
-
The parent will aggregate and re-emit this info.
|
|
69
|
-
"""
|
|
99
|
+
"""Write test phase results to a JSONL file when running in subprocess mode."""
|
|
70
100
|
path = os.environ.get(SUBPROC_REPORT_PATH)
|
|
71
101
|
if not path:
|
|
72
102
|
return
|
|
73
103
|
|
|
74
104
|
# Capture ALL phases (setup, call, teardown), not just call
|
|
75
|
-
rec = {
|
|
105
|
+
rec: _TestRecord = {
|
|
76
106
|
"nodeid": report.nodeid,
|
|
77
107
|
"when": report.when, # setup, call, or teardown
|
|
78
108
|
"outcome": report.outcome, # passed/failed/skipped
|
|
@@ -96,9 +126,6 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None:
|
|
|
96
126
|
def pytest_collection_modifyitems(
|
|
97
127
|
config: pytest.Config, items: list[pytest.Item]
|
|
98
128
|
) -> None:
|
|
99
|
-
"""
|
|
100
|
-
Partition items into subprocess groups + normal items and stash on config.
|
|
101
|
-
"""
|
|
102
129
|
if os.environ.get(SUBPROC_ENV) == "1":
|
|
103
130
|
return # child should not do grouping
|
|
104
131
|
|
|
@@ -136,23 +163,18 @@ def pytest_collection_modifyitems(
|
|
|
136
163
|
|
|
137
164
|
|
|
138
165
|
def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
139
|
-
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
Enhanced to:
|
|
144
|
-
- Capture all test phases (setup, call, teardown)
|
|
145
|
-
- Support configurable timeouts
|
|
146
|
-
- Properly handle crashes and missing results
|
|
147
|
-
- Integrate with pytest's reporting system
|
|
166
|
+
"""Execute isolated test groups in subprocesses and remaining tests in-process.
|
|
167
|
+
|
|
168
|
+
Any subprocess timeouts are caught and reported as test failures; the
|
|
169
|
+
subprocess.TimeoutExpired exception is not propagated to the caller.
|
|
148
170
|
"""
|
|
149
171
|
if os.environ.get(SUBPROC_ENV) == "1":
|
|
150
172
|
return None # child runs the normal loop
|
|
151
173
|
|
|
152
174
|
config = session.config
|
|
153
|
-
groups
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
groups = getattr(config, "_subprocess_groups", OrderedDict())
|
|
176
|
+
if not isinstance(groups, OrderedDict):
|
|
177
|
+
groups = OrderedDict()
|
|
156
178
|
group_timeouts: dict[str, int | None] = getattr(
|
|
157
179
|
config, "_subprocess_group_timeouts", {}
|
|
158
180
|
)
|
|
@@ -170,8 +192,8 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
170
192
|
|
|
171
193
|
def emit_report(
|
|
172
194
|
item: pytest.Item,
|
|
173
|
-
when:
|
|
174
|
-
outcome:
|
|
195
|
+
when: Literal["setup", "call", "teardown"],
|
|
196
|
+
outcome: Literal["passed", "failed", "skipped"],
|
|
175
197
|
longrepr: str = "",
|
|
176
198
|
duration: float = 0.0,
|
|
177
199
|
stdout: str = "",
|
|
@@ -180,10 +202,6 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
180
202
|
user_properties: list[tuple[str, Any]] | None = None,
|
|
181
203
|
wasxfail: bool = False,
|
|
182
204
|
) -> None:
|
|
183
|
-
"""
|
|
184
|
-
Emit a synthetic report for the given item and phase.
|
|
185
|
-
Attach captured output based on outcome and configuration.
|
|
186
|
-
"""
|
|
187
205
|
call = pytest.CallInfo.from_call(lambda: None, when=when)
|
|
188
206
|
rep = pytest.TestReport.from_item_and_call(item, call)
|
|
189
207
|
rep.outcome = outcome
|
|
@@ -198,7 +216,8 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
198
216
|
# For skipped tests, longrepr needs to be a tuple (path, lineno, reason)
|
|
199
217
|
if outcome == "skipped" and longrepr:
|
|
200
218
|
# Parse longrepr or create simple tuple
|
|
201
|
-
|
|
219
|
+
lineno = item.location[1] if item.location[1] is not None else -1
|
|
220
|
+
rep.longrepr = (str(item.fspath), lineno, longrepr) # type: ignore[assignment]
|
|
202
221
|
elif outcome == "failed" and longrepr:
|
|
203
222
|
rep.longrepr = longrepr
|
|
204
223
|
|
|
@@ -232,12 +251,73 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
232
251
|
env[SUBPROC_REPORT_PATH] = report_path
|
|
233
252
|
|
|
234
253
|
# Run pytest in subprocess with timeout, tracking execution time
|
|
235
|
-
|
|
254
|
+
# Preserve rootdir and run subprocess from correct directory to ensure
|
|
255
|
+
# nodeids can be resolved
|
|
256
|
+
cmd = [sys.executable, "-m", "pytest"]
|
|
257
|
+
|
|
258
|
+
# Forward relevant pytest options to subprocess for consistency
|
|
259
|
+
# We filter out options that would interfere with subprocess execution
|
|
260
|
+
if hasattr(config, "invocation_params") and hasattr(
|
|
261
|
+
config.invocation_params, "args"
|
|
262
|
+
):
|
|
263
|
+
forwarded_args = []
|
|
264
|
+
skip_next = False
|
|
265
|
+
|
|
266
|
+
for arg in config.invocation_params.args:
|
|
267
|
+
if skip_next:
|
|
268
|
+
skip_next = False
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Skip our own plugin options
|
|
272
|
+
if arg in _PLUGIN_OPTIONS_WITH_VALUE:
|
|
273
|
+
skip_next = True
|
|
274
|
+
continue
|
|
275
|
+
if arg in _PLUGIN_FLAGS:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# Skip output/reporting options that would conflict
|
|
279
|
+
if any(arg.startswith(prefix) for prefix in _EXCLUDED_ARG_PREFIXES):
|
|
280
|
+
continue
|
|
281
|
+
if arg in ("-x", "--exitfirst"):
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
# Skip test file paths and nodeids - we provide our own
|
|
285
|
+
if not arg.startswith("-") and ("::" in arg or arg.endswith(".py")):
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
forwarded_args.append(arg)
|
|
289
|
+
|
|
290
|
+
cmd.extend(forwarded_args)
|
|
291
|
+
|
|
292
|
+
# Pass rootdir to subprocess to ensure it uses the same project root
|
|
293
|
+
# (config.rootpath is available in pytest 7.0+, which is our minimum version)
|
|
294
|
+
if config.rootpath:
|
|
295
|
+
cmd.extend(["--rootdir", str(config.rootpath)])
|
|
296
|
+
|
|
297
|
+
# Add the test nodeids
|
|
298
|
+
cmd.extend(nodeids)
|
|
299
|
+
|
|
236
300
|
start_time = time.time()
|
|
237
301
|
|
|
302
|
+
# Determine the working directory for the subprocess
|
|
303
|
+
# Use rootpath if set, otherwise use invocation directory
|
|
304
|
+
# This ensures nodeids (which are relative to rootpath) can be resolved
|
|
305
|
+
subprocess_cwd = None
|
|
306
|
+
if config.rootpath:
|
|
307
|
+
subprocess_cwd = str(config.rootpath)
|
|
308
|
+
elif hasattr(config, "invocation_params") and hasattr(
|
|
309
|
+
config.invocation_params, "dir"
|
|
310
|
+
):
|
|
311
|
+
subprocess_cwd = str(config.invocation_params.dir)
|
|
312
|
+
|
|
238
313
|
try:
|
|
239
314
|
proc = subprocess.run(
|
|
240
|
-
cmd,
|
|
315
|
+
cmd,
|
|
316
|
+
env=env,
|
|
317
|
+
timeout=group_timeout,
|
|
318
|
+
capture_output=False,
|
|
319
|
+
check=False,
|
|
320
|
+
cwd=subprocess_cwd,
|
|
241
321
|
)
|
|
242
322
|
returncode = proc.returncode
|
|
243
323
|
timed_out = False
|
|
@@ -248,7 +328,7 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
248
328
|
execution_time = time.time() - start_time
|
|
249
329
|
|
|
250
330
|
# Gather results from JSONL file
|
|
251
|
-
results: dict[str, dict[str,
|
|
331
|
+
results: dict[str, dict[str, _TestRecord]] = {}
|
|
252
332
|
report_file = Path(report_path)
|
|
253
333
|
if report_file.exists():
|
|
254
334
|
with report_file.open(encoding="utf-8") as f:
|
|
@@ -256,7 +336,7 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
256
336
|
file_line = line.strip()
|
|
257
337
|
if not file_line:
|
|
258
338
|
continue
|
|
259
|
-
rec = json.loads(file_line)
|
|
339
|
+
rec = cast(_TestRecord, json.loads(file_line))
|
|
260
340
|
nodeid = rec["nodeid"]
|
|
261
341
|
when = rec["when"]
|
|
262
342
|
|
|
@@ -295,7 +375,7 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
295
375
|
node_results = results.get(it.nodeid, {})
|
|
296
376
|
|
|
297
377
|
# Emit setup, call, teardown in order
|
|
298
|
-
for when in ["setup", "call", "teardown"]:
|
|
378
|
+
for when in ["setup", "call", "teardown"]: # type: ignore[assignment]
|
|
299
379
|
if when not in node_results:
|
|
300
380
|
# If missing a phase, synthesize a passing one
|
|
301
381
|
if when == "call" and not node_results:
|
|
@@ -312,7 +392,7 @@ def pytest_runtestloop(session: pytest.Session) -> int | None:
|
|
|
312
392
|
rec = node_results[when]
|
|
313
393
|
emit_report(
|
|
314
394
|
it,
|
|
315
|
-
when=when,
|
|
395
|
+
when=when, # type: ignore[arg-type]
|
|
316
396
|
outcome=rec["outcome"],
|
|
317
397
|
longrepr=rec.get("longrepr", ""),
|
|
318
398
|
duration=rec.get("duration", 0.0),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pytest_isolated/__init__.py,sha256=b7RfFW9uJXumJ6DTDdg-VvSUEeh-Pc2srTjCKJRxB7k,89
|
|
2
|
+
pytest_isolated/plugin.py,sha256=ISfxTMyJVaddrqCFZOdBXFnC4E_Lh22gRUaNRb1Wo8I,15179
|
|
3
|
+
pytest_isolated/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pytest_isolated-0.3.0.dist-info/licenses/LICENSE,sha256=WECJyowi685PZSnKcA4Tqs7jukfzbnk7iMPLnm_q4JI,1067
|
|
5
|
+
pytest_isolated-0.3.0.dist-info/METADATA,sha256=CDzpvrFqvUFguTk9_Yr32h88GatvKDHCO0d7CsVC5Ug,5384
|
|
6
|
+
pytest_isolated-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
7
|
+
pytest_isolated-0.3.0.dist-info/entry_points.txt,sha256=HgRNPjIGoPBF1pkhma4UtaSwhpOVB8oZRZ0L1FcZXgk,45
|
|
8
|
+
pytest_isolated-0.3.0.dist-info/top_level.txt,sha256=FAtpozhvI-YaiFoZMepi9JAm6e87mW-TM1Ovu5xLOxg,16
|
|
9
|
+
pytest_isolated-0.3.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
pytest_isolated/__init__.py,sha256=PN_IEdfxCUz6vL1vf0Ka9CGmCq9ppFk33fVGirSVtMc,89
|
|
2
|
-
pytest_isolated/plugin.py,sha256=0AKRTWmdLaiwZdwRicRd3TMLrIeisGBMlFqCDOnJRX0,12206
|
|
3
|
-
pytest_isolated/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
pytest_isolated-0.2.0.dist-info/licenses/LICENSE,sha256=WECJyowi685PZSnKcA4Tqs7jukfzbnk7iMPLnm_q4JI,1067
|
|
5
|
-
pytest_isolated-0.2.0.dist-info/METADATA,sha256=q_E02kvtbnt1QgQc-ATrghLcJka7Y_2Zx50tmatVGCM,5384
|
|
6
|
-
pytest_isolated-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
-
pytest_isolated-0.2.0.dist-info/entry_points.txt,sha256=HgRNPjIGoPBF1pkhma4UtaSwhpOVB8oZRZ0L1FcZXgk,45
|
|
8
|
-
pytest_isolated-0.2.0.dist-info/top_level.txt,sha256=FAtpozhvI-YaiFoZMepi9JAm6e87mW-TM1Ovu5xLOxg,16
|
|
9
|
-
pytest_isolated-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|