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 ADDED
File without changes
flakebox/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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}