Skip to content

Commit 5aba609

Browse files
Add JSON report, confirmation stage, CI, and testpaths (v0.2.0)
- --json writes a machine-readable report (status, culprits, reproduce command, confirmation) for CI to gate on. - Confirmation stage: verify the minimal set reproduces standalone and detect additional independent polluters instead of under-reporting. - --no-confirm to skip the two confirmation runs. - Report total pytest invocations across all phases. - testpaths=[tests] so a bare pytest run ignores the self-polluting examples/ fixtures. - Add CHANGELOG and a GitHub Actions test matrix (Py 3.9-3.13).
1 parent 7aa5676 commit 5aba609

7 files changed

Lines changed: 368 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: Set up Python ${{ matrix.python-version }}
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
- name: Install pytest
22+
run: python -m pip install --upgrade pip pytest
23+
- name: Run the test suite
24+
run: PYTHONPATH=. python -m pytest -q
25+
- name: Smoke-test the CLI against the bundled demo
26+
run: |
27+
PYTHONPATH=. python -m flake_bisect \
28+
--workdir examples/polluting_demo \
29+
--target 'test_target.py::test_assumes_clean_env' \
30+
--json -

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Changelog
2+
3+
All notable changes to flake-bisect are documented here. This project follows
4+
[semantic versioning](https://semver.org/).
5+
6+
## 0.2.0
7+
8+
### Added
9+
- `--json PATH` writes a machine-readable report (`-` streams it to stdout). The
10+
report carries a stable `status`, the `culprits`, a `reproduce_command`, the
11+
invocation count, and the confirmation results — ready to gate CI on.
12+
- **Confirmation stage.** After the bisect, `flake-bisect` verifies that the
13+
minimal set reproduces the failure *standalone*, then re-runs the rest of the
14+
suite with the culprits removed. If the target still fails, it reports that at
15+
least one more independent polluter remains instead of under-reporting.
16+
- `--no-confirm` skips the two confirmation runs when you want the fastest
17+
possible result.
18+
- The stderr summary now reports the total number of pytest invocations across
19+
all phases, not just bisect iterations.
20+
21+
### Changed
22+
- Bare `pytest` at the repo root now collects only `tests/` (via `testpaths`),
23+
so the deliberately self-polluting `examples/` fixtures no longer trip up a
24+
plain test run.
25+
26+
## 0.1.0
27+
28+
- Initial release: delta-debugging (`ddmin`) over pytest ordering to find the
29+
minimal set of tests that poison a flaky target, with a bundled order-pinning
30+
plugin, sanity checks, and stable exit codes.

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ test ordering, so you stop guessing and start reading the right diff.
1313
```
1414
$ python -m flake_bisect --workdir examples/polluting_demo \
1515
--target test_target.py::test_assumes_clean_env
16-
flake-bisect 0.1.0
16+
flake-bisect 0.2.0
1717
workdir : .../examples/polluting_demo
1818
target : test_target.py::test_assumes_clean_env
1919
Collecting tests...
@@ -30,7 +30,7 @@ Minimal poisoning set (1 test):
3030
Reproduce locally:
3131
pytest test_pollute.py::test_sets_env_flag test_target.py::test_assumes_clean_env
3232
33-
pytest invocations during bisect: 3 (cap: 200)
33+
pytest invocations: 7 (bisect iterations: 3, cap: 200)
3434
```
3535

3636
A naive linear search across N candidate predecessors would take up to N runs
@@ -86,6 +86,8 @@ Or run it from inside the flake-bisect checkout with an absolute `--workdir`.
8686
| `--workdir` | Run pytest from this directory (default: cwd). |
8787
| `--max-runs` | Hard cap on pytest invocations during bisect (default: 200). |
8888
| `--pytest-arg` | Forward an arg to every pytest invocation. Repeat to pass multiple. |
89+
| `--json PATH` | Write a machine-readable report to `PATH` (`-` for stdout). |
90+
| `--no-confirm` | Skip the two post-bisect confirmation runs. |
8991
| `-v` | Show per-iteration progress. |
9092

9193
### Forwarding pytest options
@@ -111,9 +113,40 @@ python -m flake_bisect \
111113
subset is run as `pytest <subset…> <target>` in a fresh subprocess, with
112114
collection order pinned by a bundled internal plugin so the result doesn't
113115
depend on `pytest-randomly` or alphabetical ordering surprises.
114-
5. **Report** the minimal subset that still reproduces the failure plus a
116+
5. **Confirm** (unless `--no-confirm`): run the minimal set on its own to check
117+
it reproduces the failure standalone, then run the *rest* of the suite with
118+
the culprits removed. If the target still fails without them, there is at
119+
least one more independent polluter — `flake-bisect` says so instead of
120+
quietly under-reporting.
121+
6. **Report** the minimal subset that still reproduces the failure plus a
115122
copy-pasteable `pytest` command to reproduce locally.
116123

124+
### JSON report
125+
126+
Pass `--json report.json` (or `--json -` for stdout) to get a machine-readable
127+
result you can gate CI on:
128+
129+
```json
130+
{
131+
"tool": "flake-bisect",
132+
"version": "0.2.0",
133+
"status": "poisoned",
134+
"exit_code": 0,
135+
"target": "test_target.py::test_assumes_clean_env",
136+
"culprits": ["test_pollute.py::test_sets_env_flag"],
137+
"reproduce_command": "pytest test_pollute.py::test_sets_env_flag test_target.py::test_assumes_clean_env",
138+
"pytest_invocations": 7,
139+
"confirmation": {
140+
"reproduces_standalone": true,
141+
"target_clean_without_culprits": true,
142+
"additional_polluters_possible": false
143+
}
144+
}
145+
```
146+
147+
The `status` field mirrors the exit codes below, so a wrapper can branch on
148+
either.
149+
117150
Determinism note: `flake-bisect` cannot help with flakes caused by **time,
118151
threads, networking, or RNG without a fixed seed**. Those are not ordering
119152
bugs. If sanity check 2 doesn't reproduce the failure deterministically, the

flake_bisect/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""flake-bisect: find the tests that poison a flaky pytest target."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.2.0"

flake_bisect/cli.py

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import argparse
6+
import json
67
import os
78
import sys
9+
from typing import Any
810

911
from flake_bisect import __version__
1012
from flake_bisect.bisect import ddmin
@@ -50,6 +52,18 @@ def _build_parser() -> argparse.ArgumentParser:
5052
metavar="ARG",
5153
help="Extra argument forwarded to pytest. Repeat to pass multiple.",
5254
)
55+
p.add_argument(
56+
"--json",
57+
dest="json_path",
58+
default=None,
59+
metavar="PATH",
60+
help="Write a machine-readable JSON report to PATH (use '-' for stdout).",
61+
)
62+
p.add_argument(
63+
"--no-confirm",
64+
action="store_true",
65+
help="Skip the post-bisect confirmation runs (saves two pytest invocations).",
66+
)
5367
p.add_argument(
5468
"-v",
5569
"--verbose",
@@ -59,12 +73,46 @@ def _build_parser() -> argparse.ArgumentParser:
5973
return p
6074

6175

76+
def _emit_json(path: str, payload: dict[str, Any]) -> None:
77+
"""Write the JSON report to a file, or to stdout when path == '-'."""
78+
text = json.dumps(payload, indent=2, sort_keys=True)
79+
if path == "-":
80+
print(text)
81+
else:
82+
with open(path, "w", encoding="utf-8") as fh:
83+
fh.write(text + "\n")
84+
85+
6286
def main(argv: list[str] | None = None) -> int:
6387
args = _build_parser().parse_args(argv)
6488

6589
workdir = os.path.abspath(args.workdir)
6690
target = args.target
6791
extra = list(args.pytest_arg or [])
92+
json_path = args.json_path
93+
94+
# Total pytest subprocess invocations across every phase (collection excluded).
95+
runs = {"total": 0}
96+
97+
def _run(order: list[str]):
98+
runs["total"] += 1
99+
return run_ordered(workdir, order, extra)
100+
101+
def _report(status: str, exit_code: int, **fields: Any) -> int:
102+
if json_path:
103+
payload: dict[str, Any] = {
104+
"tool": "flake-bisect",
105+
"version": __version__,
106+
"status": status,
107+
"exit_code": exit_code,
108+
"target": target,
109+
"workdir": workdir,
110+
"pytest_invocations": runs["total"],
111+
"max_runs": args.max_runs,
112+
}
113+
payload.update(fields)
114+
_emit_json(json_path, payload)
115+
return exit_code
68116

69117
testpaths = args.testpaths if args.testpaths else []
70118
print(f"flake-bisect {__version__}", file=sys.stderr)
@@ -75,59 +123,59 @@ def main(argv: list[str] | None = None) -> int:
75123
all_ids = collect_tests(workdir, testpaths)
76124
if not all_ids:
77125
print("ERROR: no tests collected", file=sys.stderr)
78-
return 2
126+
return _report("no_tests_collected", 2)
79127
if target not in all_ids:
80128
print(
81129
f"ERROR: target nodeid not found in collection: {target}\n"
82130
f"Got {len(all_ids)} nodeids; first few:\n "
83131
+ "\n ".join(all_ids[:5]),
84132
file=sys.stderr,
85133
)
86-
return 2
134+
return _report("target_not_found", 2, collected=len(all_ids))
87135

88136
predecessors = [n for n in all_ids if n != target]
89137
print(f"Collected {len(all_ids)} tests ({len(predecessors)} candidates).", file=sys.stderr)
90138

91139
# Sanity check 1: target passes alone.
92140
print("Sanity check: target alone...", file=sys.stderr)
93-
solo = run_ordered(workdir, [target], extra)
141+
solo = _run([target])
94142
if target_failed(solo, target):
95143
print(
96144
f"ERROR: target fails alone — this isn't an ordering issue.\n"
97145
f"Outcome: {solo.outcomes.get(target)}\n"
98146
f"pytest exit code: {solo.exit_code}",
99147
file=sys.stderr,
100148
)
101-
return 3
149+
return _report("fails_alone", 3, target_outcome=solo.outcomes.get(target))
102150
if solo.outcomes.get(target) != "PASSED":
103151
print(
104152
f"ERROR: target was not run when invoked alone (outcome={solo.outcomes.get(target)!r}).\n"
105153
f"pytest exit code: {solo.exit_code}\n"
106154
f"--- pytest stdout ---\n{solo.stdout}",
107155
file=sys.stderr,
108156
)
109-
return 3
157+
return _report("target_not_run_alone", 3, target_outcome=solo.outcomes.get(target))
110158
print(" OK (passes alone)", file=sys.stderr)
111159

112160
# Sanity check 2: target fails when run after everything else.
113161
print("Sanity check: target after full suite...", file=sys.stderr)
114-
full = run_ordered(workdir, [*predecessors, target], extra)
162+
full = _run([*predecessors, target])
115163
if not target_failed(full, target):
116164
print(
117165
f"WARNING: target did not fail in the full ordered run "
118166
f"(outcome={full.outcomes.get(target)!r}). Pollution may be "
119167
f"order- or randomness-dependent; flake-bisect cannot help here.",
120168
file=sys.stderr,
121169
)
122-
return 4
170+
return _report("no_pollution_reproduced", 4, target_outcome=full.outcomes.get(target))
123171
print(f" OK (target outcome: {full.outcomes[target]})", file=sys.stderr)
124172

125173
# Bisect.
126174
iteration = {"i": 0}
127175

128176
def predicate(preds: list[str]) -> bool:
129177
iteration["i"] += 1
130-
res = run_ordered(workdir, [*preds, target], extra)
178+
res = _run([*preds, target])
131179
failed = target_failed(res, target)
132180
if args.verbose:
133181
print(
@@ -142,18 +190,66 @@ def predicate(preds: list[str]) -> bool:
142190
culprits = ddmin(predecessors, predicate, max_calls=args.max_runs)
143191
except RuntimeError as e:
144192
print(f"ERROR: {e}", file=sys.stderr)
145-
return 5
193+
return _report("max_runs_exhausted", 5, bisect_iterations=iteration["i"])
194+
195+
# Confirmation: (1) the minimal set reproduces the failure *standalone*, and
196+
# (2) removing it from the full suite lets the target pass. If the target
197+
# still fails without the culprits, there is at least one more independent
198+
# polluter that this run did not surface.
199+
reproduces_standalone: bool | None = None
200+
target_clean_without_culprits: bool | None = None
201+
if not args.no_confirm:
202+
print("Confirming...", file=sys.stderr)
203+
standalone = _run([*culprits, target])
204+
reproduces_standalone = target_failed(standalone, target)
205+
remaining = [n for n in predecessors if n not in set(culprits)]
206+
without = _run([*remaining, target])
207+
target_clean_without_culprits = not target_failed(without, target)
208+
print(
209+
f" minimal set reproduces standalone: "
210+
f"{'yes' if reproduces_standalone else 'NO'}",
211+
file=sys.stderr,
212+
)
213+
print(
214+
f" target clean once culprits removed: "
215+
f"{'yes' if target_clean_without_culprits else 'NO (more polluters remain)'}",
216+
file=sys.stderr,
217+
)
146218

147219
print("")
148220
print(f"Minimal poisoning set ({len(culprits)} test{'s' if len(culprits) != 1 else ''}):")
149221
for nid in culprits:
150222
print(f" {nid}")
151223
print("")
152224
print("Reproduce locally:")
153-
print(" pytest " + " ".join([*culprits, target]))
225+
reproduce_cmd = "pytest " + " ".join([*culprits, target])
226+
print(" " + reproduce_cmd)
227+
if target_clean_without_culprits is False:
228+
print("")
229+
print(
230+
"NOTE: the target still fails with these tests removed — there is at\n"
231+
" least one more independent polluter. Re-run flake-bisect after\n"
232+
" fixing this set to find the next one."
233+
)
154234
print("")
155-
print(f"pytest invocations during bisect: {iteration['i']} (cap: {args.max_runs})", file=sys.stderr)
156-
return 0
235+
print(
236+
f"pytest invocations: {runs['total']} "
237+
f"(bisect iterations: {iteration['i']}, cap: {args.max_runs})",
238+
file=sys.stderr,
239+
)
240+
241+
return _report(
242+
"poisoned",
243+
0,
244+
culprits=culprits,
245+
reproduce_command=reproduce_cmd,
246+
bisect_iterations=iteration["i"],
247+
confirmation={
248+
"reproduces_standalone": reproduces_standalone,
249+
"target_clean_without_culprits": target_clean_without_culprits,
250+
"additional_polluters_possible": target_clean_without_culprits is False,
251+
},
252+
)
157253

158254

159255
if __name__ == "__main__":

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "flake-bisect"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Bisect a pytest suite to find the test(s) poisoning a flaky target."
99
readme = "README.md"
1010
license = { file = "LICENSE" }
@@ -31,3 +31,9 @@ Source = "https://github.com/python-testing-debugging/flake-bisect"
3131
[tool.setuptools.packages.find]
3232
include = ["flake_bisect*"]
3333
exclude = ["tests*", "examples*"]
34+
35+
[tool.pytest.ini_options]
36+
# Only collect flake-bisect's own tests. The examples/ suites are deliberately
37+
# self-polluting fixtures — they are meant to be run *by* flake-bisect, not by
38+
# pytest at the repo root.
39+
testpaths = ["tests"]

0 commit comments

Comments
 (0)