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.
- pytest_flakedoctor-0.1.0/LICENSE +21 -0
- pytest_flakedoctor-0.1.0/PKG-INFO +249 -0
- pytest_flakedoctor-0.1.0/README.md +214 -0
- pytest_flakedoctor-0.1.0/pyproject.toml +71 -0
- pytest_flakedoctor-0.1.0/setup.cfg +4 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/__init__.py +42 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_axes.py +351 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_cli.py +69 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_diagnose.py +1676 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_fingerprint.py +62 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_interleave.py +220 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_plugin.py +698 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_probe.py +321 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_report.py +110 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_repro.py +257 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_runner.py +567 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/_stats.py +114 -0
- pytest_flakedoctor-0.1.0/src/flakedoctor/py.typed +0 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/PKG-INFO +249 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/SOURCES.txt +33 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/dependency_links.txt +1 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/entry_points.txt +5 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/requires.txt +11 -0
- pytest_flakedoctor-0.1.0/src/pytest_flakedoctor.egg-info/top_level.txt +1 -0
- pytest_flakedoctor-0.1.0/tests/test_diagnose.py +976 -0
- pytest_flakedoctor-0.1.0/tests/test_e2e.py +123 -0
- pytest_flakedoctor-0.1.0/tests/test_e2e_axes.py +210 -0
- pytest_flakedoctor-0.1.0/tests/test_e2e_interleave.py +273 -0
- pytest_flakedoctor-0.1.0/tests/test_e2e_order.py +180 -0
- pytest_flakedoctor-0.1.0/tests/test_fingerprint.py +59 -0
- pytest_flakedoctor-0.1.0/tests/test_marker.py +216 -0
- pytest_flakedoctor-0.1.0/tests/test_plugin_integration.py +279 -0
- pytest_flakedoctor-0.1.0/tests/test_repro.py +168 -0
- pytest_flakedoctor-0.1.0/tests/test_runner.py +127 -0
- 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,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)
|