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.
@@ -1,3 +1,3 @@
1
1
  """pytest-isolated: Run pytest tests in isolated subprocesses."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
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
- Run each subprocess group in its own subprocess once;
141
- then run normal tests in-process.
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: OrderedDict[str, list[pytest.Item]] = getattr(
154
- config, "_subprocess_groups", OrderedDict()
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: str,
174
- outcome: str,
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
- rep.longrepr = (str(item.fspath), item.location[1], longrepr)
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
- cmd = [sys.executable, "-m", "pytest", *nodeids]
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, env=env, timeout=group_timeout, capture_output=False, check=False
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, Any]] = {}
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),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-isolated
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Run marked pytest tests in grouped subprocesses (cross-platform).
5
5
  Author: pytest-isolated contributors
6
6
  License-Expression: MIT
@@ -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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,