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 +7 -0
- flakeradar/badge.py +59 -0
- flakeradar/cli.py +436 -0
- flakeradar/clustering.py +99 -0
- flakeradar/config.py +164 -0
- flakeradar/gitinfo.py +33 -0
- flakeradar/llm/__init__.py +43 -0
- flakeradar/llm/analyzer.py +149 -0
- flakeradar/llm/anthropic_provider.py +41 -0
- flakeradar/llm/base.py +28 -0
- flakeradar/llm/gemini_provider.py +36 -0
- flakeradar/llm/groq_provider.py +42 -0
- flakeradar/llm/ollama_provider.py +39 -0
- flakeradar/llm/openai_provider.py +42 -0
- flakeradar/plugin.py +152 -0
- flakeradar/py.typed +0 -0
- flakeradar/quarantine.py +119 -0
- flakeradar/report.py +206 -0
- flakeradar/scoring.py +133 -0
- flakeradar/storage.py +216 -0
- flakeradar/summary.py +79 -0
- radarflake-1.1.1.dist-info/METADATA +370 -0
- radarflake-1.1.1.dist-info/RECORD +27 -0
- radarflake-1.1.1.dist-info/WHEEL +5 -0
- radarflake-1.1.1.dist-info/entry_points.txt +5 -0
- radarflake-1.1.1.dist-info/licenses/LICENSE +21 -0
- radarflake-1.1.1.dist-info/top_level.txt +1 -0
flakeradar/__init__.py
ADDED
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())
|
flakeradar/clustering.py
ADDED
|
@@ -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
|