flakebox 0.4.4__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.
flakebox-0.4.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
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 @@
1
+ include action.yml
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.4
2
+ Name: flakebox
3
+ Version: 0.4.4
4
+ Summary: Self-hosted flaky-test triage for small teams.
5
+ Author: Calas Automations Inc.
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/flakebox
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/flakebox/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # Flakebox (`flakebox`)
19
+
20
+ Self-hosted flaky-test triage for small teams. Flaky tests eat 10–25% of
21
+ CI compute and erode trust in every red build. Trunk and Datadog will sell
22
+ you the fix per-seat; flakebox is the free, self-hosted alternative:
23
+ ingest JUnit XML, detect flakiness statistically, quarantine the flakes,
24
+ and get told when they're stable enough to come back. SQLite, stdlib-only,
25
+ no SaaS.
26
+
27
+ ## Install
28
+
29
+ Zero dependencies — Python 3.10+ stdlib only.
30
+
31
+ ```bash
32
+ pip install git+https://github.com/calasdan-sketch/flakebox
33
+ flakebox --version
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ # in CI, after your test step (pytest --junitxml=reports/junit.xml):
40
+ flakebox record --junit reports/junit.xml --commit="$GITHUB_SHA"
41
+
42
+ # what is flaky?
43
+ flakebox report
44
+ flakebox report --format json
45
+
46
+ # quarantine the flaky set for pytest:
47
+ flakebox quarantine --format pytest -o .
48
+ python -m pytest -p flakebox_quarantine # flakes are skipped, build stays green
49
+
50
+ # ...or keep running the flakes without letting them fail the build, so
51
+ # they keep recording results and can earn their way back out:
52
+ flakebox quarantine --format pytest --mode xfail -o .
53
+
54
+ # the full quarantine set as shell-quoted pytest --deselect args (best
55
+ # effort, see below; eval parses the quoting) or as JSON for other runners
56
+ eval "pytest $(flakebox quarantine --format deselect)"
57
+ flakebox quarantine --format json
58
+
59
+ # when a quarantined test stabilizes, the report tells you:
60
+ flakebox unquarantine --test "pkg.Class::test_x"
61
+ flakebox history --test "pkg.Class::test_x" # per-test run history, same
62
+ # thresholds/status words as report
63
+
64
+ # carry the history around (CI artifact -> laptop, job -> aggregator):
65
+ flakebox export --out flakebox-export.json
66
+ flakebox import flakebox-export.json # merges; re-import adds nothing
67
+ ```
68
+
69
+ `--db PATH` picks the database and goes before **or** after the
70
+ subcommand: `flakebox --db ci.db report` and `flakebox report --db ci.db`
71
+ are the same command. Default `~/.flakebox/history.db`
72
+ (`%USERPROFILE%\.flakebox\history.db` on Windows). A `--db` that is not a
73
+ SQLite database is a clean `error: cannot use database ...`, exit 2.
74
+ So are the other arguments flakebox cannot use: each ends in an `error:`
75
+ line and exit 2, never a traceback. That covers an `-o` that is a file or
76
+ cannot be created, an integer option that reaches the database
77
+ (`--retire-after`, `history --limit`) outside the 64-bit range SQLite
78
+ stores (-9223372036854775808 to 9223372036854775807), and a `--commit`,
79
+ `--branch` or `--test` that is not valid UTF-8 text (a stray byte in the
80
+ command line).
81
+
82
+ `flakebox record` hashes the report: recording the same file for the
83
+ same commit twice says `already recorded as run N` and adds nothing
84
+ (`--force` records it anyway), so a retried CI step cannot double-count.
85
+ Nested `<testsuite>` elements (surefire aggregates) are read, `time="0,5"`
86
+ is 500 ms, and a `time` that is not a finite, non-negative number
87
+ (`fast`, `inf`, `nan`, `-5`, `1e300`) is recorded as 0 with a warning
88
+ instead of rejecting the report. A report that declares a `DOCTYPE` or an
89
+ `ENTITY` is refused (exit 2) as a possible XML bomb, before anything is
90
+ expanded; text that merely mentions `<!DOCTYPE html>`, such as captured
91
+ output in a CDATA section, is fine.
92
+
93
+ A report with no test cases at all (`<testsuite tests="0"/>`, or a file
94
+ written before the tests ran) is not recorded. It is nearly always a test
95
+ step that broke, and a run without tests makes every test look absent:
96
+ three of them in a row retire the whole suite, and `report` says "No flaky
97
+ tests detected". `record` prints a warning on stderr and exits 0, so a
98
+ build that really ran no tests (docs only) is not failed. `--allow-empty`
99
+ records the report anyway; it then counts like any other run. Empty runs
100
+ that an earlier flakebox recorded stay in the history (there is no
101
+ command to delete a run) and stop mattering once `--retire-after` (3) real
102
+ runs have followed, or at once with `--retire-after 0`.
103
+
104
+ `flakebox quarantine` is idempotent and safe to run on every build: it
105
+ adds the current flaky set (never re-stamping tests already in
106
+ quarantine) and always emits the **whole** quarantine set, not just the
107
+ newly added tests. The one-line summary (`quarantined 1 new test(s); 3
108
+ in quarantine.`) goes to stderr for `--format deselect`, so stdout is
109
+ only ever the `--deselect` arguments.
110
+
111
+ Run the plugin with `python -m pytest -p flakebox_quarantine`, not as the
112
+ bare `pytest` command: `-p` imports `flakebox_quarantine` at start-up, and
113
+ only `python -m pytest` puts the current directory (where `-o .` wrote the
114
+ plugin) on `sys.path`. The bare command fails with an `ImportError` (`No
115
+ module named 'flakebox_quarantine'`). If `-o` wrote the plugin to another
116
+ directory, put that directory on `PYTHONPATH` first
117
+ (`PYTHONPATH=ci python -m pytest -p flakebox_quarantine`; `set
118
+ PYTHONPATH=ci` in `cmd.exe`, `$env:PYTHONPATH = "ci"` in PowerShell); with
119
+ it set, the bare `pytest` command works too.
120
+
121
+ `--format deselect` is **best effort**: JUnit ids are not pytest node
122
+ ids, so flakebox maps the common pytest shapes
123
+ (`pkg.module.Class::test_x` → `pkg/module.py::Class::test_x`,
124
+ `pkg.module::test_x` → `pkg/module.py::test_x`) and pytest ignores a
125
+ `--deselect` that matches nothing. A lowercase class name, a
126
+ `python_files` pattern other than `*.py`, or a non-pytest producer breaks
127
+ the guess, and pytest's `--deselect` is a *prefix* match (`--deselect
128
+ t.py::test_x` also drops `test_xy` and `test_x[1]`); the generated plugin
129
+ (`--format pytest`) matches whole names and path components instead (see
130
+ below) and is the reliable path. The line quotes each argument for
131
+ the shell (POSIX quoting; `cmd.exe` quoting on Windows), so the shell
132
+ has to parse it once more: `eval "pytest $(flakebox quarantine --format
133
+ deselect)"` hands pytest every id as one argument, even
134
+ `test_param[a b]` or one containing `$(...)`. A bare `pytest
135
+ $(flakebox quarantine --format deselect)` does not: command
136
+ substitution splits the output at whitespace and never removes the
137
+ quotes in it, so an id with a space or a shell character arrives in
138
+ pieces with the quotes still attached (ids without either are fine).
139
+ PowerShell has no `$(...)` word splitting, so `Invoke-Expression "pytest
140
+ $(flakebox quarantine --format deselect)"` is the counterpart of `eval`,
141
+ but it is **untested**: the Windows line is quoted for `cmd.exe`, which
142
+ leaves `;`, `&`, `,`, `$`, backticks and parentheses bare, and PowerShell
143
+ may parse those itself (an id that contains one may be split, or even
144
+ run). If an id can contain one, use the plugin.
145
+
146
+ On Windows the `--format deselect` line is written with a plain LF ending,
147
+ not CR LF: text-mode output would end the last id with a carriage return,
148
+ which Git-Bash's `$(...)` keeps (the id then matches nothing). That is
149
+ simulated in the tests with a text stream that writes CR LF; it has not
150
+ been run on Windows.
151
+
152
+ The plugin matches a test to a quarantined id by name and by whole path
153
+ components, in either spelling (`tests/test_mod.py::TestA::test_x` and
154
+ `tests.test_mod.TestA::test_x` are the same test): the names, with any
155
+ `[parameters]`, must be equal, and so must the module path and class
156
+ names, except that one side may lack leading directories, because CI and
157
+ your machine can have different rootdirs. So `test_mod.py::test_x` is the
158
+ quarantined `tests.test_mod::test_x`, but `pkg_a/test_util.py::test_x` is
159
+ never `pkg_b/test_util.py::test_x`. When the *recorded* id is the shorter
160
+ one, it cannot say which directory it came from and every test that ends
161
+ the same way matches: record and test from the same rootdir. Parametrised
162
+ ids are matched whole: quarantining `test_version[1.5]` skips exactly that
163
+ case, never `test_unrelated[0.5]` or `test_version[2.0]`.
164
+
165
+ Example report:
166
+
167
+ ```
168
+ ## Flaky (pass AND fail — quarantine candidates)
169
+ | test | flake rate | fails/passes | runs |
170
+ |---|---|---|---|
171
+ | `tests.test_a.TestA::test_flaky` | 50% | 3/3 | 6 |
172
+
173
+ ## Consistently failing (broken, not flaky — fix, don't quarantine)
174
+ - `tests.test_b::test_broken` (6 failures, 0 passes)
175
+
176
+ ## Stable — ready to un-quarantine
177
+ - `tests.test_c::test_old_flake` (12 consecutive passes)
178
+
179
+ ## Retired (absent from the last 3 runs — deleted or renamed, not counted)
180
+ - `tests.test_d::test_removed` (was failing; last seen run 41, commit 9f3c2a1)
181
+ ```
182
+
183
+ ## How detection works
184
+
185
+ No ML, fully explainable. Only *executed* runs count — a skipped run is
186
+ not evidence either way, so it never pushes a test over `--min-runs` and
187
+ never breaks a pass streak. "Newest" always means the newest recorded
188
+ time of a run (its row id only breaks a tie), so merging an older export
189
+ into a DB never makes old runs look recent.
190
+
191
+ - **flaky**: at least one pass *and* at least one failure (`failed` or
192
+ `error`) across ≥5 executed runs (`--min-runs`), and not currently on
193
+ a pass streak of ≥10 (`--stable-runs`).
194
+ - **recovered**: would be flaky, but the newest ≥10 executed runs all
195
+ passed — it has recovered and is left alone, so quarantining it,
196
+ releasing it and re-quarantining it from the same stale failures can't
197
+ loop. `report` leaves it out of the flaky table and `flakebox history`
198
+ says `recovered (18 consecutive passes)`: one `classify()` decides the
199
+ word for every command, and `history` takes the same `--min-runs`,
200
+ `--stable-runs` and `--retire-after` flags as `report`.
201
+ - **failing** (the "Consistently failing" section): ≥5 executed runs and
202
+ never a pass. Broken, not flaky — different problem, don't quarantine
203
+ it. `flakebox history` labels it `failing` and shows no flake rate.
204
+ - **stable**: a quarantined test whose newest ≥10 executed runs all
205
+ passed — the report tells you to un-quarantine it.
206
+ - **retired**: a test with history that appears in none of the newest 3
207
+ recorded runs (`--retire-after`, `0` disables) — deleted or renamed.
208
+ It is listed on its own with where it was last seen and counted
209
+ nowhere else, so a test you removed does not stay "consistently
210
+ failing" for the rest of its 50-run window. One or two partial runs
211
+ (someone recorded a single file) do not retire anything. A retired
212
+ test still in quarantine is flagged with the `unquarantine` command
213
+ to run.
214
+ - Everything else (always passing, or too few executed runs) is not
215
+ reported.
216
+
217
+ The flake rate is failures ÷ executed runs; the `runs` column is
218
+ executed runs. History lives in SQLite (`--db` to override).
219
+
220
+ ### Quarantine modes
221
+
222
+ The generated pytest plugin reads `flakebox_quarantine.json` (the sorted
223
+ ids, the mode and a count; no timestamp, so regenerating an unchanged set
224
+ rewrites identical files and `git` shows no diff):
225
+
226
+ - `--mode skip` (default): quarantined tests are skipped. They record no
227
+ result, so they cannot become *stable* on their own — run them
228
+ somewhere non-blocking (a separate job) and `flakebox record` that, or
229
+ use xfail mode.
230
+ - `--mode xfail`: quarantined tests still run as non-strict `xfail`,
231
+ so they can never fail the build, but their real pass/fail lands in
232
+ the JUnit report (`flakebox record` maps the plugin's xfail back to a
233
+ failure) and the report can tell you when they have stabilised.
234
+
235
+ ### Export / import
236
+
237
+ `flakebox export --out FILE` writes the whole DB (runs with results, and
238
+ the quarantine set) as one JSON document. `flakebox import FILE...`
239
+ merges exports into a DB: a run already present (same commit and same
240
+ report hash) is skipped, so importing the same file twice, or into the
241
+ DB it came from, adds nothing; quarantine entries merge as a set. Runs
242
+ recorded without a report hash (flakebox < 0.3, or through the Python
243
+ API) carry a content **fingerprint** (`legacy:` + a digest of commit,
244
+ timestamp and results) instead, stamped when the DB is opened, so they
245
+ are recognised too. Imported runs keep their original timestamp, and
246
+ the newest-runs windows (the pass streak, `--retire-after`, `history`)
247
+ follow timestamps, not the order rows were added; a run exported
248
+ without a timestamp is imported as the oldest. A malformed export is
249
+ refused as a whole before anything is written: every field is checked
250
+ first (types, finite numbers that fit a 64-bit integer, text UTF-8 can
251
+ encode, `"flakebox_export": 1` exactly) and the error names the first bad
252
+ one (`error: cannot import ...: runs[0] must be an object`, exit 2).
253
+ A path that cannot be read (a directory, no permission) and JSON nested
254
+ too deeply to parse are the same one-line error and exit 2, and so is an
255
+ `export --out` that cannot be written.
256
+ This is how CI's history reaches a laptop, and how per-job DBs of a
257
+ matrix build become one (see the Action).
258
+
259
+ ## GitHub Action
260
+
261
+ `action.yml` is a composite action: restores the DB from cache, records
262
+ the JUnit report, posts the flakebox report to the job summary, and —
263
+ optionally — quarantines the flaky set and exports the history as
264
+ artifact-ready files.
265
+
266
+ ```yaml
267
+ - uses: calasdan-sketch/flakebox@main
268
+ with:
269
+ junit: reports/junit.xml
270
+ min-runs: "5" # -> flakebox report --min-runs 5
271
+ fail-on-flaky: "true"
272
+ quarantine-output: flakebox/quarantine.json
273
+ export-output: flakebox/history.json
274
+ - uses: actions/upload-artifact@v4
275
+ with:
276
+ name: flakebox
277
+ path: flakebox/
278
+ ```
279
+
280
+ `@main` is this repository's default branch. A `@v1` reference works only once a
281
+ release is tagged `v1`; until then use the branch, or pin a commit SHA.
282
+
283
+ | input | default | what it does |
284
+ |---------------------|------------|-----------------------------------------------|
285
+ | `junit` | (required) | path to the JUnit XML → `flakebox record --junit` (a report with no test cases is warned about, not recorded) |
286
+ | `db-key` | `flakebox` | cache key prefix for the history DB |
287
+ | `min-runs` | `5` | executed runs before "flaky" → `flakebox report --min-runs` |
288
+ | `fail-on-flaky` | `false` | fail the step when flaky tests are found that are not quarantined |
289
+ | `quarantine-output` | (off) | run `flakebox quarantine --format json` after the report and write the whole set here |
290
+ | `export-output` | (off) | `flakebox export --out` the history DB here |
291
+
292
+ **The acknowledgement loop.** The history DB lives in the CI cache, where
293
+ nobody can run `flakebox quarantine` by hand — so on its own,
294
+ `fail-on-flaky: true` fails *every* build from the first flake. With
295
+ `quarantine-output` set, the action quarantines the flaky set in the
296
+ history DB right after the report (before the fail step): a new flake
297
+ fails exactly one build, is acknowledged from the next run on, and the
298
+ JSON file names it. Then, on a laptop:
299
+
300
+ ```bash
301
+ gh run download --name flakebox # quarantine.json + history.json
302
+ flakebox import flakebox/history.json # CI's history, locally
303
+ flakebox quarantine --format pytest -o . # flakebox_quarantine.{py,json}
304
+ git add flakebox_quarantine.py flakebox_quarantine.json && git commit
305
+ ```
306
+
307
+ and run the suite with `python -m pytest -p flakebox_quarantine` (skip
308
+ mode) or `--mode xfail` so the quarantined tests keep reporting and can
309
+ earn their way back out. When the report says a test is stable,
310
+ `flakebox unquarantine --test ...` locally, regenerate the plugin, commit.
311
+
312
+ Input names use dashes everywhere (`min-runs`, never `min_runs`); the test
313
+ suite reads `action.yml` and checks that every referenced input is
314
+ declared, every declared input is used, and every CLI flag it passes
315
+ exists. Matrix builds: see the cache-race note at the top of `action.yml`
316
+ (per-job DBs, then an aggregator job that `flakebox import`s every job's
317
+ export).
318
+
319
+ **No expression ever reaches a shell.** GitHub pastes the value of a
320
+ `${{ ... }}` expression into a `run:` script *before* bash parses it, so a
321
+ branch named `x";touch${IFS}pwned;echo"y` (a legal git ref) would run a
322
+ command in your job. The action passes every input and `github.*` value
323
+ through the step's `env:` and reads it as a quoted variable
324
+ (`"$FB_BRANCH"`), so the shell only ever sees a string. Each option takes
325
+ its value as one word, `--branch="$FB_BRANCH"`, so a value that starts
326
+ with `-` (a ref or a path can) is still that option's value and never the
327
+ start of another option. The test suite scans `action.yml` for `${{`
328
+ inside any `run:` and for an option and its value written as two words,
329
+ and runs each script under bash with hostile values and with values that
330
+ start with `-`. The two `actions/cache` steps are first-party
331
+ and use the `v4` tag, not a commit SHA; pin them in a fork if your policy
332
+ requires it.
333
+
334
+ ## Limitations
335
+
336
+ - **Recency is wall-clock time.** "Newest" is when a run was recorded
337
+ (an imported run keeps the time it was exported with); runs recorded in
338
+ the same second tie and fall back to the order they were added, and
339
+ machines whose clocks disagree order their merged runs by those clocks.
340
+ - **`--format deselect` is a guess** at pytest node ids (see above); the
341
+ generated plugin is the reliable path, `eval` is needed for ids with a
342
+ space or a shell character, and the PowerShell form is untested.
343
+ - **Any DOCTYPE is refused**, harmless or not: JUnit reports never need
344
+ one, and refusing them all keeps the XML-bomb check simple.
345
+ - **The Action's cache steps use the `v4` tag**, not a commit SHA (above).
346
+
347
+ ## Tests
348
+
349
+ ```bash
350
+ python3 -m unittest discover -s tests # no network, stdlib only
351
+ python3 -m pytest -q # same suite
352
+ ```
353
+
354
+ No PyYAML anywhere (the tests read `action.yml` with a tiny flat reader
355
+ and prove `import flakebox` works with `yaml` blocked). Tests point
356
+ `HOME`/`USERPROFILE`/`APPDATA` at a throwaway directory, so they never
357
+ touch your real `~/.flakebox`. A few tests drive real `pytest` as a
358
+ subprocess to prove the mapped `--deselect` ids and the generated plugin
359
+ work on parametrised ids (skipped when pytest is not installed). Runs on Windows 11: stdout/stderr are
360
+ reconfigured to replace unencodable characters (the report's emoji on a
361
+ cp1252 console), and every text file is opened as UTF-8.
362
+
363
+ ## License
364
+
365
+ MIT — see `LICENSE`.