provide-testkit 0.0.0.dev0__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.
Files changed (66) hide show
  1. provide/__init__.py +3 -0
  2. provide/testkit/__init__.py +248 -0
  3. provide/testkit/archive/__init__.py +24 -0
  4. provide/testkit/archive/fixtures.py +217 -0
  5. provide/testkit/cli.py +229 -0
  6. provide/testkit/common/__init__.py +32 -0
  7. provide/testkit/common/fixtures.py +234 -0
  8. provide/testkit/crypto.py +163 -0
  9. provide/testkit/environment.py +79 -0
  10. provide/testkit/file/__init__.py +40 -0
  11. provide/testkit/file/content_fixtures.py +275 -0
  12. provide/testkit/file/directory_fixtures.py +105 -0
  13. provide/testkit/file/fixtures.py +49 -0
  14. provide/testkit/file/special_fixtures.py +141 -0
  15. provide/testkit/fixtures.py +52 -0
  16. provide/testkit/harness.py +122 -0
  17. provide/testkit/hub.py +22 -0
  18. provide/testkit/logger/__init__.py +39 -0
  19. provide/testkit/logger/hooks.py +100 -0
  20. provide/testkit/logger/reset.py +230 -0
  21. provide/testkit/main.py +22 -0
  22. provide/testkit/mocking/__init__.py +46 -0
  23. provide/testkit/mocking/fixtures.py +340 -0
  24. provide/testkit/process/__init__.py +48 -0
  25. provide/testkit/process/async_fixtures.py +410 -0
  26. provide/testkit/process/fixtures.py +54 -0
  27. provide/testkit/process/subprocess_fixtures.py +208 -0
  28. provide/testkit/quality/__init__.py +101 -0
  29. provide/testkit/quality/artifacts.py +360 -0
  30. provide/testkit/quality/base.py +158 -0
  31. provide/testkit/quality/cli.py +394 -0
  32. provide/testkit/quality/complexity/__init__.py +30 -0
  33. provide/testkit/quality/complexity/analyzer.py +392 -0
  34. provide/testkit/quality/complexity/fixture.py +196 -0
  35. provide/testkit/quality/coverage/__init__.py +36 -0
  36. provide/testkit/quality/coverage/fixture.py +236 -0
  37. provide/testkit/quality/coverage/reporter.py +150 -0
  38. provide/testkit/quality/coverage/tracker.py +313 -0
  39. provide/testkit/quality/decorators.py +380 -0
  40. provide/testkit/quality/documentation/__init__.py +29 -0
  41. provide/testkit/quality/documentation/checker.py +361 -0
  42. provide/testkit/quality/documentation/fixture.py +187 -0
  43. provide/testkit/quality/profiling/__init__.py +30 -0
  44. provide/testkit/quality/profiling/fixture.py +332 -0
  45. provide/testkit/quality/profiling/profiler.py +428 -0
  46. provide/testkit/quality/report.py +266 -0
  47. provide/testkit/quality/runner.py +319 -0
  48. provide/testkit/quality/security/__init__.py +29 -0
  49. provide/testkit/quality/security/fixture.py +196 -0
  50. provide/testkit/quality/security/scanner.py +338 -0
  51. provide/testkit/streams.py +54 -0
  52. provide/testkit/threading/__init__.py +38 -0
  53. provide/testkit/threading/basic_fixtures.py +103 -0
  54. provide/testkit/threading/data_fixtures.py +101 -0
  55. provide/testkit/threading/execution_fixtures.py +268 -0
  56. provide/testkit/threading/fixtures.py +50 -0
  57. provide/testkit/threading/sync_fixtures.py +98 -0
  58. provide/testkit/time/__init__.py +32 -0
  59. provide/testkit/time/fixtures.py +416 -0
  60. provide/testkit/transport/__init__.py +30 -0
  61. provide/testkit/transport/fixtures.py +278 -0
  62. provide_testkit-0.0.0.dev0.dist-info/METADATA +145 -0
  63. provide_testkit-0.0.0.dev0.dist-info/RECORD +66 -0
  64. provide_testkit-0.0.0.dev0.dist-info/WHEEL +5 -0
  65. provide_testkit-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  66. provide_testkit-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,428 @@
