pytest-slowtrace 0.1.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,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
# How finely to sample a test's stack, relative to the threshold it's judged
|
|
13
|
+
# against: a test right at the threshold still gets a handful of samples, one
|
|
14
|
+
# at 10x the threshold gets ~100. Floored so a very tight --slowtrace-threshold
|
|
15
|
+
# (or a marker override) can't turn the sampler into a busy loop.
|
|
16
|
+
_SAMPLE_INTERVAL_DIVISOR = 10
|
|
17
|
+
_MIN_SAMPLE_INTERVAL = 0.001
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlowTracePlugin:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
threshold: float,
|
|
24
|
+
idle_threshold: float,
|
|
25
|
+
rootdir: Path,
|
|
26
|
+
app_packages: set[str],
|
|
27
|
+
) -> None:
|
|
28
|
+
self.threshold = threshold
|
|
29
|
+
self.idle_threshold = idle_threshold
|
|
30
|
+
self.rootdir = rootdir
|
|
31
|
+
self.app_packages = app_packages
|
|
32
|
+
self.slow_reports: list[pytest.TestReport] = []
|
|
33
|
+
self.overrides: dict[str, float] = {}
|
|
34
|
+
self.skipped: set[str] = set()
|
|
35
|
+
|
|
36
|
+
def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None:
|
|
37
|
+
for item in items:
|
|
38
|
+
if item.get_closest_marker("xslowtrace") is not None:
|
|
39
|
+
self.skipped.add(item.nodeid)
|
|
40
|
+
marker = item.get_closest_marker("slowtrace")
|
|
41
|
+
if marker is None:
|
|
42
|
+
continue
|
|
43
|
+
if "seconds" in marker.kwargs:
|
|
44
|
+
seconds = marker.kwargs["seconds"]
|
|
45
|
+
else:
|
|
46
|
+
seconds = marker.args[0]
|
|
47
|
+
self.overrides[item.nodeid] = seconds
|
|
48
|
+
|
|
49
|
+
def _app_frame(self, frame):
|
|
50
|
+
"""Walk outward from `frame` to the first frame that counts as "app
|
|
51
|
+
code", skipping over library/stdlib frames along the way.
|
|
52
|
+
|
|
53
|
+
A wait buried inside a library (e.g. requests -> urllib3 -> socket)
|
|
54
|
+
samples as a stdlib-internals line that means nothing without more
|
|
55
|
+
context; the caller in app code that made the blocking call is the
|
|
56
|
+
useful line. Falls back to `frame` itself if nothing in the chain
|
|
57
|
+
qualifies -- the wait might be entirely inside a library.
|
|
58
|
+
|
|
59
|
+
Default test: is this frame's file under config.rootpath? That
|
|
60
|
+
breaks when a project's own virtualenv is nested inside its repo
|
|
61
|
+
root (tox's .tox/, uv's .venv/) -- installed third-party packages
|
|
62
|
+
live under rootdir too, so the very first (innermost) library frame
|
|
63
|
+
satisfies the check and the walk never reaches real app code.
|
|
64
|
+
--slowtrace-app-packages sidesteps this: when set, match a frame's
|
|
65
|
+
top-level module name instead of its path, since a library's
|
|
66
|
+
__name__ (e.g. "urllib3.connectionpool") doesn't depend on where its
|
|
67
|
+
files happen to sit on disk.
|
|
68
|
+
"""
|
|
69
|
+
node = frame
|
|
70
|
+
while node is not None:
|
|
71
|
+
if self.app_packages:
|
|
72
|
+
name = node.f_globals.get("__name__", "")
|
|
73
|
+
if name.split(".", 1)[0] in self.app_packages:
|
|
74
|
+
return node
|
|
75
|
+
elif Path(node.f_code.co_filename).is_relative_to(self.rootdir):
|
|
76
|
+
return node
|
|
77
|
+
node = node.f_back
|
|
78
|
+
return frame
|
|
79
|
+
|
|
80
|
+
@pytest.hookimpl(wrapper=True)
|
|
81
|
+
def pytest_runtest_call(self, item: pytest.Item):
|
|
82
|
+
start = time.process_time()
|
|
83
|
+
samples: list[tuple[str, int, str]] = []
|
|
84
|
+
stop_sampling = threading.Event()
|
|
85
|
+
main_ident = threading.get_ident()
|
|
86
|
+
threshold = self.overrides.get(item.nodeid, self.threshold)
|
|
87
|
+
interval = max(_MIN_SAMPLE_INTERVAL, threshold / _SAMPLE_INTERVAL_DIVISOR)
|
|
88
|
+
|
|
89
|
+
def sample_stacks() -> None:
|
|
90
|
+
while not stop_sampling.wait(interval):
|
|
91
|
+
# The sampler starts concurrently with the test resuming on
|
|
92
|
+
# the main thread, so sys._current_frames() may not have
|
|
93
|
+
# `main_ident` yet on the very first tick -- skip that tick.
|
|
94
|
+
frame = sys._current_frames().get(main_ident)
|
|
95
|
+
if frame is not None:
|
|
96
|
+
frame = self._app_frame(frame)
|
|
97
|
+
samples.append(
|
|
98
|
+
(frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
sampler = threading.Thread(target=sample_stacks, daemon=True)
|
|
102
|
+
sampler.start()
|
|
103
|
+
try:
|
|
104
|
+
return (yield)
|
|
105
|
+
finally:
|
|
106
|
+
stop_sampling.set()
|
|
107
|
+
sampler.join(timeout=interval * 2)
|
|
108
|
+
# Under pytest-xdist, this hook only runs in the worker that
|
|
109
|
+
# executed the test -- the controller's own SlowTracePlugin
|
|
110
|
+
# instance never sees it. item.user_properties rides along on
|
|
111
|
+
# the TestReport (xdist's report_to_serializable/from_serializable
|
|
112
|
+
# carry it verbatim) so the data reaches pytest_runtest_logreport
|
|
113
|
+
# wherever that report ends up, worker or controller.
|
|
114
|
+
item.user_properties.append(
|
|
115
|
+
("slowtrace_cpu_time", time.process_time() - start)
|
|
116
|
+
)
|
|
117
|
+
if samples:
|
|
118
|
+
(filename, lineno, function), count = collections.Counter(
|
|
119
|
+
samples
|
|
120
|
+
).most_common(1)[0]
|
|
121
|
+
item.user_properties.append(
|
|
122
|
+
(
|
|
123
|
+
"slowtrace_stack_summary",
|
|
124
|
+
(filename, lineno, function, count, len(samples)),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _cpu_time(self, report: pytest.TestReport) -> float | None:
|
|
129
|
+
return cast("float | None", dict(report.user_properties).get("slowtrace_cpu_time"))
|
|
130
|
+
|
|
131
|
+
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
|
132
|
+
if report.when != "call":
|
|
133
|
+
return
|
|
134
|
+
if report.nodeid in self.skipped:
|
|
135
|
+
return
|
|
136
|
+
threshold = self.overrides.get(report.nodeid, self.threshold)
|
|
137
|
+
if report.duration < threshold:
|
|
138
|
+
return
|
|
139
|
+
cpu_time = self._cpu_time(report)
|
|
140
|
+
if cpu_time is not None and report.duration > 0:
|
|
141
|
+
# Clamp at 0: clock-resolution jitter between time.process_time()
|
|
142
|
+
# and the wall-clock duration (now compounded by the sampler
|
|
143
|
+
# thread's own sliver of process CPU time) can otherwise push
|
|
144
|
+
# cpu_time a hair above duration, producing a nonsensical
|
|
145
|
+
# negative "idle percentage" for a fully CPU-bound test.
|
|
146
|
+
idle_pct = max(0.0, 100 * (1 - cpu_time / report.duration))
|
|
147
|
+
if idle_pct < self.idle_threshold:
|
|
148
|
+
return
|
|
149
|
+
self.slow_reports.append(report)
|
|
150
|
+
|
|
151
|
+
def _stack_summary(self, report: pytest.TestReport) -> str:
|
|
152
|
+
"""Summarize this test's sampled stacks as one clause, or "" if none.
|
|
153
|
+
|
|
154
|
+
A slow-and-idle test was flagged because it was waiting on something;
|
|
155
|
+
the most frequently sampled frame is a cheap proxy for "what". The
|
|
156
|
+
summary was already reduced to a single (filename, lineno, function,
|
|
157
|
+
count, total) in pytest_runtest_call, so there's nothing left to
|
|
158
|
+
collapse here -- just format it.
|
|
159
|
+
"""
|
|
160
|
+
summary = cast(
|
|
161
|
+
"tuple[str, int, str, int, int] | None",
|
|
162
|
+
dict(report.user_properties).get("slowtrace_stack_summary"),
|
|
163
|
+
)
|
|
164
|
+
if summary is None:
|
|
165
|
+
return ""
|
|
166
|
+
filename, lineno, function, count, total = summary
|
|
167
|
+
return f" -- mostly at {filename}:{lineno} in {function} ({count}/{total} samples)"
|
|
168
|
+
|
|
169
|
+
def pytest_terminal_summary(self, terminalreporter: pytest.TerminalReporter) -> None:
|
|
170
|
+
if not self.slow_reports:
|
|
171
|
+
return
|
|
172
|
+
terminalreporter.section("slow tests")
|
|
173
|
+
for report in sorted(self.slow_reports, key=lambda r: r.duration, reverse=True):
|
|
174
|
+
cpu_time = self._cpu_time(report)
|
|
175
|
+
if cpu_time is None:
|
|
176
|
+
line = f"{report.duration:.2f}s {report.nodeid}"
|
|
177
|
+
else:
|
|
178
|
+
cpu_pct = 100 * cpu_time / report.duration
|
|
179
|
+
line = f"{report.duration:.2f}s ({cpu_pct:.0f}% cpu) {report.nodeid}"
|
|
180
|
+
terminalreporter.write_line(line + self._stack_summary(report))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
184
|
+
group = parser.getgroup("slowtrace")
|
|
185
|
+
group.addoption(
|
|
186
|
+
"--slowtrace-threshold",
|
|
187
|
+
type=float,
|
|
188
|
+
default=0.2,
|
|
189
|
+
help="Report tests that take longer than this many seconds to run (default: 0.2)",
|
|
190
|
+
)
|
|
191
|
+
group.addoption(
|
|
192
|
+
"--slowtrace-idle-threshold",
|
|
193
|
+
type=float,
|
|
194
|
+
default=50.0,
|
|
195
|
+
help=(
|
|
196
|
+
"Only report a slow test if it was idle (waiting, not computing) for at "
|
|
197
|
+
"least this percent of its duration (default: 50.0). A slow test that was "
|
|
198
|
+
"busy on CPU the whole time isn't worth flagging -- it's doing real work, "
|
|
199
|
+
"not waiting on something."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
group.addoption(
|
|
203
|
+
"--slowtrace-app-packages",
|
|
204
|
+
type=str,
|
|
205
|
+
default="",
|
|
206
|
+
help=(
|
|
207
|
+
"Comma-separated top-level package names to treat as app code when "
|
|
208
|
+
"picking which frame to report for a slow-and-idle test, matched "
|
|
209
|
+
"against each frame's __name__ instead of its file path. Use this "
|
|
210
|
+
"when the project's own virtualenv is nested inside its rootdir "
|
|
211
|
+
"(e.g. tox's .tox/ or uv's .venv/), which defeats the default "
|
|
212
|
+
"rootdir-based check. Default: empty, falls back to rootdir."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
218
|
+
config.addinivalue_line(
|
|
219
|
+
"markers",
|
|
220
|
+
"slowtrace(seconds): report this test as slow if it exceeds `seconds`, "
|
|
221
|
+
"overriding --slowtrace-threshold",
|
|
222
|
+
)
|
|
223
|
+
config.addinivalue_line(
|
|
224
|
+
"markers",
|
|
225
|
+
"xslowtrace: skip slowtrace reporting for this test entirely",
|
|
226
|
+
)
|
|
227
|
+
threshold = config.getoption("--slowtrace-threshold")
|
|
228
|
+
idle_threshold = config.getoption("--slowtrace-idle-threshold")
|
|
229
|
+
app_packages = {
|
|
230
|
+
name.strip()
|
|
231
|
+
for name in config.getoption("--slowtrace-app-packages").split(",")
|
|
232
|
+
if name.strip()
|
|
233
|
+
}
|
|
234
|
+
config.pluginmanager.register(
|
|
235
|
+
SlowTracePlugin(threshold, idle_threshold, config.rootpath, app_packages),
|
|
236
|
+
"slowtrace-plugin",
|
|
237
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-slowtrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pytest plugin that reports what a test was doing when it ran too long
|
|
5
|
+
Author: Tim Hatch
|
|
6
|
+
Author-email: Tim Hatch <timhatch@netflix.com>
|
|
7
|
+
Requires-Dist: pytest>=9.1.1
|
|
8
|
+
Requires-Python: >=3.13
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pytest_slowtrace/__init__.py,sha256=j0jYs6xdhNWIo4tz27zBjPzoD7MhnDZFAXuMR9p4Sbw,62
|
|
2
|
+
pytest_slowtrace/plugin.py,sha256=1kohBhXnObjm2pN6FX4_YlyHKo1YmTymA7AszLsZGvk,10078
|
|
3
|
+
pytest_slowtrace/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pytest_slowtrace-0.1.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
5
|
+
pytest_slowtrace-0.1.0.dist-info/entry_points.txt,sha256=jECWiC1APW7iV8HFa1MJSOEoh71Q8-lpnUQWPZe036M,48
|
|
6
|
+
pytest_slowtrace-0.1.0.dist-info/METADATA,sha256=U75Gj-ElF7WI2GyWy7vCrd4iHRW9PYJEsaGhcdzJ_X8,259
|
|
7
|
+
pytest_slowtrace-0.1.0.dist-info/RECORD,,
|