pytest-timing 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.
- pytest_timing/__init__.py +5 -0
- pytest_timing/cli.py +150 -0
- pytest_timing/collector.py +210 -0
- pytest_timing/model.py +522 -0
- pytest_timing/outputs.py +54 -0
- pytest_timing/plugin.py +382 -0
- pytest_timing/render/__init__.py +1 -0
- pytest_timing/render/ascii.py +283 -0
- pytest_timing/render/html.py +32 -0
- pytest_timing/render/trace.py +126 -0
- pytest_timing/static/__init__.py +0 -0
- pytest_timing/static/report.html +653 -0
- pytest_timing/xdist_compat.py +158 -0
- pytest_timing-0.1.0.dist-info/METADATA +161 -0
- pytest_timing-0.1.0.dist-info/RECORD +18 -0
- pytest_timing-0.1.0.dist-info/WHEEL +4 -0
- pytest_timing-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_timing-0.1.0.dist-info/licenses/LICENSE +21 -0
pytest_timing/plugin.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""pytest hooks: capture timings on the controller process and emit reports.
|
|
2
|
+
|
|
3
|
+
Two rules keep this adapter honest:
|
|
4
|
+
|
|
5
|
+
* Options are unambiguous. Boolean flags turn an output on; ``*-file`` options take a
|
|
6
|
+
required path. Nothing has an optional value, so argparse can never mistake a test
|
|
7
|
+
path for a report path however the plugin is loaded.
|
|
8
|
+
* Termination is recorded as it happens (``pytest_keyboard_interrupt``,
|
|
9
|
+
``pytest_internalerror``, xdist's stop and abort reasons) and ``complete`` is derived
|
|
10
|
+
from that record, never from counting tests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import shutil
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from pytest_timing import xdist_compat
|
|
26
|
+
from pytest_timing.collector import Collector, PhaseReport
|
|
27
|
+
from pytest_timing.model import Run, RunInfo
|
|
28
|
+
from pytest_timing.outputs import OUTPUTS, write_output
|
|
29
|
+
from pytest_timing.render.ascii import render_ascii
|
|
30
|
+
|
|
31
|
+
MAIN_LANE = "main"
|
|
32
|
+
DEFAULT_TOP = 10
|
|
33
|
+
DEFAULT_MIN = 0.0
|
|
34
|
+
ASCII_STYLES = ("unicode", "ascii")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
38
|
+
group = parser.getgroup("timing", "test timing reports (pytest-timing)")
|
|
39
|
+
group.addoption(
|
|
40
|
+
"--timing",
|
|
41
|
+
action="store_true",
|
|
42
|
+
default=False,
|
|
43
|
+
help="Record test timings and print an ASCII Gantt chart in the terminal summary.",
|
|
44
|
+
)
|
|
45
|
+
for kind, output in OUTPUTS.items():
|
|
46
|
+
group.addoption(
|
|
47
|
+
f"--timing-{kind}",
|
|
48
|
+
action="store_true",
|
|
49
|
+
default=False,
|
|
50
|
+
help=f"Write the {output.label} to {output.default}. Implies --timing.",
|
|
51
|
+
)
|
|
52
|
+
group.addoption(
|
|
53
|
+
f"--timing-{kind}-file",
|
|
54
|
+
metavar="PATH",
|
|
55
|
+
default=None,
|
|
56
|
+
help=f"Write the {output.label} to PATH. Implies --timing-{kind}.",
|
|
57
|
+
)
|
|
58
|
+
group.addoption(
|
|
59
|
+
"--timing-top",
|
|
60
|
+
type=int,
|
|
61
|
+
default=None,
|
|
62
|
+
metavar="N",
|
|
63
|
+
help=f"Rows in the slowest-tests section of the ASCII chart (default: {DEFAULT_TOP}, "
|
|
64
|
+
"0 hides the section).",
|
|
65
|
+
)
|
|
66
|
+
group.addoption(
|
|
67
|
+
"--timing-min",
|
|
68
|
+
type=float,
|
|
69
|
+
default=None,
|
|
70
|
+
metavar="SECONDS",
|
|
71
|
+
help="Hide tests shorter than this in the slowest-tests section (default: 0).",
|
|
72
|
+
)
|
|
73
|
+
group.addoption(
|
|
74
|
+
"--timing-ascii-style",
|
|
75
|
+
choices=ASCII_STYLES,
|
|
76
|
+
default=None,
|
|
77
|
+
help="Characters used for the ASCII chart: unicode (default) or ascii.",
|
|
78
|
+
)
|
|
79
|
+
group.addoption(
|
|
80
|
+
"--timing-width",
|
|
81
|
+
type=int,
|
|
82
|
+
default=None,
|
|
83
|
+
metavar="COLUMNS",
|
|
84
|
+
help="Override the terminal width used for the ASCII chart.",
|
|
85
|
+
)
|
|
86
|
+
parser.addini("timing", "Enable pytest-timing (same as --timing).", type="bool", default=False)
|
|
87
|
+
for kind, output in OUTPUTS.items():
|
|
88
|
+
parser.addini(
|
|
89
|
+
f"timing_{kind}",
|
|
90
|
+
f"Path for the {output.label} (implies timing); 'true' uses {output.default}.",
|
|
91
|
+
default="",
|
|
92
|
+
)
|
|
93
|
+
parser.addini("timing_top", "Rows in the slowest-tests section.", default="")
|
|
94
|
+
parser.addini("timing_min", "Minimum duration for the slowest-tests section.", default="")
|
|
95
|
+
parser.addini("timing_ascii_style", "unicode or ascii.", default="")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Settings:
|
|
102
|
+
"""Resolved configuration: command line, then environment, then ini."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
105
|
+
self.outputs: dict[str, Path] = {}
|
|
106
|
+
for kind, output in OUTPUTS.items():
|
|
107
|
+
path = self._output_path(config, kind, output.default)
|
|
108
|
+
if path is not None:
|
|
109
|
+
self.outputs[kind] = path
|
|
110
|
+
top = self._value(config, "timing_top", "PYTEST_TIMING_TOP")
|
|
111
|
+
self.top = int(top) if top is not None else DEFAULT_TOP
|
|
112
|
+
minimum = self._value(config, "timing_min", "PYTEST_TIMING_MIN")
|
|
113
|
+
self.min_duration = float(minimum) if minimum is not None else DEFAULT_MIN
|
|
114
|
+
style = self._value(config, "timing_ascii_style", "PYTEST_TIMING_ASCII_STYLE")
|
|
115
|
+
if style is not None and style not in ASCII_STYLES:
|
|
116
|
+
raise pytest.UsageError(
|
|
117
|
+
f"timing_ascii_style must be one of {', '.join(ASCII_STYLES)}, not {style!r}"
|
|
118
|
+
)
|
|
119
|
+
self.ascii_style = str(style) if style is not None else "unicode"
|
|
120
|
+
width = config.getoption("timing_width", None)
|
|
121
|
+
self.width = int(width) if width else None
|
|
122
|
+
env = os.environ.get("PYTEST_TIMING", "").lower()
|
|
123
|
+
self.enabled = bool(
|
|
124
|
+
config.getoption("timing", False)
|
|
125
|
+
or env in _TRUE
|
|
126
|
+
or config.getini("timing")
|
|
127
|
+
or self.outputs
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _value(config: pytest.Config, name: str, env: str, ini: str | None = None) -> Any:
|
|
132
|
+
"""One precedence rule for every setting: option, then environment, then ini."""
|
|
133
|
+
value = config.getoption(name, None)
|
|
134
|
+
if value is not None:
|
|
135
|
+
return value
|
|
136
|
+
if os.environ.get(env):
|
|
137
|
+
return os.environ[env]
|
|
138
|
+
value = config.getini(ini or name)
|
|
139
|
+
return value if value not in ("", None) else None
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def _output_path(cls, config: pytest.Config, kind: str, default: str) -> Path | None:
|
|
143
|
+
"""``--timing-<kind>-file`` wins, then the flag, then env / ini values.
|
|
144
|
+
|
|
145
|
+
Env and ini values are paths, or ``true`` for the default file name.
|
|
146
|
+
"""
|
|
147
|
+
explicit = config.getoption(f"timing_{kind}_file", None)
|
|
148
|
+
if explicit:
|
|
149
|
+
value = str(explicit) # a path, verbatim: never a boolean alias
|
|
150
|
+
elif config.getoption(f"timing_{kind}", False):
|
|
151
|
+
value = default
|
|
152
|
+
else:
|
|
153
|
+
env = os.environ.get(f"PYTEST_TIMING_{kind.upper()}") or config.getini(f"timing_{kind}")
|
|
154
|
+
if not env:
|
|
155
|
+
return None
|
|
156
|
+
value = default if str(env).lower() in _TRUE else str(env)
|
|
157
|
+
path = Path(value)
|
|
158
|
+
return path if path.is_absolute() else Path(config.rootpath, path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
162
|
+
if xdist_compat.is_worker(config):
|
|
163
|
+
return # xdist worker: everything happens on the controller
|
|
164
|
+
settings = Settings(config)
|
|
165
|
+
if settings.enabled:
|
|
166
|
+
config.pluginmanager.register(TimingPlugin(config, settings), "pytest_timing")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TimingPlugin:
|
|
170
|
+
def __init__(self, config: pytest.Config, settings: Settings) -> None:
|
|
171
|
+
self.config = config
|
|
172
|
+
self.settings = settings
|
|
173
|
+
self.collector = Collector(self._run_info())
|
|
174
|
+
self.result: Run | None = None
|
|
175
|
+
self.written: list[str] = []
|
|
176
|
+
self.errors: list[str] = []
|
|
177
|
+
# Lifecycle evidence, recorded as it happens.
|
|
178
|
+
self.termination: str | None = None
|
|
179
|
+
self.reason: str | None = None
|
|
180
|
+
self._collections = xdist_compat.CollectionWatch()
|
|
181
|
+
self._stop_observing: Callable[[], None] | None = None
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def distributed(self) -> bool:
|
|
185
|
+
return xdist_compat.is_distributed(self.config)
|
|
186
|
+
|
|
187
|
+
# ---- session -------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _run_info(self) -> RunInfo:
|
|
190
|
+
config = self.config
|
|
191
|
+
return RunInfo(
|
|
192
|
+
start=time.time(),
|
|
193
|
+
stop=0.0,
|
|
194
|
+
argv=list(config.invocation_params.args),
|
|
195
|
+
rootdir=str(config.rootpath),
|
|
196
|
+
python=platform.python_version(),
|
|
197
|
+
pytest=pytest.__version__,
|
|
198
|
+
xdist=xdist_compat.version() if config.pluginmanager.hasplugin("xdist") else None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@pytest.hookimpl(tryfirst=True)
|
|
202
|
+
def pytest_sessionstart(self) -> None:
|
|
203
|
+
"""Runs before xdist launches its workers, so their start events have a home."""
|
|
204
|
+
config = self.config
|
|
205
|
+
if self.distributed:
|
|
206
|
+
dist = config.getoption("dist", None)
|
|
207
|
+
numprocesses = config.getoption("numprocesses", None)
|
|
208
|
+
self.collector.run.dist = str(dist) if dist else None
|
|
209
|
+
self.collector.run.numprocesses = (
|
|
210
|
+
int(numprocesses) if isinstance(numprocesses, int) else None
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
now = time.time()
|
|
214
|
+
self.collector.worker_started(MAIN_LANE, self.collector.run.start)
|
|
215
|
+
self.collector.worker_ready(MAIN_LANE, now)
|
|
216
|
+
|
|
217
|
+
@pytest.hookimpl(trylast=True)
|
|
218
|
+
def pytest_collection_finish(self, session: pytest.Session) -> None:
|
|
219
|
+
if not self.distributed:
|
|
220
|
+
self.collector.worker_collected(MAIN_LANE, time.time(), len(session.items))
|
|
221
|
+
|
|
222
|
+
def pytest_collectreport(self, report: Any) -> None:
|
|
223
|
+
if self.distributed:
|
|
224
|
+
reason = self._collections.mismatch_reason(report)
|
|
225
|
+
if reason:
|
|
226
|
+
self._record("aborted", reason)
|
|
227
|
+
|
|
228
|
+
# ---- termination evidence ------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def pytest_keyboard_interrupt(self, excinfo: Any) -> None:
|
|
231
|
+
"""Fired for KeyboardInterrupt, ``pytest.exit()`` (any return code) and xdist's
|
|
232
|
+
``Interrupted``; the run loop did not finish on its own."""
|
|
233
|
+
self._record("interrupted", _describe(excinfo))
|
|
234
|
+
|
|
235
|
+
def pytest_internalerror(self, excrepr: Any) -> None:
|
|
236
|
+
lines = str(excrepr).strip().splitlines()
|
|
237
|
+
self._record("internal_error", lines[-1] if lines else None)
|
|
238
|
+
|
|
239
|
+
def _record(self, termination: str, reason: Any) -> None:
|
|
240
|
+
if self.termination is None:
|
|
241
|
+
self.termination = termination
|
|
242
|
+
self.reason = reason if isinstance(reason, str) or reason is None else str(reason)
|
|
243
|
+
|
|
244
|
+
def _termination(self, session: pytest.Session) -> tuple[str, str | None]:
|
|
245
|
+
if self.termination is not None:
|
|
246
|
+
return self.termination, self.reason
|
|
247
|
+
if self.config.getoption("collectonly", False):
|
|
248
|
+
return "collect_only", None
|
|
249
|
+
for attr in ("shouldfail", "shouldstop"):
|
|
250
|
+
value = getattr(session, attr, False)
|
|
251
|
+
if value:
|
|
252
|
+
return "interrupted", str(value)
|
|
253
|
+
if self.distributed:
|
|
254
|
+
reason = xdist_compat.stop_reason(self.config)
|
|
255
|
+
if reason:
|
|
256
|
+
return "interrupted", reason
|
|
257
|
+
reason = xdist_compat.abort_reason(self.config)
|
|
258
|
+
if reason:
|
|
259
|
+
return "aborted", reason
|
|
260
|
+
return "finished", None
|
|
261
|
+
|
|
262
|
+
# ---- xdist worker lifecycle (optionalhook: xdist may not be installed) -----------
|
|
263
|
+
|
|
264
|
+
@pytest.hookimpl(optionalhook=True)
|
|
265
|
+
def pytest_xdist_setupnodes(self) -> None:
|
|
266
|
+
"""Before any gateway exists: time each creation as it happens."""
|
|
267
|
+
self._stop_observing = xdist_compat.observe_gateway_creation(
|
|
268
|
+
self.config, self.collector.worker_started
|
|
269
|
+
)
|
|
270
|
+
if self._stop_observing is not None:
|
|
271
|
+
# Also at unconfigure: a failed worker start-up never reaches sessionfinish.
|
|
272
|
+
self.config.add_cleanup(self._stop_observing_now)
|
|
273
|
+
|
|
274
|
+
@pytest.hookimpl(optionalhook=True)
|
|
275
|
+
def pytest_xdist_newgateway(self, gateway: Any) -> None:
|
|
276
|
+
"""Fallback when the creation call could not be observed: the gateway exists
|
|
277
|
+
now, and nothing earlier is known."""
|
|
278
|
+
if self._stop_observing is None:
|
|
279
|
+
self.collector.worker_started(xdist_compat.gateway_id(gateway), time.time())
|
|
280
|
+
|
|
281
|
+
@pytest.hookimpl(optionalhook=True)
|
|
282
|
+
def pytest_testnodeready(self, node: Any) -> None:
|
|
283
|
+
self.collector.worker_ready(xdist_compat.node_id(node), time.time())
|
|
284
|
+
|
|
285
|
+
@pytest.hookimpl(optionalhook=True)
|
|
286
|
+
def pytest_xdist_node_collection_finished(self, node: Any, ids: Any) -> None:
|
|
287
|
+
worker = xdist_compat.node_id(node)
|
|
288
|
+
self._collections.collected(worker, ids)
|
|
289
|
+
self.collector.worker_collected(worker, time.time(), len(ids))
|
|
290
|
+
|
|
291
|
+
@pytest.hookimpl(optionalhook=True)
|
|
292
|
+
def pytest_testnodedown(self, node: Any, error: object) -> None:
|
|
293
|
+
message = None if error is None else str(error)
|
|
294
|
+
self.collector.worker_down(xdist_compat.node_id(node), time.time(), message)
|
|
295
|
+
|
|
296
|
+
# ---- reports -------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
|
299
|
+
# Called three times per test: keep it to attribute reads and one object.
|
|
300
|
+
start = getattr(report, "start", 0.0)
|
|
301
|
+
stop = getattr(report, "stop", 0.0)
|
|
302
|
+
self.collector.add_report(
|
|
303
|
+
PhaseReport(
|
|
304
|
+
nodeid=report.nodeid,
|
|
305
|
+
when=report.when,
|
|
306
|
+
outcome=report.outcome,
|
|
307
|
+
start=start,
|
|
308
|
+
stop=stop,
|
|
309
|
+
duration=report.duration,
|
|
310
|
+
worker=xdist_compat.worker_id(report) or MAIN_LANE,
|
|
311
|
+
wasxfail=hasattr(report, "wasxfail"),
|
|
312
|
+
# Receipt time is only needed when the report carries no clock of its own.
|
|
313
|
+
received=time.time() if not (start and stop) else 0.0,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# ---- finish and output ---------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _stop_observing_now(self) -> None:
|
|
320
|
+
if self._stop_observing is not None:
|
|
321
|
+
self._stop_observing()
|
|
322
|
+
self._stop_observing = None
|
|
323
|
+
|
|
324
|
+
@pytest.hookimpl(trylast=True)
|
|
325
|
+
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
|
|
326
|
+
self._stop_observing_now()
|
|
327
|
+
now = time.time()
|
|
328
|
+
if not self.distributed:
|
|
329
|
+
self.collector.worker_down(MAIN_LANE, now, None)
|
|
330
|
+
termination, reason = self._termination(session)
|
|
331
|
+
self.collector.run.exit_status = int(exitstatus)
|
|
332
|
+
self.result = self.collector.finish(now, termination=termination, reason=reason)
|
|
333
|
+
self._write_outputs(self.result)
|
|
334
|
+
|
|
335
|
+
def _write_outputs(self, run: Run) -> None:
|
|
336
|
+
if not self.settings.outputs:
|
|
337
|
+
return
|
|
338
|
+
doc = run.to_dict() # serialised once, shared by every output
|
|
339
|
+
for kind, path in self.settings.outputs.items():
|
|
340
|
+
output = OUTPUTS[kind]
|
|
341
|
+
try:
|
|
342
|
+
self.written.append(write_output(output, path, run, doc))
|
|
343
|
+
except Exception as exc: # pragma: no cover - reported, never fatal
|
|
344
|
+
self.errors.append(f"could not write {output.label} to {path}: {exc}")
|
|
345
|
+
|
|
346
|
+
@pytest.hookimpl(trylast=True)
|
|
347
|
+
def pytest_terminal_summary(self, terminalreporter: Any) -> None:
|
|
348
|
+
run = self.result
|
|
349
|
+
if run is None:
|
|
350
|
+
return
|
|
351
|
+
tr = terminalreporter
|
|
352
|
+
width = self.settings.width or _terminal_width(tr)
|
|
353
|
+
tr.write_sep("=", "timing report")
|
|
354
|
+
text = render_ascii(
|
|
355
|
+
run,
|
|
356
|
+
width=width,
|
|
357
|
+
top=self.settings.top,
|
|
358
|
+
min_duration=self.settings.min_duration,
|
|
359
|
+
style=self.settings.ascii_style,
|
|
360
|
+
color=bool(getattr(tr, "hasmarkup", False)),
|
|
361
|
+
)
|
|
362
|
+
for line in text.splitlines():
|
|
363
|
+
tr.write_line(line)
|
|
364
|
+
for line in self.written:
|
|
365
|
+
tr.write_line(line.replace(str(self.config.rootpath) + os.sep, "", 1))
|
|
366
|
+
for error in self.errors:
|
|
367
|
+
tr.write_line(error, red=True)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _describe(excinfo: Any) -> str:
|
|
371
|
+
value = getattr(excinfo, "value", excinfo)
|
|
372
|
+
text = str(getattr(value, "msg", "") or value or "").strip()
|
|
373
|
+
name = type(value).__name__
|
|
374
|
+
return f"{name}: {text}" if text else name
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _terminal_width(terminalreporter: Any) -> int:
|
|
378
|
+
writer = getattr(terminalreporter, "_tw", None)
|
|
379
|
+
width = getattr(writer, "fullwidth", None)
|
|
380
|
+
if isinstance(width, int) and width > 0:
|
|
381
|
+
return width
|
|
382
|
+
return shutil.get_terminal_size().columns
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Renderers that turn a :class:`pytest_timing.model.Run` into a report."""
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Terminal renderer: worker lanes plus a slowest-tests Gantt on a shared axis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from itertools import groupby
|
|
8
|
+
|
|
9
|
+
from pytest_timing.model import BAD_OUTCOMES, Lane, Run, TestSpan
|
|
10
|
+
|
|
11
|
+
MIN_CHART = 20
|
|
12
|
+
TICK_STEPS = (0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Glyphs:
|
|
17
|
+
boot: str
|
|
18
|
+
collect: str
|
|
19
|
+
idle: str
|
|
20
|
+
test: str
|
|
21
|
+
partial: str
|
|
22
|
+
bad: str
|
|
23
|
+
setup: str
|
|
24
|
+
call: str
|
|
25
|
+
teardown: str
|
|
26
|
+
ellipsis: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
STYLES: dict[str, Glyphs] = {
|
|
30
|
+
"unicode": Glyphs(
|
|
31
|
+
boot="░",
|
|
32
|
+
collect="▒",
|
|
33
|
+
idle=" ",
|
|
34
|
+
test="█",
|
|
35
|
+
partial="▄",
|
|
36
|
+
bad="X",
|
|
37
|
+
setup="░",
|
|
38
|
+
call="█",
|
|
39
|
+
teardown="▒",
|
|
40
|
+
ellipsis="…",
|
|
41
|
+
),
|
|
42
|
+
"ascii": Glyphs(
|
|
43
|
+
boot=".",
|
|
44
|
+
collect=":",
|
|
45
|
+
idle=" ",
|
|
46
|
+
test="#",
|
|
47
|
+
partial="+",
|
|
48
|
+
bad="X",
|
|
49
|
+
setup="-",
|
|
50
|
+
call="#",
|
|
51
|
+
teardown="=",
|
|
52
|
+
ellipsis="...",
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_RED = "\x1b[31m"
|
|
57
|
+
_BOLD = "\x1b[1m"
|
|
58
|
+
_DIM = "\x1b[2m"
|
|
59
|
+
_RESET = "\x1b[0m"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_seconds(seconds: float) -> str:
|
|
63
|
+
if seconds < 0:
|
|
64
|
+
seconds = 0.0
|
|
65
|
+
if seconds < 10:
|
|
66
|
+
return f"{seconds:.2f}s"
|
|
67
|
+
if seconds < 60:
|
|
68
|
+
return f"{seconds:.1f}s"
|
|
69
|
+
minutes, rest = divmod(seconds, 60)
|
|
70
|
+
if seconds < 3600:
|
|
71
|
+
return f"{int(minutes)}m{rest:02.0f}s"
|
|
72
|
+
hours, minutes = divmod(minutes, 60)
|
|
73
|
+
return f"{int(hours)}h{int(minutes):02d}m"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_ascii(
|
|
77
|
+
run: Run,
|
|
78
|
+
*,
|
|
79
|
+
width: int = 80,
|
|
80
|
+
top: int = 10,
|
|
81
|
+
min_duration: float = 0.0,
|
|
82
|
+
style: str = "unicode",
|
|
83
|
+
color: bool = False,
|
|
84
|
+
) -> str:
|
|
85
|
+
glyphs = STYLES.get(style, STYLES["unicode"])
|
|
86
|
+
lanes = run.lanes()
|
|
87
|
+
label_width = max([len(lane.id) for lane in lanes] + [6])
|
|
88
|
+
suffix_width = 8 # " 99.9%"
|
|
89
|
+
chart = max(MIN_CHART, width - label_width - 1 - suffix_width)
|
|
90
|
+
total = max(run.wall, 1e-9)
|
|
91
|
+
scale = Scale(total, chart)
|
|
92
|
+
|
|
93
|
+
lines: list[str] = [_header(run, lanes, color)]
|
|
94
|
+
if not run.tests and not run.workers:
|
|
95
|
+
lines.append("no tests recorded")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
axis, labels = _axis(scale)
|
|
99
|
+
lines.append(f"{'worker':<{label_width}} {axis} busy%")
|
|
100
|
+
for lane in lanes:
|
|
101
|
+
row, busy = _lane_row(lane, scale, glyphs)
|
|
102
|
+
lines.append(f"{lane.id:<{label_width}} {_colorize(row, glyphs, color)} {busy:5.1f}%")
|
|
103
|
+
lines.append(f"{'':<{label_width}} {labels}")
|
|
104
|
+
lines.append(
|
|
105
|
+
f"{'':<{label_width}} legend: {glyphs.boot} boot {glyphs.collect} collect "
|
|
106
|
+
f"{glyphs.test} tests {glyphs.partial} <50% busy {glyphs.bad} failure"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if top:
|
|
110
|
+
candidates = [t for t in run.tests if t.duration >= min_duration]
|
|
111
|
+
candidates.sort(key=lambda t: t.duration, reverse=True)
|
|
112
|
+
slowest = candidates[:top]
|
|
113
|
+
if slowest:
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.append(
|
|
116
|
+
f"slowest {len(slowest)} tests (setup {glyphs.setup} / call {glyphs.call} / "
|
|
117
|
+
f"teardown {glyphs.teardown}):"
|
|
118
|
+
)
|
|
119
|
+
for test in slowest:
|
|
120
|
+
bar = _test_bar(test, scale, glyphs)
|
|
121
|
+
if color and test.is_bad:
|
|
122
|
+
bar = f"{_RED}{bar}{_RESET}"
|
|
123
|
+
duration = f"{format_seconds(test.duration):>7}"
|
|
124
|
+
lines.append(f"{test.worker:<{label_width}} {bar} {duration}")
|
|
125
|
+
nodeid = _fit_nodeid(test.nodeid, width - label_width - 1, glyphs.ellipsis)
|
|
126
|
+
lines.append(f"{'':<{label_width}} {nodeid}")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Scale:
|
|
131
|
+
def __init__(self, total: float, columns: int) -> None:
|
|
132
|
+
self.total = total
|
|
133
|
+
self.columns = columns
|
|
134
|
+
self.per_column = total / columns
|
|
135
|
+
|
|
136
|
+
def col(self, seconds: float) -> int:
|
|
137
|
+
value = int(seconds / self.total * self.columns)
|
|
138
|
+
return min(self.columns - 1, max(0, value))
|
|
139
|
+
|
|
140
|
+
def span(self, start: float, stop: float) -> tuple[int, int]:
|
|
141
|
+
"""Half-open column range that a [start, stop] interval covers, at least one wide."""
|
|
142
|
+
first = self.col(start)
|
|
143
|
+
last = max(first + 1, min(self.columns, int(round(stop / self.total * self.columns))))
|
|
144
|
+
return first, last
|
|
145
|
+
|
|
146
|
+
def overlaps(self, start: float, stop: float, *targets: list[float]) -> None:
|
|
147
|
+
"""Add the seconds that [start, stop] overlaps each column into every target."""
|
|
148
|
+
if stop <= start:
|
|
149
|
+
return
|
|
150
|
+
first = self.col(start)
|
|
151
|
+
last = min(self.columns, max(first + 1, math.ceil(stop / self.per_column)))
|
|
152
|
+
for column in range(first, last):
|
|
153
|
+
lo = column * self.per_column
|
|
154
|
+
hi = lo + self.per_column
|
|
155
|
+
overlap = max(0.0, min(stop, hi) - max(start, lo))
|
|
156
|
+
for target in targets:
|
|
157
|
+
target[column] += overlap
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _header(run: Run, lanes: list[Lane], color: bool) -> str:
|
|
161
|
+
counts = run.outcome_counts()
|
|
162
|
+
bad = {k: v for k, v in counts.items() if k in BAD_OUTCOMES}
|
|
163
|
+
tests = f"{len(run.tests)} tests"
|
|
164
|
+
if bad:
|
|
165
|
+
detail = ", ".join(f"{n} {k}" for k, n in sorted(bad.items()))
|
|
166
|
+
detail = f"{_RED}{detail}{_RESET}" if color else detail
|
|
167
|
+
tests += f" ({detail})"
|
|
168
|
+
from pytest_timing.model import _utilisation
|
|
169
|
+
|
|
170
|
+
workers = len(lanes)
|
|
171
|
+
lane = "worker" if workers == 1 else "workers"
|
|
172
|
+
parts = [
|
|
173
|
+
f"pytest-timing: {tests}",
|
|
174
|
+
f"{workers} {lane}",
|
|
175
|
+
f"wall {format_seconds(run.wall)}",
|
|
176
|
+
f"busy {_utilisation(lanes) * 100:.1f}%",
|
|
177
|
+
]
|
|
178
|
+
if not run.run.complete:
|
|
179
|
+
ended = f"ended: {run.run.termination_label}"
|
|
180
|
+
if run.run.reason:
|
|
181
|
+
ended += f" ({run.run.reason})"
|
|
182
|
+
parts.append(f"{_RED}{ended}{_RESET}" if color else ended)
|
|
183
|
+
return ", ".join(parts)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _axis(scale: Scale) -> tuple[str, str]:
|
|
187
|
+
step = next((s for s in TICK_STEPS if s / scale.per_column >= 8), TICK_STEPS[-1])
|
|
188
|
+
axis = ["-"] * scale.columns
|
|
189
|
+
labels = [" "] * scale.columns
|
|
190
|
+
tick = 0.0
|
|
191
|
+
while tick < scale.total:
|
|
192
|
+
column = int(tick / scale.per_column)
|
|
193
|
+
if column >= scale.columns:
|
|
194
|
+
break
|
|
195
|
+
axis[column] = "|"
|
|
196
|
+
text = format_seconds(tick) if tick else "0s"
|
|
197
|
+
if column + len(text) <= scale.columns and all(
|
|
198
|
+
labels[i] == " " for i in range(column, column + len(text))
|
|
199
|
+
):
|
|
200
|
+
for i, ch in enumerate(text):
|
|
201
|
+
labels[column + i] = ch
|
|
202
|
+
tick += step
|
|
203
|
+
end = format_seconds(scale.total)
|
|
204
|
+
start = scale.columns - len(end)
|
|
205
|
+
if start > 0 and all(labels[i] == " " for i in range(start - 1, scale.columns)):
|
|
206
|
+
for i, ch in enumerate(end):
|
|
207
|
+
labels[start + i] = ch
|
|
208
|
+
return "".join(axis), "".join(labels).rstrip()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _lane_row(lane: Lane, scale: Scale, glyphs: Glyphs) -> tuple[str, float]:
|
|
212
|
+
columns = scale.columns
|
|
213
|
+
test_time = [0.0] * columns
|
|
214
|
+
bad_time = [0.0] * columns
|
|
215
|
+
for span in lane.spans:
|
|
216
|
+
if span.is_bad:
|
|
217
|
+
scale.overlaps(span.start, span.stop, test_time, bad_time)
|
|
218
|
+
else:
|
|
219
|
+
scale.overlaps(span.start, span.stop, test_time)
|
|
220
|
+
|
|
221
|
+
boot_time = [0.0] * columns
|
|
222
|
+
collect_time = [0.0] * columns
|
|
223
|
+
if lane.boot:
|
|
224
|
+
scale.overlaps(*lane.boot, boot_time)
|
|
225
|
+
if lane.collect:
|
|
226
|
+
scale.overlaps(*lane.collect, collect_time)
|
|
227
|
+
|
|
228
|
+
row: list[str] = []
|
|
229
|
+
for i in range(columns):
|
|
230
|
+
lo = i * scale.per_column
|
|
231
|
+
hi = lo + scale.per_column
|
|
232
|
+
in_window = max(0.0, min(lane.down, hi) - max(0.0, lo)) > 0
|
|
233
|
+
if bad_time[i] > 0:
|
|
234
|
+
row.append(glyphs.bad)
|
|
235
|
+
elif test_time[i] >= scale.per_column * 0.5:
|
|
236
|
+
row.append(glyphs.test)
|
|
237
|
+
elif test_time[i] > 0:
|
|
238
|
+
row.append(glyphs.partial)
|
|
239
|
+
elif boot_time[i] > 0 and boot_time[i] >= collect_time[i]:
|
|
240
|
+
row.append(glyphs.boot)
|
|
241
|
+
elif collect_time[i] > 0:
|
|
242
|
+
row.append(glyphs.collect)
|
|
243
|
+
elif in_window:
|
|
244
|
+
row.append(glyphs.idle)
|
|
245
|
+
else:
|
|
246
|
+
row.append(" ")
|
|
247
|
+
window = max(lane.down - lane.ready, 1e-9)
|
|
248
|
+
return "".join(row), min(100.0, lane.busy / window * 100)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _test_bar(test: TestSpan, scale: Scale, glyphs: Glyphs) -> str:
|
|
252
|
+
row = [" "] * scale.columns
|
|
253
|
+
phase_glyphs = (("setup", glyphs.setup), ("call", glyphs.call), ("teardown", glyphs.teardown))
|
|
254
|
+
for name, glyph in phase_glyphs:
|
|
255
|
+
phase = test.phases.get(name)
|
|
256
|
+
if phase is None:
|
|
257
|
+
continue
|
|
258
|
+
first, last = scale.span(phase.start, phase.stop)
|
|
259
|
+
for i in range(first, last):
|
|
260
|
+
row[i] = glyph
|
|
261
|
+
if not test.phases:
|
|
262
|
+
first, last = scale.span(test.start, test.stop)
|
|
263
|
+
for i in range(first, last):
|
|
264
|
+
row[i] = glyphs.bad if test.is_bad else glyphs.call
|
|
265
|
+
return "".join(row)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _fit_nodeid(nodeid: str, room: int, ellipsis: str) -> str:
|
|
269
|
+
if room >= len(nodeid) or room < 8 + len(ellipsis):
|
|
270
|
+
return nodeid
|
|
271
|
+
return ellipsis + nodeid[-(room - len(ellipsis)) :]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _colorize(row: str, glyphs: Glyphs, color: bool) -> str:
|
|
275
|
+
if not color:
|
|
276
|
+
return row
|
|
277
|
+
styles = {glyphs.bad: f"{_RED}{_BOLD}", glyphs.boot: _DIM, glyphs.collect: _DIM}
|
|
278
|
+
out: list[str] = []
|
|
279
|
+
for ch, group in groupby(row):
|
|
280
|
+
run = "".join(group)
|
|
281
|
+
style = styles.get(ch)
|
|
282
|
+
out.append(f"{style}{run}{_RESET}" if style else run)
|
|
283
|
+
return "".join(out)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Self-contained HTML report: the static template with the run JSON embedded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pytest_timing.model import Run
|
|
10
|
+
|
|
11
|
+
PLACEHOLDER = "__PYTEST_TIMING_DATA__"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_template() -> str:
|
|
15
|
+
return resources.files("pytest_timing.static").joinpath("report.html").read_text("utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def embed_json(doc: dict[str, Any]) -> str:
|
|
19
|
+
"""Compact JSON that is safe inside a ``<script type="application/json">`` element."""
|
|
20
|
+
text = json.dumps(doc, separators=(",", ":"), ensure_ascii=False)
|
|
21
|
+
return text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_html_doc(run: Run, doc: dict[str, Any]) -> str:
|
|
25
|
+
template = load_template()
|
|
26
|
+
if PLACEHOLDER not in template:
|
|
27
|
+
raise RuntimeError("report template is missing the data placeholder")
|
|
28
|
+
return template.replace(PLACEHOLDER, embed_json(doc), 1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_html(run: Run) -> str:
|
|
32
|
+
return render_html_doc(run, run.to_dict())
|