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.
- pytest_threadpool/__init__.py +6 -0
- pytest_threadpool/_api.py +23 -0
- pytest_threadpool/_constants.py +30 -0
- pytest_threadpool/_fixtures.py +79 -0
- pytest_threadpool/_grouping.py +106 -0
- pytest_threadpool/_markers.py +145 -0
- pytest_threadpool/_runner.py +558 -0
- pytest_threadpool/plugin.py +76 -0
- pytest_threadpool-0.2.0.dist-info/METADATA +168 -0
- pytest_threadpool-0.2.0.dist-info/RECORD +13 -0
- pytest_threadpool-0.2.0.dist-info/WHEEL +4 -0
- pytest_threadpool-0.2.0.dist-info/entry_points.txt +2 -0
- pytest_threadpool-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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"))
|