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/model.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Data model for a recorded test run.
|
|
2
|
+
|
|
3
|
+
All times inside :class:`Worker` and :class:`TestSpan` are seconds relative to
|
|
4
|
+
``RunInfo.start`` (an epoch timestamp). The model is deliberately free of
|
|
5
|
+
pytest objects so renderers can be run out of process from saved JSON.
|
|
6
|
+
|
|
7
|
+
Semantics that every renderer must agree on live here, once:
|
|
8
|
+
|
|
9
|
+
* :data:`OUTCOME_REGISTRY` classifies outcomes (is it a failure?) and names them.
|
|
10
|
+
* :data:`TERMINATIONS` says how a session ended; ``complete`` is derived from it.
|
|
11
|
+
* Identity: a *worker* is a lane, a *test occurrence* is one collected item on a
|
|
12
|
+
worker (duplicate selections are separate occurrences), and an *attempt* is one
|
|
13
|
+
execution of an occurrence (retries add attempts).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from operator import attrgetter
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
PHASES: tuple[str, ...] = ("setup", "call", "teardown")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---- outcomes ------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class OutcomeInfo:
|
|
33
|
+
name: str
|
|
34
|
+
label: str
|
|
35
|
+
is_failure: bool
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {"label": self.label, "is_failure": self.is_failure}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
OUTCOME_REGISTRY: dict[str, OutcomeInfo] = {
|
|
42
|
+
o.name: o
|
|
43
|
+
for o in (
|
|
44
|
+
OutcomeInfo("passed", "passed", False), # call phase passed
|
|
45
|
+
OutcomeInfo("failed", "failed", True), # call phase failed
|
|
46
|
+
OutcomeInfo("error", "error", True), # setup or teardown failed
|
|
47
|
+
OutcomeInfo("skipped", "skipped", False), # skipped during setup
|
|
48
|
+
OutcomeInfo("xfailed", "xfailed", False), # expected failure
|
|
49
|
+
OutcomeInfo("xpassed", "xpassed", False), # unexpectedly passed
|
|
50
|
+
OutcomeInfo("crashed", "crashed", True), # worker died while running it
|
|
51
|
+
OutcomeInfo("rerun", "rerun", True), # failed attempt that was retried
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
BAD_OUTCOMES: frozenset[str] = frozenset(o.name for o in OUTCOME_REGISTRY.values() if o.is_failure)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- session termination -------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class TerminationInfo:
|
|
62
|
+
name: str
|
|
63
|
+
label: str
|
|
64
|
+
complete: bool
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
return {"label": self.label, "complete": self.complete}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
TERMINATION_REGISTRY: dict[str, TerminationInfo] = {
|
|
71
|
+
t.name: t
|
|
72
|
+
for t in (
|
|
73
|
+
TerminationInfo("finished", "finished", True), # the run loop completed
|
|
74
|
+
TerminationInfo("collect_only", "collect only", True), # nothing was meant to run
|
|
75
|
+
# KeyboardInterrupt, pytest.exit(), -x / --maxfail, or an xdist stop
|
|
76
|
+
TerminationInfo("interrupted", "interrupted", False),
|
|
77
|
+
# xdist gave up early: crashed worker with restarts exhausted, collection mismatch
|
|
78
|
+
TerminationInfo("aborted", "aborted", False),
|
|
79
|
+
TerminationInfo("internal_error", "internal error", False), # pytest raised
|
|
80
|
+
TerminationInfo("unknown", "unknown", False), # no evidence was recorded
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
TERMINATIONS: tuple[str, ...] = tuple(TERMINATION_REGISTRY)
|
|
84
|
+
COMPLETE_TERMINATIONS: frozenset[str] = frozenset(
|
|
85
|
+
t.name for t in TERMINATION_REGISTRY.values() if t.complete
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _round(value: float) -> float:
|
|
90
|
+
return round(value, 6)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _migrate_termination(run: dict[str, Any]) -> Any:
|
|
94
|
+
"""Termination for a run dict, migrating the early schema-1 shape.
|
|
95
|
+
|
|
96
|
+
The first schema-1 reports carried only a ``complete`` flag. ``complete: true``
|
|
97
|
+
can only have meant the run loop finished, so it maps to ``finished``; anything
|
|
98
|
+
else is missing evidence and maps to ``unknown``. A present ``termination`` is
|
|
99
|
+
returned untouched, valid or not, so bad values still fail validation.
|
|
100
|
+
"""
|
|
101
|
+
if "termination" in run and run["termination"] is not None:
|
|
102
|
+
return run["termination"]
|
|
103
|
+
if run.get("complete") is True:
|
|
104
|
+
return "finished"
|
|
105
|
+
return "unknown"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _opt_round(value: float | None) -> float | None:
|
|
109
|
+
return None if value is None else _round(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _opt_float(data: dict[str, Any], key: str) -> float | None:
|
|
113
|
+
value = data.get(key)
|
|
114
|
+
return None if value is None else float(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _opt_shift(value: float | None, seconds: float) -> float | None:
|
|
118
|
+
return None if value is None else value + seconds
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class Phase:
|
|
123
|
+
"""One runtest phase (setup / call / teardown) of an attempt."""
|
|
124
|
+
|
|
125
|
+
start: float
|
|
126
|
+
stop: float
|
|
127
|
+
duration: float
|
|
128
|
+
|
|
129
|
+
def to_list(self) -> list[float]:
|
|
130
|
+
return [_round(self.start), _round(self.stop), _round(self.duration)]
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_list(cls, data: list[float]) -> Phase:
|
|
134
|
+
return cls(float(data[0]), float(data[1]), float(data[2]))
|
|
135
|
+
|
|
136
|
+
def shifted(self, seconds: float) -> Phase:
|
|
137
|
+
return Phase(self.start + seconds, self.stop + seconds, self.duration)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True)
|
|
141
|
+
class TestSpan:
|
|
142
|
+
"""One attempt at running one test occurrence on one worker.
|
|
143
|
+
|
|
144
|
+
``occurrence`` numbers the collected items that share a nodeid on a worker
|
|
145
|
+
(``--keep-duplicates``, ``--dist each`` across workers keeps 0). ``attempt``
|
|
146
|
+
numbers the executions of that occurrence (retries). Reports only carry the
|
|
147
|
+
nodeid, so the collector infers both from report order; see ``Collector``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
nodeid: str
|
|
151
|
+
worker: str
|
|
152
|
+
attempt: int
|
|
153
|
+
outcome: str
|
|
154
|
+
start: float
|
|
155
|
+
stop: float
|
|
156
|
+
phases: dict[str, Phase] = field(default_factory=dict)
|
|
157
|
+
occurrence: int = 0
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def duration(self) -> float:
|
|
161
|
+
return self.stop - self.start
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def is_bad(self) -> bool:
|
|
165
|
+
return self.outcome in BAD_OUTCOMES
|
|
166
|
+
|
|
167
|
+
def shifted(self, seconds: float) -> TestSpan:
|
|
168
|
+
return replace(
|
|
169
|
+
self,
|
|
170
|
+
start=self.start + seconds,
|
|
171
|
+
stop=self.stop + seconds,
|
|
172
|
+
phases={k: p.shifted(seconds) for k, p in self.phases.items()},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def to_dict(self) -> dict[str, Any]:
|
|
176
|
+
return {
|
|
177
|
+
"nodeid": self.nodeid,
|
|
178
|
+
"worker": self.worker,
|
|
179
|
+
"occurrence": self.occurrence,
|
|
180
|
+
"attempt": self.attempt,
|
|
181
|
+
"outcome": self.outcome,
|
|
182
|
+
"start": _round(self.start),
|
|
183
|
+
"stop": _round(self.stop),
|
|
184
|
+
"phases": {name: phase.to_list() for name, phase in self.phases.items()},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_dict(cls, data: dict[str, Any]) -> TestSpan:
|
|
189
|
+
return cls(
|
|
190
|
+
nodeid=str(data["nodeid"]),
|
|
191
|
+
worker=str(data["worker"]),
|
|
192
|
+
occurrence=int(data.get("occurrence", 0)),
|
|
193
|
+
attempt=int(data.get("attempt", 0)),
|
|
194
|
+
outcome=str(data["outcome"]),
|
|
195
|
+
start=float(data["start"]),
|
|
196
|
+
stop=float(data["stop"]),
|
|
197
|
+
phases={
|
|
198
|
+
str(name): Phase.from_list(value)
|
|
199
|
+
for name, value in dict(data.get("phases", {})).items()
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(slots=True)
|
|
205
|
+
class Worker:
|
|
206
|
+
"""Lifecycle of one xdist worker (or the single ``main`` lane without xdist).
|
|
207
|
+
|
|
208
|
+
``id`` is the worker's identity; merging may relabel it, but that is the merge
|
|
209
|
+
operation's job (see ``cli.merge_runs``), never a renderer's.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
id: str
|
|
213
|
+
start: float | None = None # when the controller launched it; None = not recorded
|
|
214
|
+
ready: float | None = None
|
|
215
|
+
collected: float | None = None
|
|
216
|
+
items: int | None = None
|
|
217
|
+
down: float | None = None
|
|
218
|
+
error: str | None = None
|
|
219
|
+
|
|
220
|
+
def shifted(self, seconds: float) -> Worker:
|
|
221
|
+
return replace(
|
|
222
|
+
self,
|
|
223
|
+
start=_opt_shift(self.start, seconds),
|
|
224
|
+
ready=_opt_shift(self.ready, seconds),
|
|
225
|
+
collected=_opt_shift(self.collected, seconds),
|
|
226
|
+
down=_opt_shift(self.down, seconds),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def to_dict(self) -> dict[str, Any]:
|
|
230
|
+
return {
|
|
231
|
+
"id": self.id,
|
|
232
|
+
"start": _opt_round(self.start),
|
|
233
|
+
"ready": _opt_round(self.ready),
|
|
234
|
+
"collected": _opt_round(self.collected),
|
|
235
|
+
"items": self.items,
|
|
236
|
+
"down": _opt_round(self.down),
|
|
237
|
+
"error": self.error,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def from_dict(cls, data: dict[str, Any]) -> Worker:
|
|
242
|
+
items = data.get("items")
|
|
243
|
+
return cls(
|
|
244
|
+
id=str(data["id"]),
|
|
245
|
+
start=_opt_float(data, "start"),
|
|
246
|
+
ready=_opt_float(data, "ready"),
|
|
247
|
+
collected=_opt_float(data, "collected"),
|
|
248
|
+
items=None if items is None else int(items),
|
|
249
|
+
down=_opt_float(data, "down"),
|
|
250
|
+
error=None if data.get("error") is None else str(data["error"]),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(slots=True)
|
|
255
|
+
class RunInfo:
|
|
256
|
+
"""Metadata about the whole session, including how it ended."""
|
|
257
|
+
|
|
258
|
+
start: float
|
|
259
|
+
stop: float
|
|
260
|
+
termination: str = "unknown"
|
|
261
|
+
reason: str | None = None
|
|
262
|
+
exit_status: int | None = None
|
|
263
|
+
argv: list[str] = field(default_factory=list)
|
|
264
|
+
rootdir: str = ""
|
|
265
|
+
python: str = ""
|
|
266
|
+
pytest: str = ""
|
|
267
|
+
xdist: str | None = None
|
|
268
|
+
dist: str | None = None
|
|
269
|
+
numprocesses: int | None = None
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def wall(self) -> float:
|
|
273
|
+
return self.stop - self.start
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def complete(self) -> bool:
|
|
277
|
+
"""Derived from the recorded termination, never from test counts."""
|
|
278
|
+
return self.termination in COMPLETE_TERMINATIONS
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def termination_label(self) -> str:
|
|
282
|
+
return TERMINATION_REGISTRY[self.termination].label
|
|
283
|
+
|
|
284
|
+
def to_dict(self) -> dict[str, Any]:
|
|
285
|
+
return {
|
|
286
|
+
"start": self.start,
|
|
287
|
+
"stop": self.stop,
|
|
288
|
+
"termination": self.termination,
|
|
289
|
+
"reason": self.reason,
|
|
290
|
+
"exit_status": self.exit_status,
|
|
291
|
+
"complete": self.complete,
|
|
292
|
+
"argv": list(self.argv),
|
|
293
|
+
"rootdir": self.rootdir,
|
|
294
|
+
"python": self.python,
|
|
295
|
+
"pytest": self.pytest,
|
|
296
|
+
"xdist": self.xdist,
|
|
297
|
+
"dist": self.dist,
|
|
298
|
+
"numprocesses": self.numprocesses,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def from_dict(cls, data: dict[str, Any]) -> RunInfo:
|
|
303
|
+
numprocesses = data.get("numprocesses")
|
|
304
|
+
exit_status = data.get("exit_status")
|
|
305
|
+
termination = _migrate_termination(data)
|
|
306
|
+
if termination not in TERMINATION_REGISTRY:
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"run has no valid termination (got {termination!r}); "
|
|
309
|
+
f"expected one of {', '.join(TERMINATIONS)}"
|
|
310
|
+
)
|
|
311
|
+
return cls(
|
|
312
|
+
start=float(data["start"]),
|
|
313
|
+
stop=float(data["stop"]),
|
|
314
|
+
termination=str(termination),
|
|
315
|
+
reason=None if data.get("reason") is None else str(data["reason"]),
|
|
316
|
+
exit_status=None if exit_status is None else int(exit_status),
|
|
317
|
+
argv=[str(a) for a in data.get("argv", [])],
|
|
318
|
+
rootdir=str(data.get("rootdir", "")),
|
|
319
|
+
python=str(data.get("python", "")),
|
|
320
|
+
pytest=str(data.get("pytest", "")),
|
|
321
|
+
xdist=None if data.get("xdist") is None else str(data["xdist"]),
|
|
322
|
+
dist=None if data.get("dist") is None else str(data["dist"]),
|
|
323
|
+
numprocesses=None if numprocesses is None else int(numprocesses),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(slots=True)
|
|
328
|
+
class Lane:
|
|
329
|
+
"""One row of the timeline: a worker's window and its spans, sorted by start.
|
|
330
|
+
|
|
331
|
+
``ready``/``down`` fall back to the first/last span when the worker lifecycle was
|
|
332
|
+
not recorded; ``boot`` and ``collect`` are only present when it was.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
id: str
|
|
336
|
+
worker: Worker | None
|
|
337
|
+
spans: list[TestSpan]
|
|
338
|
+
ready: float
|
|
339
|
+
down: float
|
|
340
|
+
last: float | None # when the last span finished
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def busy(self) -> float:
|
|
344
|
+
return sum(t.duration for t in self.spans)
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def boot(self) -> tuple[float, float] | None:
|
|
348
|
+
w = self.worker
|
|
349
|
+
if w is None or w.start is None or w.ready is None:
|
|
350
|
+
return None
|
|
351
|
+
return w.start, w.ready
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def collect(self) -> tuple[float, float] | None:
|
|
355
|
+
w = self.worker
|
|
356
|
+
if w is None or w.ready is None or w.collected is None:
|
|
357
|
+
return None
|
|
358
|
+
return w.ready, w.collected
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_SPAN_ORDER = attrgetter("start", "stop")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _utilisation(lanes: list[Lane]) -> float:
|
|
365
|
+
available = sum(max(0.0, lane.down - lane.ready) for lane in lanes)
|
|
366
|
+
return sum(lane.busy for lane in lanes) / available if available > 0 else 0.0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(slots=True)
|
|
370
|
+
class Run:
|
|
371
|
+
"""A complete recorded run: session metadata, worker lanes, and test spans."""
|
|
372
|
+
|
|
373
|
+
run: RunInfo
|
|
374
|
+
workers: list[Worker] = field(default_factory=list)
|
|
375
|
+
tests: list[TestSpan] = field(default_factory=list)
|
|
376
|
+
|
|
377
|
+
# ---- derived helpers used by every renderer -------------------------------------
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def wall(self) -> float:
|
|
381
|
+
"""Total timeline length in seconds (never below the last recorded event)."""
|
|
382
|
+
end = self.run.wall
|
|
383
|
+
for worker in self.workers:
|
|
384
|
+
for value in (worker.start, worker.ready, worker.collected, worker.down):
|
|
385
|
+
if value is not None:
|
|
386
|
+
end = max(end, value)
|
|
387
|
+
for test in self.tests:
|
|
388
|
+
end = max(end, test.stop)
|
|
389
|
+
return end
|
|
390
|
+
|
|
391
|
+
def worker_ids(self) -> list[str]:
|
|
392
|
+
"""Lane ids: recorded workers first, then any worker only seen in tests."""
|
|
393
|
+
ids = dict.fromkeys(w.id for w in self.workers)
|
|
394
|
+
ids.update(dict.fromkeys(t.worker for t in self.tests))
|
|
395
|
+
return list(ids)
|
|
396
|
+
|
|
397
|
+
def lanes(self) -> list[Lane]:
|
|
398
|
+
"""Lane geometry, derived once for every renderer (see :class:`Lane`)."""
|
|
399
|
+
spans_by_id: dict[str, list[TestSpan]] = {wid: [] for wid in self.worker_ids()}
|
|
400
|
+
for test in self.tests:
|
|
401
|
+
spans_by_id[test.worker].append(test)
|
|
402
|
+
workers = {w.id: w for w in self.workers}
|
|
403
|
+
wall = self.wall
|
|
404
|
+
lanes = []
|
|
405
|
+
for wid, spans in spans_by_id.items():
|
|
406
|
+
spans.sort(key=_SPAN_ORDER)
|
|
407
|
+
worker = workers.get(wid)
|
|
408
|
+
last = max((t.stop for t in spans), default=None)
|
|
409
|
+
ready = worker.ready if worker else None
|
|
410
|
+
down = worker.down if worker else None
|
|
411
|
+
lanes.append(
|
|
412
|
+
Lane(
|
|
413
|
+
id=wid,
|
|
414
|
+
worker=worker,
|
|
415
|
+
spans=spans,
|
|
416
|
+
ready=ready if ready is not None else (spans[0].start if spans else 0.0),
|
|
417
|
+
down=down if down is not None else (last if last is not None else wall),
|
|
418
|
+
last=last,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
return lanes
|
|
422
|
+
|
|
423
|
+
def tests_by_worker(self) -> dict[str, list[TestSpan]]:
|
|
424
|
+
return {lane.id: lane.spans for lane in self.lanes()}
|
|
425
|
+
|
|
426
|
+
def outcome_counts(self) -> dict[str, int]:
|
|
427
|
+
counts: dict[str, int] = {}
|
|
428
|
+
for test in self.tests:
|
|
429
|
+
counts[test.outcome] = counts.get(test.outcome, 0) + 1
|
|
430
|
+
return counts
|
|
431
|
+
|
|
432
|
+
def busy_seconds(self) -> float:
|
|
433
|
+
return sum(t.duration for t in self.tests)
|
|
434
|
+
|
|
435
|
+
def lane_window(self, worker_id: str) -> tuple[float, float]:
|
|
436
|
+
"""(ready, down) for a lane, falling back to the first/last test."""
|
|
437
|
+
lane = next(lane for lane in self.lanes() if lane.id == worker_id)
|
|
438
|
+
return lane.ready, lane.down
|
|
439
|
+
|
|
440
|
+
def utilisation(self) -> float:
|
|
441
|
+
"""Busy seconds over the sum of every lane's (ready .. down) window."""
|
|
442
|
+
return _utilisation(self.lanes())
|
|
443
|
+
|
|
444
|
+
def summary(self) -> dict[str, Any]:
|
|
445
|
+
"""Derived figures every renderer shows; embedded so the HTML never recomputes."""
|
|
446
|
+
lanes = self.lanes()
|
|
447
|
+
return {
|
|
448
|
+
"wall": _round(self.wall),
|
|
449
|
+
"busy": _round(self.busy_seconds()),
|
|
450
|
+
"utilisation": round(_utilisation(lanes), 4),
|
|
451
|
+
"lanes": [
|
|
452
|
+
{
|
|
453
|
+
"id": lane.id,
|
|
454
|
+
"ready": _round(lane.ready),
|
|
455
|
+
"down": _round(lane.down),
|
|
456
|
+
"busy": _round(lane.busy),
|
|
457
|
+
"last": _opt_round(lane.last),
|
|
458
|
+
}
|
|
459
|
+
for lane in lanes
|
|
460
|
+
],
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# ---- model operations ---------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def rebased(self, base_epoch: float) -> Run:
|
|
466
|
+
"""The same run expressed relative to ``base_epoch``.
|
|
467
|
+
|
|
468
|
+
Every relative time (worker lifecycle, tests, phases) moves together, so
|
|
469
|
+
durations and the gaps between events are preserved exactly.
|
|
470
|
+
"""
|
|
471
|
+
seconds = self.run.start - base_epoch
|
|
472
|
+
info = replace(self.run, start=base_epoch)
|
|
473
|
+
return Run(
|
|
474
|
+
run=info,
|
|
475
|
+
workers=[w.shifted(seconds) for w in self.workers],
|
|
476
|
+
tests=[t.shifted(seconds) for t in self.tests],
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def relabelled(self, mapping: dict[str, str]) -> Run:
|
|
480
|
+
"""Rename workers; tests follow their worker. Missing keys keep their id."""
|
|
481
|
+
return Run(
|
|
482
|
+
run=replace(self.run),
|
|
483
|
+
workers=[replace(w, id=mapping.get(w.id, w.id)) for w in self.workers],
|
|
484
|
+
tests=[replace(t, worker=mapping.get(t.worker, t.worker)) for t in self.tests],
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# ---- serialisation ------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def to_dict(self) -> dict[str, Any]:
|
|
490
|
+
from pytest_timing import __version__
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"schema": SCHEMA_VERSION,
|
|
494
|
+
"pytest_timing_version": __version__,
|
|
495
|
+
"outcomes": {name: info.to_dict() for name, info in OUTCOME_REGISTRY.items()},
|
|
496
|
+
"terminations": {name: info.to_dict() for name, info in TERMINATION_REGISTRY.items()},
|
|
497
|
+
"summary": self.summary(),
|
|
498
|
+
"run": self.run.to_dict(),
|
|
499
|
+
"workers": [w.to_dict() for w in self.workers],
|
|
500
|
+
"tests": [t.to_dict() for t in self.tests],
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
@classmethod
|
|
504
|
+
def from_dict(cls, data: dict[str, Any]) -> Run:
|
|
505
|
+
schema = int(data.get("schema", 0))
|
|
506
|
+
if schema != SCHEMA_VERSION:
|
|
507
|
+
raise ValueError(
|
|
508
|
+
f"unsupported pytest-timing schema {schema}, expected {SCHEMA_VERSION}"
|
|
509
|
+
)
|
|
510
|
+
return cls(
|
|
511
|
+
run=RunInfo.from_dict(data["run"]),
|
|
512
|
+
workers=[Worker.from_dict(w) for w in data.get("workers", [])],
|
|
513
|
+
tests=[TestSpan.from_dict(t) for t in data.get("tests", [])],
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def to_json(self, *, indent: int | None = None) -> str:
|
|
517
|
+
separators = (",", ":") if indent is None else None
|
|
518
|
+
return json.dumps(self.to_dict(), indent=indent, separators=separators)
|
|
519
|
+
|
|
520
|
+
@classmethod
|
|
521
|
+
def from_json(cls, text: str) -> Run:
|
|
522
|
+
return cls.from_dict(json.loads(text))
|
pytest_timing/outputs.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The report outputs, in one table shared by the pytest plugin and the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pytest_timing.model import Run
|
|
12
|
+
from pytest_timing.render.html import render_html_doc
|
|
13
|
+
from pytest_timing.render.trace import render_trace
|
|
14
|
+
|
|
15
|
+
Renderer = Callable[[Run, dict[str, Any]], str]
|
|
16
|
+
"""Renders a run; the second argument is ``run.to_dict()``, built once and shared."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _render_json(run: Run, doc: dict[str, Any]) -> str:
|
|
20
|
+
return json.dumps(doc, separators=(",", ":"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _render_trace(run: Run, doc: dict[str, Any]) -> str:
|
|
24
|
+
return render_trace(run)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Output:
|
|
29
|
+
kind: str
|
|
30
|
+
default: str # default file name
|
|
31
|
+
label: str # how the terminal refers to it
|
|
32
|
+
render: Renderer
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
OUTPUTS: dict[str, Output] = {
|
|
36
|
+
o.kind: o
|
|
37
|
+
for o in (
|
|
38
|
+
Output("html", "pytest-timing.html", "HTML report", render_html_doc),
|
|
39
|
+
Output("json", "pytest-timing.json", "JSON", _render_json),
|
|
40
|
+
Output("trace", "pytest-timing.trace.json", "Chrome trace", _render_trace),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_output(output: Output, path: Path, run: Run, doc: dict[str, Any] | None = None) -> str:
|
|
46
|
+
"""Render and write one output; returns the line to show the user.
|
|
47
|
+
|
|
48
|
+
Pass ``doc`` (``run.to_dict()``) when writing several outputs so it is built once.
|
|
49
|
+
"""
|
|
50
|
+
if doc is None:
|
|
51
|
+
doc = run.to_dict()
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
path.write_text(output.render(run, doc), encoding="utf-8")
|
|
54
|
+
return f"{output.label} written to {path}"
|