pytest-flakedoctor 0.1.0__tar.gz

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.
Files changed (35) hide show
  1. pytest_flakedoctor-0.1.0/LICENSE +21 -0
  2. pytest_flakedoctor-0.1.0/PKG-INFO +249 -0
  3. pytest_flakedoctor-0.1.0/README.md +214 -0
  4. pytest_flakedoctor-0.1.0/pyproject.toml +71 -0
  5. pytest_flakedoctor-0.1.0/setup.cfg +4 -0
  6. pytest_flakedoctor-0.1.0/src/flakedoctor/__init__.py +42 -0
  7. pytest_flakedoctor-0.1.0/src/flakedoctor/_axes.py +351 -0
  8. pytest_flakedoctor-0.1.0/src/flakedoctor/_cli.py +69 -0
  9. pytest_flakedoctor-0.1.0/src/flakedoctor/_diagnose.py +1676 -0
  10. pytest_flakedoctor-0.1.0/src/flakedoctor/_fingerprint.py +62 -0
  11. pytest_flakedoctor-0.1.0/src/flakedoctor/_interleave.py +220 -0
  12. pytest_flakedoctor-0.1.0/src/flakedoctor/_plugin.py +698 -0
  13. pytest_flakedoctor-0.1.0/src/flakedoctor/_probe.py +321 -0
  14. pytest_flakedoctor-0.1.0/src/flakedoctor/_report.py +110 -0
  15. pytest_flakedoctor-0.1.0/src/flakedoctor/_repro.py +257 -0
  16. pytest_flakedoctor-0.1.0/src/flakedoctor/_runner.py +567 -0
  17. pytest_flakedoctor-0.1.0/src/flakedoctor/_stats.py +114 -0
  18. pytest_flakedoctor-0.1.0/src/flakedoctor/py.typed +0 -0
  19. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/PKG-INFO +249 -0
  20. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/SOURCES.txt +33 -0
  21. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/dependency_links.txt +1 -0
  22. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/entry_points.txt +5 -0
  23. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/requires.txt +11 -0
  24. pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/top_level.txt +1 -0
  25. pytest_flakedoctor-0.1.0/tests/test_diagnose.py +976 -0
  26. pytest_flakedoctor-0.1.0/tests/test_e2e.py +123 -0
  27. pytest_flakedoctor-0.1.0/tests/test_e2e_axes.py +210 -0
  28. pytest_flakedoctor-0.1.0/tests/test_e2e_interleave.py +273 -0
  29. pytest_flakedoctor-0.1.0/tests/test_e2e_order.py +180 -0
  30. pytest_flakedoctor-0.1.0/tests/test_fingerprint.py +59 -0
  31. pytest_flakedoctor-0.1.0/tests/test_marker.py +216 -0
  32. pytest_flakedoctor-0.1.0/tests/test_plugin_integration.py +279 -0
  33. pytest_flakedoctor-0.1.0/tests/test_repro.py +168 -0
  34. pytest_flakedoctor-0.1.0/tests/test_runner.py +127 -0
  35. pytest_flakedoctor-0.1.0/tests/test_stats.py +89 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danny Kissel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do 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.