1
+ """Performance profiling implementation using memray and cProfile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ import cProfile
7
+ from io import StringIO
8
+ import json
9
+ from pathlib import Path
10
+ import pstats
11
+ import time
12
+ import tracemalloc
13
+ from typing import Any
14
+
15
+ from provide.foundation.file import temp_file
16
+
17
+ try:
18
+ import memray
19
+
20
+ MEMRAY_AVAILABLE = True
21
+ except ImportError:
22
+ MEMRAY_AVAILABLE = False
23
+ memray = None
24
+
25
+ from ..base import QualityResult, QualityToolError
26
+
27
+
28
+ class PerformanceProfiler:
29
+ """Performance profiler using memray, cProfile, and tracemalloc.
30
+
31
+ Provides high-level interface for performance analysis with automatic
32
+ artifact management and integration with the quality framework.
33
+ """
34
+
35
+ def __init__(self, config: dict[str, Any] | None = None):
36
+ """Initialize performance profiler.
37
+
38
+ Args:
39
+ config: Profiler configuration options
40
+ """
41
+ self.config = config or {}
42
+ self.artifact_dir: Path | None = None
43
+
44
+ def profile_function(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> QualityResult:
45
+ """Profile a function's performance.
46
+
47
+ Args:
48
+ func: Function to profile
49
+ *args: Function arguments
50
+ **kwargs: Function keyword arguments
51
+
52
+ Returns:
53
+ QualityResult with profiling data
54
+ """
55
+ start_time = time.time()
56
+
57
+ try:
58
+ # Configure profiling options
59
+ profile_memory = self.config.get("profile_memory", True)
60
+ profile_cpu = self.config.get("profile_cpu", True)
61
+ use_memray = self.config.get("use_memray", MEMRAY_AVAILABLE)
62
+
63
+ results = {}
64
+
65
+ # Memory profiling
66
+ if profile_memory:
67
+ if use_memray and MEMRAY_AVAILABLE:
68
+ memory_result = self._profile_memory_memray(func, *args, **kwargs)
69
+ else:
70
+ memory_result = self._profile_memory_tracemalloc(func, *args, **kwargs)
71
+ results.update(memory_result)
72
+
73
+ # CPU profiling
74
+ if profile_cpu:
75
+ cpu_result = self._profile_cpu(func, *args, **kwargs)
76
+ results.update(cpu_result)
77
+
78
+ # Analyze results
79
+ return self._process_profiling_results(results, time.time() - start_time)
80
+
81
+ except Exception as e:
82
+ return QualityResult(
83
+ tool="profiling",
84
+ passed=False,
85
+ details={"error": str(e), "error_type": type(e).__name__},
86
+ execution_time=time.time() - start_time,
87
+ )
88
+
89
+ def _profile_memory_memray(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> dict[str, Any]:
90
+ """Profile memory usage using memray."""
91
+ if not MEMRAY_AVAILABLE:
92
+ raise QualityToolError("Memray not available", tool="profiling")
93
+
94
+ with temp_file(suffix=".bin", cleanup=False) as output_path:
95
+ try:
96
+ # Run function with memray profiling
97
+ with memray.Tracker(output_path):
98
+ result = func(*args, **kwargs)
99
+
100
+ # Generate basic statistics
101
+ stats = memray.FileReader(output_path).get_memory_snapshots()
102
+ if stats:
103
+ peak_memory = max(snapshot.heap_size for snapshot in stats)
104
+ avg_memory = sum(snapshot.heap_size for snapshot in stats) / len(stats)
105
+ else:
106
+ peak_memory = 0
107
+ avg_memory = 0
108
+
109
+ return {
110
+ "memory_profiling": {
111
+ "tool": "memray",
112
+ "peak_memory_bytes": peak_memory,
113
+ "average_memory_bytes": avg_memory,
114
+ "peak_memory_mb": peak_memory / (1024 * 1024),
115
+ "average_memory_mb": avg_memory / (1024 * 1024),
116
+ "profile_file": str(output_path),
117
+ "function_result": result,
118
+ }
119
+ }
120
+
121
+ finally:
122
+ # Clean up temp file
123
+ if output_path.exists():
124
+ output_path.unlink()
125
+
126
+ def _profile_memory_tracemalloc(
127
+ self, func: Callable[..., Any], *args: Any, **kwargs: Any
128
+ ) -> dict[str, Any]:
129
+ """Profile memory usage using tracemalloc."""
130
+ tracemalloc.start()
131
+
132
+ try:
133
+ # Take initial snapshot
134
+ snapshot1 = tracemalloc.take_snapshot()
135
+
136
+ # Run function
137
+ result = func(*args, **kwargs)
138
+
139
+ # Take final snapshot
140
+ snapshot2 = tracemalloc.take_snapshot()
141
+
142
+ # Calculate memory usage
143
+ current, peak = tracemalloc.get_traced_memory()
144
+ top_stats = snapshot2.compare_to(snapshot1, "lineno")
145
+
146
+ # Get top memory allocations
147
+ top_allocations = []
148
+ for stat in top_stats[:10]: # Top 10 allocations
149
+ top_allocations.append(
150
+ {
151
+ "file": stat.traceback.format()[0] if stat.traceback.format() else "unknown",
152
+ "size_bytes": stat.size,
153
+ "size_mb": stat.size / (1024 * 1024),
154
+ "count": stat.count,
155
+ }
156
+ )
157
+
158
+ return {
159
+ "memory_profiling": {
160
+ "tool": "tracemalloc",
161
+ "current_memory_bytes": current,
162
+ "peak_memory_bytes": peak,
163
+ "current_memory_mb": current / (1024 * 1024),
164
+ "peak_memory_mb": peak / (1024 * 1024),
165
+ "top_allocations": top_allocations,
166
+ "function_result": result,
167
+ }
168
+ }
169
+
170
+ finally:
171
+ tracemalloc.stop()
172
+
173
+ def _profile_cpu(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> dict[str, Any]:
174
+ """Profile CPU usage using cProfile."""
175
+ profiler = cProfile.Profile()
176
+
177
+ # Profile function execution
178
+ profiler.enable()
179
+ start_time = time.perf_counter()
180
+ result = func(*args, **kwargs)
181
+ end_time = time.perf_counter()
182
+ profiler.disable()
183
+
184
+ # Generate statistics
185
+ stats_stream = StringIO()
186
+ stats = pstats.Stats(profiler, stream=stats_stream)
187
+ stats.sort_stats("cumulative")
188
+
189
+ # Get top functions by cumulative time
190
+ top_functions = []
191
+ for func_info, (cc, nc, tt, ct, callers) in stats.stats.items():
192
+ filename, line, func_name = func_info
193
+ top_functions.append(
194
+ {
195
+ "function": f"{filename}:{line}({func_name})",
196
+ "call_count": nc,
197
+ "total_time": tt,
198
+ "cumulative_time": ct,
199
+ "time_per_call": tt / nc if nc > 0 else 0,
200
+ }
201
+ )
202
+
203
+ # Sort by cumulative time and take top 10
204
+ top_functions.sort(key=lambda x: x["cumulative_time"], reverse=True)
205
+
206
+ return {
207
+ "cpu_profiling": {
208
+ "tool": "cProfile",
209
+ "execution_time": end_time - start_time,
210
+ "total_function_calls": stats.total_calls,
211
+ "primitive_calls": stats.prim_calls,
212
+ "top_functions": top_functions[:10],
213
+ "function_result": result,
214
+ }
215
+ }
216
+
217
+ def _process_profiling_results(self, results: dict[str, Any], execution_time: float) -> QualityResult:
218
+ """Process profiling results into QualityResult."""
219
+
220
+ # Extract key metrics
221
+ memory_data = results.get("memory_profiling", {})
222
+ cpu_data = results.get("cpu_profiling", {})
223
+
224
+ # Calculate scores based on thresholds
225
+ memory_score = self._calculate_memory_score(memory_data)
226
+ cpu_score = self._calculate_cpu_score(cpu_data)
227
+
228
+ # Overall score is average of component scores
229
+ overall_score = (
230
+ (memory_score + cpu_score) / 2 if memory_score and cpu_score else (memory_score or cpu_score or 0)
231
+ )
232
+
233
+ # Determine pass/fail
234
+ min_score = self.config.get("min_score", 70.0)
235
+ max_memory_mb = self.config.get("max_memory_mb")
236
+ max_execution_time = self.config.get("max_execution_time")
237
+
238
+ passed = overall_score >= min_score
239
+
240
+ # Check hard limits
241
+ if max_memory_mb and memory_data:
242
+ peak_mb = memory_data.get("peak_memory_mb", 0)
243
+ if peak_mb > max_memory_mb:
244
+ passed = False
245
+
246
+ if max_execution_time and cpu_data:
247
+ exec_time = cpu_data.get("execution_time", 0)
248
+ if exec_time > max_execution_time:
249
+ passed = False
250
+
251
+ # Create detailed results
252
+ details = {
253
+ "memory": memory_data,
254
+ "cpu": cpu_data,
255
+ "scores": {"memory_score": memory_score, "cpu_score": cpu_score, "overall_score": overall_score},
256
+ "thresholds": {
257
+ "min_score": min_score,
258
+ "max_memory_mb": max_memory_mb,
259
+ "max_execution_time": max_execution_time,
260
+ },
261
+ }
262
+
263
+ return QualityResult(
264
+ tool="profiling",
265
+ passed=passed,
266
+ score=overall_score,
267
+ details=details,
268
+ execution_time=execution_time,
269
+ )
270
+
271
+ def _calculate_memory_score(self, memory_data: dict[str, Any]) -> float | None:
272
+ """Calculate memory efficiency score."""
273
+ if not memory_data:
274
+ return None
275
+
276
+ peak_mb = memory_data.get("peak_memory_mb", 0)
277
+
278
+ # Score based on memory usage (lower is better)
279
+ if peak_mb <= 10: # Very efficient
280
+ return 100.0
281
+ elif peak_mb <= 50: # Good
282
+ return 90.0
283
+ elif peak_mb <= 100: # Acceptable
284
+ return 80.0
285
+ elif peak_mb <= 200: # Poor
286
+ return 60.0
287
+ elif peak_mb <= 500: # Very poor
288
+ return 40.0
289
+ else: # Unacceptable
290
+ return 20.0
291
+
292
+ def _calculate_cpu_score(self, cpu_data: dict[str, Any]) -> float | None:
293
+ """Calculate CPU efficiency score."""
294
+ if not cpu_data:
295
+ return None
296
+
297
+ exec_time = cpu_data.get("execution_time", 0)
298
+
299
+ # Score based on execution time (lower is better)
300
+ if exec_time <= 0.1: # Very fast
301
+ return 100.0
302
+ elif exec_time <= 0.5: # Fast
303
+ return 90.0
304
+ elif exec_time <= 1.0: # Acceptable
305
+ return 80.0
306
+ elif exec_time <= 2.0: # Slow
307
+ return 60.0
308
+ elif exec_time <= 5.0: # Very slow
309
+ return 40.0
310
+ else: # Unacceptable
311
+ return 20.0
312
+
313
+ def generate_report(self, result: QualityResult, format: str = "terminal") -> str:
314
+ """Generate profiling report.
315
+
316
+ Args:
317
+ result: Profiling result
318
+ format: Report format
319
+
320
+ Returns:
321
+ Formatted report
322
+ """
323
+ if format == "terminal":
324
+ return self._generate_text_report(result)
325
+ elif format == "json":
326
+ return json.dumps(
327
+ {
328
+ "tool": result.tool,
329
+ "passed": result.passed,
330
+ "score": result.score,
331
+ "details": result.details,
332
+ },
333
+ indent=2,
334
+ )
335
+ else:
336
+ return str(result.details)
337
+
338
+ def _generate_text_report(self, result: QualityResult) -> str:
339
+ """Generate text profiling report."""
340
+ lines = [
341
+ f"Performance Profiling Report - {result.tool}",
342
+ "=" * 50,
343
+ f"Status: {'✅ PASSED' if result.passed else '❌ FAILED'}",
344
+ f"Overall Score: {result.score:.1f}%",
345
+ ]
346
+
347
+ details = result.details
348
+ scores = details.get("scores", {})
349
+
350
+ if scores:
351
+ lines.extend(
352
+ [
353
+ "",
354
+ "Component Scores:",
355
+ f" Memory Score: {scores.get('memory_score', 'N/A')}%",
356
+ f" CPU Score: {scores.get('cpu_score', 'N/A')}%",
357
+ ]
358
+ )
359
+
360
+ # Memory analysis
361
+ memory_data = details.get("memory", {})
362
+ if memory_data:
363
+ lines.extend(
364
+ [
365
+ "",
366
+ f"Memory Analysis ({memory_data.get('tool', 'unknown')}):",
367
+ f" Peak Memory: {memory_data.get('peak_memory_mb', 0):.2f} MB",
368
+ ]
369
+ )
370
+
371
+ if "average_memory_mb" in memory_data:
372
+ lines.append(f" Average Memory: {memory_data['average_memory_mb']:.2f} MB")
373
+
374
+ # CPU analysis
375
+ cpu_data = details.get("cpu", {})
376
+ if cpu_data:
377
+ lines.extend(
378
+ [
379
+ "",
380
+ f"CPU Analysis ({cpu_data.get('tool', 'unknown')}):",
381
+ f" Execution Time: {cpu_data.get('execution_time', 0):.4f}s",
382
+ f" Total Function Calls: {cpu_data.get('total_function_calls', 0):,}",
383
+ ]
384
+ )
385
+
386
+ top_functions = cpu_data.get("top_functions", [])
387
+ if top_functions:
388
+ lines.extend(
389
+ [
390
+ "",
391
+ "Top CPU Consumers:",
392
+ ]
393
+ )
394
+ for i, func in enumerate(top_functions[:5], 1):
395
+ lines.append(f" {i}. {func['function']} ({func['cumulative_time']:.4f}s)")
396
+
397
+ # Thresholds
398
+ thresholds = details.get("thresholds", {})
399
+ if thresholds:
400
+ lines.extend(
401
+ [
402
+ "",
403
+ "Thresholds:",
404
+ f" Minimum Score: {thresholds.get('min_score', 'N/A')}%",
405
+ ]
406
+ )
407
+
408
+ if thresholds.get("max_memory_mb"):
409
+ lines.append(f" Maximum Memory: {thresholds['max_memory_mb']} MB")
410
+ if thresholds.get("max_execution_time"):
411
+ lines.append(f" Maximum Execution Time: {thresholds['max_execution_time']}s")
412
+
413
+ if result.execution_time:
414
+ lines.append(f"\nProfiling Time: {result.execution_time:.2f}s")
415
+
416
+ return "\n".join(lines)
417
+
418
+ def report(self, result: QualityResult, format: str = "terminal") -> str:
419
+ """Generate report from QualityResult (implements QualityTool protocol).
420
+
421
+ Args:
422
+ result: Profiling result
423
+ format: Report format
424
+
425
+ Returns:
426
+ Formatted report
427
+ """
428
+ return self.generate_report(result, format)
@@ -0,0 +1,266 @@
1
+ """Report generation for quality analysis results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from provide.foundation.file import atomic_write_text, ensure_dir
10
+
11
+ from .base import QualityResult
12
+
13
+
14
+ class ReportGenerator:
15
+ """Generates reports from quality analysis results.
16
+
17
+ Supports multiple output formats including terminal, JSON, HTML, and Markdown.
18
+ """
19
+
20
+ def __init__(self, config: dict[str, Any] | None = None):
21
+ """Initialize report generator.
22
+
23
+ Args:
24
+ config: Configuration for report generation
25
+ """
26
+ self.config = config or {}
27
+
28
+ def generate(self, results: dict[str, QualityResult], format: str = "terminal") -> str:
29
+ """Generate a report from quality results.
30
+
31
+ Args:
32
+ results: Quality results to report on
33
+ format: Output format (terminal, json, html, markdown)
34
+
35
+ Returns:
36
+ Formatted report string
37
+ """
38
+ if format == "terminal":
39
+ return self._generate_terminal_report(results)
40
+ elif format == "json":
41
+ return self._generate_json_report(results)
42
+ elif format == "html":
43
+ return self._generate_html_report(results)
44
+ elif format == "markdown":
45
+ return self._generate_markdown_report(results)
46
+ else:
47
+ raise ValueError(f"Unsupported report format: {format}")
48
+
49
+ def _generate_terminal_report(self, results: dict[str, QualityResult]) -> str:
50
+ """Generate terminal-friendly report."""
51
+ lines = []
52
+ lines.append("🔍 Quality Analysis Report")
53
+ lines.append("=" * 50)
54
+ lines.append("")
55
+
56
+ # Summary
57
+ total = len(results)
58
+ passed = sum(1 for r in results.values() if r.passed)
59
+ failed = total - passed
60
+
61
+ lines.append(f"📊 Summary: {passed}/{total} tools passed")
62
+ if failed > 0:
63
+ lines.append(f"❌ {failed} tools failed")
64
+ lines.append("")
65
+
66
+ # Individual results
67
+ for tool_name, result in results.items():
68
+ lines.append(self._format_tool_result(result))
69
+
70
+ # Details for failed tools
71
+ failed_results = {name: result for name, result in results.items() if not result.passed}
72
+ if failed_results:
73
+ lines.append("")
74
+ lines.append("🔍 Failure Details:")
75
+ lines.append("-" * 30)
76
+ for tool_name, result in failed_results.items():
77
+ lines.append(f"\n{tool_name}:")
78
+ if "error" in result.details:
79
+ lines.append(f" Error: {result.details['error']}")
80
+ for key, value in result.details.items():
81
+ if key != "error":
82
+ lines.append(f" {key}: {value}")
83
+
84
+ return "\n".join(lines)
85
+
86
+ def _format_tool_result(self, result: QualityResult) -> str:
87
+ """Format a single tool result for terminal display."""
88
+ status_icon = "✅" if result.passed else "❌"
89
+ score_text = f" ({result.score:.1f}%)" if result.score is not None else ""
90
+ time_text = f" [{result.execution_time:.2f}s]" if result.execution_time is not None else ""
91
+
92
+ return f"{status_icon} {result.tool.title()}{score_text}{time_text}"
93
+
94
+ def _generate_json_report(self, results: dict[str, QualityResult]) -> str:
95
+ """Generate JSON report."""
96
+ report_data: dict[str, Any] = {
97
+ "summary": {
98
+ "total_tools": len(results),
99
+ "passed": sum(1 for r in results.values() if r.passed),
100
+ "failed": sum(1 for r in results.values() if not r.passed),
101
+ "overall_score": self._calculate_overall_score(results),
102
+ },
103
+ "results": {},
104
+ }
105
+
106
+ for tool_name, result in results.items():
107
+ report_data["results"][tool_name] = {
108
+ "tool": result.tool,
109
+ "passed": result.passed,
110
+ "score": result.score,
111
+ "execution_time": result.execution_time,
112
+ "details": result.details,
113
+ "artifacts": [str(path) for path in result.artifacts],
114
+ }
115
+
116
+ return json.dumps(report_data, indent=2)
117
+
118
+ def _generate_html_report(self, results: dict[str, QualityResult]) -> str:
119
+ """Generate HTML report."""
120
+ overall_score = self._calculate_overall_score(results)
121
+ passed_count = sum(1 for r in results.values() if r.passed)
122
+ total_count = len(results)
123
+
124
+ html = f"""<!DOCTYPE html>
125
+ <html>
126
+ <head>
127
+ <title>Quality Analysis Report</title>
128
+ <style>
129
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
130
+ .header {{ background: #f8f9fa; padding: 20px; border-radius: 5px; }}
131
+ .summary {{ margin: 20px 0; }}
132
+ .tool-result {{ margin: 10px 0; padding: 15px; border-radius: 5px; }}
133
+ .passed {{ background: #d4edda; border-left: 4px solid #28a745; }}
134
+ .failed {{ background: #f8d7da; border-left: 4px solid #dc3545; }}
135
+ .score {{ font-weight: bold; }}
136
+ .details {{ margin-top: 10px; font-size: 0.9em; color: #666; }}
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <div class="header">
141
+ <h1>🔍 Quality Analysis Report</h1>
142
+ <div class="summary">
143
+ <p><strong>Overall Score:</strong> {overall_score:.1f}%</p>
144
+ <p><strong>Tools Passed:</strong> {passed_count}/{total_count}</p>
145
+ </div>
146
+ </div>
147
+ """
148
+
149
+ for tool_name, result in results.items():
150
+ status_class = "passed" if result.passed else "failed"
151
+ score_text = f" ({result.score:.1f}%)" if result.score is not None else ""
152
+
153
+ html += f"""
154
+ <div class="tool-result {status_class}">
155
+ <h3>{result.tool.title()}{score_text}</h3>
156
+ <p class="score">Status: {"✅ PASSED" if result.passed else "❌ FAILED"}</p>
157
+ """
158
+
159
+ if result.details:
160
+ html += '<div class="details"><strong>Details:</strong><ul>'
161
+ for key, value in result.details.items():
162
+ html += f"<li><strong>{key}:</strong> {value}</li>"
163
+ html += "</ul></div>"
164
+
165
+ html += " </div>"
166
+
167
+ html += """
168
+ </body>
169
+ </html>"""
170
+
171
+ return html
172
+
173
+ def _generate_markdown_report(self, results: dict[str, QualityResult]) -> str:
174
+ """Generate Markdown report."""
175
+ lines = []
176
+ lines.append("# 🔍 Quality Analysis Report")
177
+ lines.append("")
178
+
179
+ # Summary
180
+ total = len(results)
181
+ passed = sum(1 for r in results.values() if r.passed)
182
+ overall_score = self._calculate_overall_score(results)
183
+
184
+ lines.append("## 📊 Summary")
185
+ lines.append("")
186
+ lines.append(f"- **Overall Score:** {overall_score:.1f}%")
187
+ lines.append(f"- **Tools Passed:** {passed}/{total}")
188
+ lines.append("")
189
+
190
+ # Results table
191
+ lines.append("## 📋 Results")
192
+ lines.append("")
193
+ lines.append("| Tool | Status | Score | Time |")
194
+ lines.append("|------|--------|-------|------|")
195
+
196
+ for tool_name, result in results.items():
197
+ status = "✅ PASSED" if result.passed else "❌ FAILED"
198
+ score = f"{result.score:.1f}%" if result.score is not None else "N/A"
199
+ time = f"{result.execution_time:.2f}s" if result.execution_time is not None else "N/A"
200
+ lines.append(f"| {result.tool.title()} | {status} | {score} | {time} |")
201
+
202
+ # Failed tool details
203
+ failed_results = {name: result for name, result in results.items() if not result.passed}
204
+ if failed_results:
205
+ lines.append("")
206
+ lines.append("## ❌ Failure Details")
207
+ lines.append("")
208
+
209
+ for tool_name, result in failed_results.items():
210
+ lines.append(f"### {result.tool.title()}")
211
+ lines.append("")
212
+ for key, value in result.details.items():
213
+ lines.append(f"- **{key}:** {value}")
214
+ lines.append("")
215
+
216
+ return "\n".join(lines)
217
+
218
+ def _calculate_overall_score(self, results: dict[str, QualityResult]) -> float:
219
+ """Calculate overall quality score from all results.
220
+
221
+ Args:
222
+ results: Quality results
223
+
224
+ Returns:
225
+ Overall score (0-100)
226
+ """
227
+ if not results:
228
+ return 0.0
229
+
230
+ # Weight passing vs failing (passing gets base score)
231
+ scores = []
232
+ for result in results.values():
233
+ if result.passed:
234
+ # Use tool score if available, otherwise 100 for passing
235
+ scores.append(result.score if result.score is not None else 100.0)
236
+ else:
237
+ # Failing tools get 0
238
+ scores.append(0.0)
239
+
240
+ return sum(scores) / len(scores) if scores else 0.0
241
+
242
+ def save_report(
243
+ self, results: dict[str, QualityResult], output_path: Path, format: str | None = None
244
+ ) -> None:
245
+ """Save report to file.
246
+
247
+ Args:
248
+ results: Quality results to report on
249
+ output_path: Path to save report to
250
+ format: Output format (auto-detected from extension if None)
251
+ """
252
+ if format is None:
253
+ # Auto-detect format from file extension
254
+ suffix = output_path.suffix.lower()
255
+ if suffix == ".json":
256
+ format = "json"
257
+ elif suffix == ".html":
258
+ format = "html"
259
+ elif suffix == ".md":
260
+ format = "markdown"
261
+ else:
262
+ format = "terminal"
263
+
264
+ report_content = self.generate(results, format)
265
+ ensure_dir(output_path.parent)
266
+ atomic_write_text(output_path, report_content)