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/store.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""SQLite store: runs, per-test results, quarantine set; portable JSON
|
|
2
|
+
export/import so history can travel between CI caches and laptops."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
SCHEMA = """
|
|
14
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
commit_sha TEXT,
|
|
17
|
+
branch TEXT,
|
|
18
|
+
created_at INTEGER,
|
|
19
|
+
report_sha TEXT
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS results (
|
|
22
|
+
run_id INTEGER,
|
|
23
|
+
test_id TEXT,
|
|
24
|
+
outcome TEXT,
|
|
25
|
+
duration_ms INTEGER,
|
|
26
|
+
message TEXT
|
|
27
|
+
);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_results_test ON results(test_id);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_results_run ON results(run_id);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS quarantine (
|
|
31
|
+
test_id TEXT PRIMARY KEY,
|
|
32
|
+
quarantined_at INTEGER,
|
|
33
|
+
reason TEXT
|
|
34
|
+
);
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
EXPORT_FORMAT = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def connect(db_path: str) -> sqlite3.Connection:
|
|
41
|
+
"""Open (creating/migrating as needed). A file that is not a SQLite
|
|
42
|
+
database raises sqlite3.DatabaseError - the CLI turns that into a
|
|
43
|
+
clean error exit."""
|
|
44
|
+
if db_path != ":memory:":
|
|
45
|
+
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
|
46
|
+
conn = sqlite3.connect(db_path)
|
|
47
|
+
conn.executescript(SCHEMA)
|
|
48
|
+
_migrate(conn)
|
|
49
|
+
# concurrent writers on stock SQLite: wait instead of immediately
|
|
50
|
+
# raising "database is locked", and use WAL so readers don't block
|
|
51
|
+
# writers (no-op on :memory:, which has no journal file).
|
|
52
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
53
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
54
|
+
return conn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
58
|
+
"""DBs recorded by flakebox < 0.3 have no report_sha column; runs
|
|
59
|
+
recorded without a report hash (older flakebox, or the API) get a
|
|
60
|
+
content fingerprint so export/import can recognise them."""
|
|
61
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)")}
|
|
62
|
+
if "report_sha" not in cols:
|
|
63
|
+
conn.execute("ALTER TABLE runs ADD COLUMN report_sha TEXT")
|
|
64
|
+
conn.commit()
|
|
65
|
+
fingerprint_legacy_runs(conn)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
LEGACY_PREFIX = "legacy:"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def legacy_fingerprint(commit_sha, created_at, results) -> str:
|
|
72
|
+
"""Stand-in for the report hash of a run recorded without one: a
|
|
73
|
+
digest of commit + timestamp + the results, so the same run exported
|
|
74
|
+
and imported again is recognised. `results` are
|
|
75
|
+
[test_id, outcome, duration_ms, message] rows in recorded order.
|
|
76
|
+
Prefixed so it can never collide with a real report hash."""
|
|
77
|
+
body = json.dumps([commit_sha, created_at,
|
|
78
|
+
[list(r) for r in results]],
|
|
79
|
+
sort_keys=True, separators=(",", ":"))
|
|
80
|
+
return LEGACY_PREFIX + hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fingerprint_legacy_runs(conn: sqlite3.Connection) -> int:
|
|
84
|
+
"""Give every run without a report_sha its fingerprint. Returns how
|
|
85
|
+
many were stamped (normally 0: a quick NULL scan)."""
|
|
86
|
+
rows = conn.execute("SELECT id, commit_sha, created_at FROM runs "
|
|
87
|
+
"WHERE report_sha IS NULL").fetchall()
|
|
88
|
+
for run_id, commit, at in rows:
|
|
89
|
+
results = conn.execute(
|
|
90
|
+
"SELECT test_id, outcome, duration_ms, message FROM results "
|
|
91
|
+
"WHERE run_id = ? ORDER BY rowid", (run_id,)).fetchall()
|
|
92
|
+
conn.execute("UPDATE runs SET report_sha = ? WHERE id = ?",
|
|
93
|
+
(legacy_fingerprint(commit, at, results), run_id))
|
|
94
|
+
if rows:
|
|
95
|
+
conn.commit()
|
|
96
|
+
return len(rows)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_run(conn: sqlite3.Connection, commit_sha: str | None,
|
|
100
|
+
report_sha: str | None) -> int | None:
|
|
101
|
+
"""The id of a run already recorded from the same report (same
|
|
102
|
+
commit, same report bytes), or None."""
|
|
103
|
+
if report_sha is None:
|
|
104
|
+
return None
|
|
105
|
+
row = conn.execute(
|
|
106
|
+
"SELECT id FROM runs WHERE commit_sha IS ? AND report_sha = ? "
|
|
107
|
+
"ORDER BY id LIMIT 1", (commit_sha, report_sha)).fetchone()
|
|
108
|
+
return row[0] if row else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def record_run(conn: sqlite3.Connection, results: list[dict],
|
|
112
|
+
commit_sha: str | None = None,
|
|
113
|
+
branch: str | None = None,
|
|
114
|
+
report_sha: str | None = None,
|
|
115
|
+
created_at: int | None = None) -> int:
|
|
116
|
+
at = int(time.time()) if created_at is None else int(created_at)
|
|
117
|
+
rows = [(r["test_id"], r["outcome"], r["duration_ms"], r["message"])
|
|
118
|
+
for r in results]
|
|
119
|
+
if report_sha is None: # no report bytes to hash: fingerprint the content
|
|
120
|
+
report_sha = legacy_fingerprint(commit_sha, at, rows)
|
|
121
|
+
cur = conn.cursor()
|
|
122
|
+
cur.execute("INSERT INTO runs (commit_sha, branch, created_at, report_sha) "
|
|
123
|
+
"VALUES (?, ?, ?, ?)", (commit_sha, branch, at, report_sha))
|
|
124
|
+
run_id = cur.lastrowid
|
|
125
|
+
cur.executemany(
|
|
126
|
+
"INSERT INTO results (run_id, test_id, outcome, duration_ms, message)"
|
|
127
|
+
" VALUES (?, ?, ?, ?, ?)", [(run_id,) + row for row in rows])
|
|
128
|
+
conn.commit()
|
|
129
|
+
return run_id
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Recency. "Newest" means the newest `created_at`, and the row id only
|
|
133
|
+
# breaks a tie: `flakebox import` appends rows, so an older export imported
|
|
134
|
+
# after newer local runs gets the HIGHER ids, and ordering by id would make
|
|
135
|
+
# its old runs look like the latest (a test that ran in every recent run
|
|
136
|
+
# reported retired, a recovered one flaky). A run without a timestamp
|
|
137
|
+
# (NULL) sorts as the oldest.
|
|
138
|
+
NEWEST_FIRST = "runs.created_at DESC, runs.id DESC"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_history(conn: sqlite3.Connection, test_id: str,
|
|
142
|
+
limit: int = 50) -> list[dict]:
|
|
143
|
+
"""One test's results, newest run first (see NEWEST_FIRST)."""
|
|
144
|
+
cur = conn.cursor()
|
|
145
|
+
cur.execute(
|
|
146
|
+
"SELECT r.outcome, r.duration_ms, runs.commit_sha, runs.created_at"
|
|
147
|
+
" FROM results r JOIN runs ON r.run_id = runs.id"
|
|
148
|
+
f" WHERE r.test_id = ? ORDER BY {NEWEST_FIRST} LIMIT ?",
|
|
149
|
+
(test_id, limit))
|
|
150
|
+
return [{"outcome": o, "duration_ms": d, "commit": c, "at": t}
|
|
151
|
+
for o, d, c, t in cur.fetchall()]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def recent_run_ids(conn: sqlite3.Connection, n: int) -> list[int]:
|
|
155
|
+
"""Ids of the newest `n` recorded runs (newest first, see
|
|
156
|
+
NEWEST_FIRST)."""
|
|
157
|
+
cur = conn.execute(f"SELECT id FROM runs ORDER BY {NEWEST_FIRST} "
|
|
158
|
+
f"LIMIT ?", (max(int(n), 0),))
|
|
159
|
+
return [row[0] for row in cur.fetchall()]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def active_test_ids(conn: sqlite3.Connection, retire_after: int) -> set[str]:
|
|
163
|
+
"""Tests that appeared in at least one of the newest `retire_after`
|
|
164
|
+
runs. Anything else with history is retired: deleted or renamed, and
|
|
165
|
+
must not be reported as failing forever."""
|
|
166
|
+
ids = recent_run_ids(conn, retire_after)
|
|
167
|
+
if not ids:
|
|
168
|
+
return set()
|
|
169
|
+
marks = ",".join("?" * len(ids))
|
|
170
|
+
cur = conn.execute(
|
|
171
|
+
f"SELECT DISTINCT test_id FROM results WHERE run_id IN ({marks})", ids)
|
|
172
|
+
return {row[0] for row in cur.fetchall()}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def quarantine_add(conn: sqlite3.Connection, test_id: str,
|
|
176
|
+
reason: str = "", at: int | None = None) -> bool:
|
|
177
|
+
"""Add a test to the quarantine set. Idempotent (set semantics): an
|
|
178
|
+
already-quarantined test keeps its original timestamp and reason.
|
|
179
|
+
Returns True if the test was newly quarantined."""
|
|
180
|
+
cur = conn.execute("INSERT OR IGNORE INTO quarantine "
|
|
181
|
+
"(test_id, quarantined_at, reason) VALUES (?, ?, ?)",
|
|
182
|
+
(test_id, int(time.time()) if at is None else int(at),
|
|
183
|
+
reason))
|
|
184
|
+
conn.commit()
|
|
185
|
+
return cur.rowcount == 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def quarantine_remove(conn: sqlite3.Connection, test_id: str) -> None:
|
|
189
|
+
conn.execute("DELETE FROM quarantine WHERE test_id = ?", (test_id,))
|
|
190
|
+
conn.commit()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def quarantined(conn: sqlite3.Connection) -> list[dict]:
|
|
194
|
+
cur = conn.cursor()
|
|
195
|
+
cur.execute("SELECT test_id, quarantined_at, reason FROM quarantine")
|
|
196
|
+
return [{"test_id": t, "at": a, "reason": r} for t, a, r in cur.fetchall()]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_quarantined(conn: sqlite3.Connection, test_id: str) -> bool:
|
|
200
|
+
cur = conn.cursor()
|
|
201
|
+
cur.execute("SELECT 1 FROM quarantine WHERE test_id = ?", (test_id,))
|
|
202
|
+
return cur.fetchone() is not None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# export / import: the whole DB as one JSON document
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def export_data(conn: sqlite3.Connection) -> dict:
|
|
211
|
+
"""Everything in the DB as plain JSON (runs with their results, and
|
|
212
|
+
the quarantine set). Written by `flakebox export`; read back by
|
|
213
|
+
`flakebox import`, which merges instead of overwriting."""
|
|
214
|
+
runs = []
|
|
215
|
+
cur = conn.execute("SELECT id, commit_sha, branch, created_at, report_sha "
|
|
216
|
+
"FROM runs ORDER BY id")
|
|
217
|
+
for run_id, commit, branch, at, sha in cur.fetchall():
|
|
218
|
+
rows = conn.execute(
|
|
219
|
+
"SELECT test_id, outcome, duration_ms, message FROM results "
|
|
220
|
+
"WHERE run_id = ? ORDER BY rowid", (run_id,)).fetchall()
|
|
221
|
+
runs.append({"commit": commit, "branch": branch, "created_at": at,
|
|
222
|
+
"report_sha": sha,
|
|
223
|
+
"results": [list(r) for r in rows]})
|
|
224
|
+
return {"flakebox_export": EXPORT_FORMAT, "runs": runs,
|
|
225
|
+
"quarantine": quarantined(conn)}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def export_file(conn: sqlite3.Connection, path: str) -> dict:
|
|
229
|
+
data = export_data(conn)
|
|
230
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
231
|
+
json.dump(data, f, indent=1)
|
|
232
|
+
f.write("\n")
|
|
233
|
+
return data
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_INT64 = 2 ** 63 # SQLite INTEGER: -_INT64 <= n < _INT64
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _text(value, where: str, optional: bool = True) -> None:
|
|
240
|
+
"""`value` must be a str (or None, if optional) that UTF-8 can encode:
|
|
241
|
+
JSON allows a lone surrogate, which SQLite cannot store."""
|
|
242
|
+
if value is None and optional:
|
|
243
|
+
return
|
|
244
|
+
if not isinstance(value, str):
|
|
245
|
+
raise ValueError(f"export: {where} has the wrong type "
|
|
246
|
+
f"({type(value).__name__})")
|
|
247
|
+
try:
|
|
248
|
+
value.encode("utf-8")
|
|
249
|
+
except UnicodeEncodeError:
|
|
250
|
+
raise ValueError(f"export: {where} is not valid text "
|
|
251
|
+
f"(a lone surrogate)") from None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _number(value, where: str) -> None:
|
|
255
|
+
"""`value` must be None or a finite int / float (never a bool) that an
|
|
256
|
+
SQLite INTEGER can hold once truncated: NaN, Infinity and 1e300 are
|
|
257
|
+
valid JSON to Python's parser and used to fail halfway through."""
|
|
258
|
+
if value is None:
|
|
259
|
+
return
|
|
260
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
261
|
+
raise ValueError(f"export: {where} has the wrong type "
|
|
262
|
+
f"({type(value).__name__})")
|
|
263
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
264
|
+
raise ValueError(f"export: {where} must be a finite number")
|
|
265
|
+
if not -_INT64 <= int(value) < _INT64:
|
|
266
|
+
raise ValueError(f"export: {where} is out of range")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate_export(data) -> dict:
|
|
270
|
+
"""Check every field of an export document before anything is written;
|
|
271
|
+
a malformed file is a ValueError naming the first bad field, never a
|
|
272
|
+
traceback (or a half-finished import) later. Returns the data."""
|
|
273
|
+
if (not isinstance(data, dict)
|
|
274
|
+
or type(data.get("flakebox_export")) is not int
|
|
275
|
+
or data["flakebox_export"] != EXPORT_FORMAT):
|
|
276
|
+
raise ValueError("not a flakebox export "
|
|
277
|
+
f"(expected \"flakebox_export\": {EXPORT_FORMAT})")
|
|
278
|
+
runs = data.get("runs")
|
|
279
|
+
if runs is None:
|
|
280
|
+
runs = data["runs"] = []
|
|
281
|
+
if not isinstance(runs, list):
|
|
282
|
+
raise ValueError("export: \"runs\" must be a list")
|
|
283
|
+
for i, run in enumerate(runs):
|
|
284
|
+
if not isinstance(run, dict):
|
|
285
|
+
raise ValueError(f"export: runs[{i}] must be an object")
|
|
286
|
+
_text(run.get("commit"), f"runs[{i}].commit")
|
|
287
|
+
_text(run.get("branch"), f"runs[{i}].branch")
|
|
288
|
+
_text(run.get("report_sha"), f"runs[{i}].report_sha")
|
|
289
|
+
_number(run.get("created_at"), f"runs[{i}].created_at")
|
|
290
|
+
results = run.get("results")
|
|
291
|
+
if results is None:
|
|
292
|
+
results = run["results"] = []
|
|
293
|
+
if not isinstance(results, list):
|
|
294
|
+
raise ValueError(f"export: runs[{i}].results must be a list")
|
|
295
|
+
for j, row in enumerate(results):
|
|
296
|
+
where = f"runs[{i}].results[{j}]"
|
|
297
|
+
if (not isinstance(row, (list, tuple)) or len(row) != 4
|
|
298
|
+
or not isinstance(row[0], str) or not isinstance(row[1], str)):
|
|
299
|
+
raise ValueError(f"export: {where} must be "
|
|
300
|
+
f"[test_id, outcome, duration_ms, message]")
|
|
301
|
+
_text(row[0], f"{where}[0] (test_id)", optional=False)
|
|
302
|
+
_text(row[1], f"{where}[1] (outcome)", optional=False)
|
|
303
|
+
_number(row[2], f"{where}[2] (duration_ms)")
|
|
304
|
+
_text(row[3], f"{where}[3] (message)")
|
|
305
|
+
quarantine = data.get("quarantine")
|
|
306
|
+
if quarantine is None:
|
|
307
|
+
quarantine = data["quarantine"] = []
|
|
308
|
+
if not isinstance(quarantine, list):
|
|
309
|
+
raise ValueError("export: \"quarantine\" must be a list")
|
|
310
|
+
for i, q in enumerate(quarantine):
|
|
311
|
+
if not isinstance(q, dict) or not isinstance(q.get("test_id"), str):
|
|
312
|
+
raise ValueError(f"export: quarantine[{i}] must be an object "
|
|
313
|
+
f"with a test_id")
|
|
314
|
+
_text(q["test_id"], f"quarantine[{i}].test_id", optional=False)
|
|
315
|
+
_text(q.get("reason"), f"quarantine[{i}].reason")
|
|
316
|
+
_number(q.get("at"), f"quarantine[{i}].at")
|
|
317
|
+
return data
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def import_data(conn: sqlite3.Connection, data: dict) -> dict:
|
|
321
|
+
"""Merge an export into this DB. A run is skipped when a run from the
|
|
322
|
+
same report (commit + report_sha) is already here, so importing the
|
|
323
|
+
same file twice, or importing into the DB it came from, adds nothing.
|
|
324
|
+
A run exported without a report_sha (recorded by flakebox < 0.3) is
|
|
325
|
+
recognised by its content fingerprint instead. Every run keeps the
|
|
326
|
+
`created_at` it was exported with - that, not the order of arrival,
|
|
327
|
+
decides which runs are the newest - and a run exported without one is
|
|
328
|
+
imported as the oldest (0). Quarantine entries merge as a set. Returns
|
|
329
|
+
counts."""
|
|
330
|
+
validate_export(data)
|
|
331
|
+
added = skipped = 0
|
|
332
|
+
for run in sorted(data["runs"], key=lambda r: (r.get("created_at") or 0)):
|
|
333
|
+
rows = [tuple(r) for r in run["results"]]
|
|
334
|
+
at = run.get("created_at")
|
|
335
|
+
sha = run.get("report_sha") or legacy_fingerprint(run.get("commit"),
|
|
336
|
+
at, rows)
|
|
337
|
+
if find_run(conn, run.get("commit"), sha) is not None:
|
|
338
|
+
skipped += 1
|
|
339
|
+
continue
|
|
340
|
+
results = [{"test_id": t, "outcome": o, "duration_ms": d, "message": m}
|
|
341
|
+
for t, o, d, m in rows]
|
|
342
|
+
record_run(conn, results, run.get("commit"), run.get("branch"),
|
|
343
|
+
report_sha=sha, created_at=0 if at is None else at)
|
|
344
|
+
added += 1
|
|
345
|
+
q_added = 0
|
|
346
|
+
for q in data["quarantine"]:
|
|
347
|
+
if quarantine_add(conn, q["test_id"], q.get("reason") or "",
|
|
348
|
+
at=q.get("at")):
|
|
349
|
+
q_added += 1
|
|
350
|
+
return {"runs_added": added, "runs_skipped": skipped,
|
|
351
|
+
"quarantine_added": q_added}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def import_file(conn: sqlite3.Connection, path: str) -> dict:
|
|
355
|
+
"""Merge the export in `path` into the DB. Raises OSError if the file
|
|
356
|
+
cannot be read, ValueError if it is not a usable export (JSON nested
|
|
357
|
+
deeper than the parser can follow included)."""
|
|
358
|
+
with open(path, encoding="utf-8") as f:
|
|
359
|
+
try:
|
|
360
|
+
data = json.load(f)
|
|
361
|
+
except RecursionError:
|
|
362
|
+
raise ValueError("nested too deeply") from None
|
|
363
|
+
return import_data(conn, data)
|