radarflake 1.1.1__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.
flakeradar/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """flakeradar - flaky test detection, historical tracking, and AI-assisted
2
+ root cause analysis for pytest.
3
+ """
4
+
5
+ __version__ = "1.1.1"
6
+
7
+ __all__ = ["__version__"]
flakeradar/badge.py ADDED
@@ -0,0 +1,59 @@
1
+ """Generate a small, self-contained SVG badge showing the flaky test count.
2
+
3
+ Styled after shields.io's flat badges, but rendered entirely locally with
4
+ no network call and no hosted service dependency - regenerate it as part
5
+ of your CI and commit it, or upload it as an artifact.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+
12
+ _FONT = "Verdana,Geneva,DejaVu Sans,sans-serif"
13
+ _CHAR_WIDTH = 6.5 # rough average glyph width in px at font-size 11
14
+ _PADDING = 10
15
+
16
+
17
+ def _text_width(text: str) -> int:
18
+ return max(6, int(len(text) * _CHAR_WIDTH)) + _PADDING
19
+
20
+
21
+ def color_for_count(count: int) -> str:
22
+ """Green when clean, yellow for a handful, red once it piles up."""
23
+ if count <= 0:
24
+ return "#4c1"
25
+ if count <= 3:
26
+ return "#dfb317"
27
+ return "#e05d44"
28
+
29
+
30
+ def generate_badge_svg(label: str, value: str, color: str) -> str:
31
+ label = html.escape(label)
32
+ value = html.escape(value)
33
+ label_w = _text_width(label)
34
+ value_w = _text_width(value)
35
+ total_w = label_w + value_w
36
+
37
+ return (
38
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" '
39
+ f'role="img" aria-label="{label}: {value}">'
40
+ f'<linearGradient id="s" x2="0" y2="100%">'
41
+ f'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
42
+ f'<stop offset="1" stop-opacity=".1"/>'
43
+ f"</linearGradient>"
44
+ f'<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>'
45
+ f'<g clip-path="url(#r)">'
46
+ f'<rect width="{label_w}" height="20" fill="#555"/>'
47
+ f'<rect x="{label_w}" width="{value_w}" height="20" fill="{color}"/>'
48
+ f'<rect width="{total_w}" height="20" fill="url(#s)"/>'
49
+ f"</g>"
50
+ f'<g fill="#fff" text-anchor="middle" font-family="{_FONT}" font-size="11">'
51
+ f'<text x="{label_w / 2:.1f}" y="14">{label}</text>'
52
+ f'<text x="{label_w + value_w / 2:.1f}" y="14">{value}</text>'
53
+ f"</g>"
54
+ f"</svg>"
55
+ )
56
+
57
+
58
+ def badge_for_flaky_count(count: int, label: str = "flaky tests") -> str:
59
+ return generate_badge_svg(label, str(count), color_for_count(count))
flakeradar/cli.py ADDED
@@ -0,0 +1,436 @@
1
+ """flakeradar command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ import uuid
10
+ import webbrowser
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from . import __version__
15
+ from .badge import badge_for_flaky_count
16
+ from .clustering import cluster_failures
17
+ from .config import Config, load_config
18
+ from .gitinfo import current_branch, current_sha
19
+ from .llm import LLMError, build_provider, provider_names
20
+ from .llm.analyzer import try_analyze_test
21
+ from .quarantine import QuarantineEntry, read_quarantine, sync_quarantine, write_quarantine
22
+ from .report import generate_html_report, generate_json_report, write_report
23
+ from .scoring import FlakinessResult, score_test
24
+ from .storage import Storage, TestResult
25
+
26
+ _RESET = "\033[0m"
27
+ _BOLD = "\033[1m"
28
+ _RED = "\033[31m"
29
+ _YELLOW = "\033[33m"
30
+ _GREEN = "\033[32m"
31
+ _DIM = "\033[2m"
32
+
33
+
34
+ def _color(text: str, code: str) -> str:
35
+ if not sys.stdout.isatty():
36
+ return text
37
+ return f"{code}{text}{_RESET}"
38
+
39
+
40
+ def _compute_all_scores(store: Storage, config: Config) -> List[FlakinessResult]:
41
+ results = []
42
+ for nodeid in store.all_nodeids():
43
+ history = store.history_for(nodeid)
44
+ outcomes = [h.outcome for h in history]
45
+ results.append(
46
+ score_test(
47
+ nodeid,
48
+ outcomes,
49
+ min_runs=config.min_runs,
50
+ flakiness_threshold=config.flakiness_threshold,
51
+ )
52
+ )
53
+ return results
54
+
55
+
56
+ # -- subcommands ------------------------------------------------------------
57
+
58
+
59
+ def cmd_init(args: argparse.Namespace) -> int:
60
+ root = Path.cwd()
61
+ toml_path = root / "flakeradar.toml"
62
+ if toml_path.exists() and not args.force:
63
+ print(f"{toml_path} already exists. Use --force to overwrite.")
64
+ return 1
65
+ toml_path.write_text(
66
+ "# flakeradar configuration\n"
67
+ "# https://github.com/Lethe044/flakeradar\n\n"
68
+ "min_runs = 5\n"
69
+ "flakiness_threshold = 0.15\n"
70
+ "quarantine_threshold = 0.30\n\n"
71
+ "# Leave llm_provider as \"none\" to use flakeradar with zero AI cost.\n"
72
+ "# Options: none, groq, gemini, ollama, openai, anthropic\n"
73
+ "llm_provider = \"none\"\n"
74
+ "# llm_model = \"llama-3.1-8b-instant\"\n\n"
75
+ "report_out = \"flakeradar-report.html\"\n",
76
+ encoding="utf-8",
77
+ )
78
+ gitignore = root / ".gitignore"
79
+ entry = ".flakeradar/history.db\n"
80
+ if gitignore.exists():
81
+ content = gitignore.read_text(encoding="utf-8")
82
+ if ".flakeradar/history.db" not in content:
83
+ gitignore.write_text(content.rstrip("\n") + "\n\n# flakeradar\n" + entry, encoding="utf-8")
84
+ else:
85
+ gitignore.write_text("# flakeradar\n" + entry, encoding="utf-8")
86
+
87
+ print(f"Created {toml_path}")
88
+ print("Added .flakeradar/history.db to .gitignore (the db is per-machine; commit quarantine.txt instead).")
89
+ print("\nNext steps:")
90
+ print(" 1. Run your suite with tracking on: pytest --flakeradar")
91
+ print(" 2. Generate a report: flakeradar report --open")
92
+ print(" 3. (optional) Configure a free-tier LLM provider for AI root-cause analysis.")
93
+ return 0
94
+
95
+
96
+ def cmd_report(args: argparse.Namespace) -> int:
97
+ config = load_config(min_runs=args.min_runs)
98
+ db_path = config.resolve_db_path()
99
+ if not db_path.exists():
100
+ print(f"No history database found at {db_path}. Run 'pytest --flakeradar' first.")
101
+ return 1
102
+
103
+ store = Storage(db_path)
104
+ results = _compute_all_scores(store, config)
105
+ histories = {r.nodeid: store.history_for(r.nodeid) for r in results}
106
+ run_count = store.run_count()
107
+ store.close()
108
+
109
+ if not results:
110
+ print("No recorded test results yet. Run 'pytest --flakeradar' first.")
111
+ return 1
112
+
113
+ if args.format == "json":
114
+ content = generate_json_report(results, run_count, config.flakiness_threshold)
115
+ out_path = Path(args.out or "flakeradar-report.json")
116
+ else:
117
+ content = generate_html_report(
118
+ results, histories, run_count, config.flakiness_threshold, window=args.window
119
+ )
120
+ out_path = Path(args.out or config.report_out)
121
+
122
+ write_report(out_path, content)
123
+ print(f"Report written to {out_path.resolve()}")
124
+
125
+ flaky = [r for r in results if r.classification == "flaky"]
126
+ broken = [r for r in results if r.classification == "broken"]
127
+ if flaky:
128
+ print(_color(f"\n{len(flaky)} flaky test(s):", _YELLOW))
129
+ for r in sorted(flaky, key=lambda r: -r.score)[:10]:
130
+ print(f" {_color(f'{r.score:.2f}', _YELLOW)} {r.nodeid}")
131
+ if broken:
132
+ print(_color(f"\n{len(broken)} consistently failing test(s) (not flaky - likely a real bug):", _RED))
133
+ for r in sorted(broken, key=lambda r: -r.fail_rate)[:10]:
134
+ print(f" {_color(f'{r.fail_rate:.0%}', _RED)} {r.nodeid}")
135
+
136
+ if args.open and args.format == "html":
137
+ webbrowser.open(out_path.resolve().as_uri())
138
+
139
+ exit_code = 0
140
+ if args.max_flaky is not None and len(flaky) > args.max_flaky:
141
+ print(_color(f"\nFAIL: {len(flaky)} flaky test(s) exceeds --max-flaky {args.max_flaky}", _RED))
142
+ exit_code = 1
143
+ if args.max_broken is not None and len(broken) > args.max_broken:
144
+ print(_color(f"FAIL: {len(broken)} broken test(s) exceeds --max-broken {args.max_broken}", _RED))
145
+ exit_code = 1
146
+ return exit_code
147
+
148
+
149
+ def cmd_history(args: argparse.Namespace) -> int:
150
+ config = load_config()
151
+ db_path = config.resolve_db_path()
152
+ if not db_path.exists():
153
+ print(f"No history database found at {db_path}.")
154
+ return 1
155
+ store = Storage(db_path)
156
+ entries = store.history_for(args.nodeid)
157
+ store.close()
158
+ if not entries:
159
+ print(f"No history for '{args.nodeid}'. Check the nodeid (e.g. tests/test_x.py::test_y).")
160
+ return 1
161
+ for e in entries:
162
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(e.started_at))
163
+ sha = e.git_sha or "-"
164
+ marker = _color("PASS", _GREEN) if e.outcome == "passed" else _color(e.outcome.upper(), _RED)
165
+ print(f"{ts} {sha:>10} {marker:<12} {e.duration:.2f}s")
166
+ outcomes = [e.outcome for e in entries]
167
+ result = score_test(args.nodeid, outcomes, config.min_runs, config.flakiness_threshold)
168
+ print(f"\n{result.total_runs} runs, fail rate {result.fail_rate:.0%}, score {result.score:.2f} -> {result.classification}")
169
+ return 0
170
+
171
+
172
+ def _run_pytest_once(pytest_args: List[str]) -> str:
173
+ """Run pytest once, return the raw outcome ('passed'|'failed'|'error')."""
174
+ try:
175
+ proc = subprocess.run(
176
+ [sys.executable, "-m", "pytest", *pytest_args, "-q"],
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=600,
180
+ )
181
+ except subprocess.TimeoutExpired:
182
+ return "error", "Timed out after 600s"
183
+ output = (proc.stdout or "") + (proc.stderr or "")
184
+ if proc.returncode == 0:
185
+ return "passed", output
186
+ return "failed", output
187
+
188
+
189
+ def cmd_stress(args: argparse.Namespace) -> int:
190
+ config = load_config()
191
+ db_path = config.resolve_db_path()
192
+ store = Storage(db_path)
193
+
194
+ pytest_args = [args.path]
195
+ if args.k:
196
+ pytest_args += ["-k", args.k]
197
+
198
+ run_id = f"stress-{int(time.time())}-{uuid.uuid4().hex[:6]}"
199
+ store.start_run(run_id=run_id, git_sha=current_sha(), git_branch=current_branch(), source="stress")
200
+
201
+ print(f"Running {pytest_args} {args.n} time(s)...")
202
+ outcomes = []
203
+ last_failure_output = ""
204
+ for i in range(args.n):
205
+ outcome, output = _run_pytest_once(pytest_args)
206
+ outcomes.append(outcome)
207
+ if outcome != "passed":
208
+ last_failure_output = output
209
+ sys.stdout.write(_color("+", _GREEN) if outcome == "passed" else _color("x", _RED))
210
+ sys.stdout.flush()
211
+ print()
212
+
213
+ nodeid = args.k or args.path
214
+ results = [
215
+ TestResult(nodeid=nodeid, outcome=o, duration=0.0, longrepr=last_failure_output if o != "passed" else None)
216
+ for o in outcomes
217
+ ]
218
+ store.record_results(run_id, results)
219
+
220
+ history = store.history_for(nodeid)
221
+ all_outcomes = [h.outcome for h in history]
222
+ stats = score_test(nodeid, all_outcomes, config.min_runs, config.flakiness_threshold)
223
+
224
+ print(f"\n{stats.pass_count} passed / {stats.fail_count} failed out of {stats.total_runs} total run(s)")
225
+ print(f"Flakiness score: {stats.score:.2f} -> {_color(stats.classification.upper(), _YELLOW)}")
226
+
227
+ if args.analyze and stats.classification in ("flaky", "broken"):
228
+ longreprs = [h.longrepr for h in history if h.longrepr]
229
+ clusters = cluster_failures(longreprs)
230
+ provider = build_provider(config)
231
+ analysis, error = try_analyze_test(provider, nodeid, stats, clusters)
232
+ if analysis:
233
+ _print_analysis(analysis)
234
+ elif error:
235
+ print(f"\n(AI analysis skipped: {error})")
236
+
237
+ store.close()
238
+ return 0 if stats.classification == "stable" else 1
239
+
240
+
241
+ def cmd_analyze(args: argparse.Namespace) -> int:
242
+ config = load_config(llm_provider=args.llm_provider)
243
+ db_path = config.resolve_db_path()
244
+ if not db_path.exists():
245
+ print(f"No history database found at {db_path}.")
246
+ return 1
247
+ store = Storage(db_path)
248
+ history = store.history_for(args.nodeid)
249
+ store.close()
250
+ if not history:
251
+ print(f"No history for '{args.nodeid}'.")
252
+ return 1
253
+
254
+ outcomes = [h.outcome for h in history]
255
+ stats = score_test(args.nodeid, outcomes, config.min_runs, config.flakiness_threshold)
256
+ longreprs = [h.longrepr for h in history if h.longrepr]
257
+ clusters = cluster_failures(longreprs)
258
+
259
+ print(f"{args.nodeid}")
260
+ print(f" runs={stats.total_runs} fail_rate={stats.fail_rate:.0%} score={stats.score:.2f} -> {stats.classification}")
261
+ print(f" {len(clusters)} distinct failure cluster(s)")
262
+
263
+ source_snippet = None
264
+ if args.source:
265
+ src_path = Path(args.source)
266
+ if src_path.is_file():
267
+ source_snippet = src_path.read_text(encoding="utf-8", errors="replace")
268
+
269
+ try:
270
+ provider = build_provider(config)
271
+ except LLMError as exc:
272
+ print(f"\nCould not build LLM provider: {exc}")
273
+ return 1
274
+
275
+ analysis, error = try_analyze_test(provider, args.nodeid, stats, clusters, source_snippet)
276
+ if analysis:
277
+ _print_analysis(analysis)
278
+ return 0
279
+ print(f"\nAI analysis unavailable: {error}")
280
+ print("(Statistical data above is still valid without AI analysis.)")
281
+ return 1 if not clusters else 0
282
+
283
+
284
+ def _print_analysis(analysis) -> None:
285
+ print(f"\n{_color('AI root cause analysis', _BOLD)} (confidence: {analysis.confidence})")
286
+ print(f" Category: {analysis.category_label}")
287
+ print(f" Explanation: {analysis.explanation}")
288
+ print(f" Suggested fix: {analysis.suggested_fix}")
289
+
290
+
291
+ def cmd_badge(args: argparse.Namespace) -> int:
292
+ config = load_config()
293
+ db_path = config.resolve_db_path()
294
+ if not db_path.exists():
295
+ print(f"No history database found at {db_path}. Run 'pytest --flakeradar' first.")
296
+ return 1
297
+ store = Storage(db_path)
298
+ results = _compute_all_scores(store, config)
299
+ store.close()
300
+
301
+ flaky_count = sum(1 for r in results if r.classification == "flaky")
302
+ svg = badge_for_flaky_count(flaky_count, label=args.label)
303
+
304
+ out_path = Path(args.out)
305
+ out_path.parent.mkdir(parents=True, exist_ok=True)
306
+ out_path.write_text(svg, encoding="utf-8")
307
+ print(f"Badge written to {out_path.resolve()} ({flaky_count} flaky test(s))")
308
+ return 0
309
+
310
+
311
+ def cmd_quarantine(args: argparse.Namespace) -> int:
312
+ config = load_config()
313
+ q_path = config.resolve_quarantine_path()
314
+
315
+ if args.qcmd == "list":
316
+ entries = read_quarantine(q_path)
317
+ if not entries:
318
+ print("Quarantine list is empty.")
319
+ return 0
320
+ for nodeid, e in sorted(entries.items()):
321
+ tag = _color("[auto]", _DIM) if e.auto else _color("[manual]", _GREEN)
322
+ print(f"{tag} {nodeid} {('# ' + e.comment) if e.comment else ''}")
323
+ return 0
324
+
325
+ if args.qcmd == "add":
326
+ entries = read_quarantine(q_path)
327
+ entries[args.nodeid] = QuarantineEntry(nodeid=args.nodeid, comment=args.reason or "manually pinned", auto=False)
328
+ write_quarantine(q_path, entries.values())
329
+ print(f"Added {args.nodeid} to quarantine.")
330
+ return 0
331
+
332
+ if args.qcmd == "remove":
333
+ entries = read_quarantine(q_path)
334
+ if args.nodeid in entries:
335
+ del entries[args.nodeid]
336
+ write_quarantine(q_path, entries.values())
337
+ print(f"Removed {args.nodeid} from quarantine.")
338
+ else:
339
+ print(f"{args.nodeid} was not in the quarantine list.")
340
+ return 0
341
+
342
+ if args.qcmd == "sync":
343
+ db_path = config.resolve_db_path()
344
+ if not db_path.exists():
345
+ print(f"No history database found at {db_path}.")
346
+ return 1
347
+ store = Storage(db_path)
348
+ results = _compute_all_scores(store, config)
349
+ store.close()
350
+ diff = sync_quarantine(q_path, results, config.quarantine_threshold, dry_run=args.dry_run)
351
+ prefix = "(dry run) " if args.dry_run else ""
352
+ for nodeid in diff["added"]:
353
+ print(_color(f"{prefix}+ quarantined {nodeid}", _YELLOW))
354
+ for nodeid in diff["removed"]:
355
+ print(_color(f"{prefix}- released {nodeid} (no longer flaky)", _GREEN))
356
+ if not diff["added"] and not diff["removed"]:
357
+ print("Quarantine list is already up to date.")
358
+ elif args.dry_run:
359
+ print("\nNo changes written (--dry-run). Re-run without --dry-run to apply.")
360
+ return 0
361
+
362
+ return 1
363
+
364
+
365
+ def build_parser() -> argparse.ArgumentParser:
366
+ parser = argparse.ArgumentParser(prog="flakeradar", description="Flaky test detection and diagnosis for pytest.")
367
+ parser.add_argument("--version", action="version", version=f"flakeradar {__version__}")
368
+ sub = parser.add_subparsers(dest="command", required=True)
369
+
370
+ p_init = sub.add_parser("init", help="Create a flakeradar.toml config file in the current directory.")
371
+ p_init.add_argument("--force", action="store_true", help="Overwrite an existing config file.")
372
+ p_init.set_defaults(func=cmd_init)
373
+
374
+ p_report = sub.add_parser("report", help="Generate the flakiness report from recorded history.")
375
+ p_report.add_argument("--out", default=None, help="Output path (default: from config, or format-specific default).")
376
+ p_report.add_argument("--format", choices=["html", "json"], default="html", help="Report format (default: html).")
377
+ p_report.add_argument("--open", action="store_true", help="Open the HTML report in a browser after generating it.")
378
+ p_report.add_argument("--min-runs", type=int, default=None, help="Minimum runs before a test is classified.")
379
+ p_report.add_argument("--window", type=int, default=40, help="Number of most recent runs shown per sparkline (HTML only).")
380
+ p_report.add_argument("--max-flaky", type=int, default=None, help="Exit non-zero if more than N tests are flaky.")
381
+ p_report.add_argument("--max-broken", type=int, default=None, help="Exit non-zero if more than N tests are consistently failing.")
382
+ p_report.set_defaults(func=cmd_report)
383
+
384
+ p_history = sub.add_parser("history", help="Show the recorded pass/fail history for one test.")
385
+ p_history.add_argument("nodeid", help="Test nodeid, e.g. tests/test_x.py::test_y")
386
+ p_history.set_defaults(func=cmd_history)
387
+
388
+ p_stress = sub.add_parser("stress", help="Run a test repeatedly right now to quickly check if it's flaky.")
389
+ p_stress.add_argument("path", help="Path to test file or directory (passed through to pytest).")
390
+ p_stress.add_argument("-k", default=None, help="pytest -k expression to select a specific test.")
391
+ p_stress.add_argument("-n", type=int, default=20, help="Number of times to run (default: 20).")
392
+ p_stress.add_argument("--analyze", action="store_true", help="Run AI root-cause analysis if flakiness is found.")
393
+ p_stress.set_defaults(func=cmd_stress)
394
+
395
+ p_analyze = sub.add_parser("analyze", help="Run AI root-cause analysis for a specific test's recorded history.")
396
+ p_analyze.add_argument("nodeid", help="Test nodeid, e.g. tests/test_x.py::test_y")
397
+ p_analyze.add_argument("--source", default=None, help="Path to the test's source file, included as context.")
398
+ p_analyze.add_argument(
399
+ "--llm-provider", default=None, choices=[*provider_names(), "none"], help="Override the configured LLM provider."
400
+ )
401
+ p_analyze.set_defaults(func=cmd_analyze)
402
+
403
+ p_quarantine = sub.add_parser("quarantine", help="Manage the quarantine list of known-flaky tests.")
404
+ q_sub = p_quarantine.add_subparsers(dest="qcmd", required=True)
405
+ q_sub.add_parser("list", help="List quarantined tests.").set_defaults(func=cmd_quarantine)
406
+ q_sync = q_sub.add_parser("sync", help="Recompute the quarantine list from recorded history.")
407
+ q_sync.add_argument("--dry-run", action="store_true", help="Show what would change without writing the file.")
408
+ q_sync.set_defaults(func=cmd_quarantine)
409
+ q_add = q_sub.add_parser("add", help="Manually pin a test to the quarantine list.")
410
+ q_add.add_argument("nodeid")
411
+ q_add.add_argument("--reason", default=None)
412
+ q_add.set_defaults(func=cmd_quarantine)
413
+ q_remove = q_sub.add_parser("remove", help="Remove a test from the quarantine list.")
414
+ q_remove.add_argument("nodeid")
415
+ q_remove.set_defaults(func=cmd_quarantine)
416
+
417
+ p_badge = sub.add_parser("badge", help="Generate a self-contained SVG badge showing the flaky test count.")
418
+ p_badge.add_argument("--out", default="flakeradar-badge.svg", help="Output SVG path (default: flakeradar-badge.svg).")
419
+ p_badge.add_argument("--label", default="flaky tests", help="Badge label text (default: 'flaky tests').")
420
+ p_badge.set_defaults(func=cmd_badge)
421
+
422
+ return parser
423
+
424
+
425
+ def main(argv: Optional[List[str]] = None) -> int:
426
+ parser = build_parser()
427
+ args = parser.parse_args(argv)
428
+ try:
429
+ return args.func(args)
430
+ except KeyboardInterrupt:
431
+ print("\nInterrupted.")
432
+ return 130
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ """Group failures of the same test by a normalized fingerprint.
2
+
3
+ A flaky test can fail for more than one reason across its history. Before
4
+ handing a batch of failures to an LLM (or a human), it helps to know
5
+ whether they are "one bug, N occurrences" or "three different bugs". We
6
+ fingerprint each failure by its exception type plus a normalized version
7
+ of the message (numbers, hex addresses, UUIDs, and file paths stripped
8
+ out), so that superficially different but structurally identical failures
9
+ collapse into the same cluster.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass
18
+ from typing import Dict, List, Optional
19
+
20
+ _HEX_RE = re.compile(r"\b0x[0-9a-fA-F]+\b")
21
+ _UUID_RE = re.compile(
22
+ r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
23
+ )
24
+ _NUMBER_RE = re.compile(r"\b\d+\b")
25
+ _PATH_RE = re.compile(r"(/[\w\-.]+)+\.py")
26
+ _EXC_LINE_RE = re.compile(r"^E\s+([\w.]+(?:Error|Exception|Warning|Failure|Timeout)\b)")
27
+ _QUOTED_RE = re.compile(r"'[^']*'|\"[^\"]*\"")
28
+
29
+
30
+ @dataclass
31
+ class FailureCluster:
32
+ fingerprint: str
33
+ exception_type: str
34
+ sample_message: str
35
+ occurrences: int
36
+ example_longrepr: Optional[str]
37
+
38
+
39
+ def _normalize_message(longrepr: str) -> str:
40
+ text = _PATH_RE.sub("<path>", longrepr)
41
+ text = _HEX_RE.sub("<hex>", text)
42
+ text = _UUID_RE.sub("<uuid>", text)
43
+ text = _QUOTED_RE.sub("<str>", text)
44
+ text = _NUMBER_RE.sub("<n>", text)
45
+ return text.strip()
46
+
47
+
48
+ def _extract_exception_type(longrepr: str) -> str:
49
+ for line in longrepr.splitlines():
50
+ m = _EXC_LINE_RE.match(line.strip())
51
+ if m:
52
+ return m.group(1)
53
+ # Fallback: last non-empty line often reads "ExceptionType: message"
54
+ for line in reversed(longrepr.strip().splitlines()):
55
+ line = line.strip()
56
+ if ":" in line and not line.startswith(("File ", "E ")):
57
+ candidate = line.split(":", 1)[0].strip()
58
+ if candidate and " " not in candidate:
59
+ return candidate
60
+ return "UnknownError"
61
+
62
+
63
+ def fingerprint_failure(longrepr: str) -> str:
64
+ """Return a stable short fingerprint id for a traceback/message string."""
65
+ exc_type = _extract_exception_type(longrepr)
66
+ normalized = _normalize_message(longrepr)
67
+ # Keep the fingerprint stable and short; only use the first ~300 chars of
68
+ # the normalized text so unrelated trailing frames don't split clusters
69
+ # that are really the same root cause.
70
+ digest_input = f"{exc_type}:{normalized[:300]}"
71
+ digest = hashlib.sha1(digest_input.encode("utf-8", errors="replace")).hexdigest()[:10]
72
+ return f"{exc_type}-{digest}"
73
+
74
+
75
+ def cluster_failures(longreprs: List[str]) -> List[FailureCluster]:
76
+ """Cluster a list of failure longrepr strings into groups by fingerprint."""
77
+ groups: Dict[str, List[str]] = defaultdict(list)
78
+ for lr in longreprs:
79
+ if not lr:
80
+ continue
81
+ fp = fingerprint_failure(lr)
82
+ groups[fp].append(lr)
83
+
84
+ clusters = []
85
+ for fp, samples in groups.items():
86
+ exc_type = fp.rsplit("-", 1)[0]
87
+ sample = samples[0]
88
+ first_line = next((ln for ln in sample.splitlines() if ln.strip()), sample[:200])
89
+ clusters.append(
90
+ FailureCluster(
91
+ fingerprint=fp,
92
+ exception_type=exc_type,
93
+ sample_message=first_line.strip()[:300],
94
+ occurrences=len(samples),
95
+ example_longrepr=sample[:2000],
96
+ )
97
+ )
98
+ clusters.sort(key=lambda c: c.occurrences, reverse=True)
99
+ return clusters