pytest-threadpool 0.2.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,558 @@
1
+ """Parallel test runner orchestration."""
2
+
3
+ import os
4
+ import queue
5
+ import threading
6
+ from collections import OrderedDict
7
+
8
+ from _pytest.runner import CallInfo, call_and_report, show_test_item
9
+
10
+ from pytest_threadpool._fixtures import FixtureManager
11
+ from pytest_threadpool._grouping import GroupKeyBuilder
12
+
13
+ # Test slot states
14
+ _SCHEDULED = "scheduled"
15
+ _RUNNING = "running"
16
+ _DONE = "done"
17
+
18
+
19
+ class _LiveReporter:
20
+ """Reports parallel results with live per-file line updates.
21
+
22
+ Pre-prints all collected file lines before execution starts.
23
+ Each test slot shows one of three states:
24
+ - scheduled: dim dot (waiting to run)
25
+ - running: bright spinning indicator
26
+ - done: colored result letter (./F/s)
27
+
28
+ Falls back to plain immediate reporting when stdout is not a terminal.
29
+ """
30
+
31
+ def __init__(self, session, items):
32
+ tr = session.config.pluginmanager.get_plugin("terminalreporter")
33
+ self._tr = tr
34
+ # noinspection PyProtectedMember
35
+ # No public accessor for TerminalWriter; needed for hasmarkup,
36
+ # raw file handle (ANSI cursor movement), and write suppression.
37
+ self._tw = tr._tw if tr else None # pyright: ignore[reportPrivateUsage]
38
+ self._total = len(items)
39
+ self._reported = 0
40
+ self._startpath = session.config.rootpath
41
+ self._lock = threading.Lock()
42
+
43
+ # Build file→items mapping in collection order
44
+ self._file_order = []
45
+ self._file_idx = {}
46
+ self._file_items = OrderedDict()
47
+ for item in items:
48
+ fspath = item.fspath
49
+ if fspath not in self._file_idx:
50
+ self._file_idx[fspath] = len(self._file_order)
51
+ self._file_order.append(fspath)
52
+ self._file_items[fspath] = []
53
+ self._file_items[fspath].append(item)
54
+
55
+ # Per-item state: maps item → (_SCHEDULED | _RUNNING | _DONE, letter, color)
56
+ self._item_state = {}
57
+ for item in items:
58
+ self._item_state[item] = (_SCHEDULED, ".", "")
59
+
60
+ # detect capability
61
+ self._live = (
62
+ tr is not None
63
+ and self._tw is not None
64
+ and hasattr(tr, "_tw")
65
+ and self._tw.hasmarkup
66
+ and session.config.get_verbosity() <= 0
67
+ )
68
+ self._width = getattr(self._tw, "fullwidth", 80) if self._tw else 80
69
+ # noinspection PyProtectedMember
70
+ # No public accessor on TerminalWriter for the underlying file handle.
71
+ # We need it for direct ANSI escape writes (cursor movement, colors)
72
+ # that bypass TerminalWriter formatting.
73
+ self._file = self._tw._file if self._tw else None # pyright: ignore[reportPrivateUsage]
74
+
75
+ # Pre-compute colors for worker-thread display updates
76
+ markup = self._tw.hasmarkup if self._tw else False
77
+ self._pass_color = "\033[32m" if markup else ""
78
+ self._fail_color = "\033[31;1m" if markup else ""
79
+
80
+ @property
81
+ def live(self):
82
+ return self._live
83
+
84
+ # -- suppression helpers --------------------------------------------------
85
+
86
+ def suppress(self):
87
+ """Temporarily suppress terminal reporter output."""
88
+ if self._tw:
89
+ self._orig_write = self._tw.write
90
+ self._orig_line = self._tw.line
91
+ self._tw.write = lambda *a, **kw: None
92
+ self._tw.line = lambda *a, **kw: None
93
+
94
+ def restore(self):
95
+ """Restore terminal reporter output."""
96
+ if self._tw:
97
+ self._tw.write = self._orig_write
98
+ self._tw.line = self._orig_line
99
+
100
+ # -- pre-print all files --------------------------------------------------
101
+
102
+ def pre_print(self):
103
+ """Print all collected file lines with dim scheduled dots and a progress line."""
104
+ if not self._live:
105
+ return
106
+ assert self._file is not None
107
+ f = self._file
108
+ for fspath in self._file_order:
109
+ self._write_line_live(fspath)
110
+ f.write("\n")
111
+ self._write_progress_line()
112
+ f.flush()
113
+
114
+ # -- state transitions ----------------------------------------------------
115
+
116
+ def mark_running(self, item):
117
+ """Mark a test as currently running and update its file line."""
118
+ with self._lock:
119
+ self._item_state[item] = (_RUNNING, "●", "")
120
+ if self._live:
121
+ self._update_file_line(item.fspath)
122
+
123
+ def mark_done(self, item, report):
124
+ """Mark a test as completed and update its file line.
125
+
126
+ In live mode, updates in-place with cursor movement.
127
+ In dumb mode, writes the full file line once all tests in
128
+ that file have completed (no cursor movement needed).
129
+
130
+ If already marked done by mark_call_done, corrects the letter
131
+ if the final report differs (e.g. skip detected as failure).
132
+ """
133
+ with self._lock:
134
+ letter = self._letter_for(report)
135
+ color = self._color_for(report)
136
+ already_done = self._item_state[item][0] == _DONE
137
+ if already_done and self._item_state[item][1] == letter:
138
+ return
139
+ if not already_done:
140
+ self._reported += 1
141
+ self._item_state[item] = (_DONE, letter, color)
142
+ if self._live:
143
+ self._update_file_line(item.fspath)
144
+ else:
145
+ self._maybe_flush_file(item.fspath)
146
+
147
+ def mark_call_done(self, item, excinfo):
148
+ """Mark a test call as completed from the worker thread.
149
+
150
+ Uses excinfo to determine pass/fail immediately, so the display
151
+ updates as soon as the call finishes rather than waiting for the
152
+ main thread to process hooks.
153
+ """
154
+ with self._lock:
155
+ self._reported += 1
156
+ if excinfo is None:
157
+ letter, color = ".", self._pass_color
158
+ else:
159
+ letter, color = "F", self._fail_color
160
+ self._item_state[item] = (_DONE, letter, color)
161
+ if self._live:
162
+ self._update_file_line(item.fspath)
163
+ else:
164
+ self._maybe_flush_file(item.fspath)
165
+
166
+ def finish(self):
167
+ """Reset terminal reporter state after live output."""
168
+ if self._live and self._tw and self._file:
169
+ # Finalize the progress line and move to the next line
170
+ self._file.write("\n")
171
+ self._file.flush()
172
+ if self._tr:
173
+ self._tr.currentfspath = None
174
+ # noinspection PyProtectedMember
175
+ # No public API to suppress the final "[100%]" that the terminal
176
+ # reporter's pytest_runtestloop wrapper writes after the test loop.
177
+ self._tr._write_progress_information_filling_space = lambda: None # pyright: ignore[reportPrivateUsage]
178
+
179
+ # -- internals ------------------------------------------------------------
180
+
181
+ def _update_file_line(self, fspath):
182
+ """Rewrite a single file line and progress using cursor movement (live mode)."""
183
+ assert self._file is not None
184
+ f = self._file
185
+ idx = self._file_idx[fspath]
186
+ bottom = len(self._file_order)
187
+ lines_up = bottom - idx
188
+ f.write(f"\033[{lines_up}A")
189
+ self._write_line_live(fspath)
190
+ f.write(f"\033[{lines_up}B")
191
+ f.write("\r")
192
+ self._write_progress_line()
193
+ f.flush()
194
+
195
+ def _maybe_flush_file(self, fspath):
196
+ """In dumb mode, write a file line once all its tests are done."""
197
+ if not self._tw or not self._file:
198
+ return
199
+ file_items = self._file_items[fspath]
200
+ if all(self._item_state[it][0] == _DONE for it in file_items):
201
+ self._write_line_plain(fspath)
202
+
203
+ def _write_line_live(self, fspath):
204
+ """Write a file line with ANSI formatting (live terminal mode)."""
205
+ assert self._file is not None
206
+ f = self._file
207
+ rel = self._rel_path(fspath)
208
+
209
+ f.write("\r\033[K")
210
+ f.write(rel + " ")
211
+
212
+ for item in self._file_items[fspath]:
213
+ state, letter, color = self._item_state[item]
214
+ if state == _SCHEDULED:
215
+ f.write("\033[2m·\033[0m")
216
+ elif state == _RUNNING:
217
+ f.write("\033[36;1m●\033[0m")
218
+ else:
219
+ if color:
220
+ f.write(f"{color}{letter}\033[0m")
221
+ else:
222
+ f.write(letter)
223
+
224
+ def _write_line_plain(self, fspath):
225
+ """Write a file line without ANSI codes (dumb/pipe mode)."""
226
+ assert self._file is not None
227
+ f = self._file
228
+ rel = self._rel_path(fspath)
229
+ progress = f" [{100 * self._reported // self._total:3d}%]"
230
+
231
+ letters = ""
232
+ for item in self._file_items[fspath]:
233
+ _, letter, _ = self._item_state[item]
234
+ letters += letter
235
+
236
+ line = f"{rel} {letters}{progress}\n"
237
+ f.write(line)
238
+ f.flush()
239
+
240
+ def _write_progress_line(self):
241
+ """Write/update the progress line at the bottom."""
242
+ assert self._file is not None
243
+ f = self._file
244
+ pct = 100 * self._reported // self._total
245
+ f.write(f"\r\033[K{self._reported}/{self._total} [{pct:3d}%]")
246
+
247
+ def _rel_path(self, fspath):
248
+ try:
249
+ return os.path.relpath(str(fspath), str(self._startpath))
250
+ except ValueError:
251
+ return str(fspath)
252
+
253
+ @staticmethod
254
+ def _letter_for(report):
255
+ if report.passed:
256
+ return "."
257
+ if report.failed:
258
+ return "F"
259
+ if report.skipped:
260
+ return "s"
261
+ return "?"
262
+
263
+ def _color_for(self, report):
264
+ if not self._tw or not self._tw.hasmarkup:
265
+ return ""
266
+ if report.passed:
267
+ return "\033[32m" # green
268
+ if report.failed:
269
+ return "\033[31;1m" # red bold
270
+ if report.skipped:
271
+ return "\033[33m" # yellow
272
+ return ""
273
+
274
+
275
+ class ParallelRunner:
276
+ """Orchestrates parallel test execution.
277
+
278
+ Groups consecutive items by parallel group key and runs each group
279
+ either sequentially (key is None or single item) or in parallel.
280
+ """
281
+
282
+ def __init__(self, session, nthreads: int):
283
+ self._session = session
284
+ self._nthreads = nthreads
285
+
286
+ def run_all(self) -> bool:
287
+ """Main entry: group items and run each group."""
288
+ session = self._session
289
+
290
+ if session.testsfailed and not session.config.option.continue_on_collection_errors:
291
+ raise session.Interrupted(
292
+ f"{session.testsfailed} error"
293
+ f"{'s' if session.testsfailed != 1 else ''} during collection"
294
+ )
295
+
296
+ if session.config.option.collectonly:
297
+ return True
298
+
299
+ groups = GroupKeyBuilder.build_groups(session.items)
300
+
301
+ for group_key, items in groups:
302
+ if session.shouldfail:
303
+ raise session.Failed(session.shouldfail)
304
+ if session.shouldstop:
305
+ raise session.Interrupted(session.shouldstop)
306
+
307
+ if group_key is None or len(items) <= 1 or self._nthreads <= 1:
308
+ for i, item in enumerate(items):
309
+ nextitem = items[i + 1] if i + 1 < len(items) else None
310
+ self._run_sequential(item, nextitem)
311
+ else:
312
+ self._run_parallel(items)
313
+
314
+ return True
315
+
316
+ def _run_sequential(self, item, nextitem) -> None:
317
+ """Run the full default protocol for a single item."""
318
+ ihook = item.ihook
319
+ ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
320
+
321
+ # noinspection PyProtectedMember
322
+ # No public API for request lifecycle management; mirrors pytest's
323
+ # own runner.py (_pytest.runner.runtestprotocol).
324
+ if hasattr(item, "_request") and not item._request: # pyright: ignore[reportPrivateUsage]
325
+ item._initrequest() # pyright: ignore[reportPrivateUsage]
326
+
327
+ rep_setup = call_and_report(item, "setup", log=True)
328
+ if rep_setup.passed:
329
+ if item.config.getoption("setupshow", False):
330
+ show_test_item(item)
331
+ if not item.config.getoption("setuponly", False):
332
+ call_and_report(item, "call", log=True)
333
+ call_and_report(item, "teardown", log=True, nextitem=nextitem)
334
+
335
+ if hasattr(item, "_request"):
336
+ item._request = False # pyright: ignore[reportPrivateUsage]
337
+ item.funcargs = None
338
+
339
+ ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
340
+
341
+ def _run_parallel(self, items) -> None:
342
+ """Run a group's tests with parallel call phases.
343
+
344
+ 1. Sequential: setup every item (no reporting yet).
345
+ 2. Parallel: item.runtest() in a thread pool.
346
+ 3. Sequential: report setup and call results per item.
347
+ 4. Sequential: teardown.
348
+ """
349
+ session = self._session
350
+ setup_passed = {}
351
+ setup_reports = {}
352
+ per_item_fixture_fins = {}
353
+ saved_collector_fins = []
354
+
355
+ # Phase 1: sequential setup (silent)
356
+ # noinspection PyProtectedMember
357
+ # item._request/_initrequest and session._setupstate: no public API
358
+ # for request lifecycle or setup state management. Mirrors pytest's
359
+ # own runner.py (_pytest.runner.runtestprotocol / SetupState).
360
+ for item in items:
361
+ if hasattr(item, "_request") and not item._request: # pyright: ignore[reportPrivateUsage]
362
+ item._initrequest() # pyright: ignore[reportPrivateUsage]
363
+
364
+ needed = set(item.listchain())
365
+ if any(node not in needed for node in session._setupstate.stack): # pyright: ignore[reportPrivateUsage]
366
+ saved_collector_fins.extend(
367
+ FixtureManager.save_collector_finalizers(session, item)
368
+ )
369
+ session._setupstate.teardown_exact(nextitem=item) # pyright: ignore[reportPrivateUsage]
370
+
371
+ rep = call_and_report(item, "setup", log=False)
372
+ setup_reports[item] = rep
373
+ setup_passed[item] = rep.passed
374
+
375
+ if rep.passed:
376
+ per_item_fixture_fins[item] = FixtureManager.save_and_clear_function_fixtures(item)
377
+ else:
378
+ FixtureManager.clear_function_fixture_caches(item)
379
+
380
+ if item in session._setupstate.stack: # pyright: ignore[reportPrivateUsage]
381
+ session._setupstate.stack.pop(item) # pyright: ignore[reportPrivateUsage]
382
+
383
+ if session.config.getoption("setuponly", False):
384
+ for item in items:
385
+ ihook = item.ihook
386
+ ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
387
+ ihook.pytest_runtest_logreport(report=setup_reports[item])
388
+ if setup_passed[item] and session.config.getoption("setupshow", False):
389
+ show_test_item(item)
390
+ self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
391
+ return
392
+
393
+ # Phase 2: parallel calls with live reporting
394
+ callable_items = [it for it in items if setup_passed.get(it)]
395
+ workers = min(self._nthreads, len(callable_items)) if callable_items else 1
396
+
397
+ call_results = {}
398
+ reported = set()
399
+ live = _LiveReporter(session, items)
400
+ live.pre_print()
401
+
402
+ def _do_call(test_item):
403
+ if cancelled.is_set():
404
+ return test_item, CallInfo.from_call(lambda: None, when="call")
405
+ live.mark_running(test_item)
406
+ call_info = CallInfo.from_call(lambda: test_item.runtest(), when="call")
407
+ if not cancelled.is_set():
408
+ live.mark_call_done(test_item, call_info.excinfo)
409
+ return test_item, call_info
410
+
411
+ def _report_item(item):
412
+ """Report a single item — live cursor mode or plain fallback."""
413
+ ihook = item.ihook
414
+
415
+ # Build the call report first (needed for a live dot letter)
416
+ call_rep = None
417
+ if setup_passed[item] and item in call_results:
418
+ call_info = call_results[item]
419
+ call_rep = ihook.pytest_runtest_makereport(item=item, call=call_info)
420
+
421
+ report = call_rep or setup_reports[item]
422
+
423
+ # Suppress terminal reporter output, fire hooks for stats only.
424
+ # try/finally ensures restore() runs even on KeyboardInterrupt,
425
+ # otherwise the terminal writer stays suppressed and pytest's
426
+ # interrupt traceback is silently lost.
427
+ live.suppress()
428
+ try:
429
+ ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
430
+ ihook.pytest_runtest_logreport(report=setup_reports[item])
431
+ if setup_passed[item]:
432
+ if session.config.getoption("setupshow", False):
433
+ show_test_item(item)
434
+ if call_rep is not None:
435
+ ihook.pytest_runtest_logreport(report=call_rep)
436
+ finally:
437
+ live.restore()
438
+ live.mark_done(item, report)
439
+
440
+ reported.add(item)
441
+
442
+ interrupted = False
443
+ cancelled = threading.Event()
444
+ if workers > 1 and len(callable_items) > 1:
445
+ work_queue = queue.SimpleQueue()
446
+ result_queue = queue.SimpleQueue()
447
+
448
+ def _pool_worker():
449
+ while True:
450
+ work_item = work_queue.get()
451
+ if work_item is None or cancelled.is_set():
452
+ return
453
+ try:
454
+ result = _do_call(work_item)
455
+ except BaseException as exc:
456
+ # Ensure the result queue always gets an entry so the
457
+ # main thread never blocks forever on .get().
458
+ call_info = CallInfo.from_call(
459
+ lambda e=exc: (_ for _ in ()).throw(e),
460
+ when="call",
461
+ )
462
+ result = (work_item, call_info)
463
+ result_queue.put(result)
464
+
465
+ threads = []
466
+ for _ in range(workers):
467
+ t = threading.Thread(target=_pool_worker, daemon=True)
468
+ t.start()
469
+ threads.append(t)
470
+
471
+ for ci in callable_items:
472
+ work_queue.put(ci)
473
+
474
+ try:
475
+ remaining = len(callable_items)
476
+ while remaining > 0:
477
+ item, call_info = result_queue.get()
478
+ call_results[item] = call_info
479
+ _report_item(item)
480
+ remaining -= 1
481
+ except KeyboardInterrupt:
482
+ interrupted = True
483
+ cancelled.set()
484
+
485
+ # Signal workers to stop (best-effort; they're daemon threads)
486
+ for _ in range(workers):
487
+ work_queue.put(None)
488
+ if not interrupted:
489
+ for t in threads:
490
+ t.join()
491
+ else:
492
+ try:
493
+ for item in callable_items:
494
+ _, call_info = _do_call(item)
495
+ call_results[item] = call_info
496
+ _report_item(item)
497
+ except KeyboardInterrupt:
498
+ interrupted = True
499
+
500
+ # Phase 3: report any remaining items (setup failures, stragglers)
501
+ if not interrupted:
502
+ for item in items:
503
+ if item not in reported:
504
+ _report_item(item)
505
+
506
+ live.finish()
507
+
508
+ # Phase 4: teardown (always runs, even after interrupt)
509
+ self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
510
+
511
+ if interrupted:
512
+ raise KeyboardInterrupt
513
+
514
+ def _teardown_all(self, items, per_item_fixture_fins, saved_collector_fins) -> None:
515
+ """Run per-item function-level finalizers, saved collector finalizers,
516
+ then tear down remaining collectors."""
517
+ session = self._session
518
+
519
+ for item in items:
520
+ fins = per_item_fixture_fins.get(item, [])
521
+
522
+ def _run_fins(fns=fins):
523
+ exceptions = []
524
+ for fn in reversed(fns):
525
+ try:
526
+ fn()
527
+ except BaseException as e:
528
+ exceptions.append(e)
529
+ if len(exceptions) == 1:
530
+ raise exceptions[0]
531
+ if exceptions:
532
+ raise BaseExceptionGroup("errors during fixture teardown", exceptions)
533
+
534
+ teardown_info = CallInfo.from_call(_run_fins, when="teardown")
535
+ rep = item.ihook.pytest_runtest_makereport(item=item, call=teardown_info)
536
+ item.ihook.pytest_runtest_logreport(report=rep)
537
+
538
+ # noinspection PyProtectedMember
539
+ if hasattr(item, "_request"):
540
+ item._request = False # pyright: ignore[reportPrivateUsage]
541
+ item.funcargs = None
542
+
543
+ item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
544
+
545
+ # noinspection PyProtectedMember
546
+ session._setupstate.teardown_exact(nextitem=None) # pyright: ignore[reportPrivateUsage]
547
+
548
+ exceptions = []
549
+ for _node, fins in reversed(saved_collector_fins):
550
+ for fin in reversed(fins):
551
+ try:
552
+ fin()
553
+ except BaseException as e:
554
+ exceptions.append(e)
555
+ if len(exceptions) == 1:
556
+ raise exceptions[0]
557
+ if exceptions:
558
+ raise BaseExceptionGroup("errors during deferred collector teardown", exceptions)
@@ -0,0 +1,76 @@
1
+ """pytest plugin hooks -- wiring only, delegates to classes."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from pytest_threadpool._constants import (
8
+ MARKER_NOT_PARALLELIZABLE,
9
+ MARKER_PARALLEL_ONLY,
10
+ MARKER_PARALLELIZABLE,
11
+ )
12
+ from pytest_threadpool._markers import MarkerResolver
13
+ from pytest_threadpool._runner import ParallelRunner
14
+
15
+
16
+ def pytest_addoption(parser):
17
+ parser.addoption(
18
+ "--threadpool",
19
+ default=None,
20
+ metavar="N",
21
+ help=("Parallelize marked test calls using N threads. 'auto' uses os.cpu_count()."),
22
+ )
23
+
24
+
25
+ def pytest_configure(config):
26
+ config.addinivalue_line(
27
+ "markers",
28
+ f"{MARKER_PARALLEL_ONLY}: skip test when not using --threadpool",
29
+ )
30
+ config.addinivalue_line(
31
+ "markers",
32
+ f"{MARKER_PARALLELIZABLE}(scope): mark for parallel execution. "
33
+ "scope: 'children' | 'parameters' | 'all'",
34
+ )
35
+ config.addinivalue_line(
36
+ "markers",
37
+ f"{MARKER_NOT_PARALLELIZABLE}: opt out of inherited parallel execution",
38
+ )
39
+
40
+
41
+ def pytest_collection_modifyitems(config, items):
42
+ if config.getoption("threadpool") is not None:
43
+ return
44
+ skip = pytest.mark.skip(reason="requires --threadpool")
45
+ for item in items:
46
+ if MARKER_PARALLEL_ONLY in item.keywords or MarkerResolver.has_package_parallel_only(item):
47
+ item.add_marker(skip)
48
+
49
+
50
+ @pytest.hookimpl(tryfirst=True)
51
+ def pytest_runtestloop(session):
52
+ nthreads = _thread_count(session.config)
53
+ if nthreads is None:
54
+ return None
55
+ if not _is_free_threaded():
56
+ raise pytest.UsageError(
57
+ "--threadpool requires a free-threaded Python build (e.g. python3.13t or python3.14t)"
58
+ )
59
+ runner = ParallelRunner(session, nthreads)
60
+ return runner.run_all()
61
+
62
+
63
+ def _thread_count(config) -> int | None:
64
+ val = config.getoption("threadpool")
65
+ if val is None:
66
+ return None
67
+ if val == "auto":
68
+ return os.cpu_count() or 4
69
+ return int(val)
70
+
71
+
72
+ def _is_free_threaded() -> bool:
73
+ """Check if running on a free-threaded Python build (GIL disabled)."""
74
+ from sysconfig import get_config_vars
75
+
76
+ return bool(get_config_vars().get("Py_GIL_DISABLED"))