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,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