traffic-diff 0.1.0__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.
shadowdiff/replay.py ADDED
@@ -0,0 +1,141 @@
1
+ """Async replay of recorded exchanges against a target, plus run orchestration."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+ from shadowdiff.config import Config
11
+ from shadowdiff.diff import compare
12
+ from shadowdiff.proxy import HOP_BY_HOP, STRIP_FROM_RESPONSE, strip_headers
13
+ from shadowdiff.store import Exchange, Result, Store
14
+
15
+ DEFAULT_TIMEOUT = 10.0
16
+
17
+
18
+ @dataclass
19
+ class Replayed:
20
+ exchange_id: int
21
+ status: int | None
22
+ headers: dict[str, str]
23
+ body: bytes | None
24
+ latency_ms: float | None
25
+ error: str | None = None
26
+
27
+
28
+ async def replay_exchanges(
29
+ exchanges: list[Exchange],
30
+ target: str,
31
+ concurrency: int = 20,
32
+ timeout: float = DEFAULT_TIMEOUT,
33
+ transport: httpx.AsyncBaseTransport | None = None,
34
+ ) -> list[Replayed]:
35
+ """Send each recorded request to TARGET; failures are captured, never raised."""
36
+ sem = asyncio.Semaphore(concurrency)
37
+ # One single-connection client per concurrency slot. A single httpx client
38
+ # with a large pool spends CPU per request proportional to the pool size,
39
+ # which capped replay at ~250 req/s at concurrency 50; small pools scale.
40
+ limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
41
+ async with contextlib.AsyncExitStack() as stack:
42
+ free = [
43
+ await stack.enter_async_context(
44
+ httpx.AsyncClient(
45
+ base_url=target.rstrip("/"), timeout=timeout, limits=limits, transport=transport
46
+ )
47
+ )
48
+ for _ in range(min(concurrency, len(exchanges)))
49
+ ]
50
+
51
+ async def one(ex: Exchange) -> Replayed:
52
+ url = ex.path + (f"?{ex.query}" if ex.query else "")
53
+ async with sem:
54
+ # The semaphore guarantees a free client is available.
55
+ client = free.pop()
56
+ start = time.perf_counter()
57
+ try:
58
+ resp = await client.request(
59
+ ex.method,
60
+ url,
61
+ headers=strip_headers(ex.req_headers, HOP_BY_HOP),
62
+ content=ex.req_body or None,
63
+ )
64
+ except httpx.TimeoutException:
65
+ return Replayed(ex.id, None, {}, None, None, f"timeout after {timeout:g}s")
66
+ except httpx.HTTPError as exc:
67
+ return Replayed(ex.id, None, {}, None, None, f"{type(exc).__name__}: {exc}")
68
+ finally:
69
+ free.append(client)
70
+ latency_ms = (time.perf_counter() - start) * 1000
71
+ return Replayed(
72
+ ex.id,
73
+ resp.status_code,
74
+ strip_headers(resp.headers, STRIP_FROM_RESPONSE),
75
+ resp.content,
76
+ latency_ms,
77
+ )
78
+
79
+ return await asyncio.gather(*(one(ex) for ex in exchanges))
80
+
81
+
82
+ @dataclass
83
+ class RunSummary:
84
+ run_id: int
85
+ total: int
86
+ passed: int
87
+ failed: int
88
+ replay_seconds: float
89
+
90
+ @property
91
+ def requests_per_second(self) -> float:
92
+ return self.total / self.replay_seconds if self.replay_seconds else 0.0
93
+
94
+
95
+ def run_replay(
96
+ store: Store,
97
+ session: str,
98
+ target: str,
99
+ config: Config,
100
+ concurrency: int = 20,
101
+ timeout: float = DEFAULT_TIMEOUT,
102
+ transport: httpx.AsyncBaseTransport | None = None,
103
+ ) -> RunSummary:
104
+ """Replay a recorded session against TARGET, diff every response and store the run."""
105
+ exchanges = store.exchanges(session)
106
+ if not exchanges:
107
+ raise ValueError(f"no recorded exchanges for session {session!r}")
108
+ run_id = store.create_run(session, target, concurrency, total=len(exchanges))
109
+
110
+ start = time.perf_counter()
111
+ try:
112
+ replayed = asyncio.run(replay_exchanges(exchanges, target, concurrency, timeout, transport))
113
+ except Exception:
114
+ store.finish_run(run_id, "failed", done=0, passed=0, failed=0)
115
+ raise
116
+ replay_seconds = time.perf_counter() - start
117
+
118
+ results = []
119
+ for ex, rep in zip(exchanges, replayed, strict=True):
120
+ if rep.error is not None:
121
+ diffs, breaking = [], True
122
+ else:
123
+ diffs = compare(ex.status, ex.resp_body, rep.status, rep.body, config)
124
+ breaking = any(d.breaking for d in diffs)
125
+ results.append(
126
+ Result(
127
+ exchange_id=ex.id,
128
+ status=rep.status,
129
+ resp_body=rep.body,
130
+ latency_ms=rep.latency_ms,
131
+ error=rep.error,
132
+ diffs=[d.to_dict() for d in diffs],
133
+ breaking=breaking,
134
+ )
135
+ )
136
+ store.add_results(run_id, results)
137
+
138
+ failed = sum(r.breaking for r in results)
139
+ passed = len(results) - failed
140
+ store.finish_run(run_id, "done", done=len(results), passed=passed, failed=failed)
141
+ return RunSummary(run_id, len(results), passed, failed, replay_seconds)
shadowdiff/report.py ADDED
@@ -0,0 +1,299 @@
1
+ """Grouped regression report for a replay run, rendered as Markdown or HTML."""
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from typing import Any
10
+
11
+ from shadowdiff.diff import normalize_path
12
+ from shadowdiff.store import Exchange, Result, Run, Store
13
+
14
+ _ID_SEGMENT = re.compile(
15
+ r"^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{24,})$",
16
+ re.IGNORECASE,
17
+ )
18
+ _EXAMPLE_CHARS = 80
19
+
20
+
21
+ def endpoint_template(method: str, path: str) -> str:
22
+ """'GET /products/42' -> 'GET /products/{id}'."""
23
+ segments = ["{id}" if _ID_SEGMENT.match(s) else s for s in path.split("/")]
24
+ return f"{method} {'/'.join(segments)}"
25
+
26
+
27
+ @dataclass
28
+ class DiffGroup:
29
+ endpoint: str
30
+ path: str
31
+ kind: str
32
+ breaking: bool
33
+ detail: str
34
+ count: int = 0
35
+ example_url: str = ""
36
+ example_old: Any = None
37
+ example_new: Any = None
38
+
39
+
40
+ @dataclass
41
+ class EndpointStats:
42
+ requests: int = 0
43
+ passed: int = 0
44
+ failed: int = 0
45
+
46
+
47
+ @dataclass
48
+ class Report:
49
+ run: Run
50
+ breaking: list[DiffGroup] = field(default_factory=list)
51
+ warnings: list[DiffGroup] = field(default_factory=list)
52
+ errors: dict[str, int] = field(default_factory=dict)
53
+ endpoints: dict[str, EndpointStats] = field(default_factory=dict)
54
+
55
+ @property
56
+ def exit_code(self) -> int:
57
+ return 1 if self.run.failed else 0
58
+
59
+
60
+ def build_report(store: Store, run_id: int) -> Report:
61
+ run = store.run(run_id)
62
+ if run is None:
63
+ raise ValueError(f"run {run_id} not found")
64
+ results = store.results(run_id)
65
+ exchanges = store.exchanges_by_id([r.exchange_id for r in results])
66
+ return group_results(run, results, exchanges)
67
+
68
+
69
+ def group_results(run: Run, results: list[Result], exchanges: dict[int, Exchange]) -> Report:
70
+ groups: dict[tuple, DiffGroup] = {}
71
+ errors: dict[str, int] = defaultdict(int)
72
+ endpoints: dict[str, EndpointStats] = defaultdict(EndpointStats)
73
+
74
+ for result in results:
75
+ ex = exchanges[result.exchange_id]
76
+ endpoint = endpoint_template(ex.method, ex.path)
77
+ stats = endpoints[endpoint]
78
+ stats.requests += 1
79
+ if result.breaking:
80
+ stats.failed += 1
81
+ else:
82
+ stats.passed += 1
83
+ if result.error:
84
+ errors[result.error] += 1
85
+
86
+ # Count each group at most once per request, even if a list repeats the diff.
87
+ seen: set[tuple] = set()
88
+ for d in result.diffs:
89
+ detail = _detail(d)
90
+ key = (endpoint, normalize_path(d["path"]), d["kind"], detail)
91
+ if key in seen:
92
+ continue
93
+ seen.add(key)
94
+ group = groups.get(key)
95
+ if group is None:
96
+ group = groups[key] = DiffGroup(
97
+ endpoint=endpoint,
98
+ path=key[1],
99
+ kind=d["kind"],
100
+ breaking=d["breaking"],
101
+ detail=detail,
102
+ example_url=ex.path + (f"?{ex.query}" if ex.query else ""),
103
+ example_old=d.get("old"),
104
+ example_new=d.get("new"),
105
+ )
106
+ group.count += 1
107
+
108
+ ordered = sorted(groups.values(), key=lambda g: (-g.count, g.endpoint, g.path))
109
+ return Report(
110
+ run=run,
111
+ breaking=[g for g in ordered if g.breaking],
112
+ warnings=[g for g in ordered if not g.breaking],
113
+ errors=dict(sorted(errors.items(), key=lambda kv: -kv[1])),
114
+ endpoints=dict(sorted(endpoints.items())),
115
+ )
116
+
117
+
118
+ def _detail(d: dict) -> str:
119
+ kind = d["kind"]
120
+ if kind == "type_changed":
121
+ return f"type changed {d['old_type']} → {d['new_type']}"
122
+ if kind == "status_changed":
123
+ return f"status changed {d['old']} → {d['new']}"
124
+ if kind == "field_removed":
125
+ return "field removed"
126
+ if kind == "field_added":
127
+ return "field added"
128
+ return "value changed"
129
+
130
+
131
+ # --- rendering -------------------------------------------------------------------
132
+ #
133
+ # The report is built once as a list of simple blocks, then rendered to either
134
+ # format. Inline `code` spans in text are honoured by both renderers.
135
+
136
+
137
+ def _blocks(report: Report) -> list[tuple]:
138
+ run = report.run
139
+ started = datetime.fromtimestamp(run.started_at or 0, UTC).strftime("%Y-%m-%d %H:%M UTC")
140
+ duration = (run.finished_at or 0) - (run.started_at or 0)
141
+ errors = sum(report.errors.values())
142
+
143
+ if report.breaking or errors:
144
+ verdict = (
145
+ f"**FAIL**: {len(report.breaking)} breaking change(s) across {run.failed} "
146
+ f"request(s)" + (f", {errors} request error(s)" if errors else "")
147
+ )
148
+ else:
149
+ verdict = "**PASS**: no breaking changes"
150
+
151
+ blocks: list[tuple] = [
152
+ ("h1", f"shadowdiff report: run {run.id}"),
153
+ (
154
+ "p",
155
+ f"Session `{run.session}` replayed against `{run.target}` "
156
+ f"at {started} (concurrency {run.concurrency}).",
157
+ ),
158
+ ("p", verdict),
159
+ (
160
+ "table",
161
+ ["Replayed", "Passed", "Failed", "Breaking changes", "Warnings", "Duration"],
162
+ [
163
+ [
164
+ run.done,
165
+ run.passed,
166
+ run.failed,
167
+ len(report.breaking),
168
+ len(report.warnings),
169
+ f"{duration:.2f}s",
170
+ ]
171
+ ],
172
+ ),
173
+ ]
174
+
175
+ for title, groups in [("Breaking changes", report.breaking), ("Warnings", report.warnings)]:
176
+ blocks.append(("h2", title))
177
+ if not groups:
178
+ blocks.append(("p", "None."))
179
+ continue
180
+ blocks.append(
181
+ (
182
+ "table",
183
+ ["Endpoint", "Path", "Change", "Requests", "Example"],
184
+ [
185
+ [
186
+ f"`{g.endpoint}`",
187
+ f"`{g.path}`",
188
+ g.detail,
189
+ g.count,
190
+ f"`{g.example_url}`: {_example(g)}",
191
+ ]
192
+ for g in groups
193
+ ],
194
+ )
195
+ )
196
+
197
+ if report.errors:
198
+ blocks.append(("h2", "Request errors"))
199
+ blocks.append(
200
+ ("table", ["Error", "Requests"], [[f"`{e}`", n] for e, n in report.errors.items()])
201
+ )
202
+
203
+ blocks.append(("h2", "By endpoint"))
204
+ blocks.append(
205
+ (
206
+ "table",
207
+ ["Endpoint", "Requests", "Passed", "Failed"],
208
+ [[f"`{name}`", s.requests, s.passed, s.failed] for name, s in report.endpoints.items()],
209
+ )
210
+ )
211
+ return blocks
212
+
213
+
214
+ def _example(g: DiffGroup) -> str:
215
+ if g.kind == "field_added":
216
+ return f"added `{_short(g.example_new)}`"
217
+ if g.kind == "field_removed":
218
+ return f"was `{_short(g.example_old)}`"
219
+ return f"`{_short(g.example_old)}` → `{_short(g.example_new)}`"
220
+
221
+
222
+ def _short(value: Any) -> str:
223
+ text = json.dumps(value, ensure_ascii=False)
224
+ text = text if len(text) <= _EXAMPLE_CHARS else text[: _EXAMPLE_CHARS - 1] + "…"
225
+ # Keep table cells intact in Markdown.
226
+ return text.replace("|", "\\|").replace("`", "'")
227
+
228
+
229
+ def render_markdown(report: Report) -> str:
230
+ out = []
231
+ for block in _blocks(report):
232
+ kind = block[0]
233
+ if kind == "h1":
234
+ out.append(f"# {block[1]}")
235
+ elif kind == "h2":
236
+ out.append(f"## {block[1]}")
237
+ elif kind == "p":
238
+ out.append(block[1])
239
+ elif kind == "table":
240
+ headers, rows = block[1], block[2]
241
+ lines = ["| " + " | ".join(headers) + " |", "|" + " --- |" * len(headers)]
242
+ lines += ["| " + " | ".join(str(c) for c in row) + " |" for row in rows]
243
+ out.append("\n".join(lines))
244
+ return "\n\n".join(out) + "\n"
245
+
246
+
247
+ _CODE = re.compile(r"`([^`]*)`")
248
+ _BOLD = re.compile(r"\*\*([^*]+)\*\*")
249
+
250
+ _HTML_STYLE = """
251
+ body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 1100px;
252
+ padding: 0 1rem; color: #1f2328; background: #fff; }
253
+ table { border-collapse: collapse; margin: 0.5rem 0 1.5rem; width: 100%; }
254
+ th, td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; vertical-align: top; }
255
+ th { background: #f6f8fa; }
256
+ code { background: #f6f8fa; padding: 1px 4px; border-radius: 4px; font-size: 13px; }
257
+ @media (prefers-color-scheme: dark) {
258
+ body { color: #e6edf3; background: #0d1117; }
259
+ th, code { background: #161b22; }
260
+ th, td { border-color: #30363d; }
261
+ }
262
+ """
263
+
264
+
265
+ def _inline_html(text: Any) -> str:
266
+ text = html.escape(str(text).replace("\\|", "|"), quote=False)
267
+ text = _CODE.sub(r"<code>\1</code>", text)
268
+ return _BOLD.sub(r"<strong>\1</strong>", text)
269
+
270
+
271
+ def render_html(report: Report) -> str:
272
+ body = []
273
+ for block in _blocks(report):
274
+ kind = block[0]
275
+ if kind in ("h1", "h2"):
276
+ body.append(f"<{kind}>{_inline_html(block[1])}</{kind}>")
277
+ elif kind == "p":
278
+ body.append(f"<p>{_inline_html(block[1])}</p>")
279
+ elif kind == "table":
280
+ head = "".join(f"<th>{_inline_html(h)}</th>" for h in block[1])
281
+ rows = "".join(
282
+ "<tr>" + "".join(f"<td>{_inline_html(c)}</td>" for c in row) + "</tr>"
283
+ for row in block[2]
284
+ )
285
+ body.append(f"<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>")
286
+ return (
287
+ '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
288
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
289
+ f"<title>shadowdiff run {report.run.id}</title><style>{_HTML_STYLE}</style></head>"
290
+ f"<body>{''.join(body)}</body></html>\n"
291
+ )
292
+
293
+
294
+ def render(report: Report, fmt: str = "md") -> str:
295
+ if fmt == "md":
296
+ return render_markdown(report)
297
+ if fmt == "html":
298
+ return render_html(report)
299
+ raise ValueError(f"unknown report format {fmt!r}; use md or html")
shadowdiff/store.py ADDED
@@ -0,0 +1,242 @@
1
+ """SQLite storage for recorded exchanges, replay runs and their results."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ SCHEMA = """
10
+ CREATE TABLE IF NOT EXISTS exchanges (
11
+ id INTEGER PRIMARY KEY,
12
+ session TEXT NOT NULL,
13
+ method TEXT NOT NULL,
14
+ path TEXT NOT NULL,
15
+ query TEXT NOT NULL DEFAULT '',
16
+ req_headers TEXT NOT NULL DEFAULT '{}',
17
+ req_body BLOB,
18
+ status INTEGER NOT NULL,
19
+ resp_headers TEXT NOT NULL DEFAULT '{}',
20
+ resp_body BLOB,
21
+ latency_ms REAL,
22
+ recorded_at REAL NOT NULL
23
+ );
24
+ CREATE INDEX IF NOT EXISTS idx_exchanges_session_path ON exchanges (session, path);
25
+
26
+ CREATE TABLE IF NOT EXISTS runs (
27
+ id INTEGER PRIMARY KEY,
28
+ session TEXT NOT NULL,
29
+ target TEXT NOT NULL,
30
+ concurrency INTEGER NOT NULL,
31
+ status TEXT NOT NULL DEFAULT 'queued',
32
+ total INTEGER NOT NULL DEFAULT 0,
33
+ done INTEGER NOT NULL DEFAULT 0,
34
+ passed INTEGER NOT NULL DEFAULT 0,
35
+ failed INTEGER NOT NULL DEFAULT 0,
36
+ started_at REAL,
37
+ finished_at REAL
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS results (
41
+ id INTEGER PRIMARY KEY,
42
+ run_id INTEGER NOT NULL REFERENCES runs (id),
43
+ exchange_id INTEGER NOT NULL REFERENCES exchanges (id),
44
+ status INTEGER,
45
+ resp_body BLOB,
46
+ latency_ms REAL,
47
+ error TEXT,
48
+ diffs TEXT NOT NULL DEFAULT '[]',
49
+ breaking INTEGER NOT NULL DEFAULT 0
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_results_run_breaking ON results (run_id, breaking);
52
+ """
53
+
54
+
55
+ @dataclass
56
+ class Exchange:
57
+ session: str
58
+ method: str
59
+ path: str
60
+ query: str
61
+ req_headers: dict[str, str]
62
+ req_body: bytes
63
+ status: int
64
+ resp_headers: dict[str, str]
65
+ resp_body: bytes
66
+ latency_ms: float
67
+ recorded_at: float = field(default_factory=time.time)
68
+ id: int | None = None
69
+
70
+
71
+ @dataclass
72
+ class Result:
73
+ exchange_id: int
74
+ status: int | None
75
+ resp_body: bytes | None
76
+ latency_ms: float | None
77
+ error: str | None = None
78
+ diffs: list[dict[str, Any]] = field(default_factory=list)
79
+ breaking: bool = False
80
+
81
+
82
+ @dataclass
83
+ class Run:
84
+ id: int
85
+ session: str
86
+ target: str
87
+ concurrency: int
88
+ status: str
89
+ total: int
90
+ done: int
91
+ passed: int
92
+ failed: int
93
+ started_at: float | None
94
+ finished_at: float | None
95
+
96
+
97
+ class Store:
98
+ def __init__(self, path: str = "shadowdiff.db"):
99
+ self.path = path
100
+ # The proxy writes from worker threads; callers serialize writes themselves.
101
+ self.conn = sqlite3.connect(path, check_same_thread=False)
102
+ self.conn.row_factory = sqlite3.Row
103
+ self.conn.execute("PRAGMA journal_mode=WAL")
104
+ self.conn.execute("PRAGMA synchronous=NORMAL")
105
+ self.conn.executescript(SCHEMA)
106
+
107
+ def close(self) -> None:
108
+ self.conn.close()
109
+
110
+ # exchanges
111
+
112
+ def add_exchanges(self, exchanges: list[Exchange]) -> None:
113
+ with self.conn:
114
+ self.conn.executemany(
115
+ "INSERT INTO exchanges (session, method, path, query, req_headers, req_body,"
116
+ " status, resp_headers, resp_body, latency_ms, recorded_at)"
117
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
118
+ [
119
+ (
120
+ e.session,
121
+ e.method,
122
+ e.path,
123
+ e.query,
124
+ json.dumps(e.req_headers),
125
+ e.req_body,
126
+ e.status,
127
+ json.dumps(e.resp_headers),
128
+ e.resp_body,
129
+ e.latency_ms,
130
+ e.recorded_at,
131
+ )
132
+ for e in exchanges
133
+ ],
134
+ )
135
+
136
+ def exchanges(self, session: str) -> list[Exchange]:
137
+ rows = self.conn.execute(
138
+ "SELECT * FROM exchanges WHERE session = ? ORDER BY id", (session,)
139
+ ).fetchall()
140
+ return [_exchange_from_row(r) for r in rows]
141
+
142
+ def exchanges_by_id(self, ids: list[int]) -> dict[int, Exchange]:
143
+ if not ids:
144
+ return {}
145
+ out: dict[int, Exchange] = {}
146
+ # Stay under SQLite's bound-parameter limit.
147
+ for i in range(0, len(ids), 500):
148
+ chunk = ids[i : i + 500]
149
+ placeholders = ",".join("?" * len(chunk))
150
+ rows = self.conn.execute(
151
+ f"SELECT * FROM exchanges WHERE id IN ({placeholders})", chunk
152
+ ).fetchall()
153
+ out.update({r["id"]: _exchange_from_row(r) for r in rows})
154
+ return out
155
+
156
+ def sessions(self) -> list[dict[str, Any]]:
157
+ rows = self.conn.execute(
158
+ "SELECT session, COUNT(*) AS exchanges, MIN(recorded_at) AS first_seen,"
159
+ " MAX(recorded_at) AS last_seen FROM exchanges GROUP BY session ORDER BY session"
160
+ ).fetchall()
161
+ return [dict(r) for r in rows]
162
+
163
+ # runs and results
164
+
165
+ def create_run(self, session: str, target: str, concurrency: int, total: int) -> int:
166
+ with self.conn:
167
+ cur = self.conn.execute(
168
+ "INSERT INTO runs (session, target, concurrency, status, total, started_at)"
169
+ " VALUES (?, ?, ?, 'running', ?, ?)",
170
+ (session, target, concurrency, total, time.time()),
171
+ )
172
+ return cur.lastrowid
173
+
174
+ def add_results(self, run_id: int, results: list[Result]) -> None:
175
+ with self.conn:
176
+ self.conn.executemany(
177
+ "INSERT INTO results (run_id, exchange_id, status, resp_body, latency_ms,"
178
+ " error, diffs, breaking) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
179
+ [
180
+ (
181
+ run_id,
182
+ r.exchange_id,
183
+ r.status,
184
+ r.resp_body,
185
+ r.latency_ms,
186
+ r.error,
187
+ json.dumps(r.diffs),
188
+ int(r.breaking),
189
+ )
190
+ for r in results
191
+ ],
192
+ )
193
+
194
+ def finish_run(self, run_id: int, status: str, done: int, passed: int, failed: int) -> None:
195
+ with self.conn:
196
+ self.conn.execute(
197
+ "UPDATE runs SET status = ?, done = ?, passed = ?, failed = ?, finished_at = ?"
198
+ " WHERE id = ?",
199
+ (status, done, passed, failed, time.time(), run_id),
200
+ )
201
+
202
+ def run(self, run_id: int) -> Run | None:
203
+ row = self.conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
204
+ return Run(**dict(row)) if row else None
205
+
206
+ def latest_run_id(self) -> int | None:
207
+ row = self.conn.execute("SELECT MAX(id) FROM runs").fetchone()
208
+ return row[0]
209
+
210
+ def results(self, run_id: int) -> list[Result]:
211
+ rows = self.conn.execute(
212
+ "SELECT * FROM results WHERE run_id = ? ORDER BY id", (run_id,)
213
+ ).fetchall()
214
+ return [
215
+ Result(
216
+ exchange_id=r["exchange_id"],
217
+ status=r["status"],
218
+ resp_body=r["resp_body"],
219
+ latency_ms=r["latency_ms"],
220
+ error=r["error"],
221
+ diffs=json.loads(r["diffs"]),
222
+ breaking=bool(r["breaking"]),
223
+ )
224
+ for r in rows
225
+ ]
226
+
227
+
228
+ def _exchange_from_row(r: sqlite3.Row) -> Exchange:
229
+ return Exchange(
230
+ id=r["id"],
231
+ session=r["session"],
232
+ method=r["method"],
233
+ path=r["path"],
234
+ query=r["query"],
235
+ req_headers=json.loads(r["req_headers"]),
236
+ req_body=r["req_body"] or b"",
237
+ status=r["status"],
238
+ resp_headers=json.loads(r["resp_headers"]),
239
+ resp_body=r["resp_body"] or b"",
240
+ latency_ms=r["latency_ms"],
241
+ recorded_at=r["recorded_at"],
242
+ )