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/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)
|