flakebox 0.4.4__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.
- flakebox/__init__.py +0 -0
- flakebox/__main__.py +4 -0
- flakebox/cli.py +475 -0
- flakebox/detect.py +175 -0
- flakebox/junit.py +139 -0
- flakebox/quarantine.py +194 -0
- flakebox/store.py +363 -0
- flakebox-0.4.4.dist-info/METADATA +365 -0
- flakebox-0.4.4.dist-info/RECORD +13 -0
- flakebox-0.4.4.dist-info/WHEEL +5 -0
- flakebox-0.4.4.dist-info/entry_points.txt +2 -0
- flakebox-0.4.4.dist-info/licenses/LICENSE +21 -0
- flakebox-0.4.4.dist-info/top_level.txt +1 -0
flakebox/__init__.py
ADDED
|
File without changes
|
flakebox/__main__.py
ADDED
flakebox/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Flakebox: self-hosted flaky-test triage for small teams.
|
|
2
|
+
|
|
3
|
+
Ingests JUnit XML, detects flakiness statistically, quarantines the
|
|
4
|
+
flakes, and tells you when they're stable enough to come back.
|
|
5
|
+
|
|
6
|
+
flakebox record --junit reports/junit.xml --commit="$SHA"
|
|
7
|
+
flakebox report # flaky / broken / stable tables
|
|
8
|
+
flakebox quarantine --format pytest -o . # skip flakes in CI
|
|
9
|
+
flakebox unquarantine --test pkg.Class::test_x
|
|
10
|
+
flakebox export --out history.json # carry the DB (CI artifact)
|
|
11
|
+
flakebox import history.json # merge it into the local DB
|
|
12
|
+
|
|
13
|
+
--db works before or after the subcommand:
|
|
14
|
+
flakebox --db ci.db report == flakebox report --db ci.db
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import errno
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from . import detect as detect_mod
|
|
27
|
+
from . import junit as junit_mod
|
|
28
|
+
from . import quarantine as quar_mod
|
|
29
|
+
from . import store as store_mod
|
|
30
|
+
|
|
31
|
+
VERSION = "0.4.4"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_db() -> str:
|
|
35
|
+
"""~/.flakebox/history.db, resolved at call time so HOME/USERPROFILE
|
|
36
|
+
overrides (tests, CI) are honoured."""
|
|
37
|
+
return os.path.join(os.path.expanduser("~"), ".flakebox", "history.db")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _harden_streams() -> None:
|
|
41
|
+
"""Never crash on a console that cannot encode the report's emoji or
|
|
42
|
+
a test id (e.g. cp1252 on Windows): replace instead of raising."""
|
|
43
|
+
for stream in (sys.stdout, sys.stderr):
|
|
44
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
45
|
+
# Both handlers raise UnicodeEncodeError on a character the code
|
|
46
|
+
# page lacks: "strict" (the default) and "surrogateescape" (what a
|
|
47
|
+
# piped stdout reports on Windows 11 / Python 3.11, with cp1252).
|
|
48
|
+
if reconfigure is None or getattr(stream, "errors", None) not in (
|
|
49
|
+
"strict", "surrogateescape"):
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
reconfigure(errors="replace")
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _db(path: str | None):
|
|
58
|
+
return store_mod.connect(path or default_db())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _short(text: str) -> str:
|
|
62
|
+
return text if len(text) <= 30 else text[:27] + "..."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _int64(text: str) -> int:
|
|
66
|
+
"""argparse `type` for an integer that reaches the database. SQLite
|
|
67
|
+
stores 64 bits; binding a bigger Python int is an OverflowError, which
|
|
68
|
+
used to end in a traceback."""
|
|
69
|
+
try:
|
|
70
|
+
value = int(text)
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise argparse.ArgumentTypeError(
|
|
73
|
+
f"invalid int value: {_short(text)!r}") from None
|
|
74
|
+
low, high = -store_mod._INT64, store_mod._INT64 - 1
|
|
75
|
+
if not low <= value <= high:
|
|
76
|
+
raise argparse.ArgumentTypeError(
|
|
77
|
+
f"{_short(text)} is out of range (allowed: {low} to {high})")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _utf8(text: str) -> str:
|
|
82
|
+
"""argparse `type` for text that is stored in the database. A byte of
|
|
83
|
+
argv that is not UTF-8 reaches Python as a lone surrogate, which SQLite
|
|
84
|
+
cannot store (a UnicodeEncodeError, and a traceback)."""
|
|
85
|
+
try:
|
|
86
|
+
text.encode("utf-8")
|
|
87
|
+
except UnicodeEncodeError:
|
|
88
|
+
raise argparse.ArgumentTypeError(
|
|
89
|
+
"must be valid UTF-8 text (it is stored in the database)") from None
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _lf_only(stream) -> None:
|
|
94
|
+
"""Write "\\n" as "\\n" on `stream`. Text-mode stdout turns it into
|
|
95
|
+
"\\r\\n" on Windows, and Git-Bash's `$(...)` strips only the "\\n", so
|
|
96
|
+
the last word of the deselect line kept a carriage return."""
|
|
97
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
98
|
+
if reconfigure is not None:
|
|
99
|
+
try:
|
|
100
|
+
reconfigure(newline="\n")
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_cwd(path: str) -> bool:
|
|
106
|
+
"""Is `path` the current directory (however it is spelled)?"""
|
|
107
|
+
try:
|
|
108
|
+
return os.path.samefile(path, os.getcwd())
|
|
109
|
+
except OSError:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_record(args) -> int:
|
|
114
|
+
try:
|
|
115
|
+
raw = junit_mod.read(args.junit)
|
|
116
|
+
results = junit_mod.parse_bytes(raw, args.junit)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
print(f"error: no such file: {args.junit}", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"error: cannot parse {args.junit}: {e}", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
sha = junit_mod.report_sha256(raw)
|
|
124
|
+
if not results and not args.allow_empty:
|
|
125
|
+
# Nearly always a test step that broke. Recorded, a run with no
|
|
126
|
+
# tests makes every test look absent, and three of them in a row
|
|
127
|
+
# retire the whole suite. Exit 0 all the same: a build that really
|
|
128
|
+
# ran no tests (docs only) is not a failure.
|
|
129
|
+
junit_mod._warn(f"{args.junit} has no test cases, so it was not "
|
|
130
|
+
f"recorded (a test step that broke looks like this; "
|
|
131
|
+
f"--allow-empty records it anyway)")
|
|
132
|
+
return 0
|
|
133
|
+
conn = _db(args.db)
|
|
134
|
+
try:
|
|
135
|
+
if not args.force:
|
|
136
|
+
dup = store_mod.find_run(conn, args.commit, sha)
|
|
137
|
+
if dup is not None:
|
|
138
|
+
print(f"already recorded as run {dup} (same commit and "
|
|
139
|
+
f"report); nothing added. --force records it again.")
|
|
140
|
+
return 0
|
|
141
|
+
run_id = store_mod.record_run(conn, results, args.commit, args.branch,
|
|
142
|
+
report_sha=sha)
|
|
143
|
+
finally:
|
|
144
|
+
conn.close()
|
|
145
|
+
n_fail = sum(1 for r in results if r["outcome"] in ("failed", "error"))
|
|
146
|
+
print(f"recorded run {run_id}: {len(results)} tests, "
|
|
147
|
+
f"{n_fail} failed/error")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pct(f: float) -> str:
|
|
152
|
+
return f"{f:.0%}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_report(args) -> int:
|
|
156
|
+
conn = _db(args.db)
|
|
157
|
+
try:
|
|
158
|
+
a = detect_mod.analyze(conn, args.min_runs, args.stable_runs,
|
|
159
|
+
retire_after=args.retire_after)
|
|
160
|
+
finally:
|
|
161
|
+
conn.close()
|
|
162
|
+
if args.format == "json":
|
|
163
|
+
print(json.dumps(a, indent=2))
|
|
164
|
+
return 0
|
|
165
|
+
print(f"\n# Flakebox report ({args.db or default_db()})\n")
|
|
166
|
+
if a["flaky"]:
|
|
167
|
+
print("## Flaky (pass AND fail - quarantine candidates)")
|
|
168
|
+
print("| test | flake rate | fails/passes | runs |")
|
|
169
|
+
print("|---|---|---|---|")
|
|
170
|
+
for s in a["flaky"]:
|
|
171
|
+
q = " \U0001f512" if s["quarantined"] else ""
|
|
172
|
+
print(f"| `{s['test_id']}` | {_pct(s['flake_rate'])} | "
|
|
173
|
+
f"{s['failures']}/{s['passes']} | {s['runs']} |{q}")
|
|
174
|
+
print()
|
|
175
|
+
else:
|
|
176
|
+
print("No flaky tests detected. \U0001f389\n")
|
|
177
|
+
if a["broken"]:
|
|
178
|
+
print("## Consistently failing (broken, not flaky - fix, don't quarantine)")
|
|
179
|
+
for s in a["broken"]:
|
|
180
|
+
print(f"- `{s['test_id']}` ({s['failures']} failures, "
|
|
181
|
+
f"0 passes)")
|
|
182
|
+
print()
|
|
183
|
+
if a["stable"]:
|
|
184
|
+
print("## Stable - ready to un-quarantine")
|
|
185
|
+
for s in a["stable"]:
|
|
186
|
+
print(f"- `{s['test_id']}` ({s['pass_streak']} consecutive passes)")
|
|
187
|
+
print(f" run: flakebox unquarantine --test \"{s['test_id']}\"")
|
|
188
|
+
print()
|
|
189
|
+
if a["retired"]:
|
|
190
|
+
print(f"## Retired (absent from the last {args.retire_after} runs - "
|
|
191
|
+
f"deleted or renamed, not counted)")
|
|
192
|
+
for s in a["retired"]:
|
|
193
|
+
q = " - still quarantined, run: flakebox unquarantine --test " \
|
|
194
|
+
f"\"{s['test_id']}\"" if s["quarantined"] else ""
|
|
195
|
+
print(f"- `{s['test_id']}` (was {s['status']}; last seen run "
|
|
196
|
+
f"{s['last_run']}, commit {s.get('last_commit') or '-'}){q}")
|
|
197
|
+
print()
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_quarantine(args) -> int:
|
|
202
|
+
"""Quarantine the current flaky set and emit the WHOLE quarantine set
|
|
203
|
+
in the requested format. Idempotent: running it again adds nothing,
|
|
204
|
+
resets nothing, and still prints the full set (so a CI job can run
|
|
205
|
+
it every build)."""
|
|
206
|
+
conn = _db(args.db)
|
|
207
|
+
try:
|
|
208
|
+
a = detect_mod.analyze(conn, args.min_runs, args.stable_runs,
|
|
209
|
+
retire_after=args.retire_after)
|
|
210
|
+
new = [s["test_id"] for s in a["flaky"]
|
|
211
|
+
if store_mod.quarantine_add(conn, s["test_id"],
|
|
212
|
+
reason="auto: flaky")]
|
|
213
|
+
full = sorted(q["test_id"] for q in store_mod.quarantined(conn))
|
|
214
|
+
finally:
|
|
215
|
+
conn.close()
|
|
216
|
+
summary = (f"quarantined {len(new)} new test(s); "
|
|
217
|
+
f"{len(full)} in quarantine.")
|
|
218
|
+
if args.format == "pytest":
|
|
219
|
+
out_dir = args.out or "."
|
|
220
|
+
try:
|
|
221
|
+
written = quar_mod.write_pytest(out_dir, full, mode=args.mode)
|
|
222
|
+
except OSError as e: # -o is a file, no permission, no such drive
|
|
223
|
+
print(f"error: cannot write the pytest plugin to {out_dir}: "
|
|
224
|
+
f"{e.strerror or e}", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
print(summary)
|
|
227
|
+
for w in written:
|
|
228
|
+
print(f" wrote {w}")
|
|
229
|
+
# `-p` imports the plugin at start-up. `python -m pytest` puts the
|
|
230
|
+
# current directory on sys.path, the bare `pytest` command does not.
|
|
231
|
+
print("run pytest with: python -m pytest -p flakebox_quarantine")
|
|
232
|
+
if not _is_cwd(out_dir):
|
|
233
|
+
print(f" ({out_dir} is not the current directory: put it on "
|
|
234
|
+
f"PYTHONPATH first, or python cannot import the plugin)")
|
|
235
|
+
elif args.format == "deselect":
|
|
236
|
+
# stdout is meant for `eval "pytest $(flakebox quarantine --format
|
|
237
|
+
# deselect)"`: only the (shell-quoted) args go there, the summary
|
|
238
|
+
# goes to stderr
|
|
239
|
+
print(summary, file=sys.stderr)
|
|
240
|
+
_lf_only(sys.stdout) # no CR at the end of what `$(...)` captures
|
|
241
|
+
print(quar_mod.deselect_line(full))
|
|
242
|
+
else:
|
|
243
|
+
print(json.dumps({"quarantined": full, "new": new}, indent=2))
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_unquarantine(args) -> int:
|
|
248
|
+
conn = _db(args.db)
|
|
249
|
+
try:
|
|
250
|
+
store_mod.quarantine_remove(conn, args.test)
|
|
251
|
+
finally:
|
|
252
|
+
conn.close()
|
|
253
|
+
print(f"un-quarantined {args.test}")
|
|
254
|
+
print("regenerate the pytest plugin: flakebox quarantine --format pytest")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_history(args) -> int:
|
|
259
|
+
conn = _db(args.db)
|
|
260
|
+
try:
|
|
261
|
+
hist = store_mod.test_history(conn, args.test, limit=args.limit)
|
|
262
|
+
if not hist:
|
|
263
|
+
print(f"no history for {args.test}")
|
|
264
|
+
return 0
|
|
265
|
+
# the same thresholds as `report`, so the two never disagree on
|
|
266
|
+
# what a test is (a recovered test is "recovered", not "flaky")
|
|
267
|
+
s = detect_mod.summarize(conn, args.test,
|
|
268
|
+
retire_after=args.retire_after,
|
|
269
|
+
stable_runs=args.stable_runs)
|
|
270
|
+
finally:
|
|
271
|
+
conn.close()
|
|
272
|
+
status = s["status"]
|
|
273
|
+
rate = (f", flake rate {_pct(s['flake_rate'])}"
|
|
274
|
+
if status in (detect_mod.FLAKY, detect_mod.RECOVERED) else "")
|
|
275
|
+
if status == detect_mod.RECOVERED:
|
|
276
|
+
status += f" ({s['pass_streak']} consecutive passes)"
|
|
277
|
+
if s["runs"] < args.min_runs:
|
|
278
|
+
status += (f" (only {s['runs']} executed runs; report needs "
|
|
279
|
+
f"{args.min_runs})")
|
|
280
|
+
flags = ("".join([" [quarantined]" if s["quarantined"] else "",
|
|
281
|
+
" [retired: absent from the newest runs]"
|
|
282
|
+
if s["retired"] else ""]))
|
|
283
|
+
print(f"\n{args.test}: {status} - {s['passes']} passed, "
|
|
284
|
+
f"{s['failures']} failed over {s['runs']} executed runs "
|
|
285
|
+
f"({s['skips']} skipped{rate}){flags}\n")
|
|
286
|
+
for h in hist[:20]:
|
|
287
|
+
print(f" {h['outcome']:7} {h['commit'] or '-'}")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_export(args) -> int:
|
|
292
|
+
conn = _db(args.db)
|
|
293
|
+
try:
|
|
294
|
+
try:
|
|
295
|
+
data = store_mod.export_file(conn, args.out)
|
|
296
|
+
except OSError as e: # a directory, no permission, no such folder
|
|
297
|
+
print(f"error: cannot export to {args.out}: {e.strerror or e}",
|
|
298
|
+
file=sys.stderr)
|
|
299
|
+
return 2
|
|
300
|
+
finally:
|
|
301
|
+
conn.close()
|
|
302
|
+
print(f"exported {len(data['runs'])} run(s), "
|
|
303
|
+
f"{len(data['quarantine'])} quarantine entr"
|
|
304
|
+
f"{'y' if len(data['quarantine']) == 1 else 'ies'} to {args.out}")
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_import(args) -> int:
|
|
309
|
+
conn = _db(args.db)
|
|
310
|
+
try:
|
|
311
|
+
totals = {"runs_added": 0, "runs_skipped": 0, "quarantine_added": 0}
|
|
312
|
+
for path in args.files:
|
|
313
|
+
try:
|
|
314
|
+
counts = store_mod.import_file(conn, path)
|
|
315
|
+
except FileNotFoundError:
|
|
316
|
+
print(f"error: no such file: {path}", file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
except OSError as e: # a directory, no permission, ...
|
|
319
|
+
print(f"error: cannot import {path}: {e.strerror or e}",
|
|
320
|
+
file=sys.stderr)
|
|
321
|
+
return 2
|
|
322
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
323
|
+
print(f"error: cannot import {path}: {e}", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
for k in totals:
|
|
326
|
+
totals[k] += counts[k]
|
|
327
|
+
finally:
|
|
328
|
+
conn.close()
|
|
329
|
+
print(f"imported {totals['runs_added']} run(s), skipped "
|
|
330
|
+
f"{totals['runs_skipped']} already recorded, "
|
|
331
|
+
f"{totals['quarantine_added']} new quarantine entr"
|
|
332
|
+
f"{'y' if totals['quarantine_added'] == 1 else 'ies'}")
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
337
|
+
p = argparse.ArgumentParser(
|
|
338
|
+
prog="flakebox", description=__doc__,
|
|
339
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
340
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
|
341
|
+
p.add_argument("--db", default=None, metavar="PATH",
|
|
342
|
+
help=f"SQLite DB path (default {default_db()}). "
|
|
343
|
+
f"Accepted before or after the subcommand.")
|
|
344
|
+
# the same --db on every subcommand (argparse parents); SUPPRESS so a
|
|
345
|
+
# subcommand that does not mention it leaves the top-level value alone
|
|
346
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
347
|
+
common.add_argument("--db", default=argparse.SUPPRESS, metavar="PATH",
|
|
348
|
+
help="SQLite DB path (same as the top-level --db)")
|
|
349
|
+
detect = argparse.ArgumentParser(add_help=False)
|
|
350
|
+
detect.add_argument("--min-runs", type=int, default=5,
|
|
351
|
+
help="executed runs (skips excluded) before a test "
|
|
352
|
+
"can be called flaky or failing (default 5)")
|
|
353
|
+
detect.add_argument("--stable-runs", type=int,
|
|
354
|
+
default=detect_mod.STABLE_RUNS,
|
|
355
|
+
help="consecutive passes that make a test stable "
|
|
356
|
+
f"(default {detect_mod.STABLE_RUNS})")
|
|
357
|
+
detect.add_argument("--retire-after", type=_int64,
|
|
358
|
+
default=detect_mod.RETIRE_AFTER, metavar="N",
|
|
359
|
+
help="a test absent from the newest N runs is "
|
|
360
|
+
"retired (deleted/renamed): listed apart, "
|
|
361
|
+
f"not counted (default {detect_mod.RETIRE_AFTER}; "
|
|
362
|
+
"0 disables)")
|
|
363
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
364
|
+
|
|
365
|
+
r = sub.add_parser("record", parents=[common],
|
|
366
|
+
help="Ingest a JUnit XML report.")
|
|
367
|
+
r.add_argument("--junit", required=True)
|
|
368
|
+
r.add_argument("--commit", type=_utf8, default=None)
|
|
369
|
+
r.add_argument("--branch", type=_utf8, default=None)
|
|
370
|
+
r.add_argument("--force", action="store_true",
|
|
371
|
+
help="record even if this exact report (same commit, "
|
|
372
|
+
"same bytes) was recorded before")
|
|
373
|
+
r.add_argument("--allow-empty", action="store_true",
|
|
374
|
+
help="record a report that has no test cases. By default "
|
|
375
|
+
"it is not recorded (a warning on stderr, exit 0): "
|
|
376
|
+
"it is nearly always a test step that broke, and "
|
|
377
|
+
"three of them in a row retire every test")
|
|
378
|
+
|
|
379
|
+
rp = sub.add_parser("report", parents=[common, detect],
|
|
380
|
+
help="Flaky / broken / stable / retired tables.")
|
|
381
|
+
rp.add_argument("--format", choices=["markdown", "json"],
|
|
382
|
+
default="markdown")
|
|
383
|
+
|
|
384
|
+
q = sub.add_parser("quarantine", parents=[common, detect],
|
|
385
|
+
help="Quarantine the flaky set.")
|
|
386
|
+
q.add_argument("--format", choices=["pytest", "json", "deselect"],
|
|
387
|
+
default="pytest")
|
|
388
|
+
q.add_argument("-o", "--out", default=".",
|
|
389
|
+
help="Output dir for --format pytest.")
|
|
390
|
+
q.add_argument("--mode", choices=list(quar_mod.MODES), default="skip",
|
|
391
|
+
help="pytest plugin behaviour for quarantined tests: "
|
|
392
|
+
"skip them (default) or run them as non-strict "
|
|
393
|
+
"xfail so they keep recording results")
|
|
394
|
+
|
|
395
|
+
u = sub.add_parser("unquarantine", parents=[common], help="Release a test.")
|
|
396
|
+
u.add_argument("--test", required=True, type=_utf8)
|
|
397
|
+
|
|
398
|
+
h = sub.add_parser("history", parents=[common, detect],
|
|
399
|
+
help="Per-test run history (same thresholds as "
|
|
400
|
+
"report, so the status word agrees).")
|
|
401
|
+
h.add_argument("--test", required=True, type=_utf8)
|
|
402
|
+
h.add_argument("--limit", type=_int64, default=50)
|
|
403
|
+
|
|
404
|
+
e = sub.add_parser("export", parents=[common],
|
|
405
|
+
help="Write the whole DB (runs, results, quarantine) "
|
|
406
|
+
"as one JSON file - a CI artifact that "
|
|
407
|
+
"`flakebox import` merges locally.")
|
|
408
|
+
e.add_argument("--out", required=True, metavar="FILE")
|
|
409
|
+
|
|
410
|
+
i = sub.add_parser("import", parents=[common],
|
|
411
|
+
help="Merge one or more `flakebox export` files into "
|
|
412
|
+
"the DB; runs already present (same commit and "
|
|
413
|
+
"report) are skipped.")
|
|
414
|
+
i.add_argument("files", nargs="+", metavar="FILE")
|
|
415
|
+
return p
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# Tests patch this to exercise the Windows branch on any OS.
|
|
419
|
+
_WINDOWS = os.name == "nt"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _pipe_gone(exc: BaseException) -> bool:
|
|
423
|
+
"""Did the reader of our stdout go away?
|
|
424
|
+
|
|
425
|
+
POSIX raises BrokenPipeError (EPIPE). Windows reports the same thing as
|
|
426
|
+
a plain OSError with errno EINVAL and no file name (measured on Windows
|
|
427
|
+
11 / Python 3.11: errno 22, winerror None), raised by the FLUSH rather
|
|
428
|
+
than by the print, and it fails a second time at interpreter exit
|
|
429
|
+
(status 120) unless fd 1 is pointed at the null device first."""
|
|
430
|
+
if isinstance(exc, BrokenPipeError):
|
|
431
|
+
return True
|
|
432
|
+
if not isinstance(exc, OSError):
|
|
433
|
+
return False
|
|
434
|
+
if exc.errno == errno.EPIPE:
|
|
435
|
+
return True
|
|
436
|
+
return (_WINDOWS and exc.errno == errno.EINVAL
|
|
437
|
+
and getattr(exc, "filename", None) is None)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _broken_pipe() -> int:
|
|
441
|
+
"""stdout's reader went away (`flakebox report | head`): leave
|
|
442
|
+
quietly. stdout is pointed at the null device first so the
|
|
443
|
+
interpreter's exit-time flush cannot raise the same error again."""
|
|
444
|
+
try:
|
|
445
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
446
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
447
|
+
except (OSError, ValueError, AttributeError):
|
|
448
|
+
pass
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main(argv=None) -> int:
|
|
453
|
+
_harden_streams()
|
|
454
|
+
args = build_parser().parse_args(argv)
|
|
455
|
+
cmds = {"record": cmd_record, "report": cmd_report,
|
|
456
|
+
"quarantine": cmd_quarantine, "unquarantine": cmd_unquarantine,
|
|
457
|
+
"history": cmd_history, "export": cmd_export,
|
|
458
|
+
"import": cmd_import}
|
|
459
|
+
try:
|
|
460
|
+
rc = cmds[args.cmd](args)
|
|
461
|
+
if sys.stdout is not None:
|
|
462
|
+
sys.stdout.flush() # a closed pipe surfaces here, not at interpreter exit
|
|
463
|
+
return rc
|
|
464
|
+
except sqlite3.DatabaseError as e:
|
|
465
|
+
print(f"error: cannot use database {args.db or default_db()}: {e}",
|
|
466
|
+
file=sys.stderr)
|
|
467
|
+
return 2
|
|
468
|
+
except OSError as e:
|
|
469
|
+
if _pipe_gone(e):
|
|
470
|
+
return _broken_pipe()
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
sys.exit(main())
|
flakebox/detect.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Flake detection: statistical, explainable, no ML.
|
|
2
|
+
|
|
3
|
+
Definitions (skipped runs never count as evidence either way):
|
|
4
|
+
|
|
5
|
+
- executed runs = passes + failures (``failed`` and ``error`` both fail)
|
|
6
|
+
- FLAKY = at least one pass AND at least one failure across the window,
|
|
7
|
+
over at least ``min_runs`` executed runs, and not currently on
|
|
8
|
+
a pass streak of ``stable_runs``.
|
|
9
|
+
- RECOVERED = would be flaky, but the newest ``stable_runs`` executed
|
|
10
|
+
runs all passed: it has recovered and is left alone (not
|
|
11
|
+
re-quarantined); ``report`` and ``history`` both say so.
|
|
12
|
+
- FAILING = executed at least ``min_runs`` times and never passed.
|
|
13
|
+
Broken, not flaky - a different problem, don't quarantine it.
|
|
14
|
+
- PASSING = executed at least ``min_runs`` times and never failed.
|
|
15
|
+
- STABLE = a quarantined test whose newest ``stable_runs`` executed runs
|
|
16
|
+
all passed - the report tells you to un-quarantine it.
|
|
17
|
+
- RETIRED = a test with history that appears in none of the newest
|
|
18
|
+
``retire_after`` runs: deleted or renamed. Listed on its own
|
|
19
|
+
and otherwise left out, so a test removed from the suite
|
|
20
|
+
does not stay "consistently failing" for fifty more builds.
|
|
21
|
+
|
|
22
|
+
"Newest" is by the run's ``created_at`` (the row id only breaks a tie), so
|
|
23
|
+
importing an older export after newer local runs changes no verdict.
|
|
24
|
+
|
|
25
|
+
classify() is the one place these words are decided; report, history
|
|
26
|
+
and the JSON output all go through it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from . import store as store_mod
|
|
32
|
+
|
|
33
|
+
FLAKY, FAILING, PASSING, UNKNOWN = "flaky", "failing", "passing", "unknown"
|
|
34
|
+
RECOVERED = "recovered"
|
|
35
|
+
FAIL_OUTCOMES = ("failed", "error")
|
|
36
|
+
RETIRE_AFTER = 3 # absent from this many newest runs -> retired
|
|
37
|
+
STABLE_RUNS = 10 # consecutive newest passes that make a test stable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(passes: int, failures: int, pass_streak: int = 0,
|
|
41
|
+
stable_runs: int = 0) -> str:
|
|
42
|
+
"""The status word for a test's executed runs (no min-runs
|
|
43
|
+
threshold). With `stable_runs` > 0, a pass-and-fail history whose
|
|
44
|
+
newest `stable_runs` executed runs all passed is RECOVERED rather
|
|
45
|
+
than FLAKY - the same call `report` makes, so `history` agrees."""
|
|
46
|
+
if passes and failures:
|
|
47
|
+
if stable_runs and pass_streak >= stable_runs:
|
|
48
|
+
return RECOVERED
|
|
49
|
+
return FLAKY
|
|
50
|
+
if failures:
|
|
51
|
+
return FAILING
|
|
52
|
+
if passes:
|
|
53
|
+
return PASSING
|
|
54
|
+
return UNKNOWN
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stats(outcomes_newest_first: list[str], stable_runs: int = 0) -> dict:
|
|
58
|
+
"""Pass/fail/skip counts, fail rate over executed runs, the current
|
|
59
|
+
pass streak (consecutive newest passes; a skip neither extends nor
|
|
60
|
+
breaks it) and the status from classify()."""
|
|
61
|
+
passes = sum(1 for o in outcomes_newest_first if o == "passed")
|
|
62
|
+
fails = sum(1 for o in outcomes_newest_first if o in FAIL_OUTCOMES)
|
|
63
|
+
skips = sum(1 for o in outcomes_newest_first if o == "skipped")
|
|
64
|
+
streak = 0
|
|
65
|
+
for o in outcomes_newest_first:
|
|
66
|
+
if o == "passed":
|
|
67
|
+
streak += 1
|
|
68
|
+
elif o == "skipped":
|
|
69
|
+
continue
|
|
70
|
+
else:
|
|
71
|
+
break
|
|
72
|
+
executed = passes + fails
|
|
73
|
+
return {
|
|
74
|
+
"runs": executed, # executed runs (skips excluded)
|
|
75
|
+
"recorded": len(outcomes_newest_first),
|
|
76
|
+
"passes": passes,
|
|
77
|
+
"failures": fails,
|
|
78
|
+
"skips": skips,
|
|
79
|
+
"flake_rate": (fails / executed) if executed else 0.0,
|
|
80
|
+
"pass_streak": streak,
|
|
81
|
+
"status": classify(passes, fails, streak, stable_runs),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def summarize(conn, test_id: str, window: int = 50,
|
|
86
|
+
retire_after: int = RETIRE_AFTER,
|
|
87
|
+
stable_runs: int = STABLE_RUNS) -> dict:
|
|
88
|
+
"""One test's stats with the same status word `report` would use
|
|
89
|
+
(`flakebox history`)."""
|
|
90
|
+
hist = store_mod.test_history(conn, test_id, limit=window) # newest first
|
|
91
|
+
s = stats([h["outcome"] for h in hist], stable_runs)
|
|
92
|
+
s["test_id"] = test_id
|
|
93
|
+
s["quarantined"] = store_mod.is_quarantined(conn, test_id)
|
|
94
|
+
# retire_after 0 disables retirement, as it does for analyze(): with no
|
|
95
|
+
# recent runs to look in, every test would otherwise be "absent"
|
|
96
|
+
s["retired"] = (retire_after > 0 and bool(hist)
|
|
97
|
+
and test_id not in store_mod.active_test_ids(
|
|
98
|
+
conn, retire_after))
|
|
99
|
+
return s
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def analyze(conn, min_runs: int = 5, stable_runs: int = STABLE_RUNS,
|
|
103
|
+
window: int = 50, retire_after: int = RETIRE_AFTER) -> dict:
|
|
104
|
+
"""Return {flaky: [...], broken: [...], stable: [...], retired: [...]}
|
|
105
|
+
ranked lists.
|
|
106
|
+
|
|
107
|
+
Single batched query (the most recent `window` runs per test, via
|
|
108
|
+
ROW_NUMBER over (created_at, id) - see store.NEWEST_FIRST), then the
|
|
109
|
+
counts from stats() in Python - no per-test N+1 history fetches.
|
|
110
|
+
Iteration order is first appearance in the results table, which is the
|
|
111
|
+
order of the unsorted `stable` list. Tests absent from the newest
|
|
112
|
+
`retire_after` runs go to `retired` (with where they were last seen)
|
|
113
|
+
and nowhere else.
|
|
114
|
+
"""
|
|
115
|
+
cur = conn.cursor()
|
|
116
|
+
cur.execute(
|
|
117
|
+
"SELECT test_id, outcome, run_id, created_at, first_seen FROM ("
|
|
118
|
+
" SELECT r.test_id AS test_id, r.outcome AS outcome,"
|
|
119
|
+
" r.run_id AS run_id, runs.created_at AS created_at,"
|
|
120
|
+
" ROW_NUMBER() OVER (PARTITION BY r.test_id"
|
|
121
|
+
f" ORDER BY {store_mod.NEWEST_FIRST}"
|
|
122
|
+
" ) AS rn,"
|
|
123
|
+
" MIN(r.rowid) OVER (PARTITION BY r.test_id) AS first_seen"
|
|
124
|
+
" FROM results r JOIN runs ON r.run_id = runs.id"
|
|
125
|
+
") WHERE rn <= ? ORDER BY test_id, rn",
|
|
126
|
+
(window,))
|
|
127
|
+
# outcomes per test, newest-run first (rn 1..window); the first row of
|
|
128
|
+
# a test is its newest run
|
|
129
|
+
by_test: dict[str, tuple[list[str], int, int, int | None]] = {}
|
|
130
|
+
for test_id, outcome, run_id, at, first_seen in cur.fetchall():
|
|
131
|
+
if test_id in by_test:
|
|
132
|
+
by_test[test_id][0].append(outcome)
|
|
133
|
+
else:
|
|
134
|
+
by_test[test_id] = ([outcome], first_seen, run_id, at)
|
|
135
|
+
qcur = conn.cursor()
|
|
136
|
+
qcur.execute("SELECT test_id FROM quarantine")
|
|
137
|
+
quarantined_ids = {row[0] for row in qcur.fetchall()}
|
|
138
|
+
recent = set(store_mod.recent_run_ids(conn, retire_after))
|
|
139
|
+
flaky, broken, stable, retired = [], [], [], []
|
|
140
|
+
last_seen_at: dict[str, int | None] = {}
|
|
141
|
+
for test_id, (outcomes, _first_seen, last_run, last_at) in sorted(
|
|
142
|
+
by_test.items(), key=lambda kv: kv[1][1]):
|
|
143
|
+
s = stats(outcomes, stable_runs)
|
|
144
|
+
s["test_id"] = test_id
|
|
145
|
+
s["quarantined"] = test_id in quarantined_ids
|
|
146
|
+
if recent and last_run not in recent: # none of its runs is recent
|
|
147
|
+
s["retired"] = True
|
|
148
|
+
s["last_run"] = last_run
|
|
149
|
+
last_seen_at[test_id] = last_at
|
|
150
|
+
retired.append(s)
|
|
151
|
+
continue
|
|
152
|
+
s["retired"] = False
|
|
153
|
+
if s["runs"] < min_runs:
|
|
154
|
+
continue # not enough executed runs to say anything
|
|
155
|
+
if s["status"] == FLAKY: # classify() already set RECOVERED aside
|
|
156
|
+
flaky.append(s)
|
|
157
|
+
elif s["status"] == FAILING:
|
|
158
|
+
broken.append(s)
|
|
159
|
+
if s["quarantined"] and s["pass_streak"] >= stable_runs:
|
|
160
|
+
stable.append(s)
|
|
161
|
+
flaky.sort(key=lambda s: (-s["flake_rate"], -s["failures"]))
|
|
162
|
+
broken.sort(key=lambda s: -s["failures"])
|
|
163
|
+
never = float("-inf") # a run without a timestamp is the oldest
|
|
164
|
+
retired.sort(key=lambda s: (
|
|
165
|
+
-(never if last_seen_at[s["test_id"]] is None
|
|
166
|
+
else last_seen_at[s["test_id"]]), -s["last_run"], s["test_id"]))
|
|
167
|
+
if retired:
|
|
168
|
+
commits = dict(conn.execute(
|
|
169
|
+
"SELECT id, commit_sha FROM runs WHERE id IN (%s)"
|
|
170
|
+
% ",".join("?" * len(retired)),
|
|
171
|
+
[s["last_run"] for s in retired]).fetchall())
|
|
172
|
+
for s in retired:
|
|
173
|
+
s["last_commit"] = commits.get(s["last_run"])
|
|
174
|
+
return {"flaky": flaky, "broken": broken, "stable": stable,
|
|
175
|
+
"retired": retired}
|