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,2 @@
1
+ def hello() -> str:
2
+ return "Hello from pytest-slowtrace!"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [pytest11]
2
+ slowtrace = pytest_slowtrace.plugin
3
+