@@ -0,0 +1,249 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-flakedoctor
3
+ Version: 0.1.0
4
+ Summary: The flaky-test doctor: diagnoses WHY a test is flaky and hands you a deterministic reproduction
5
+ Author: Danny Kissel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Therealdk8890/pytest-flakedoctor
8
+ Project-URL: Repository, https://github.com/Therealdk8890/pytest-flakedoctor
9
+ Project-URL: Changelog, https://github.com/Therealdk8890/pytest-flakedoctor/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/Therealdk8890/pytest-flakedoctor/issues
11
+ Keywords: pytest,flaky,flaky-tests,heisenbug,testing,nondeterminism,reproducibility,diagnosis,bisection,hashseed
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pytest>=7
28
+ Requires-Dist: hermetic-sandbox>=0.1
29
+ Provides-Extra: interleave
30
+ Requires-Dist: interleave-test>=0.1.0; python_version >= "3.12" and extra == "interleave"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest-asyncio; extra == "dev"
33
+ Requires-Dist: pytest-randomly; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # pytest-flakedoctor
37
+
38
+ **Every other tool retries your flaky test. flakedoctor tells you *why* it's
39
+ flaky — and hands you a command that makes it fail every time.**
40
+
41
+ Point it at a test that fails one run in twenty. It re-runs the test in fresh
42
+ subprocesses under controlled perturbation, bisects which axis of
43
+ nondeterminism triggers the failure, verifies the repro statistically, checks
44
+ the counterfactual, and prints this:
45
+
46
+ ```
47
+ ━━━ flakedoctor ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
+ test_billing.py::test_invoice_ids_stable
49
+ py3.14.4 · darwin · 35 runs · 8.5s
50
+
51
+ DIAGNOSIS hash-order dependent (PYTHONHASHSEED)
52
+ Failed 3/10 isolated baseline runs; fails 10/10 with PYTHONHASHSEED=2
53
+ and passed the counterfactual with PYTHONHASHSEED=0. The test's
54
+ outcome depends on Python's hash randomization: dict/set iteration
55
+ order (or hash-based ordering) leaks into an assertion or into order-
56
+ sensitive logic. PYTHONHASHSEED=2 pins the order that triggers the
57
+ failure.
58
+
59
+ EVIDENCE runs failed
60
+ baseline (isolated, no perturbation) 10 3
61
+ provoke: PYTHONHASHSEED=0 2 0
62
+ provoke: PYTHONHASHSEED=1 2 0
63
+ provoke: PYTHONHASHSEED=2 2 2
64
+ provoke: PYTHONHASHSEED=3855983186 2 2
65
+ provoke: PYTHONHASHSEED=2386784001 2 0
66
+ VERIFY: PYTHONHASHSEED=2 10 10 ✓ deterministic
67
+ counterfactual: PYTHONHASHSEED=0 5 0 ✓ passes
68
+
69
+ REPRO (fails 10/10; ≥74% repro rate at 95% confidence)
70
+ PYTHONHASHSEED=2 pytest "test_billing.py::test_invoice_ids_stable"
71
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72
+ ```
73
+
74
+ Three minutes ago this failed once a week. Now it fails on demand. Paste the
75
+ command into the bug report and fix it.
76
+
77
+ ## Usage
78
+
79
+ ```sh
80
+ pytest tests/test_billing.py::test_invoice_ids_stable --doctor # diagnose this test
81
+ pytest --doctor # run the suite; diagnose the first failure
82
+ flakedoctor tests/test_x.py::test_y # console-script sugar for the first form
83
+ ```
84
+
85
+ Options (also settable via ini):
86
+
87
+ | Flag | Default | Meaning |
88
+ |---|---|---|
89
+ | `--doctor-runs=N` | 10 | baseline sample size |
90
+ | `--doctor-budget=SECS` | 300 | wall-clock budget; the planner degrades gracefully |
91
+ | `--doctor-json=PATH` | — | machine-readable `flakedoctor-report` v1 (CI artifact) |
92
+
93
+ ## Axes
94
+
95
+ | Axis | What it catches | How it's controlled |
96
+ |---|---|---|
97
+ | `order` | a *polluter* test that leaks state the victim depends on being absent | run the victim after a shrinking prefix of the suite |
98
+ | `interleave` | thread races and deadlocks — a schedule that makes a threaded test fail | drive the test through a deterministic scheduler searching interleavings |
99
+ | `time` | month/day/year rollovers, DST, leap day, "assumes the code runs fast" | virtual clock frozen at adversarial instants, plus tick jitter |
100
+ | `rng` | unseeded `random`/`secrets`/`uuid4`, colliding fixture data | all randomness seeded from one seed |
101
+ | `network` | hidden live-network dependencies | outbound connections blocked |
102
+ | `fs` | ambient files, `$HOME` leftovers, cross-test residue | isolated filesystem tree |
103
+ | `hashseed` | dict/set iteration order leaking into behavior | `PYTHONHASHSEED` per subprocess |
104
+
105
+ The four sandbox axes are powered by [hermetic](https://pypi.org/project/hermetic-sandbox/)
106
+ (installed automatically; distribution `hermetic-sandbox`, import name `hermetic`).
107
+
108
+ ### The interleave axis (opt-in)
109
+
110
+ For threaded tests, the doctor can search for the exact thread schedule that
111
+ makes them fail — a race or a deadlock — using
112
+ [interleave-test](https://pypi.org/project/interleave-test/)'s deterministic
113
+ scheduler, then hand you a schedule that reproduces it every time:
114
+
115
+ ```
116
+ DIAGNOSIS race condition (thread interleaving)
117
+ The test passes 10/10 times on its own, but a specific thread
118
+ interleaving makes it fail — a real race the scheduler usually hides.
119
+ The found schedule reproduces it 3/3 times. Cause: AssertionError at
120
+ test_counter.py:16 (lost update). Add synchronization ...
121
+ ```
122
+
123
+ It's an **opt-in extra** — `pip install pytest-flakedoctor[interleave]` — and
124
+ needs Python ≥3.12 (the core supports ≥3.10). It only activates when a test
125
+ actually starts threads, verifies every finding by deterministic replay before
126
+ claiming anything, and is honest about what it *can't* see: threads or locks
127
+ created at import time, thread-pool internals, and C-level threads are outside
128
+ the modelled primitives, so it reports "no failing interleaving under the
129
+ modelled primitives," never "no race exists."
130
+
131
+ ### The order axis
132
+
133
+ Test-order dependence is the most common cause of flaky tests in Python, and
134
+ its diagnosis is the most satisfying: not "something is nondeterministic" but a
135
+ *named culprit*. Run the whole suite under `--doctor`; when a test passes alone
136
+ but failed in the suite, the doctor runs it after the collected prefix, then
137
+ binary-searches that prefix down to the polluter:
138
+
139
+ ```
140
+ DIAGNOSIS test-order dependent
141
+ The test passes 10/10 times on its own, but fails when run after the
142
+ test test_registry.py::test_register_plugin — fails every run in this
143
+ order. This is a test-order dependency: that test leaves behind state
144
+ the victim depends on being absent.
145
+
146
+ EVIDENCE runs failed
147
+ baseline (isolated, no perturbation) 10 0
148
+ after full suite prefix (4 tests) 3 3
149
+ VERIFY: after test_registry.py::test_register_plugin 10 10 ✓ deterministic
150
+ ```
151
+
152
+ The repro command runs just `[polluter, victim]` in the order that fails.
153
+
154
+ ## How it works
155
+
156
+ Every diagnostic run is a **fresh pytest subprocess** — that buys clean
157
+ module/fixture state per run, hang/crash immunity via OS kill, and axes that
158
+ are impossible to vary in-process (hash randomization is fixed at interpreter
159
+ start). The loop:
160
+
161
+ 1. **Baseline** — N isolated, unperturbed runs. Deterministic failure and
162
+ hangs are called out immediately; a mixed result means a live flake.
163
+ 2. **Control sweep** — determinize every axis at once. If the flakiness
164
+ disappears, the cause is one of them, and controlling each axis alone
165
+ identifies *which* — the stabilizer. That axis gets swept first.
166
+ 3. **Provocation sweep** — pin adversarial values on the suspect axis. Every
167
+ failure is *fingerprinted* (phase, exception type, crash site) so a
168
+ perturbation that breaks the test differently than the observed flake never
169
+ earns causal credit — and failures raised by the sandbox itself
170
+ (`NetworkBlockedError` and friends) are reported as perturbation-induced,
171
+ never as your bug.
172
+ 4. **Verification gate** — the winning value must fail n/n fresh runs. The
173
+ report never claims 100%; it prints the count and the exact
174
+ Clopper-Pearson lower bound.
175
+ 5. **Counterfactual gate** — a benign value on the same axis must actually
176
+ pass, or the verdict is downgraded to "elevates failure rate".
177
+
178
+ ## Reproducing
179
+
180
+ hermetic's CLI can express a seed but not a clock, network, or filesystem
181
+ configuration, so a diagnosed perturbation travels as a compact blob:
182
+
183
+ ```sh
184
+ pytest tests/test_billing.py::test_x --doctor-repro=fd1:eNodTs0KwjAYe5dc...
185
+ ```
186
+
187
+ That applies the perturbation **in-process**, so you can attach a debugger and
188
+ get a normal traceback. Blobs are versioned per axis and decode strictly: an
189
+ unknown axis, a newer payload version, or a blob recorded for a different test
190
+ fails loudly rather than quietly doing less than it claims. A repro that passes
191
+ exits non-zero with `DID NOT REPRODUCE` (Hypothesis semantics) — staleness is
192
+ never silent.
193
+
194
+ For the sandbox axes (time/rng/network/fs), the report also prints a marker:
195
+
196
+ ```python
197
+ @pytest.mark.flakedoctor_repro("fd1:eNoVTksKw...")
198
+ def test_invoice_period_label():
199
+ ...
200
+ ```
201
+
202
+ Paste it above the test and commit it. Now the flake reproduces
203
+ deterministically on every ordinary `pytest` run — in CI and in code review —
204
+ until someone fixes it, exactly like Hypothesis's `@reproduce_failure`. (It's
205
+ inert during `--doctor` runs and in the doctor's own subprocesses; hashseed and
206
+ order repros can't be re-applied by a marker, so those print the command to use
207
+ instead.)
208
+
209
+ Verdicts are honest: `flaky-time` / `flaky-rng` / `flaky-network` / `flaky-fs` /
210
+ `flaky-hashseed` (each either *observed* — it explains the failure you actually
211
+ saw — or *latent*, a bug the doctor provoked), plus `deterministic-failure`,
212
+ `hang`, `flaky-unattributed` ("real flake, cause not in covered axes — here's
213
+ the evidence"), `incomplete`, and `not-flaky`.
214
+
215
+ The tool is built to refuse a diagnosis it can't support. An elevated failure
216
+ rate has to clear a significance test against the baseline rate, so an ordinary
217
+ coin-flip flake never gets blamed on whichever hash seed happened to streak;
218
+ "passing" evidence must come from runs that actually executed the test, not
219
+ merely from runs that didn't fail; and any provocation failure left
220
+ unattributed is reported rather than dropped.
221
+
222
+ ## Status
223
+
224
+ Alpha; not yet on PyPI. Working today: the subprocess engine, the full
225
+ control → provoke → verify → counterfactual loop, five axes (time, rng,
226
+ network, fs, hashseed), failure fingerprinting, repro blobs, terminal + JSON
227
+ reports, and suite mode.
228
+
229
+ Roadmap (see [DESIGN.md](DESIGN.md) for the full architecture):
230
+
231
+ - **later** — in-process fast path for slow-import suites; xdist-parallel
232
+ order simulation; free-threading safety auditor
233
+ (`flakedoctor audit <package>` on 3.13t/3.14t); GitHub Action posting
234
+ diagnoses on PRs.
235
+
236
+ Known gaps, stated plainly: async tests skip the clock axis (a virtual clock
237
+ hangs awaited sleeps), and async-wait is a large share of real-world flakes;
238
+ a time-based repro may not transfer across machines unless `TZ` is set, which
239
+ the report warns about; and re-running a side-effectful test dozens of times
240
+ is not yet gated behind a safety check.
241
+
242
+ ## Development
243
+
244
+ ```sh
245
+ PYTHONPATH=src python -m pytest tests/ # full suite (~1 min; spawns real children)
246
+ PYTHONPATH=src python -m pytest tests/ -k "not e2e and not runner" # fast logic tests
247
+ ```
248
+
249
+ MIT license.
@@ -0,0 +1,214 @@
1
+ # pytest-flakedoctor
2
+
3
+ **Every other tool retries your flaky test. flakedoctor tells you *why* it's
4
+ flaky — and hands you a command that makes it fail every time.**
5
+
6
+ Point it at a test that fails one run in twenty. It re-runs the test in fresh
7
+ subprocesses under controlled perturbation, bisects which axis of
8
+ nondeterminism triggers the failure, verifies the repro statistically, checks
9
+ the counterfactual, and prints this:
10
+
11
+ ```
12
+ ━━━ flakedoctor ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
+ test_billing.py::test_invoice_ids_stable
14
+ py3.14.4 · darwin · 35 runs · 8.5s
15
+
16
+ DIAGNOSIS hash-order dependent (PYTHONHASHSEED)
17
+ Failed 3/10 isolated baseline runs; fails 10/10 with PYTHONHASHSEED=2
18
+ and passed the counterfactual with PYTHONHASHSEED=0. The test's
19
+ outcome depends on Python's hash randomization: dict/set iteration
20
+ order (or hash-based ordering) leaks into an assertion or into order-
21
+ sensitive logic. PYTHONHASHSEED=2 pins the order that triggers the
22
+ failure.
23
+
24
+ EVIDENCE runs failed
25
+ baseline (isolated, no perturbation) 10 3
26
+ provoke: PYTHONHASHSEED=0 2 0
27
+ provoke: PYTHONHASHSEED=1 2 0
28
+ provoke: PYTHONHASHSEED=2 2 2
29
+ provoke: PYTHONHASHSEED=3855983186 2 2
30
+ provoke: PYTHONHASHSEED=2386784001 2 0
31
+ VERIFY: PYTHONHASHSEED=2 10 10 ✓ deterministic
32
+ counterfactual: PYTHONHASHSEED=0 5 0 ✓ passes
33
+
34
+ REPRO (fails 10/10; ≥74% repro rate at 95% confidence)
35
+ PYTHONHASHSEED=2 pytest "test_billing.py::test_invoice_ids_stable"
36
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37
+ ```
38
+
39
+ Three minutes ago this failed once a week. Now it fails on demand. Paste the
40
+ command into the bug report and fix it.
41
+
42
+ ## Usage
43
+
44
+ ```sh
45
+ pytest tests/test_billing.py::test_invoice_ids_stable --doctor # diagnose this test
46
+ pytest --doctor # run the suite; diagnose the first failure
47
+ flakedoctor tests/test_x.py::test_y # console-script sugar for the first form
48
+ ```
49
+
50
+ Options (also settable via ini):
51
+
52
+ | Flag | Default | Meaning |
53
+ |---|---|---|
54
+ | `--doctor-runs=N` | 10 | baseline sample size |
55
+ | `--doctor-budget=SECS` | 300 | wall-clock budget; the planner degrades gracefully |
56
+ | `--doctor-json=PATH` | — | machine-readable `flakedoctor-report` v1 (CI artifact) |
57
+
58
+ ## Axes
59
+
60
+ | Axis | What it catches | How it's controlled |
61
+ |---|---|---|
62
+ | `order` | a *polluter* test that leaks state the victim depends on being absent | run the victim after a shrinking prefix of the suite |
63
+ | `interleave` | thread races and deadlocks — a schedule that makes a threaded test fail | drive the test through a deterministic scheduler searching interleavings |
64
+ | `time` | month/day/year rollovers, DST, leap day, "assumes the code runs fast" | virtual clock frozen at adversarial instants, plus tick jitter |
65
+ | `rng` | unseeded `random`/`secrets`/`uuid4`, colliding fixture data | all randomness seeded from one seed |
66
+ | `network` | hidden live-network dependencies | outbound connections blocked |
67
+ | `fs` | ambient files, `$HOME` leftovers, cross-test residue | isolated filesystem tree |
68
+ | `hashseed` | dict/set iteration order leaking into behavior | `PYTHONHASHSEED` per subprocess |
69
+
70
+ The four sandbox axes are powered by [hermetic](https://pypi.org/project/hermetic-sandbox/)
71
+ (installed automatically; distribution `hermetic-sandbox`, import name `hermetic`).
72
+
73
+ ### The interleave axis (opt-in)
74
+
75
+ For threaded tests, the doctor can search for the exact thread schedule that
76
+ makes them fail — a race or a deadlock — using
77
+ [interleave-test](https://pypi.org/project/interleave-test/)'s deterministic
78
+ scheduler, then hand you a schedule that reproduces it every time:
79
+
80
+ ```
81
+ DIAGNOSIS race condition (thread interleaving)
82
+ The test passes 10/10 times on its own, but a specific thread
83
+ interleaving makes it fail — a real race the scheduler usually hides.
84
+ The found schedule reproduces it 3/3 times. Cause: AssertionError at
85
+ test_counter.py:16 (lost update). Add synchronization ...
86
+ ```
87
+
88
+ It's an **opt-in extra** — `pip install pytest-flakedoctor[interleave]` — and
89
+ needs Python ≥3.12 (the core supports ≥3.10). It only activates when a test
90
+ actually starts threads, verifies every finding by deterministic replay before
91
+ claiming anything, and is honest about what it *can't* see: threads or locks
92
+ created at import time, thread-pool internals, and C-level threads are outside
93
+ the modelled primitives, so it reports "no failing interleaving under the
94
+ modelled primitives," never "no race exists."
95
+
96
+ ### The order axis
97
+
98
+ Test-order dependence is the most common cause of flaky tests in Python, and
99
+ its diagnosis is the most satisfying: not "something is nondeterministic" but a
100
+ *named culprit*. Run the whole suite under `--doctor`; when a test passes alone
101
+ but failed in the suite, the doctor runs it after the collected prefix, then
102
+ binary-searches that prefix down to the polluter:
103
+
104
+ ```
105
+ DIAGNOSIS test-order dependent
106
+ The test passes 10/10 times on its own, but fails when run after the
107
+ test test_registry.py::test_register_plugin — fails every run in this
108
+ order. This is a test-order dependency: that test leaves behind state
109
+ the victim depends on being absent.
110
+
111
+ EVIDENCE runs failed
112
+ baseline (isolated, no perturbation) 10 0
113
+ after full suite prefix (4 tests) 3 3
114
+ VERIFY: after test_registry.py::test_register_plugin 10 10 ✓ deterministic
115
+ ```
116
+
117
+ The repro command runs just `[polluter, victim]` in the order that fails.
118
+
119
+ ## How it works
120
+
121
+ Every diagnostic run is a **fresh pytest subprocess** — that buys clean
122
+ module/fixture state per run, hang/crash immunity via OS kill, and axes that
123
+ are impossible to vary in-process (hash randomization is fixed at interpreter
124
+ start). The loop:
125
+
126
+ 1. **Baseline** — N isolated, unperturbed runs. Deterministic failure and
127
+ hangs are called out immediately; a mixed result means a live flake.
128
+ 2. **Control sweep** — determinize every axis at once. If the flakiness
129
+ disappears, the cause is one of them, and controlling each axis alone
130
+ identifies *which* — the stabilizer. That axis gets swept first.
131
+ 3. **Provocation sweep** — pin adversarial values on the suspect axis. Every
132
+ failure is *fingerprinted* (phase, exception type, crash site) so a
133
+ perturbation that breaks the test differently than the observed flake never
134
+ earns causal credit — and failures raised by the sandbox itself
135
+ (`NetworkBlockedError` and friends) are reported as perturbation-induced,
136
+ never as your bug.
137
+ 4. **Verification gate** — the winning value must fail n/n fresh runs. The
138
+ report never claims 100%; it prints the count and the exact
139
+ Clopper-Pearson lower bound.
140
+ 5. **Counterfactual gate** — a benign value on the same axis must actually
141
+ pass, or the verdict is downgraded to "elevates failure rate".
142
+
143
+ ## Reproducing
144
+
145
+ hermetic's CLI can express a seed but not a clock, network, or filesystem
146
+ configuration, so a diagnosed perturbation travels as a compact blob:
147
+
148
+ ```sh
149
+ pytest tests/test_billing.py::test_x --doctor-repro=fd1:eNodTs0KwjAYe5dc...
150
+ ```
151
+
152
+ That applies the perturbation **in-process**, so you can attach a debugger and
153
+ get a normal traceback. Blobs are versioned per axis and decode strictly: an
154
+ unknown axis, a newer payload version, or a blob recorded for a different test
155
+ fails loudly rather than quietly doing less than it claims. A repro that passes
156
+ exits non-zero with `DID NOT REPRODUCE` (Hypothesis semantics) — staleness is
157
+ never silent.
158
+
159
+ For the sandbox axes (time/rng/network/fs), the report also prints a marker:
160
+
161
+ ```python
162
+ @pytest.mark.flakedoctor_repro("fd1:eNoVTksKw...")
163
+ def test_invoice_period_label():
164
+ ...
165
+ ```
166
+
167
+ Paste it above the test and commit it. Now the flake reproduces
168
+ deterministically on every ordinary `pytest` run — in CI and in code review —
169
+ until someone fixes it, exactly like Hypothesis's `@reproduce_failure`. (It's
170
+ inert during `--doctor` runs and in the doctor's own subprocesses; hashseed and
171
+ order repros can't be re-applied by a marker, so those print the command to use
172
+ instead.)
173
+
174
+ Verdicts are honest: `flaky-time` / `flaky-rng` / `flaky-network` / `flaky-fs` /
175
+ `flaky-hashseed` (each either *observed* — it explains the failure you actually
176
+ saw — or *latent*, a bug the doctor provoked), plus `deterministic-failure`,
177
+ `hang`, `flaky-unattributed` ("real flake, cause not in covered axes — here's
178
+ the evidence"), `incomplete`, and `not-flaky`.
179
+
180
+ The tool is built to refuse a diagnosis it can't support. An elevated failure
181
+ rate has to clear a significance test against the baseline rate, so an ordinary
182
+ coin-flip flake never gets blamed on whichever hash seed happened to streak;
183
+ "passing" evidence must come from runs that actually executed the test, not
184
+ merely from runs that didn't fail; and any provocation failure left
185
+ unattributed is reported rather than dropped.
186
+
187
+ ## Status
188
+
189
+ Alpha; not yet on PyPI. Working today: the subprocess engine, the full
190
+ control → provoke → verify → counterfactual loop, five axes (time, rng,
191
+ network, fs, hashseed), failure fingerprinting, repro blobs, terminal + JSON
192
+ reports, and suite mode.
193
+
194
+ Roadmap (see [DESIGN.md](DESIGN.md) for the full architecture):
195
+
196
+ - **later** — in-process fast path for slow-import suites; xdist-parallel
197
+ order simulation; free-threading safety auditor
198
+ (`flakedoctor audit <package>` on 3.13t/3.14t); GitHub Action posting
199
+ diagnoses on PRs.
200
+
201
+ Known gaps, stated plainly: async tests skip the clock axis (a virtual clock
202
+ hangs awaited sleeps), and async-wait is a large share of real-world flakes;
203
+ a time-based repro may not transfer across machines unless `TZ` is set, which
204
+ the report warns about; and re-running a side-effectful test dozens of times
205
+ is not yet gated behind a safety check.
206
+
207
+ ## Development
208
+
209
+ ```sh
210
+ PYTHONPATH=src python -m pytest tests/ # full suite (~1 min; spawns real children)
211
+ PYTHONPATH=src python -m pytest tests/ -k "not e2e and not runner" # fast logic tests
212
+ ```
213
+
214
+ MIT license.
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-flakedoctor"
7
+ version = "0.1.0"
8
+ description = "The flaky-test doctor: diagnoses WHY a test is flaky and hands you a deterministic reproduction"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Danny Kissel" }]
14
+ keywords = [
15
+ "pytest",
16
+ "flaky",
17
+ "flaky-tests",
18
+ "heisenbug",
19
+ "testing",
20
+ "nondeterminism",
21
+ "reproducibility",
22
+ "diagnosis",
23
+ "bisection",
24
+ "hashseed",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 3 - Alpha",
28
+ "Framework :: Pytest",
29
+ "Intended Audience :: Developers",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
37
+ "Topic :: Software Development :: Testing",
38
+ "Typing :: Typed",
39
+ ]
40
+ # hermetic-sandbox powers the time/rng/network/fs axes. Note the distribution
41
+ # name differs from the import name, which is `hermetic`.
42
+ dependencies = ["pytest>=7", "hermetic-sandbox>=0.1"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/Therealdk8890/pytest-flakedoctor"
46
+ Repository = "https://github.com/Therealdk8890/pytest-flakedoctor"
47
+ Changelog = "https://github.com/Therealdk8890/pytest-flakedoctor/blob/main/CHANGELOG.md"
48
+ Issues = "https://github.com/Therealdk8890/pytest-flakedoctor/issues"
49
+
50
+ [project.optional-dependencies]
51
+ # The thread-interleaving axis. interleave-test needs Python >=3.12 (sys.monitoring),
52
+ # while the core supports >=3.10, so it is an opt-in extra, not a core dependency.
53
+ interleave = ["interleave-test>=0.1.0; python_version >= '3.12'"]
54
+ # Optional third-party pytest plugins the suite integrates with. A couple of
55
+ # tests exercise real interoperation with these and importorskip without them.
56
+ dev = ["pytest-asyncio", "pytest-randomly"]
57
+
58
+ [project.entry-points.pytest11]
59
+ flakedoctor = "flakedoctor._plugin"
60
+
61
+ [project.scripts]
62
+ flakedoctor = "flakedoctor._cli:main"
63
+
64
+ [tool.setuptools.packages.find]
65
+ where = ["src"]
66
+
67
+ [tool.setuptools.package-data]
68
+ flakedoctor = ["py.typed"]
69
+
70
+ [tool.pytest.ini_options]
71
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ """pytest-flakedoctor: diagnoses WHY a test is flaky and hands you a
2
+ deterministic reproduction.
3
+
4
+ Every other tool retries your flaky test. flakedoctor tells you why it's
5
+ flaky — and gives you a command that makes it fail every time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys as _sys
11
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
12
+ from importlib.metadata import version as _pkg_version
13
+
14
+ from ._diagnose import Diagnosis, DoctorSettings
15
+ from ._diagnose import diagnose as _engine_diagnose
16
+ from ._runner import SubprocessRunner
17
+
18
+ try:
19
+ # Single source of truth: the version declared in pyproject.toml, read from
20
+ # the installed distribution metadata so the two can never drift.
21
+ __version__ = _pkg_version("pytest-flakedoctor")
22
+ except _PackageNotFoundError: # running from a source tree that was never installed
23
+ __version__ = "0.0.0+unknown"
24
+
25
+ __all__ = ["Diagnosis", "DoctorSettings", "__version__", "diagnose"]
26
+
27
+
28
+ def diagnose(
29
+ nodeid: str,
30
+ *,
31
+ runs: int = 10,
32
+ budget: float = 300.0,
33
+ invocation_dir: str = ".",
34
+ python: str = _sys.executable,
35
+ ) -> Diagnosis:
36
+ """Scripting entry point: diagnose one test and return the Diagnosis.
37
+
38
+ Equivalent to ``pytest <nodeid> --doctor`` but usable from Python.
39
+ """
40
+ runner = SubprocessRunner(invocation_dir, python=python)
41
+ settings = DoctorSettings(runs=runs, budget=budget)
42
+ return _engine_diagnose(nodeid, runner, settings)