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,5 @@
1
+ """pytest-timing: record test timings under pytest-xdist and render timing reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
pytest_timing/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ """``pytest-timing`` command line: re-render or merge saved JSON runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pytest_timing.model import Run, RunInfo, TestSpan, Worker
10
+ from pytest_timing.outputs import OUTPUTS, write_output
11
+ from pytest_timing.render.ascii import render_ascii
12
+
13
+
14
+ def _load(path: str) -> Run:
15
+ return Run.from_json(Path(path).read_text("utf-8"))
16
+
17
+
18
+ def cmd_render(args: argparse.Namespace) -> int:
19
+ run = _load(args.run)
20
+ wrote = False
21
+ doc = None
22
+ for kind, output in OUTPUTS.items():
23
+ path = getattr(args, kind)
24
+ if path:
25
+ doc = doc or run.to_dict()
26
+ print(write_output(output, Path(path), run, doc))
27
+ wrote = True
28
+ if args.ascii or not wrote:
29
+ print(
30
+ render_ascii(
31
+ run,
32
+ width=args.width,
33
+ top=args.top,
34
+ min_duration=args.min,
35
+ style=args.style,
36
+ color=sys.stdout.isatty() and not args.no_color,
37
+ )
38
+ )
39
+ return 0
40
+
41
+
42
+ def _uniquify(candidate: str, used: set[str]) -> str:
43
+ """``candidate``, or ``candidate#2``, ``#3``... until it is not in ``used``."""
44
+ name, n = candidate, 1
45
+ while name in used:
46
+ n += 1
47
+ name = f"{candidate}#{n}"
48
+ used.add(name)
49
+ return name
50
+
51
+
52
+ def merge_runs(runs: list[Run], names: list[str]) -> Run:
53
+ """Place several runs on one shared axis.
54
+
55
+ Each run is rebased to the earliest start (``Run.rebased`` moves every time
56
+ together, so durations survive), and workers are relabelled so every input worker
57
+ stays a distinct lane. This function is the single owner of worker relabelling.
58
+ """
59
+ if not runs:
60
+ raise ValueError("nothing to merge")
61
+ if len(names) != len(runs):
62
+ raise ValueError("one name per run is required")
63
+ base = min(r.run.start for r in runs)
64
+ first = runs[0].run
65
+ info = RunInfo(
66
+ start=base,
67
+ stop=max(r.run.stop for r in runs),
68
+ termination=_merged_termination(runs),
69
+ reason=None,
70
+ exit_status=max((r.run.exit_status or 0) for r in runs),
71
+ argv=list(first.argv),
72
+ rootdir=first.rootdir,
73
+ python=first.python,
74
+ pytest=first.pytest,
75
+ xdist=first.xdist,
76
+ dist=first.dist,
77
+ numprocesses=sum(r.run.numprocesses or 0 for r in runs) or None,
78
+ )
79
+ distinct = {w for r in runs for w in r.worker_ids()}
80
+ collide = len(distinct) < sum(len(r.worker_ids()) for r in runs)
81
+ used: set[str] = set()
82
+ workers: list[Worker] = []
83
+ tests: list[TestSpan] = []
84
+ for run, name in zip(runs, names, strict=True):
85
+ mapping = {
86
+ wid: _uniquify(f"{name}/{wid}" if collide else wid, used) for wid in run.worker_ids()
87
+ }
88
+ placed = run.rebased(base).relabelled(mapping)
89
+ workers.extend(placed.workers)
90
+ tests.extend(placed.tests)
91
+ return Run(run=info, workers=workers, tests=tests)
92
+
93
+
94
+ def _merged_termination(runs: list[Run]) -> str:
95
+ if all(r.run.complete for r in runs):
96
+ return "finished"
97
+ for candidate in ("internal_error", "aborted", "interrupted", "unknown"):
98
+ if any(r.run.termination == candidate for r in runs):
99
+ return candidate
100
+ return "unknown"
101
+
102
+
103
+ def unique_names(paths: list[str]) -> list[str]:
104
+ """Short, distinct labels for input files: stems, then paths, then a counter."""
105
+ names = [Path(p).stem for p in paths]
106
+ if len(set(names)) == len(names):
107
+ return names
108
+ used: set[str] = set()
109
+ return [_uniquify(Path(p).with_suffix("").as_posix().lstrip("./"), used) for p in paths]
110
+
111
+
112
+ def cmd_merge(args: argparse.Namespace) -> int:
113
+ runs = [_load(p) for p in args.runs]
114
+ merged = merge_runs(runs, unique_names(args.runs))
115
+ Path(args.output).write_text(merged.to_json(), encoding="utf-8")
116
+ print(f"merged {len(runs)} runs into {args.output}")
117
+ return 0
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(prog="pytest-timing", description=__doc__)
122
+ sub = parser.add_subparsers(dest="command", required=True)
123
+
124
+ render = sub.add_parser("render", help="render a saved run (JSON) as ASCII, HTML or trace")
125
+ render.add_argument("run", help="pytest-timing JSON file")
126
+ for kind, output in OUTPUTS.items():
127
+ render.add_argument(f"--{kind}", metavar="PATH", help=f"write the {output.label}")
128
+ render.add_argument("--ascii", action="store_true", help="print the ASCII chart")
129
+ render.add_argument("--width", type=int, default=100)
130
+ render.add_argument("--top", type=int, default=10)
131
+ render.add_argument("--min", type=float, default=0.0, help="min duration for slowest tests")
132
+ render.add_argument("--style", choices=("unicode", "ascii"), default="unicode")
133
+ render.add_argument("--no-color", action="store_true")
134
+ render.set_defaults(func=cmd_render)
135
+
136
+ merge = sub.add_parser("merge", help="merge several runs (e.g. CI shards) onto one axis")
137
+ merge.add_argument("runs", nargs="+", help="pytest-timing JSON files")
138
+ merge.add_argument("-o", "--output", required=True, metavar="PATH")
139
+ merge.set_defaults(func=cmd_merge)
140
+ return parser
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ args = build_parser().parse_args(argv)
145
+ result: int = args.func(args)
146
+ return result
147
+
148
+
149
+ if __name__ == "__main__": # pragma: no cover
150
+ sys.exit(main())
@@ -0,0 +1,210 @@
1
+ """Fold pytest phase reports and xdist worker events into a :class:`Run`.
2
+
3
+ The collector knows nothing about pytest objects; :mod:`pytest_timing.plugin`
4
+ extracts plain values from reports and feeds them in. That keeps this module
5
+ trivially unit-testable, and every operation here is O(1) per report.
6
+
7
+ Identity is inferred, and the inference is deliberately narrow: reports carry only
8
+ a nodeid and a worker, so when a ``setup`` report arrives for a nodeid whose previous
9
+ span on that worker is closed, it is a new *attempt* of the same occurrence if that
10
+ span was retried (outcome ``rerun``), and otherwise a new *occurrence* (a duplicate
11
+ selection). Nothing else about identity is guessed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ from pytest_timing.model import PHASES, Phase, Run, RunInfo, TestSpan, Worker
19
+
20
+ CRASH_WHEN = "???" # xdist synthesises a report with this ``when`` for crashed items
21
+ RETRIED = "rerun"
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class PhaseReport:
26
+ """The subset of a pytest ``TestReport`` the collector needs."""
27
+
28
+ nodeid: str
29
+ when: str
30
+ outcome: str # passed / failed / skipped / rerun as pytest (or a plugin) reports it
31
+ start: float # epoch seconds, 0.0 when unknown
32
+ stop: float
33
+ duration: float
34
+ worker: str
35
+ wasxfail: bool = False
36
+ received: float = 0.0 # epoch seconds when the controller saw the report
37
+
38
+
39
+ class Collector:
40
+ def __init__(self, run: RunInfo) -> None:
41
+ self.run = run
42
+ self.workers: dict[str, Worker] = {}
43
+ self.tests: list[TestSpan] = []
44
+ self._open: dict[tuple[str, str], TestSpan] = {}
45
+ self._last: dict[tuple[str, str], TestSpan] = {}
46
+ self._last_stop: dict[str, float] = {} # when each worker's latest span closed
47
+
48
+ # ---- worker lifecycle ----------------------------------------------------------
49
+
50
+ def _worker(self, worker_id: str) -> Worker:
51
+ worker = self.workers.get(worker_id)
52
+ if worker is None:
53
+ worker = self.workers[worker_id] = Worker(id=worker_id)
54
+ return worker
55
+
56
+ def _rel(self, epoch: float) -> float:
57
+ return epoch - self.run.start
58
+
59
+ def worker_started(self, worker_id: str, epoch: float) -> None:
60
+ """The controller began launching this worker (also for replacements)."""
61
+ self._worker(worker_id).start = self._rel(epoch)
62
+
63
+ def worker_ready(self, worker_id: str, epoch: float) -> None:
64
+ self._worker(worker_id).ready = self._rel(epoch)
65
+
66
+ def worker_collected(self, worker_id: str, epoch: float, items: int) -> None:
67
+ worker = self._worker(worker_id)
68
+ worker.collected = self._rel(epoch)
69
+ worker.items = items
70
+
71
+ def worker_down(self, worker_id: str, epoch: float, error: str | None) -> None:
72
+ worker = self._worker(worker_id)
73
+ worker.down = self._rel(epoch)
74
+ if error:
75
+ worker.error = error
76
+ # Anything still open on this worker will never get a teardown.
77
+ for span in [s for s in self._open.values() if s.worker == worker_id]:
78
+ span.stop = max(span.stop, worker.down)
79
+ span.outcome = "crashed"
80
+ self._close(span)
81
+
82
+ # ---- phase reports -------------------------------------------------------------
83
+
84
+ def add_report(self, report: PhaseReport) -> None:
85
+ key = (report.worker, report.nodeid)
86
+ if report.worker not in self.workers:
87
+ self._worker(report.worker)
88
+
89
+ if report.when == CRASH_WHEN:
90
+ self._crash(key, report)
91
+ return
92
+
93
+ if report.start > 0 and report.stop > 0:
94
+ base = self.run.start
95
+ start, stop = report.start - base, report.stop - base
96
+ else:
97
+ start, stop = self._times(report)
98
+ span = self._open.get(key)
99
+ if report.when == "setup" or span is None:
100
+ if span is not None:
101
+ self._close(span)
102
+ span = self._new_span(key, start, stop)
103
+
104
+ span.phases[report.when] = Phase(start=start, stop=stop, duration=report.duration)
105
+ span.start = min(span.start, start)
106
+ span.stop = max(span.stop, stop)
107
+ self._apply_outcome(span, report)
108
+
109
+ if report.when == "teardown":
110
+ self._close(span)
111
+
112
+ def _new_span(self, key: tuple[str, str], start: float, stop: float) -> TestSpan:
113
+ worker, nodeid = key
114
+ previous = self._last.get(key)
115
+ if previous is None:
116
+ occurrence, attempt = 0, 0
117
+ elif previous.outcome == RETRIED:
118
+ occurrence, attempt = previous.occurrence, previous.attempt + 1
119
+ else:
120
+ occurrence, attempt = previous.occurrence + 1, 0
121
+ span = TestSpan(
122
+ nodeid=nodeid,
123
+ worker=worker,
124
+ occurrence=occurrence,
125
+ attempt=attempt,
126
+ outcome="passed",
127
+ start=start,
128
+ stop=stop,
129
+ )
130
+ self._open[key] = span
131
+ self._last[key] = span
132
+ self.tests.append(span)
133
+ return span
134
+
135
+ def _times(self, report: PhaseReport) -> tuple[float, float]:
136
+ if report.start > 0 and report.stop > 0:
137
+ return self._rel(report.start), self._rel(report.stop)
138
+ # pytest < 7.3 or a synthetic report: anchor on receipt time.
139
+ stop = self._rel(report.received) if report.received > 0 else 0.0
140
+ return max(0.0, stop - report.duration), stop
141
+
142
+ def _crash(self, key: tuple[str, str], report: PhaseReport) -> None:
143
+ now = self._rel(report.received) if report.received > 0 else None
144
+ span = self._open.get(key)
145
+ if span is None:
146
+ last = self._last.get(key)
147
+ if last is not None and last.outcome == "crashed":
148
+ # worker_down already closed it; the crash report only refines the end.
149
+ if now is not None:
150
+ last.stop = max(last.stop, now)
151
+ return
152
+ # No phase report ever arrived: the test began when this lane last went idle.
153
+ start = self._last_stop.get(report.worker)
154
+ if start is None:
155
+ worker = self.workers[report.worker]
156
+ start = worker.collected if worker.collected is not None else worker.ready
157
+ span = self._new_span(key, start or 0.0, start or 0.0)
158
+ if now is not None:
159
+ span.stop = max(span.stop, now)
160
+ span.outcome = "crashed"
161
+ self._close(span)
162
+
163
+ @staticmethod
164
+ def _apply_outcome(span: TestSpan, report: PhaseReport) -> None:
165
+ if span.outcome in ("crashed", RETRIED):
166
+ return
167
+ if report.outcome == RETRIED:
168
+ # pytest-rerunfailures: this attempt failed and will be retried.
169
+ span.outcome = RETRIED
170
+ elif report.outcome == "failed":
171
+ span.outcome = "failed" if report.when == "call" else "error"
172
+ elif report.outcome == "skipped" and span.outcome == "passed":
173
+ span.outcome = "xfailed" if report.wasxfail else "skipped"
174
+ elif report.wasxfail and report.when == "call" and span.outcome == "passed":
175
+ span.outcome = "xpassed"
176
+
177
+ def _close(self, span: TestSpan) -> None:
178
+ self._open.pop((span.worker, span.nodeid), None)
179
+ previous = self._last_stop.get(span.worker, 0.0)
180
+ self._last_stop[span.worker] = max(previous, span.stop)
181
+
182
+ # ---- finish --------------------------------------------------------------------
183
+
184
+ def finish(self, epoch: float, *, termination: str, reason: str | None = None) -> Run:
185
+ self.run.stop = epoch
186
+ self.run.termination = termination
187
+ self.run.reason = reason
188
+ complete = self.run.complete
189
+ for span in list(self._open.values()):
190
+ if not complete:
191
+ # Still running when the session was interrupted.
192
+ span.stop = max(span.stop, self._rel(epoch))
193
+ if "teardown" not in span.phases:
194
+ span.outcome = "crashed"
195
+ self._close(span)
196
+ # Give every span a stable phase order for serialisation.
197
+ for span in self.tests:
198
+ span.phases = {p: span.phases[p] for p in PHASES if p in span.phases} | {
199
+ p: v for p, v in span.phases.items() if p not in PHASES
200
+ }
201
+ workers = sorted(self.workers.values(), key=_worker_sort_key)
202
+ return Run(run=self.run, workers=workers, tests=list(self.tests))
203
+
204
+
205
+ def _worker_sort_key(worker: Worker) -> tuple[int, int, str]:
206
+ """Sort gw0, gw1, ... gw10 numerically, anything else after."""
207
+ wid = worker.id
208
+ if wid.startswith("gw") and wid[2:].isdigit():
209
+ return (0, int(wid[2:]), wid)
210
+ return (1, 0, wid)