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,158 @@
1
+ """Everything that touches pytest-xdist internals, in one place.
2
+
3
+ xdist has no public API for "why did the controller stop?", so this module reads the
4
+ few attributes the controller session (``DSession``) exposes. Keep every such lookup
5
+ here so a change in xdist has exactly one place to break.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import pytest
15
+
16
+
17
+ def is_distributed(config: pytest.Config) -> bool:
18
+ """True on a controller that is actually distributing tests (``-n`` > 0)."""
19
+ return config.pluginmanager.hasplugin("dsession")
20
+
21
+
22
+ def is_worker(config: pytest.Config) -> bool:
23
+ return hasattr(config, "workerinput")
24
+
25
+
26
+ def worker_id(report: pytest.TestReport) -> str | None:
27
+ """The gateway id (``gw0``) xdist attaches to reports on the controller."""
28
+ node = getattr(report, "node", None)
29
+ if node is None:
30
+ return None
31
+ gateway = getattr(node, "gateway", None)
32
+ ident = getattr(gateway, "id", None)
33
+ return str(ident) if ident is not None else None
34
+
35
+
36
+ def node_id(node: Any) -> str:
37
+ return str(node.gateway.id)
38
+
39
+
40
+ def gateway_id(gateway: Any) -> str:
41
+ return str(gateway.id)
42
+
43
+
44
+ _ABSENT = object()
45
+
46
+
47
+ def observe_gateway_creation(
48
+ config: pytest.Config, on_created: Callable[[str, float], None]
49
+ ) -> Callable[[], None] | None:
50
+ """Time every gateway creation on the controller's execnet group.
51
+
52
+ ``pytest_xdist_newgateway`` fires only after a gateway exists, and every hook-based
53
+ marker before it sits ahead of some of xdist's own work (hook wrappers' post-yield
54
+ code, preparing the remote module), so inferring a launch time from neighbouring
55
+ events always leaks the previous worker's work into the next boot span. Instead,
56
+ wrap the node manager's ``group.makegateway`` for the session: the timestamp is
57
+ taken immediately before the call and associated with the gateway it returns.
58
+ Covers initial workers and replacements alike. Returns a function that removes the
59
+ observer, or ``None`` when the group is not reachable (then nothing is patched).
60
+ """
61
+ dsession = config.pluginmanager.getplugin("dsession")
62
+ group = getattr(getattr(dsession, "nodemanager", None), "group", None)
63
+ original = getattr(group, "makegateway", None)
64
+ if group is None or original is None:
65
+ return None
66
+ # Another observer may already sit on the instance; keep it and put it back later.
67
+ previous = group.__dict__.get("makegateway", _ABSENT)
68
+
69
+ def makegateway(*args: Any, **kwargs: Any) -> Any:
70
+ started = time.time()
71
+ gateway = original(*args, **kwargs)
72
+ on_created(gateway_id(gateway), started)
73
+ return gateway
74
+
75
+ group.makegateway = makegateway
76
+
77
+ def restore() -> None:
78
+ if group.__dict__.get("makegateway") is not makegateway:
79
+ return # someone else replaced it after us; not ours to touch
80
+ if previous is _ABSENT:
81
+ del group.__dict__["makegateway"]
82
+ else:
83
+ group.makegateway = previous
84
+
85
+ return restore
86
+
87
+
88
+ class CollectionWatch:
89
+ """Observe what each worker collected, to recognise a genuine mismatch abort.
90
+
91
+ xdist's load-style schedulers abort when workers collect different node ids. They
92
+ announce it with a failed ``CollectReport`` whose nodeid is the disagreeing worker's
93
+ gateway id; but a worker can be given any id (``--tx popen//id=test_x.py``), so that
94
+ shape alone is ambiguous with a forwarded collection error from a file of the same
95
+ name. The abort is recognised only when this watch has itself seen two workers
96
+ collect different ids.
97
+ """
98
+
99
+ def __init__(self) -> None:
100
+ self._digests: dict[str, str] = {}
101
+
102
+ def collected(self, worker_id: str, ids: Any) -> None:
103
+ import hashlib
104
+
105
+ digest = hashlib.sha1("\0".join(str(i) for i in ids).encode("utf-8")).hexdigest()
106
+ self._digests[worker_id] = digest
107
+
108
+ @property
109
+ def differs(self) -> bool:
110
+ return len(set(self._digests.values())) > 1
111
+
112
+ def mismatch_reason(self, report: Any) -> str | None:
113
+ """The abort reason if ``report`` is the scheduler's mismatch announcement."""
114
+ if not self.differs:
115
+ return None
116
+ if getattr(report, "outcome", None) != "failed":
117
+ return None
118
+ nodeid = getattr(report, "nodeid", None)
119
+ if nodeid not in self._digests:
120
+ return None
121
+ longrepr = getattr(report, "longrepr", None)
122
+ if not isinstance(longrepr, str):
123
+ return None # the scheduler's announcement is a plain message
124
+ first = longrepr.strip().splitlines()[0] if longrepr.strip() else "collections differ"
125
+ return f"{nodeid}: {first}"
126
+
127
+
128
+ def stop_reason(config: pytest.Config) -> str | None:
129
+ """Why the controller decided to stop scheduling, if it did.
130
+
131
+ Covers ``-x`` / ``--maxfail`` relayed from workers and a worker keyboard interrupt.
132
+ A stop raises ``Interrupted`` out of the run loop, so it usually also surfaces as
133
+ ``pytest_keyboard_interrupt``; this is the fallback when it does not.
134
+ """
135
+ dsession = config.pluginmanager.getplugin("dsession")
136
+ value = getattr(dsession, "shouldstop", None)
137
+ return str(value) if value else None
138
+
139
+
140
+ def abort_reason(config: pytest.Config) -> str | None:
141
+ """Why the controller shut down early without raising, if it did.
142
+
143
+ When a worker crashes and restarts are exhausted, ``DSession`` records the message
144
+ it prints in the terminal summary and triggers a quiet shutdown. A crash that was
145
+ recovered by restarting the worker leaves no such record, and the run completes.
146
+ """
147
+ dsession = config.pluginmanager.getplugin("dsession")
148
+ value = getattr(dsession, "_summary_report", None)
149
+ return str(value) if value else None
150
+
151
+
152
+ def version() -> str | None:
153
+ import importlib.metadata
154
+
155
+ try:
156
+ return importlib.metadata.version("pytest-xdist")
157
+ except importlib.metadata.PackageNotFoundError:
158
+ return None
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-timing
3
+ Version: 0.1.0
4
+ Summary: Record test timings under pytest-xdist and render cargo-style timing reports (ASCII Gantt and HTML).
5
+ Project-URL: Homepage, https://github.com/messense/pytest-timing
6
+ Author-email: messense <messense@icloud.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: gantt,profiling,pytest,timing,xdist
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: pytest>=7.3
23
+ Provides-Extra: xdist
24
+ Requires-Dist: pytest-xdist>=3.0; extra == 'xdist'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pytest-timing
28
+
29
+ Record when every test ran, on which [pytest-xdist](https://github.com/pytest-dev/pytest-xdist)
30
+ worker, and for how long, then render the run as a timing report in the spirit of
31
+ `cargo build --timings`: an ASCII Gantt chart in the terminal and a self-contained
32
+ HTML report with worker lanes, a concurrency graph and a sortable table.
33
+
34
+ ```
35
+ pytest -n 4 --timing --timing-html
36
+ ```
37
+
38
+ ```
39
+ ================================ timing report =================================
40
+ pytest-timing: 129 tests (1 error, 1 failed), 4 workers, wall 1.37s, busy 95.4%
41
+ worker |-----------|-----------|------------|-----------|-----------|-------- busy%
42
+ gw0 ░░░░░░ ▄███████████████▄███████████████████████████████████████████ 93.6%
43
+ gw1 ░░░░░░░ ▄██████████████████████████████████████████████████X 94.1%
44
+ gw2 ░░░░░░░░▒▄███████████████████████████████████████████████████▄ 96.2%
45
+ gw3 ░░░░░░░░░▄████████████████████████████████▄███████████XXXXX 98.2%
46
+ 0s 0.20s 0.40s 0.60s 0.80s 1.00s 1.37s
47
+ legend: ░ boot ▒ collect █ tests ▄ <50% busy X failure
48
+
49
+ slowest 3 tests (setup ░ / call █ / teardown ▒):
50
+ gw0 ████████████████████▒ 0.36s
51
+ test_big.py::test_slow[5]
52
+ gw2 ████████████████▒ 0.30s
53
+ test_big.py::test_slow[4]
54
+ gw1 ░░░░░░░░░░░░░▒ 0.21s
55
+ test_big.py::test_many[8]
56
+ HTML report written to pytest-timing.html
57
+ ```
58
+
59
+ Works with and without `-n`. Without xdist the run is a single `main` lane.
60
+
61
+ Each lane shows worker boot, collection, tests (`▄` marks a column that is under half busy,
62
+ so idle gaps stand out) and failures. The slowest tests are drawn on the same axis with
63
+ their setup / call / teardown split; note how the module-scoped fixture above lands in the
64
+ setup phase of the first test on each worker.
65
+
66
+ The HTML report has the same data with a zoomable lane Gantt, a concurrency graph, filters,
67
+ hover details, and a sortable table:
68
+
69
+ ![HTML report](docs/report.png)
70
+
71
+ ## Install
72
+
73
+ ```
74
+ pip install pytest-timing # plugin only
75
+ pip install "pytest-timing[xdist]" # with pytest-xdist
76
+ ```
77
+
78
+ Python 3.10+, pytest 7.3+ (the version that added wall-clock `start`/`stop` to test reports).
79
+
80
+ ## Usage
81
+
82
+ | Option | Effect |
83
+ |---|---|
84
+ | `--timing` | Record timings and print the ASCII chart in the terminal summary. |
85
+ | `--timing-html` | Write a self-contained HTML report to `pytest-timing.html`. |
86
+ | `--timing-json` | Write the recorded run to `pytest-timing.json`. |
87
+ | `--timing-trace` | Write a Chrome trace file for [Perfetto](https://ui.perfetto.dev) to `pytest-timing.trace.json`. |
88
+ | `--timing-html-file PATH`, `--timing-json-file PATH`, `--timing-trace-file PATH` | Same, to an explicit path. |
89
+ | `--timing-top=N` | Rows in the slowest-tests section (default 10, `0` hides it). |
90
+ | `--timing-min=SECONDS` | Hide tests shorter than this from the slowest-tests section. |
91
+ | `--timing-ascii-style=unicode\|ascii` | Chart glyphs. |
92
+ | `--timing-width=N` | Override the terminal width for the chart. |
93
+
94
+ Any output option implies `--timing`. The output flags are plain booleans and the `-file`
95
+ options always take a path, so `pytest --timing-json test_x.py` runs exactly `test_x.py`.
96
+
97
+ The same settings are accepted as ini keys (`timing`, `timing_html`, `timing_json`,
98
+ `timing_trace` as paths or `true`, plus `timing_top`, `timing_min`, `timing_ascii_style`)
99
+ and environment variables (`PYTEST_TIMING=1`, `PYTEST_TIMING_HTML=path`, ...), so CI can
100
+ enable it without touching the command line.
101
+
102
+ Every JSON run records how the session ended (`finished`, `collect_only`, `interrupted`,
103
+ `aborted`, `internal_error`) with the reason pytest gave, and `complete` is derived from that.
104
+
105
+ ### View the trace
106
+
107
+ `--timing-trace` writes a Chrome Trace Event file. Open it in
108
+ [Perfetto UI](https://ui.perfetto.dev) with "Open trace file", or in `chrome://tracing`.
109
+ Each worker is a track, every test is a bar, and the setup / call / teardown phases nest
110
+ underneath it. Zoom with W/A/S/D and select a range to aggregate durations.
111
+
112
+ ### Re-render or merge saved runs
113
+
114
+ ```
115
+ pytest-timing render pytest-timing.json --html report.html --ascii
116
+ pytest-timing merge shard1.json shard2.json -o all.json
117
+ ```
118
+
119
+ `merge` places several runs (for example CI shards) on one shared time axis using their
120
+ absolute start times.
121
+
122
+ ## How it works
123
+
124
+ Since pytest 7.3 every `TestReport` carries wall-clock `start` and `stop` timestamps. xdist
125
+ serialises those to the controller unchanged and attaches the worker to the report, so the
126
+ plugin only needs controller-side hooks: `pytest_runtest_logreport` for the setup / call /
127
+ teardown phases, plus xdist's node-ready, collection-finished and node-down hooks for the
128
+ worker lifecycle. Nothing runs inside the workers and nothing touches the execnet channel.
129
+
130
+ Each lane in the report shows boot (worker start-up until it is ready), collection, tests,
131
+ idle gaps and the point the worker shut down. Session-scoped fixture setup is attributed to
132
+ the first test's setup phase and its teardown to the last test's teardown phase, exactly as
133
+ pytest reports it.
134
+
135
+ ## Overhead
136
+
137
+ Nothing runs inside the workers: the plugin only listens to the reports xdist already
138
+ sends to the controller, and each of the three phase reports per test costs a few
139
+ microseconds of bookkeeping. Rendering happens once, at the end of the session.
140
+
141
+ Measured on 5,000 trivial tests (a worst case, since the per-test cost is fixed while the
142
+ tests themselves take almost nothing), best of five runs:
143
+
144
+ | Configuration | Wall time | Overhead |
145
+ |---|---|---|
146
+ | single process, plugin disabled | 1.29 s | |
147
+ | single process, `--timing` | 1.38 s | +0.09 s |
148
+ | single process, `--timing` plus JSON, HTML and trace files | 1.45 s | +0.16 s |
149
+ | `-n 4`, plugin disabled | 1.18 s | |
150
+ | `-n 4`, `--timing` | 1.25 s | +0.07 s |
151
+ | `-n 4`, `--timing` plus all three files | 1.32 s | +0.14 s |
152
+
153
+ That is under 20 microseconds per test for recording, plus a fixed serialisation cost per
154
+ output file of roughly 10 ms per thousand tests. Output size is about 250 bytes per test
155
+ for the JSON and HTML files and 600 bytes for the trace. Memory held during the run is on
156
+ the same order as the JSON. The plugin registers nothing at all unless one of its options
157
+ is enabled.
158
+
159
+ ## License
160
+
161
+ MIT
@@ -0,0 +1,18 @@
1
+ pytest_timing/__init__.py,sha256=XqvwgGSYCEapcf0BaHvba9XJuOoorW4ess_UNnumltY,146
2
+ pytest_timing/cli.py,sha256=1LU-Sx2OmU-FAUM6oA_-ya_7bGqaZxwmNE21QWBkZ5o,5417
3
+ pytest_timing/collector.py,sha256=YBxL2ELS7m2VIt_PTgKkfluNqbLjG2a5AG2KaTpImJs,8643
4
+ pytest_timing/model.py,sha256=f8NXCoFMQ-IPn2z4QEv3dYYLQpgutUNRkv8PwfOWhok,18494
5
+ pytest_timing/outputs.py,sha256=tRyRA2wR3U99ToTWqot0KJBntdnki2pGoCHivL_7mBY,1671
6
+ pytest_timing/plugin.py,sha256=UJYAsgjpx7mD-BziZVF3kwD_x3IFYFYFrS7MtsdwSCo,15443
7
+ pytest_timing/xdist_compat.py,sha256=iUT-vuOA77SxTvLlxSF677hs0UR_awotlac1fnsInhU,5993
8
+ pytest_timing/render/__init__.py,sha256=oHRYmicsmVDtBkuH8330F9J8WIvHQJuwGdVN4ItSE_c,76
9
+ pytest_timing/render/ascii.py,sha256=oAfQmpxu3EGFp52Ye3VpAkKtekMgkxwmtVwMSE-t0cc,9322
10
+ pytest_timing/render/html.py,sha256=MNnXOW_w5_lRYE993n4UAnl-LYyNxZgTD-_THD22NZE,1032
11
+ pytest_timing/render/trace.py,sha256=zTwPxFCMQyGCCJZnvAU2JtYLnWsifpS2GMYcJLHJ9D0,3881
12
+ pytest_timing/static/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pytest_timing/static/report.html,sha256=2q_WoeekdyNKD8hKlEoV8IhOxglMbZLb7lqduHkjQy8,38606
14
+ pytest_timing-0.1.0.dist-info/METADATA,sha256=XJPSW7AmZedz16skF8gydecbFPn__d58t3j9C-Zgrg8,8077
15
+ pytest_timing-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ pytest_timing-0.1.0.dist-info/entry_points.txt,sha256=DCRyOBo-gKQW2s0SY5a_lW9Zzya49sVde-vklRBxDLo,99
17
+ pytest_timing-0.1.0.dist-info/licenses/LICENSE,sha256=3Cd2jLg5VJrONe-6tuTRguU6hr5h7fniBENwTGIhZLg,1086
18
+ pytest_timing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ pytest-timing = pytest_timing.cli:main
3
+
4
+ [pytest11]
5
+ timing = pytest_timing.plugin
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026-present Messense Lv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.