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/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))
@@ -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}"