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/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)