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.
@@ -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())