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,6 @@
|
|
|
1
|
+
"""pytest-threadpool: Parallel test execution for free-threaded Python."""
|
|
2
|
+
|
|
3
|
+
from pytest_threadpool._api import not_parallelizable, parallelizable
|
|
4
|
+
from pytest_threadpool._constants import ParallelScope
|
|
5
|
+
|
|
6
|
+
__all__ = ["ParallelScope", "not_parallelizable", "parallelizable"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Public marker helpers for pytest-threadpool."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parallelizable(
|
|
9
|
+
scope: Literal["children", "parameters", "all"],
|
|
10
|
+
) -> pytest.MarkDecorator:
|
|
11
|
+
"""Mark a test/class/module for parallel execution.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
scope: Parallelism strategy.
|
|
15
|
+
``"children"`` -- all nested tests run concurrently.
|
|
16
|
+
``"parameters"`` -- parametrized variants run concurrently.
|
|
17
|
+
``"all"`` -- children + parameters combined.
|
|
18
|
+
"""
|
|
19
|
+
return pytest.mark.parallelizable(scope)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
not_parallelizable: pytest.MarkDecorator = pytest.mark.not_parallelizable
|
|
23
|
+
"""Opt out of inherited parallel execution."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Constants and enums for pytest-threadpool."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParallelScope(StrEnum):
|
|
7
|
+
"""Parallelism strategies for test execution."""
|
|
8
|
+
|
|
9
|
+
CHILDREN = "children"
|
|
10
|
+
PARAMETERS = "parameters"
|
|
11
|
+
ALL = "all"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PARALLEL_SCOPES = frozenset(s.value for s in ParallelScope)
|
|
15
|
+
|
|
16
|
+
MARKER_PARALLELIZABLE = "parallelizable"
|
|
17
|
+
MARKER_NOT_PARALLELIZABLE = "not_parallelizable"
|
|
18
|
+
MARKER_PARALLEL_ONLY = "parallel_only"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _GroupPrefix(StrEnum):
|
|
22
|
+
"""Internal group key prefixes for parallel batching."""
|
|
23
|
+
|
|
24
|
+
CLASS = "class"
|
|
25
|
+
MOD_CHILDREN = "mod_children"
|
|
26
|
+
PKG_CHILDREN = "pkg_children"
|
|
27
|
+
PARAMS = "params"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SCOPE_NOT = "not"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Fixture finalizer save/restore helpers for parallel execution."""
|
|
2
|
+
|
|
3
|
+
from _pytest.scope import Scope
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FixtureManager:
|
|
7
|
+
"""Helpers for saving/restoring fixture finalizers during parallel execution.
|
|
8
|
+
|
|
9
|
+
Uses protected members of pytest internals (FixtureDef, TopRequest,
|
|
10
|
+
SetupState) because there is no public API for direct finalizer
|
|
11
|
+
manipulation. These are the same internals that pytest's own
|
|
12
|
+
runner.py and fixtures.py use.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def save_and_clear_function_fixtures(item) -> list:
|
|
17
|
+
"""After an item's setup, save its function-scoped fixture finalizers
|
|
18
|
+
and invalidate FixtureDef caches so the next item gets fresh fixtures.
|
|
19
|
+
|
|
20
|
+
Returns a list of saved finalizer callables for this item.
|
|
21
|
+
"""
|
|
22
|
+
saved = []
|
|
23
|
+
request = getattr(item, "_request", None)
|
|
24
|
+
if not request or not hasattr(request, "_fixture_defs"):
|
|
25
|
+
return saved
|
|
26
|
+
|
|
27
|
+
# noinspection PyProtectedMember
|
|
28
|
+
# request._fixture_defs, fixturedef._scope, fixturedef._finalizers:
|
|
29
|
+
# No public API for direct finalizer access; mirrors pytest's own
|
|
30
|
+
# FixtureDef/TopRequest internals.
|
|
31
|
+
for fixturedef in request._fixture_defs.values(): # pyright: ignore[reportPrivateUsage]
|
|
32
|
+
if fixturedef._scope is Scope.Function: # pyright: ignore[reportPrivateUsage]
|
|
33
|
+
saved.extend(fixturedef._finalizers) # pyright: ignore[reportPrivateUsage]
|
|
34
|
+
fixturedef._finalizers.clear() # pyright: ignore[reportPrivateUsage]
|
|
35
|
+
fixturedef.cached_result = None
|
|
36
|
+
|
|
37
|
+
return saved
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def clear_function_fixture_caches(item) -> None:
|
|
41
|
+
"""Invalidate function-scoped fixture caches after a failed setup.
|
|
42
|
+
|
|
43
|
+
When setup fails, the fixture def's execute() raises before
|
|
44
|
+
registering in request._fixture_defs. We use _arg2fixturedefs
|
|
45
|
+
(populated during collection) to find all candidate fixture defs.
|
|
46
|
+
"""
|
|
47
|
+
request = getattr(item, "_request", None)
|
|
48
|
+
if not request:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# noinspection PyProtectedMember
|
|
52
|
+
# request._arg2fixturedefs: populated during collection, maps
|
|
53
|
+
# argname -> list[FixtureDef]. Unlike _fixture_defs, this is
|
|
54
|
+
# available even when setup fails mid-execution.
|
|
55
|
+
arg2fds = getattr(request, "_arg2fixturedefs", {})
|
|
56
|
+
for fixturedefs in arg2fds.values(): # pyright: ignore[reportPrivateUsage]
|
|
57
|
+
for fixturedef in fixturedefs:
|
|
58
|
+
if fixturedef._scope is Scope.Function and fixturedef.cached_result is not None: # pyright: ignore[reportPrivateUsage]
|
|
59
|
+
fixturedef.cached_result = None
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def save_collector_finalizers(session, next_item) -> list:
|
|
63
|
+
"""Save finalizers from stack nodes that would be torn down when
|
|
64
|
+
transitioning to next_item. Clears them from the stack so
|
|
65
|
+
teardown_exact() pops the nodes without side effects.
|
|
66
|
+
|
|
67
|
+
Returns a list of (node, [finalizers]) tuples.
|
|
68
|
+
"""
|
|
69
|
+
needed = set(next_item.listchain())
|
|
70
|
+
saved = []
|
|
71
|
+
# noinspection PyProtectedMember
|
|
72
|
+
# session._setupstate: no public API for setup state management;
|
|
73
|
+
# mirrors pytest's own runner.py (SetupState).
|
|
74
|
+
for node in list(session._setupstate.stack): # pyright: ignore[reportPrivateUsage]
|
|
75
|
+
if node not in needed:
|
|
76
|
+
fins_list, _exc_info = session._setupstate.stack[node] # pyright: ignore[reportPrivateUsage]
|
|
77
|
+
saved.append((node, list(fins_list)))
|
|
78
|
+
fins_list.clear()
|
|
79
|
+
return saved
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Group key computation for parallel test batching."""
|
|
2
|
+
|
|
3
|
+
from pytest_threadpool._constants import (
|
|
4
|
+
SCOPE_NOT,
|
|
5
|
+
ParallelScope,
|
|
6
|
+
_GroupPrefix,
|
|
7
|
+
)
|
|
8
|
+
from pytest_threadpool._markers import MarkerResolver
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GroupKeyBuilder:
|
|
12
|
+
"""Computes parallel batch group keys for test items."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def group_key(item) -> tuple | None:
|
|
16
|
+
"""Compute a group key for parallel batching.
|
|
17
|
+
|
|
18
|
+
Returns a hashable key if the item should be part of a parallel batch,
|
|
19
|
+
or None for sequential execution.
|
|
20
|
+
Consecutive items with the same non-None key form a parallel batch.
|
|
21
|
+
|
|
22
|
+
Marker priority: not_parallelizable > own > class > module > package.
|
|
23
|
+
"""
|
|
24
|
+
own = MarkerResolver.own_scope(item)
|
|
25
|
+
cls = MarkerResolver.class_scope(item)
|
|
26
|
+
mod = MarkerResolver.module_scope(item)
|
|
27
|
+
pkg = MarkerResolver.package_scope(item)
|
|
28
|
+
|
|
29
|
+
# not_parallelizable at any level forces sequential
|
|
30
|
+
if own == SCOPE_NOT:
|
|
31
|
+
return None
|
|
32
|
+
if item.cls and cls == SCOPE_NOT:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
child_parallel = False
|
|
36
|
+
param_parallel = False
|
|
37
|
+
|
|
38
|
+
# The most specific level with an explicit marker determines behavior
|
|
39
|
+
if own is not None:
|
|
40
|
+
if own == ParallelScope.ALL:
|
|
41
|
+
child_parallel = True
|
|
42
|
+
param_parallel = True
|
|
43
|
+
elif own == ParallelScope.PARAMETERS:
|
|
44
|
+
param_parallel = True
|
|
45
|
+
elif item.cls and cls is not None:
|
|
46
|
+
if cls in (ParallelScope.CHILDREN, ParallelScope.ALL):
|
|
47
|
+
child_parallel = True
|
|
48
|
+
if cls in (ParallelScope.PARAMETERS, ParallelScope.ALL):
|
|
49
|
+
param_parallel = True
|
|
50
|
+
elif mod is not None and mod != SCOPE_NOT:
|
|
51
|
+
if mod in (ParallelScope.CHILDREN, ParallelScope.ALL):
|
|
52
|
+
child_parallel = True
|
|
53
|
+
if mod in (ParallelScope.PARAMETERS, ParallelScope.ALL):
|
|
54
|
+
param_parallel = True
|
|
55
|
+
elif pkg is not None and pkg != SCOPE_NOT:
|
|
56
|
+
if pkg in (ParallelScope.CHILDREN, ParallelScope.ALL):
|
|
57
|
+
child_parallel = True
|
|
58
|
+
if pkg in (ParallelScope.PARAMETERS, ParallelScope.ALL):
|
|
59
|
+
param_parallel = True
|
|
60
|
+
|
|
61
|
+
if child_parallel:
|
|
62
|
+
if GroupKeyBuilder._is_package_level(item, own, cls, mod, pkg):
|
|
63
|
+
return (_GroupPrefix.PKG_CHILDREN, item.module.__package__)
|
|
64
|
+
if item.cls:
|
|
65
|
+
fp_key = MarkerResolver.fixture_param_key(item)
|
|
66
|
+
if fp_key:
|
|
67
|
+
return (_GroupPrefix.CLASS, item.cls, fp_key)
|
|
68
|
+
return (_GroupPrefix.CLASS, item.cls)
|
|
69
|
+
return (_GroupPrefix.MOD_CHILDREN, id(item.module))
|
|
70
|
+
|
|
71
|
+
if param_parallel:
|
|
72
|
+
callspec = getattr(item, "callspec", None)
|
|
73
|
+
if callspec:
|
|
74
|
+
fp_key = MarkerResolver.fixture_param_key(item)
|
|
75
|
+
if fp_key:
|
|
76
|
+
return (_GroupPrefix.PARAMS, item.cls, item.originalname, fp_key)
|
|
77
|
+
return (_GroupPrefix.PARAMS, item.cls, item.originalname)
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _is_package_level(item, own, cls, mod, pkg) -> bool:
|
|
83
|
+
"""True when the effective children marker comes from the package level."""
|
|
84
|
+
if own is not None:
|
|
85
|
+
return False
|
|
86
|
+
if item.cls and cls is not None:
|
|
87
|
+
return False
|
|
88
|
+
if mod is not None and mod != SCOPE_NOT:
|
|
89
|
+
return False
|
|
90
|
+
return pkg is not None and pkg in (ParallelScope.CHILDREN, ParallelScope.ALL)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def build_groups(items) -> list[tuple[object, list]]:
|
|
94
|
+
"""Group consecutive items by parallel group key.
|
|
95
|
+
|
|
96
|
+
Returns a list of (key, [items]) tuples.
|
|
97
|
+
"""
|
|
98
|
+
groups: list[tuple[object, list]] = []
|
|
99
|
+
prev_key = object()
|
|
100
|
+
for item in items:
|
|
101
|
+
key = GroupKeyBuilder.group_key(item)
|
|
102
|
+
if key != prev_key:
|
|
103
|
+
groups.append((key, []))
|
|
104
|
+
prev_key = key
|
|
105
|
+
groups[-1][1].append(item)
|
|
106
|
+
return groups
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Marker introspection for resolving effective parallel scope."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pytest_threadpool._constants import (
|
|
6
|
+
MARKER_NOT_PARALLELIZABLE,
|
|
7
|
+
MARKER_PARALLEL_ONLY,
|
|
8
|
+
MARKER_PARALLELIZABLE,
|
|
9
|
+
PARALLEL_SCOPES,
|
|
10
|
+
SCOPE_NOT,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MarkerResolver:
|
|
15
|
+
"""Resolves the effective parallel scope for a test item.
|
|
16
|
+
|
|
17
|
+
Walks the marker priority chain: own > class > module > package.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def scope_from_marks(marks) -> str | None:
|
|
22
|
+
"""Extract a parallelizable scope from a list of pytest marks."""
|
|
23
|
+
if not isinstance(marks, (list, tuple)):
|
|
24
|
+
marks = [marks]
|
|
25
|
+
for m in marks:
|
|
26
|
+
if m.name == MARKER_PARALLELIZABLE:
|
|
27
|
+
scope = m.args[0] if m.args else ParallelScope.ALL
|
|
28
|
+
return scope if scope in PARALLEL_SCOPES else None
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def has_not_marker(marks) -> bool:
|
|
33
|
+
"""Check if marks contain not_parallelizable."""
|
|
34
|
+
if not isinstance(marks, (list, tuple)):
|
|
35
|
+
marks = [marks]
|
|
36
|
+
return any(m.name == MARKER_NOT_PARALLELIZABLE for m in marks)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def own_scope(item) -> str | None:
|
|
40
|
+
"""Parallel scope from the item's own markers (not inherited)."""
|
|
41
|
+
if any(m.name == MARKER_NOT_PARALLELIZABLE for m in item.own_markers):
|
|
42
|
+
return SCOPE_NOT
|
|
43
|
+
for m in item.own_markers:
|
|
44
|
+
if m.name == MARKER_PARALLELIZABLE:
|
|
45
|
+
scope = m.args[0] if m.args else ParallelScope.ALL
|
|
46
|
+
return scope if scope in PARALLEL_SCOPES else None
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def class_scope(item) -> str | None:
|
|
51
|
+
"""Parallel scope from the item's class."""
|
|
52
|
+
if not item.cls:
|
|
53
|
+
return None
|
|
54
|
+
marks = getattr(item.cls, "pytestmark", [])
|
|
55
|
+
if MarkerResolver.has_not_marker(marks):
|
|
56
|
+
return SCOPE_NOT
|
|
57
|
+
return MarkerResolver.scope_from_marks(marks)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def module_scope(item) -> str | None:
|
|
61
|
+
"""Parallel scope from the item's module."""
|
|
62
|
+
marks = getattr(item.module, "pytestmark", [])
|
|
63
|
+
if MarkerResolver.has_not_marker(marks):
|
|
64
|
+
return SCOPE_NOT
|
|
65
|
+
return MarkerResolver.scope_from_marks(marks)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def package_scope(item) -> str | None:
|
|
69
|
+
"""Parallel scope from the item's package hierarchy."""
|
|
70
|
+
pkg_name = getattr(item.module, "__package__", None)
|
|
71
|
+
if not pkg_name:
|
|
72
|
+
return None
|
|
73
|
+
parts = pkg_name.split(".")
|
|
74
|
+
for i in range(len(parts), 0, -1):
|
|
75
|
+
pkg = ".".join(parts[:i])
|
|
76
|
+
mod = sys.modules.get(pkg)
|
|
77
|
+
if mod is None:
|
|
78
|
+
continue
|
|
79
|
+
marks = getattr(mod, "pytestmark", [])
|
|
80
|
+
if MarkerResolver.has_not_marker(marks):
|
|
81
|
+
return SCOPE_NOT
|
|
82
|
+
scope = MarkerResolver.scope_from_marks(marks)
|
|
83
|
+
if scope:
|
|
84
|
+
return scope
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def has_package_parallel_only(item) -> bool:
|
|
89
|
+
"""Check if any package in the item's hierarchy has parallel_only."""
|
|
90
|
+
pkg_name = getattr(item.module, "__package__", None)
|
|
91
|
+
if not pkg_name:
|
|
92
|
+
return False
|
|
93
|
+
parts = pkg_name.split(".")
|
|
94
|
+
for i in range(len(parts), 0, -1):
|
|
95
|
+
mod = sys.modules.get(".".join(parts[:i]))
|
|
96
|
+
if mod is None:
|
|
97
|
+
continue
|
|
98
|
+
marks = getattr(mod, "pytestmark", [])
|
|
99
|
+
if not isinstance(marks, (list, tuple)):
|
|
100
|
+
marks = [marks]
|
|
101
|
+
if any(m.name == MARKER_PARALLEL_ONLY for m in marks):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def parametrize_argnames(item) -> set[str]:
|
|
107
|
+
"""Collect arg names from all @pytest.mark.parametrize markers."""
|
|
108
|
+
names = set()
|
|
109
|
+
for marker in item.iter_markers("parametrize"):
|
|
110
|
+
argnames = marker.args[0]
|
|
111
|
+
if isinstance(argnames, str):
|
|
112
|
+
names.update(n.strip() for n in argnames.split(","))
|
|
113
|
+
elif isinstance(argnames, (list, tuple)):
|
|
114
|
+
names.update(argnames)
|
|
115
|
+
return names
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def fixture_param_key(item) -> tuple:
|
|
119
|
+
"""Extract non-@parametrize params (fixture params with broader scope).
|
|
120
|
+
|
|
121
|
+
These must stay in the group key to prevent merging groups whose
|
|
122
|
+
class/module/session-scoped fixtures differ.
|
|
123
|
+
"""
|
|
124
|
+
from _pytest.scope import Scope
|
|
125
|
+
|
|
126
|
+
callspec = getattr(item, "callspec", None)
|
|
127
|
+
if not callspec or not callspec.params:
|
|
128
|
+
return ()
|
|
129
|
+
parametrize_names = MarkerResolver.parametrize_argnames(item)
|
|
130
|
+
# noinspection PyProtectedMember
|
|
131
|
+
# callspec._arg2scope: no public API; needed to identify
|
|
132
|
+
# function-scoped params from pytest_generate_tests (which
|
|
133
|
+
# don't produce parametrize markers visible to iter_markers).
|
|
134
|
+
arg2scope = getattr(callspec, "_arg2scope", {})
|
|
135
|
+
fixture_params = {
|
|
136
|
+
k: v
|
|
137
|
+
for k, v in callspec.params.items()
|
|
138
|
+
if k not in parametrize_names
|
|
139
|
+
and arg2scope.get(k, Scope.Function) is not Scope.Function
|
|
140
|
+
}
|
|
141
|
+
return tuple(sorted(fixture_params.items())) if fixture_params else ()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Import here to avoid circular; used only in scope_from_marks / own_scope
|
|
145
|
+
from pytest_threadpool._constants import ParallelScope # noqa: E402
|