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.
- provide/__init__.py +3 -0
- provide/testkit/__init__.py +248 -0
- provide/testkit/archive/__init__.py +24 -0
- provide/testkit/archive/fixtures.py +217 -0
- provide/testkit/cli.py +229 -0
- provide/testkit/common/__init__.py +32 -0
- provide/testkit/common/fixtures.py +234 -0
- provide/testkit/crypto.py +163 -0
- provide/testkit/environment.py +79 -0
- provide/testkit/file/__init__.py +40 -0
- provide/testkit/file/content_fixtures.py +275 -0
- provide/testkit/file/directory_fixtures.py +105 -0
- provide/testkit/file/fixtures.py +49 -0
- provide/testkit/file/special_fixtures.py +141 -0
- provide/testkit/fixtures.py +52 -0
- provide/testkit/harness.py +122 -0
- provide/testkit/hub.py +22 -0
- provide/testkit/logger/__init__.py +39 -0
- provide/testkit/logger/hooks.py +100 -0
- provide/testkit/logger/reset.py +230 -0
- provide/testkit/main.py +22 -0
- provide/testkit/mocking/__init__.py +46 -0
- provide/testkit/mocking/fixtures.py +340 -0
- provide/testkit/process/__init__.py +48 -0
- provide/testkit/process/async_fixtures.py +410 -0
- provide/testkit/process/fixtures.py +54 -0
- provide/testkit/process/subprocess_fixtures.py +208 -0
- provide/testkit/quality/__init__.py +101 -0
- provide/testkit/quality/artifacts.py +360 -0
- provide/testkit/quality/base.py +158 -0
- provide/testkit/quality/cli.py +394 -0
- provide/testkit/quality/complexity/__init__.py +30 -0
- provide/testkit/quality/complexity/analyzer.py +392 -0
- provide/testkit/quality/complexity/fixture.py +196 -0
- provide/testkit/quality/coverage/__init__.py +36 -0
- provide/testkit/quality/coverage/fixture.py +236 -0
- provide/testkit/quality/coverage/reporter.py +150 -0
- provide/testkit/quality/coverage/tracker.py +313 -0
- provide/testkit/quality/decorators.py +380 -0
- provide/testkit/quality/documentation/__init__.py +29 -0
- provide/testkit/quality/documentation/checker.py +361 -0
- provide/testkit/quality/documentation/fixture.py +187 -0
- provide/testkit/quality/profiling/__init__.py +30 -0
- provide/testkit/quality/profiling/fixture.py +332 -0
- provide/testkit/quality/profiling/profiler.py +428 -0
- provide/testkit/quality/report.py +266 -0
- provide/testkit/quality/runner.py +319 -0
- provide/testkit/quality/security/__init__.py +29 -0
- provide/testkit/quality/security/fixture.py +196 -0
- provide/testkit/quality/security/scanner.py +338 -0
- provide/testkit/streams.py +54 -0
- provide/testkit/threading/__init__.py +38 -0
- provide/testkit/threading/basic_fixtures.py +103 -0
- provide/testkit/threading/data_fixtures.py +101 -0
- provide/testkit/threading/execution_fixtures.py +268 -0
- provide/testkit/threading/fixtures.py +50 -0
- provide/testkit/threading/sync_fixtures.py +98 -0
- provide/testkit/time/__init__.py +32 -0
- provide/testkit/time/fixtures.py +416 -0
- provide/testkit/transport/__init__.py +30 -0
- provide/testkit/transport/fixtures.py +278 -0
- provide_testkit-0.0.0.dev0.dist-info/METADATA +145 -0
- provide_testkit-0.0.0.dev0.dist-info/RECORD +66 -0
- provide_testkit-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_testkit-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- 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)
|