pytest-boorst 0.1.0a5__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_boorst/__init__.py +1 -0
- pytest_boorst/_discovery.py +207 -0
- pytest_boorst/_ids.py +29 -0
- pytest_boorst/plugin.py +161 -0
- pytest_boorst-0.1.0a5.dist-info/METADATA +184 -0
- pytest_boorst-0.1.0a5.dist-info/RECORD +9 -0
- pytest_boorst-0.1.0a5.dist-info/WHEEL +4 -0
- pytest_boorst-0.1.0a5.dist-info/entry_points.txt +3 -0
- pytest_boorst-0.1.0a5.dist-info/licenses/LICENSE +70 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional, experimental acceleration for pytest."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Reuse stock directory reports for a narrow, session-local file plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import inspect
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from _pytest.main import Dir, Session
|
|
11
|
+
from _pytest.python import Package
|
|
12
|
+
|
|
13
|
+
MIN_FILES = 32
|
|
14
|
+
_METHODS = (Session.collect, Session._collect_one_node, Dir.collect, Package.collect)
|
|
15
|
+
_METHOD_HASHES = (
|
|
16
|
+
"059b4635fb9e56688b817887ea750616b10738cb6d29571f5b7721f401dc32d6",
|
|
17
|
+
"41c0b9cb38ec89c9157f5df1c6d6af9b3986f45de538c02366c5887e63065225",
|
|
18
|
+
"f314ca1c43aa632c04b5570c9b273419aa848c3dcf9275aa58d6091a8bd36848",
|
|
19
|
+
"2dc823bfc8facccd8af8e0b2c61363202832efd0feee92d7ebacc8c70e9f1ff8",
|
|
20
|
+
)
|
|
21
|
+
_HOOKS = (
|
|
22
|
+
"pytest_collect_directory",
|
|
23
|
+
"pytest_collect_file",
|
|
24
|
+
"pytest_pycollect_makemodule",
|
|
25
|
+
"pytest_ignore_collect",
|
|
26
|
+
"pytest_collectstart",
|
|
27
|
+
"pytest_make_collect_report",
|
|
28
|
+
"pytest_collectreport",
|
|
29
|
+
)
|
|
30
|
+
_HOOK_HASHES = {
|
|
31
|
+
"_pytest.python.pytest_pycollect_makemodule": "942b7cdbbcf74ea989b5b373f8429c1b0570f26c31c3d084330e0c3f6fe83f15",
|
|
32
|
+
"_pytest.main.Session.pytest_collectstart": "bcd2d6b3082c4a77f602a5453a9e551669491ef8a505ee9f8efab75710de15ac",
|
|
33
|
+
"_pytest.main.Session.pytest_runtest_logreport": "d3003ff2a48eccec1ec963a0786c4619b1775d95e78dd081a31ccbbe0d5e7473",
|
|
34
|
+
"_pytest.main.pytest_collect_directory": "681454321faaf96bc7fd65e5eceb0e55b4f4825e63e4bc7c9a4ec971f850d0a6",
|
|
35
|
+
"_pytest.python.pytest_collect_directory": "7d337767579e97d39cf47b6abe040675a80a967bf39e27e1aa309c3fc4c7372a",
|
|
36
|
+
"_pytest.python.pytest_collect_file": "8812fc7a1632f71df0f2aaea1e61b011e8293b41166135b9e8561088a7ff22f3",
|
|
37
|
+
"_pytest.doctest.pytest_collect_file": "674f6e18c5e7e3596fbadd8c77f6f6e78f409fa931e48e3bc113b340ed0596ba",
|
|
38
|
+
"_pytest.main.pytest_ignore_collect": "7329f1c31d9a480829163bd7d1a3083b3ffe434553edbc2c3ff7a3636b41cf08",
|
|
39
|
+
"_pytest.runner.pytest_make_collect_report": "dd5756e160df76070b1cdbabf033ce39b5a5392320106eb42409b2bd82022d08",
|
|
40
|
+
"_pytest.capture.CaptureManager.pytest_make_collect_report": "d2f4f10b82e12a841c41bd07310902cd727c044ac502611f99afd6b8f81de122",
|
|
41
|
+
"_pytest.fixtures.FixtureManager.pytest_make_collect_report": "29629dfaf02a19320d591003af61b89743f472fb2e370dc23067ee71f0b786d3",
|
|
42
|
+
"_pytest.cacheprovider.LFPlugin.pytest_collectreport": "52a840fb38a24163aa35e4adea902fe4e1206c19d4b8b51a2984b27c71129bc9",
|
|
43
|
+
"_pytest.terminal.TerminalReporter.pytest_collectreport": "1e4d932bbc21fa9f4b68e25bdbd0cf5dce6ff8d6c508a19463083dc532e8e946",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _hash(function):
|
|
48
|
+
return hashlib.sha256(inspect.getsource(function).encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hooks(config):
|
|
52
|
+
return tuple(
|
|
53
|
+
(
|
|
54
|
+
impl.function,
|
|
55
|
+
getattr(impl.function, "__code__", None),
|
|
56
|
+
impl.wrapper,
|
|
57
|
+
impl.hookwrapper,
|
|
58
|
+
)
|
|
59
|
+
for name in _HOOKS
|
|
60
|
+
for impl in getattr(config.hook, name).get_hookimpls()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _options(config):
|
|
65
|
+
return (
|
|
66
|
+
tuple(config.args),
|
|
67
|
+
tuple(config.getini("python_files")),
|
|
68
|
+
tuple(config.option.ignore or ()),
|
|
69
|
+
tuple(config.option.ignore_glob or ()),
|
|
70
|
+
config.option.collect_in_virtualenv,
|
|
71
|
+
getattr(config.option, "doctestmodules", False),
|
|
72
|
+
tuple(getattr(config.option, "doctestglob", ()) or ()),
|
|
73
|
+
tuple(
|
|
74
|
+
getattr(config.option, name, False)
|
|
75
|
+
for name in ("pyargs", "keepduplicates", "lf", "failedfirst", "debug")
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def install(session, state):
|
|
81
|
+
config = session.config
|
|
82
|
+
state.update(discovery_status="fallback", directory_reuses=0)
|
|
83
|
+
# ponytail: only large sibling groups; small groups keep stock discovery.
|
|
84
|
+
if len(config.args) < MIN_FILES or any(
|
|
85
|
+
getattr(config.option, name, False)
|
|
86
|
+
for name in (
|
|
87
|
+
"pyargs",
|
|
88
|
+
"keepduplicates",
|
|
89
|
+
"lf",
|
|
90
|
+
"failedfirst",
|
|
91
|
+
"doctestmodules",
|
|
92
|
+
"debug",
|
|
93
|
+
)
|
|
94
|
+
):
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
paths = [Path(arg).absolute() for arg in config.args]
|
|
98
|
+
groups = Counter(path.parent for path in paths)
|
|
99
|
+
parents = {parent for parent, count in groups.items() if count >= MIN_FILES}
|
|
100
|
+
# Overlapping parents rebuild child collectors and change fixture visibility.
|
|
101
|
+
if not parents or any(
|
|
102
|
+
parent in groups for child in groups for parent in child.parents
|
|
103
|
+
):
|
|
104
|
+
return
|
|
105
|
+
if len(set(paths)) != len(paths) or any(
|
|
106
|
+
"::" in arg
|
|
107
|
+
or path.suffix != ".py"
|
|
108
|
+
or path.resolve() != path
|
|
109
|
+
or not path.is_file()
|
|
110
|
+
for arg, path in zip(config.args, paths, strict=True)
|
|
111
|
+
):
|
|
112
|
+
return
|
|
113
|
+
if (
|
|
114
|
+
(Session.collect, Session._collect_one_node, Dir.collect, Package.collect)
|
|
115
|
+
!= _METHODS
|
|
116
|
+
or "_collect_one_node" in session.__dict__
|
|
117
|
+
or tuple(map(_hash, _METHODS)) != _METHOD_HASHES
|
|
118
|
+
):
|
|
119
|
+
return
|
|
120
|
+
hooks = _hooks(config)
|
|
121
|
+
for function, _, _, _ in hooks:
|
|
122
|
+
name = f"{function.__module__}.{function.__qualname__}"
|
|
123
|
+
if hasattr(function, "__wrapped__") or _hash(function) != _HOOK_HASHES.get(
|
|
124
|
+
name
|
|
125
|
+
):
|
|
126
|
+
return
|
|
127
|
+
options = _options(config)
|
|
128
|
+
plugins = frozenset(config.pluginmanager.get_plugins())
|
|
129
|
+
codes = tuple(function.__code__ for function in _METHODS)
|
|
130
|
+
except (OSError, TypeError, AttributeError, ValueError):
|
|
131
|
+
return
|
|
132
|
+
original = session._collect_one_node
|
|
133
|
+
cached = {}
|
|
134
|
+
validated_epoch = None
|
|
135
|
+
|
|
136
|
+
def accelerated(node, handle_dupes=True):
|
|
137
|
+
nonlocal validated_epoch
|
|
138
|
+
if (
|
|
139
|
+
state["discovery_status"] != "active"
|
|
140
|
+
or Session._collect_one_node is not _METHODS[1]
|
|
141
|
+
):
|
|
142
|
+
state["discovery_status"] = "fallback"
|
|
143
|
+
restore()
|
|
144
|
+
return session._collect_one_node(node, handle_dupes)
|
|
145
|
+
if handle_dupes or type(node) not in (Dir, Package) or node.path not in parents:
|
|
146
|
+
return original(node, handle_dupes)
|
|
147
|
+
if session._collection_cache is not validated_epoch:
|
|
148
|
+
cached.clear()
|
|
149
|
+
parts = session._initial_parts
|
|
150
|
+
if len(parts) != len(paths) or any(
|
|
151
|
+
part.path != path
|
|
152
|
+
or part.parts
|
|
153
|
+
or part.parametrization
|
|
154
|
+
or part.module_name
|
|
155
|
+
for part, path in zip(parts, paths, strict=True)
|
|
156
|
+
):
|
|
157
|
+
state["discovery_status"] = "fallback"
|
|
158
|
+
validated_epoch = session._collection_cache
|
|
159
|
+
if (
|
|
160
|
+
state["discovery_status"] != "active"
|
|
161
|
+
or _hooks(config) != hooks
|
|
162
|
+
or _options(config) != options
|
|
163
|
+
or frozenset(config.pluginmanager.get_plugins()) != plugins
|
|
164
|
+
or tuple(function.__code__ for function in _METHODS) != codes
|
|
165
|
+
or (
|
|
166
|
+
Session.collect,
|
|
167
|
+
Session._collect_one_node,
|
|
168
|
+
Dir.collect,
|
|
169
|
+
Package.collect,
|
|
170
|
+
)
|
|
171
|
+
!= _METHODS
|
|
172
|
+
or "collect" in node.__dict__
|
|
173
|
+
):
|
|
174
|
+
cached.clear()
|
|
175
|
+
state["discovery_status"] = "fallback"
|
|
176
|
+
restore()
|
|
177
|
+
return session._collect_one_node(node, handle_dupes)
|
|
178
|
+
try:
|
|
179
|
+
stat = node.path.stat()
|
|
180
|
+
current = (stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_ctime_ns)
|
|
181
|
+
except OSError:
|
|
182
|
+
cached.pop(node, None)
|
|
183
|
+
return original(node, handle_dupes)
|
|
184
|
+
entry = cached.get(node)
|
|
185
|
+
if (
|
|
186
|
+
entry is not None
|
|
187
|
+
and session._collection_cache is entry[1]
|
|
188
|
+
and current == entry[2]
|
|
189
|
+
):
|
|
190
|
+
node.ihook.pytest_collectstart(collector=node)
|
|
191
|
+
state["directory_reuses"] += 1
|
|
192
|
+
# This is still a new file argument, not a duplicate argument.
|
|
193
|
+
return entry[0], False
|
|
194
|
+
report, duplicate = original(node, handle_dupes)
|
|
195
|
+
if report.passed:
|
|
196
|
+
cached[node] = (report, session._collection_cache, current)
|
|
197
|
+
else:
|
|
198
|
+
cached.pop(node, None)
|
|
199
|
+
return report, duplicate
|
|
200
|
+
|
|
201
|
+
def restore():
|
|
202
|
+
if session.__dict__.get("_collect_one_node") is accelerated:
|
|
203
|
+
del session._collect_one_node
|
|
204
|
+
|
|
205
|
+
config.add_cleanup(restore)
|
|
206
|
+
session._collect_one_node = accelerated
|
|
207
|
+
state["discovery_status"] = "active"
|
pytest_boorst/_ids.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Python comparison implementation for the parameter ID benchmark."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def unique_ids(ids: list[str]) -> list[str]:
|
|
7
|
+
"""Match pytest suffixing while maintaining the live ID counts."""
|
|
8
|
+
result = list(ids)
|
|
9
|
+
counts = Counter(ids)
|
|
10
|
+
if len(counts) == len(ids):
|
|
11
|
+
return result
|
|
12
|
+
occupied = counts.copy()
|
|
13
|
+
suffixes: dict[str, int] = defaultdict(int)
|
|
14
|
+
for index, value in enumerate(ids):
|
|
15
|
+
if counts[value] < 2:
|
|
16
|
+
continue
|
|
17
|
+
separator = "_" if value and value[-1].isdigit() else ""
|
|
18
|
+
suffix = suffixes[value]
|
|
19
|
+
candidate = f"{value}{separator}{suffix}"
|
|
20
|
+
while candidate in occupied:
|
|
21
|
+
suffix += 1
|
|
22
|
+
candidate = f"{value}{separator}{suffix}"
|
|
23
|
+
occupied[value] -= 1
|
|
24
|
+
if not occupied[value]:
|
|
25
|
+
del occupied[value]
|
|
26
|
+
occupied[candidate] += 1
|
|
27
|
+
result[index] = candidate
|
|
28
|
+
suffixes[value] = suffix + 1
|
|
29
|
+
return result
|
pytest_boorst/plugin.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Opt-in adapters for verified pytest implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import sysconfig
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from _pytest.python import IdMaker
|
|
16
|
+
except ImportError:
|
|
17
|
+
IdMaker = None
|
|
18
|
+
|
|
19
|
+
STATE_KEY = pytest.StashKey[dict]() if hasattr(pytest, "StashKey") else None
|
|
20
|
+
MIN_IDS = 64
|
|
21
|
+
_ORIGINAL = getattr(IdMaker, "make_unique_parameterset_ids", None)
|
|
22
|
+
_SOURCE_SHA256 = "6baee487481ac9da41d36fee0c8870f093aab91b9f507434d5c89620ddf588b8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _ResolvedIds:
|
|
26
|
+
"""Let the original method consume IDs without repeating user callbacks."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, maker, ids, strict=None):
|
|
29
|
+
self.maker = maker
|
|
30
|
+
self.ids = ids
|
|
31
|
+
self.strict = strict
|
|
32
|
+
|
|
33
|
+
def _resolve_ids(self):
|
|
34
|
+
return iter(self.ids)
|
|
35
|
+
|
|
36
|
+
def _strict_parametrization_ids_enabled(self):
|
|
37
|
+
if self.strict is not None:
|
|
38
|
+
return self.strict
|
|
39
|
+
return self.maker._strict_parametrization_ids_enabled()
|
|
40
|
+
|
|
41
|
+
def __getattr__(self, name):
|
|
42
|
+
return getattr(self.maker, name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pytest_configure(config):
|
|
46
|
+
if STATE_KEY is None:
|
|
47
|
+
return
|
|
48
|
+
state = {
|
|
49
|
+
"status": "disabled",
|
|
50
|
+
"reason": "set PYTEST_BOORST=1 to enable the alpha",
|
|
51
|
+
"native_calls": 0,
|
|
52
|
+
"native_ids": 0,
|
|
53
|
+
"fallback_calls": 0,
|
|
54
|
+
}
|
|
55
|
+
config.stash[STATE_KEY] = state
|
|
56
|
+
if os.environ.get("PYTEST_BOORST") != "1":
|
|
57
|
+
return
|
|
58
|
+
state.update(status="fallback", reason="unsupported interpreter or pytest version")
|
|
59
|
+
if (
|
|
60
|
+
pytest.__version__ not in {"9.0.2", "9.0.3", "9.1.1"}
|
|
61
|
+
or sys.implementation.name != "cpython"
|
|
62
|
+
or not (3, 10) <= sys.version_info[:2] <= (3, 14)
|
|
63
|
+
or sysconfig.get_config_var("Py_GIL_DISABLED")
|
|
64
|
+
):
|
|
65
|
+
return
|
|
66
|
+
state["reason"] = "pytest ID method was changed or cannot be verified"
|
|
67
|
+
if (
|
|
68
|
+
IdMaker is None
|
|
69
|
+
or _ORIGINAL is None
|
|
70
|
+
or getattr(IdMaker, "make_unique_parameterset_ids", None) is not _ORIGINAL
|
|
71
|
+
or not inspect.isfunction(_ORIGINAL)
|
|
72
|
+
or hasattr(_ORIGINAL, "__wrapped__")
|
|
73
|
+
or _ORIGINAL.__module__ != "_pytest.python"
|
|
74
|
+
or _ORIGINAL.__qualname__ != "IdMaker.make_unique_parameterset_ids"
|
|
75
|
+
):
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
source_hash = hashlib.sha256(inspect.getsource(_ORIGINAL).encode()).hexdigest()
|
|
79
|
+
except (OSError, TypeError):
|
|
80
|
+
return
|
|
81
|
+
if source_hash != _SOURCE_SHA256:
|
|
82
|
+
return
|
|
83
|
+
state["reason"] = "native extension unavailable"
|
|
84
|
+
try:
|
|
85
|
+
native = importlib.import_module("pytest_boorst._native")
|
|
86
|
+
except (ImportError, OSError):
|
|
87
|
+
return
|
|
88
|
+
unique_ids = native.unique_ids
|
|
89
|
+
|
|
90
|
+
def accelerated(maker):
|
|
91
|
+
if maker.config is not config:
|
|
92
|
+
return _ORIGINAL(maker)
|
|
93
|
+
# ponytail: small inputs use pytest; revisit the cutoff with real workloads.
|
|
94
|
+
if len(maker.parametersets) < MIN_IDS:
|
|
95
|
+
state["fallback_calls"] += 1
|
|
96
|
+
return _ORIGINAL(maker)
|
|
97
|
+
ids = list(maker._resolve_ids())
|
|
98
|
+
if not all(type(value) is str and value.isascii() for value in ids):
|
|
99
|
+
state["fallback_calls"] += 1
|
|
100
|
+
return _ORIGINAL(_ResolvedIds(maker, ids))
|
|
101
|
+
if len(ids) == len(set(ids)):
|
|
102
|
+
state["fallback_calls"] += 1
|
|
103
|
+
return ids
|
|
104
|
+
if maker._strict_parametrization_ids_enabled():
|
|
105
|
+
state["fallback_calls"] += 1
|
|
106
|
+
return _ORIGINAL(_ResolvedIds(maker, ids, strict=True))
|
|
107
|
+
result = unique_ids(ids)
|
|
108
|
+
state["native_calls"] += 1
|
|
109
|
+
state["native_ids"] += len(ids)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def restore():
|
|
113
|
+
if IdMaker.make_unique_parameterset_ids is accelerated:
|
|
114
|
+
IdMaker.make_unique_parameterset_ids = _ORIGINAL
|
|
115
|
+
|
|
116
|
+
config.add_cleanup(restore)
|
|
117
|
+
IdMaker.make_unique_parameterset_ids = accelerated
|
|
118
|
+
state.update(status="active", reason="ASCII parameter ID deduplication")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pytest_report_header(config):
|
|
122
|
+
if STATE_KEY is None:
|
|
123
|
+
return
|
|
124
|
+
state = config.stash[STATE_KEY]
|
|
125
|
+
if state["status"] != "disabled":
|
|
126
|
+
return f"boorst: {state['status']} ({state['reason']})"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.hookimpl(trylast=True)
|
|
130
|
+
def pytest_sessionstart(session):
|
|
131
|
+
if (
|
|
132
|
+
STATE_KEY is None
|
|
133
|
+
or os.environ.get("PYTEST_BOORST") != "1"
|
|
134
|
+
or pytest.__version__ != "9.1.1"
|
|
135
|
+
or sys.platform == "win32"
|
|
136
|
+
or os.environ.get("PYTEST_DEBUG")
|
|
137
|
+
or sys.implementation.name != "cpython"
|
|
138
|
+
or not (3, 10) <= sys.version_info[:2] <= (3, 14)
|
|
139
|
+
or sysconfig.get_config_var("Py_GIL_DISABLED")
|
|
140
|
+
):
|
|
141
|
+
return
|
|
142
|
+
from ._discovery import install
|
|
143
|
+
|
|
144
|
+
install(session, session.config.stash[STATE_KEY])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pytest_terminal_summary(terminalreporter, config):
|
|
148
|
+
if STATE_KEY is None:
|
|
149
|
+
return
|
|
150
|
+
state = config.stash[STATE_KEY]
|
|
151
|
+
if state["status"] == "active":
|
|
152
|
+
terminalreporter.write_line(
|
|
153
|
+
f"boorst: {state['native_calls']} native ID batches "
|
|
154
|
+
f"({state['native_ids']} IDs), {state['fallback_calls']} stock batches"
|
|
155
|
+
)
|
|
156
|
+
elif state["status"] == "fallback":
|
|
157
|
+
terminalreporter.write_line(f"boorst: fallback ({state['reason']})")
|
|
158
|
+
if state.get("directory_reuses"):
|
|
159
|
+
terminalreporter.write_line(
|
|
160
|
+
f"boorst: {state['directory_reuses']} directory reports reused"
|
|
161
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-boorst
|
|
3
|
+
Version: 0.1.0a5
|
|
4
|
+
Summary: Experimental Rust-powered acceleration for pytest
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Framework :: Pytest
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Rust
|
|
11
|
+
Requires-Dist: pytest
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Project-URL: Repository, https://github.com/dnikolayev/pytest-boorst
|
|
14
|
+
Project-URL: Issues, https://github.com/dnikolayev/pytest-boorst/issues
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# pytest-boorst
|
|
18
|
+
|
|
19
|
+
Experimental Rust-powered acceleration for pytest.
|
|
20
|
+
|
|
21
|
+
This alpha accelerates duplicate parameter-ID bookkeeping and repeated directory
|
|
22
|
+
discovery while pytest keeps control of test collection, fixtures, execution, and
|
|
23
|
+
reporting. Acceleration is opt-in and guarded by a tested pytest version and
|
|
24
|
+
implementation fingerprint. See the [measured results](#performance-evidence).
|
|
25
|
+
|
|
26
|
+
## Try the alpha
|
|
27
|
+
|
|
28
|
+
With uv installed, run this command from your project with its test dependencies
|
|
29
|
+
already installed in its virtual environment. It works in POSIX shells and PowerShell:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uv run --no-sync https://raw.githubusercontent.com/dnikolayev/pytest-boorst/v0.1.0a5/scripts/try.py
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Append your usual pytest arguments, such as `-q tests/`. The launcher installs the
|
|
36
|
+
pinned GitHub release using a compatible wheel and enables Boorst for that pytest
|
|
37
|
+
process. It preserves the existing Python, pytest, dependencies, project configuration,
|
|
38
|
+
and lockfile. An already-installed matching alpha needs no wheel download or reinstallation.
|
|
39
|
+
|
|
40
|
+
Native wheels cover macOS Intel/ARM64, Windows x86-64/x86/ARM64, and Linux
|
|
41
|
+
glibc/musl on x86-64/x86/ARM64. See the [platform matrix](CONTRIBUTING.md#platform-wheels).
|
|
42
|
+
Other platforms can install the universal Python wheel: it retains the guarded
|
|
43
|
+
directory optimization on supported Linux/macOS environments and uses stock IDs.
|
|
44
|
+
|
|
45
|
+
Boorst remains disabled for ordinary pytest runs unless `PYTEST_BOORST=1`. Use
|
|
46
|
+
`-p no:boorst` to prevent plugin loading; uninstalling the package restores ordinary
|
|
47
|
+
pytest behavior. The launcher respects plugin-autoload settings.
|
|
48
|
+
|
|
49
|
+
You can also install the pinned alpha from PyPI with `uv pip install pytest-boorst==0.1.0a5`.
|
|
50
|
+
[GitHub releases](https://github.com/dnikolayev/pytest-boorst/releases) trigger the full CI
|
|
51
|
+
matrix, then publish its tested distributions to PyPI using Trusted Publishing.
|
|
52
|
+
Release assets contain the same wheels, source distribution and SHA-256 manifest.
|
|
53
|
+
|
|
54
|
+
## Compatibility boundary
|
|
55
|
+
|
|
56
|
+
The parameter-ID adapter currently accelerates **pytest 9.0.2, 9.0.3, and 9.1.1 on CPython
|
|
57
|
+
3.10–3.14 with the GIL enabled**. It checks the original method's source fingerprint
|
|
58
|
+
before changing it and restores its owned patch at session cleanup. Other pytest versions,
|
|
59
|
+
modified methods, and missing or unloadable native extensions use stock parameter IDs.
|
|
60
|
+
Installation does not require changing an already-installed pytest version.
|
|
61
|
+
On older releases without pytest's Stash API, the plugin falls back quietly.
|
|
62
|
+
|
|
63
|
+
Only duplicate batches of at least 64 resolved, exact ASCII strings enter Rust.
|
|
64
|
+
Small batches, unique IDs, Unicode, string subclasses, hidden IDs, and strict-ID
|
|
65
|
+
errors use pytest behavior. Pytest still resolves custom IDs and calls user hooks
|
|
66
|
+
exactly once. Unexpected native computation errors propagate rather than rerunning
|
|
67
|
+
callbacks or hiding a bug.
|
|
68
|
+
|
|
69
|
+
On **pytest 9.1.1, CPython 3.10–3.14, Linux and macOS**, directory discovery can
|
|
70
|
+
reuse successful reports for directories with at least 32 distinct `.py` files
|
|
71
|
+
passed as plain arguments. Groups may be interleaved across several directories;
|
|
72
|
+
smaller groups retain stock discovery. Selected parent directories must not
|
|
73
|
+
contain one another. This Python optimization avoids recreating every
|
|
74
|
+
sibling collector for each requested file. It needs no native extension and does
|
|
75
|
+
not change the arguments, import unrequested tests, or replace file collection.
|
|
76
|
+
|
|
77
|
+
Directory reuse requires verified stock directory methods and discovery/report
|
|
78
|
+
hooks. Custom discovery/report hooks, modified methods, selectors, duplicate paths, symlinks,
|
|
79
|
+
overlapping parent directories, doctest-module mode, last-failed/failed-first modes, and debug
|
|
80
|
+
tracing use stock discovery. Changed hooks, plugins, or collection options stop
|
|
81
|
+
reuse; changed directory metadata invalidates the report. Windows currently uses
|
|
82
|
+
stock discovery. Plugins inspecting discarded collectors or pytest's private
|
|
83
|
+
collection-cache layout are outside this experimental compatibility boundary.
|
|
84
|
+
|
|
85
|
+
This uses a narrowly guarded private pytest method. The tested compatibility
|
|
86
|
+
boundary does not establish compatibility with every possible third-party plugin
|
|
87
|
+
or modification of pytest internals. Tests cover ordered IDs, phase outcomes,
|
|
88
|
+
coverage, asyncio, xdist, callbacks, errors, and repeated invocation.
|
|
89
|
+
|
|
90
|
+
On releases with the Stash API, the header reports activation or fallback.
|
|
91
|
+
The summary reports native ID batches, stock batches, and reused directory reports.
|
|
92
|
+
Counters are per process; xdist workers collect their own batches, so the
|
|
93
|
+
controller can report zero native calls.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
Install uv and Rust, then:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
uv sync --locked --python 3.14
|
|
101
|
+
uv run --no-sync maturin develop --release --locked
|
|
102
|
+
uv run --no-sync ruff check .
|
|
103
|
+
uv run --no-sync ruff format --check .
|
|
104
|
+
uv run --no-sync pytest
|
|
105
|
+
cargo fmt --check
|
|
106
|
+
cargo clippy --locked --all-targets -- -D warnings
|
|
107
|
+
cargo test --locked --lib
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`uv.lock` and `Cargo.lock` pin the development environments. Maturin builds the
|
|
111
|
+
PyO3 extension. GitHub CI installs built wheels and tests Python 3.10–3.14 on Linux,
|
|
112
|
+
plus macOS and Windows smoke coverage, and rebuilds the source distribution.
|
|
113
|
+
The Linux Python 3.12 wheel lane also runs the complete suite and installed-wheel
|
|
114
|
+
smoke checks with pytest 9.0.2 and 9.0.3, and verifies stock fallback on pytest 8.4.2.
|
|
115
|
+
Normal wheel-installation checks preserve pytest 6.2.5 and 7.4.4 in separate
|
|
116
|
+
environments and compare fallback IDs and phase outcomes against stock pytest.
|
|
117
|
+
An independent benchmark job retains raw timings and CPU/memory observations.
|
|
118
|
+
|
|
119
|
+
## Performance evidence
|
|
120
|
+
|
|
121
|
+
Measured on Ubuntu 24.04, CPython 3.14.8, pytest 9.1.1 and Boorst 0.1.0a2.
|
|
122
|
+
Times are medians of three fresh **full pytest runs**, including startup,
|
|
123
|
+
collection, fixtures and execution. Percentages use unrounded medians.
|
|
124
|
+
|
|
125
|
+
| Synthetic workload | Stock pytest | Boorst enabled | Time change |
|
|
126
|
+
| --- | ---: | ---: | ---: |
|
|
127
|
+
| 10,000 duplicate parameter IDs | 8.491 s | 6.685 s | 21.3% less |
|
|
128
|
+
| 10,000 colliding numeric-suffix IDs | 8.291 s | 6.495 s | 21.7% less |
|
|
129
|
+
| 384 explicit sibling test files | 7.755 s | 1.003 s | 87.1% less |
|
|
130
|
+
| 10,000 unique IDs (control) | 6.526 s | 6.511 s | 0.2% less |
|
|
131
|
+
| 8 IDs (control) | 0.244 s | 0.248 s | 1.7% more |
|
|
132
|
+
|
|
133
|
+
| Optimization | Why it helps |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| Duplicate-ID bookkeeping | Avoids rebuilding the used-ID set for every collision; PyO3 accelerates the bounded helper. |
|
|
136
|
+
| Directory-report reuse | Reuses a successful sibling-collector listing across file arguments, avoiding repeated scans and collector construction. This optimization uses Python. |
|
|
137
|
+
|
|
138
|
+
Most ID savings come from the algorithm: the optimized Python comparison took
|
|
139
|
+
6.739 s for duplicate IDs, versus 6.685 s with Rust. The isolated helper took
|
|
140
|
+
6.40 ms in Python and 4.34 ms in Rust; helper timings describe only that component.
|
|
141
|
+
Unique and small controls made zero native calls. Ordinary suites can see little
|
|
142
|
+
benefit or overhead; these synthetic results do not predict overall project CI.
|
|
143
|
+
|
|
144
|
+
Both harnesses verify exact ordered node IDs, setup/call/teardown outcomes and
|
|
145
|
+
exit codes. The ID harness checks parity before timing; the discovery harness
|
|
146
|
+
includes identical receipt recording during timing and disables bytecode writes
|
|
147
|
+
for both modes. CI requires at least 10% duplicate-workload improvement and 5%
|
|
148
|
+
directory-workload improvement. Separate compatibility tests cover callbacks,
|
|
149
|
+
fixtures, errors, coverage, asyncio, xdist and fallback behavior. Multi-directory
|
|
150
|
+
checks include interleaved groups, directory-local fixtures, syntax errors,
|
|
151
|
+
independent directory invalidation and stock fallback for overlapping groups.
|
|
152
|
+
|
|
153
|
+
Sources: [ID measurements](benchmarks/results/alpha2-python314-ids-ci.json),
|
|
154
|
+
[directory measurements](benchmarks/results/alpha2-python314-discovery-ci.json), and the
|
|
155
|
+
[successful CI run](https://github.com/dnikolayev/pytest-boorst/actions/runs/37696156274).
|
|
156
|
+
|
|
157
|
+
Alpha 4 also covers independent sibling groups. A macOS ARM64 / CPython
|
|
158
|
+
3.14.7 / pytest 9.1.1 trial with the universal wheel measured three fresh full runs:
|
|
159
|
+
|
|
160
|
+
| Additional synthetic workload | Stock pytest | Boorst enabled | Time change |
|
|
161
|
+
| --- | ---: | ---: | ---: |
|
|
162
|
+
| 384 files across four directories | 1.963 s | 0.973 s | 50.4% less |
|
|
163
|
+
|
|
164
|
+
[Raw measurements](benchmarks/results/alpha4-multiple-directories-local.json)
|
|
165
|
+
record exact ordered-ID, phase and exit-code parity, with 380 directory reuses and
|
|
166
|
+
zero native ID calls per run. This measures the Python discovery optimization.
|
|
167
|
+
Alpha 3 uses stock discovery for this multi-directory plan. CI repeats the four-directory
|
|
168
|
+
workload on Python 3.14.8 and retains its own raw receipt.
|
|
169
|
+
|
|
170
|
+
The benchmark job uses CPython 3.14.8. Existing projects can keep their own
|
|
171
|
+
Python version; the trial launcher uses their existing environment.
|
|
172
|
+
|
|
173
|
+
Reproduce with Python 3.14 and a release build:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
uv sync --locked --python 3.14
|
|
177
|
+
uv run --no-sync maturin develop --release --locked
|
|
178
|
+
uv run --no-sync python benchmarks/run.py --size 10000 --repeats 3
|
|
179
|
+
uv run --no-sync python benchmarks/discovery.py --repeats 3
|
|
180
|
+
uv run --no-sync python benchmarks/discovery.py --directories 4 --repeats 3 --output benchmark-results-multiple-directories.json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
See [benchmark methodology](benchmarks/README.md) for detailed compatibility
|
|
184
|
+
controls and earlier public-project measurements.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pytest_boorst/__init__.py,sha256=V_9FjAHepAeKdccveW3tuEEzkFB_odn7XmVJK6DcYdQ,54
|
|
2
|
+
pytest_boorst/_discovery.py,sha256=FjLRRrk7xvU6i0MPbJ6zKW0ro6hK40ys4cYt-XX0Mb4,8310
|
|
3
|
+
pytest_boorst/_ids.py,sha256=FHVWuqeqzUg4QMmxHSOt5IGN3AFtj9-34Rp0FpXuhoo,984
|
|
4
|
+
pytest_boorst/plugin.py,sha256=4Exm5NJ0Tzd-nahVgnCDyYIkaPOPS79s-l2l2lLolu4,5284
|
|
5
|
+
pytest_boorst-0.1.0a5.dist-info/licenses/LICENSE,sha256=C3b0bJzAQcmo6MKZY7wMWxSBm8wFhozZCQQhF3trN4Y,3521
|
|
6
|
+
pytest_boorst-0.1.0a5.dist-info/WHEEL,sha256=WKk67IZPzp9uSkBO-iWoZzrqp9-AwH7FE9EB8EoJbf4,81
|
|
7
|
+
pytest_boorst-0.1.0a5.dist-info/entry_points.txt,sha256=C-OM76ixrlFiPIKKmGlQ8BBQVTFx90B4N63BsbkUgx4,42
|
|
8
|
+
pytest_boorst-0.1.0a5.dist-info/METADATA,sha256=Lk-KQLNpKvVYkqwjgZM1-v52UlEdAW1pZ5dev1cDnXU,9794
|
|
9
|
+
pytest_boorst-0.1.0a5.dist-info/RECORD,,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pytest-boorst contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Third-party notices
|
|
24
|
+
===================
|
|
25
|
+
|
|
26
|
+
PyO3 provides the native Python bindings and is used under its MIT license:
|
|
27
|
+
https://github.com/PyO3/pyo3
|
|
28
|
+
|
|
29
|
+
Copyright (c) 2023-present PyO3 Project and Contributors. https://github.com/PyO3
|
|
30
|
+
|
|
31
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
32
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
33
|
+
in the Software without restriction, including without limitation the rights
|
|
34
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
35
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
36
|
+
furnished to do so, subject to the following conditions:
|
|
37
|
+
|
|
38
|
+
The above copyright notice and this permission notice shall be included in all
|
|
39
|
+
copies or substantial portions of the Software.
|
|
40
|
+
|
|
41
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
42
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
43
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
44
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
45
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
46
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
47
|
+
SOFTWARE.
|
|
48
|
+
|
|
49
|
+
Parameter ID suffix semantics follow pytest, which is distributed under MIT:
|
|
50
|
+
https://github.com/pytest-dev/pytest
|
|
51
|
+
|
|
52
|
+
Copyright (c) 2004 Holger Krekel and others
|
|
53
|
+
|
|
54
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
55
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
56
|
+
in the Software without restriction, including without limitation the rights
|
|
57
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
58
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
59
|
+
furnished to do so, subject to the following conditions:
|
|
60
|
+
|
|
61
|
+
The above copyright notice and this permission notice shall be included in all
|
|
62
|
+
copies or substantial portions of the Software.
|
|
63
|
+
|
|
64
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
65
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
66
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
67
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
68
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
69
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
70
|
+
SOFTWARE.
|