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,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code quality analysis utilities for the provide testkit.
|
|
3
|
+
|
|
4
|
+
This module provides pytest fixtures and utilities for integrating code quality
|
|
5
|
+
tools into testing workflows. All quality tools are optional and only activated
|
|
6
|
+
when explicitly requested.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Coverage tracking and reporting
|
|
10
|
+
- Security scanning with Bandit
|
|
11
|
+
- Complexity analysis with Radon
|
|
12
|
+
- Performance profiling with py-spy
|
|
13
|
+
- Documentation coverage with Interrogate
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
# Basic quality fixture
|
|
17
|
+
def test_with_coverage(quality_coverage):
|
|
18
|
+
result = quality_coverage.track_coverage()
|
|
19
|
+
|
|
20
|
+
# Quality decorator
|
|
21
|
+
@quality_check(coverage=90, security=True)
|
|
22
|
+
def test_with_gates():
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# CLI usage
|
|
26
|
+
provide-testkit quality analyze src/
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Core exports
|
|
30
|
+
from .base import BaseQualityFixture, QualityResult, QualityTool
|
|
31
|
+
from .report import ReportGenerator
|
|
32
|
+
from .runner import QualityRunner
|
|
33
|
+
|
|
34
|
+
# Lazy imports for performance - only import when used
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BaseQualityFixture",
|
|
37
|
+
"QualityResult",
|
|
38
|
+
"QualityRunner",
|
|
39
|
+
"QualityTool",
|
|
40
|
+
"ReportGenerator",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name: str):
|
|
45
|
+
"""Lazy import quality tools to avoid import overhead."""
|
|
46
|
+
if name == "CoverageFixture":
|
|
47
|
+
from .coverage import CoverageFixture
|
|
48
|
+
|
|
49
|
+
return CoverageFixture
|
|
50
|
+
elif name == "SecurityFixture":
|
|
51
|
+
from .security import SecurityFixture
|
|
52
|
+
|
|
53
|
+
return SecurityFixture
|
|
54
|
+
elif name == "ComplexityFixture":
|
|
55
|
+
from .complexity import ComplexityFixture
|
|
56
|
+
|
|
57
|
+
return ComplexityFixture
|
|
58
|
+
elif name == "ProfilingFixture":
|
|
59
|
+
from .profiling import ProfilingFixture
|
|
60
|
+
|
|
61
|
+
return ProfilingFixture
|
|
62
|
+
elif name == "DocumentationFixture":
|
|
63
|
+
from .documentation import DocumentationFixture
|
|
64
|
+
|
|
65
|
+
return DocumentationFixture
|
|
66
|
+
|
|
67
|
+
# Quality decorators
|
|
68
|
+
elif name in [
|
|
69
|
+
"quality_gate",
|
|
70
|
+
"coverage_gate",
|
|
71
|
+
"security_gate",
|
|
72
|
+
"complexity_gate",
|
|
73
|
+
"documentation_gate",
|
|
74
|
+
"performance_gate",
|
|
75
|
+
"quality_check",
|
|
76
|
+
"coverage_required",
|
|
77
|
+
"security_required",
|
|
78
|
+
"complexity_required",
|
|
79
|
+
"documentation_required",
|
|
80
|
+
"performance_required",
|
|
81
|
+
"quality_required",
|
|
82
|
+
]:
|
|
83
|
+
from .decorators import (
|
|
84
|
+
complexity_gate,
|
|
85
|
+
complexity_required,
|
|
86
|
+
coverage_gate,
|
|
87
|
+
coverage_required,
|
|
88
|
+
documentation_gate,
|
|
89
|
+
documentation_required,
|
|
90
|
+
performance_gate,
|
|
91
|
+
performance_required,
|
|
92
|
+
quality_check,
|
|
93
|
+
quality_gate,
|
|
94
|
+
quality_required,
|
|
95
|
+
security_gate,
|
|
96
|
+
security_required,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return locals()[name]
|
|
100
|
+
|
|
101
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Artifact management for quality analysis results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from provide.foundation.file import ensure_dir
|
|
11
|
+
|
|
12
|
+
from .base import QualityResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArtifactManager:
|
|
16
|
+
"""Manages artifacts generated during quality analysis.
|
|
17
|
+
|
|
18
|
+
Provides centralized artifact management with organization, cleanup,
|
|
19
|
+
and metadata tracking capabilities.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_dir: Path | str = ".quality-artifacts"):
|
|
23
|
+
"""Initialize artifact manager.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_dir: Base directory for all artifacts
|
|
27
|
+
"""
|
|
28
|
+
self.base_dir = Path(base_dir)
|
|
29
|
+
self.session_id = str(int(time.time()))
|
|
30
|
+
|
|
31
|
+
def create_session_dir(self, tool: str) -> Path:
|
|
32
|
+
"""Create a session-specific directory for a tool.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
tool: Tool name
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to tool's session directory
|
|
39
|
+
"""
|
|
40
|
+
session_dir = self.base_dir / self.session_id / tool
|
|
41
|
+
ensure_dir(session_dir)
|
|
42
|
+
return session_dir
|
|
43
|
+
|
|
44
|
+
def create_timestamped_dir(self, tool: str) -> Path:
|
|
45
|
+
"""Create a timestamped directory for a tool.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
tool: Tool name
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path to tool's timestamped directory
|
|
52
|
+
"""
|
|
53
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
54
|
+
timestamped_dir = self.base_dir / tool / timestamp
|
|
55
|
+
ensure_dir(timestamped_dir)
|
|
56
|
+
return timestamped_dir
|
|
57
|
+
|
|
58
|
+
def get_latest_dir(self, tool: str) -> Path | None:
|
|
59
|
+
"""Get the latest artifact directory for a tool.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
tool: Tool name
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Path to latest directory or None if none exist
|
|
66
|
+
"""
|
|
67
|
+
tool_dir = self.base_dir / tool
|
|
68
|
+
if not tool_dir.exists():
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Find latest timestamped directory
|
|
72
|
+
subdirs = [d for d in tool_dir.iterdir() if d.is_dir()]
|
|
73
|
+
if not subdirs:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return max(subdirs, key=lambda d: d.stat().st_mtime)
|
|
77
|
+
|
|
78
|
+
def organize_artifacts(self, result: QualityResult, target_dir: Path) -> None:
|
|
79
|
+
"""Organize artifacts from a quality result.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
result: Quality result with artifacts
|
|
83
|
+
target_dir: Target directory for organized artifacts
|
|
84
|
+
"""
|
|
85
|
+
if not result.artifacts:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
ensure_dir(target_dir)
|
|
89
|
+
|
|
90
|
+
# Copy artifacts to organized location
|
|
91
|
+
for artifact_path in result.artifacts:
|
|
92
|
+
if not artifact_path.exists():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Determine target filename
|
|
96
|
+
target_filename = f"{result.tool}_{artifact_path.name}"
|
|
97
|
+
target_path = target_dir / target_filename
|
|
98
|
+
|
|
99
|
+
# Copy artifact
|
|
100
|
+
if artifact_path.is_file():
|
|
101
|
+
shutil.copy2(artifact_path, target_path)
|
|
102
|
+
elif artifact_path.is_dir():
|
|
103
|
+
shutil.copytree(artifact_path, target_path, dirs_exist_ok=True)
|
|
104
|
+
|
|
105
|
+
# Create metadata file
|
|
106
|
+
metadata = {
|
|
107
|
+
"tool": result.tool,
|
|
108
|
+
"passed": result.passed,
|
|
109
|
+
"score": result.score,
|
|
110
|
+
"execution_time": result.execution_time,
|
|
111
|
+
"timestamp": time.time(),
|
|
112
|
+
"artifacts": [str(p) for p in result.artifacts],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
metadata_path = target_dir / f"{result.tool}_metadata.json"
|
|
116
|
+
with open(metadata_path, "w") as f:
|
|
117
|
+
import json
|
|
118
|
+
|
|
119
|
+
json.dump(metadata, f, indent=2)
|
|
120
|
+
|
|
121
|
+
def cleanup_old_artifacts(self, tool: str | None = None, keep_count: int = 5) -> None:
|
|
122
|
+
"""Clean up old artifact directories.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
tool: Specific tool to clean up (None for all tools)
|
|
126
|
+
keep_count: Number of recent directories to keep
|
|
127
|
+
"""
|
|
128
|
+
if tool:
|
|
129
|
+
tool_dirs = [self.base_dir / tool] if (self.base_dir / tool).exists() else []
|
|
130
|
+
else:
|
|
131
|
+
tool_dirs = [d for d in self.base_dir.iterdir() if d.is_dir()]
|
|
132
|
+
|
|
133
|
+
for tool_dir in tool_dirs:
|
|
134
|
+
if not tool_dir.is_dir():
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Get all timestamped subdirectories
|
|
138
|
+
subdirs = [d for d in tool_dir.iterdir() if d.is_dir()]
|
|
139
|
+
if len(subdirs) <= keep_count:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Sort by modification time and remove oldest
|
|
143
|
+
subdirs.sort(key=lambda d: d.stat().st_mtime, reverse=True)
|
|
144
|
+
for old_dir in subdirs[keep_count:]:
|
|
145
|
+
shutil.rmtree(old_dir, ignore_errors=True)
|
|
146
|
+
|
|
147
|
+
def create_summary_report(self, results: dict[str, QualityResult]) -> Path:
|
|
148
|
+
"""Create a summary report across all tools.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
results: Results from multiple tools
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Path to summary report
|
|
155
|
+
"""
|
|
156
|
+
summary_dir = self.base_dir / "summaries"
|
|
157
|
+
ensure_dir(summary_dir)
|
|
158
|
+
|
|
159
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
160
|
+
summary_path = summary_dir / f"quality_summary_{timestamp}.json"
|
|
161
|
+
|
|
162
|
+
# Build summary data
|
|
163
|
+
summary = {
|
|
164
|
+
"timestamp": time.time(),
|
|
165
|
+
"session_id": self.session_id,
|
|
166
|
+
"overall_passed": all(result.passed for result in results.values()),
|
|
167
|
+
"tools": {},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for tool, result in results.items():
|
|
171
|
+
summary["tools"][tool] = {
|
|
172
|
+
"passed": result.passed,
|
|
173
|
+
"score": result.score,
|
|
174
|
+
"execution_time": result.execution_time,
|
|
175
|
+
"artifact_count": len(result.artifacts),
|
|
176
|
+
"key_metrics": self._extract_key_metrics(result),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Write summary
|
|
180
|
+
with open(summary_path, "w") as f:
|
|
181
|
+
import json
|
|
182
|
+
|
|
183
|
+
json.dump(summary, f, indent=2)
|
|
184
|
+
|
|
185
|
+
return summary_path
|
|
186
|
+
|
|
187
|
+
def _extract_key_metrics(self, result: QualityResult) -> dict[str, Any]:
|
|
188
|
+
"""Extract key metrics from a quality result.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
result: Quality result
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Dictionary of key metrics
|
|
195
|
+
"""
|
|
196
|
+
details = result.details
|
|
197
|
+
metrics = {}
|
|
198
|
+
|
|
199
|
+
if result.tool == "coverage":
|
|
200
|
+
metrics.update(
|
|
201
|
+
{
|
|
202
|
+
"coverage_percentage": details.get("coverage_percentage"),
|
|
203
|
+
"lines_covered": details.get("lines_covered"),
|
|
204
|
+
"lines_missing": details.get("lines_missing"),
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
elif result.tool == "security":
|
|
208
|
+
metrics.update(
|
|
209
|
+
{
|
|
210
|
+
"total_issues": details.get("total_issues"),
|
|
211
|
+
"high_severity": details.get("severity_counts", {}).get("HIGH"),
|
|
212
|
+
"medium_severity": details.get("severity_counts", {}).get("MEDIUM"),
|
|
213
|
+
"low_severity": details.get("severity_counts", {}).get("LOW"),
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
elif result.tool == "complexity":
|
|
217
|
+
metrics.update(
|
|
218
|
+
{
|
|
219
|
+
"average_complexity": details.get("average_complexity"),
|
|
220
|
+
"max_complexity": details.get("max_complexity"),
|
|
221
|
+
"overall_grade": details.get("overall_grade"),
|
|
222
|
+
"total_functions": details.get("total_functions"),
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
elif result.tool == "documentation":
|
|
226
|
+
metrics.update(
|
|
227
|
+
{
|
|
228
|
+
"total_coverage": details.get("total_coverage"),
|
|
229
|
+
"covered_count": details.get("covered_count"),
|
|
230
|
+
"missing_count": details.get("missing_count"),
|
|
231
|
+
"grade": details.get("grade"),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
elif result.tool == "profiling":
|
|
235
|
+
memory_data = details.get("memory", {})
|
|
236
|
+
cpu_data = details.get("cpu", {})
|
|
237
|
+
metrics.update(
|
|
238
|
+
{
|
|
239
|
+
"peak_memory_mb": memory_data.get("peak_memory_mb"),
|
|
240
|
+
"execution_time": cpu_data.get("execution_time"),
|
|
241
|
+
"memory_score": details.get("scores", {}).get("memory_score"),
|
|
242
|
+
"cpu_score": details.get("scores", {}).get("cpu_score"),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Remove None values
|
|
247
|
+
return {k: v for k, v in metrics.items() if v is not None}
|
|
248
|
+
|
|
249
|
+
def export_artifacts(self, export_path: Path | str, compress: bool = True) -> Path:
|
|
250
|
+
"""Export all artifacts to a specified location.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
export_path: Path to export to
|
|
254
|
+
compress: Whether to create a compressed archive
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Path to exported artifacts
|
|
258
|
+
"""
|
|
259
|
+
export_path = Path(export_path)
|
|
260
|
+
|
|
261
|
+
if compress:
|
|
262
|
+
# Create compressed archive
|
|
263
|
+
if not export_path.suffix:
|
|
264
|
+
archive_path = export_path.with_suffix(".tar.gz")
|
|
265
|
+
else:
|
|
266
|
+
archive_path = export_path
|
|
267
|
+
|
|
268
|
+
shutil.make_archive(str(archive_path.with_suffix("")), "gztar", self.base_dir)
|
|
269
|
+
return archive_path
|
|
270
|
+
else:
|
|
271
|
+
# Copy directory tree
|
|
272
|
+
if export_path.exists():
|
|
273
|
+
shutil.rmtree(export_path)
|
|
274
|
+
|
|
275
|
+
shutil.copytree(self.base_dir, export_path)
|
|
276
|
+
return export_path
|
|
277
|
+
|
|
278
|
+
def get_disk_usage(self) -> dict[str, int]:
|
|
279
|
+
"""Get disk usage statistics for artifacts.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dictionary with disk usage information
|
|
283
|
+
"""
|
|
284
|
+
if not self.base_dir.exists():
|
|
285
|
+
return {"total_bytes": 0, "tool_breakdown": {}}
|
|
286
|
+
|
|
287
|
+
total_size = 0
|
|
288
|
+
tool_breakdown = {}
|
|
289
|
+
|
|
290
|
+
for item in self.base_dir.rglob("*"):
|
|
291
|
+
if item.is_file():
|
|
292
|
+
size = item.stat().st_size
|
|
293
|
+
total_size += size
|
|
294
|
+
|
|
295
|
+
# Determine which tool this belongs to
|
|
296
|
+
relative_path = item.relative_to(self.base_dir)
|
|
297
|
+
tool = relative_path.parts[0] if relative_path.parts else "unknown"
|
|
298
|
+
tool_breakdown[tool] = tool_breakdown.get(tool, 0) + size
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"total_bytes": total_size,
|
|
302
|
+
"total_mb": total_size / (1024 * 1024),
|
|
303
|
+
"tool_breakdown": tool_breakdown,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
def generate_index(self) -> Path:
|
|
307
|
+
"""Generate an index of all artifacts.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Path to generated index file
|
|
311
|
+
"""
|
|
312
|
+
index_path = self.base_dir / "index.json"
|
|
313
|
+
|
|
314
|
+
index_data = {
|
|
315
|
+
"generated_at": time.time(),
|
|
316
|
+
"session_id": self.session_id,
|
|
317
|
+
"base_directory": str(self.base_dir),
|
|
318
|
+
"disk_usage": self.get_disk_usage(),
|
|
319
|
+
"tools": {},
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Scan for tool directories and artifacts
|
|
323
|
+
for tool_dir in self.base_dir.iterdir():
|
|
324
|
+
if not tool_dir.is_dir() or tool_dir.name in ["summaries", "exports"]:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
tool_info = {"latest_run": None, "total_runs": 0, "artifacts": []}
|
|
328
|
+
|
|
329
|
+
# Find all run directories
|
|
330
|
+
run_dirs = [d for d in tool_dir.iterdir() if d.is_dir()]
|
|
331
|
+
tool_info["total_runs"] = len(run_dirs)
|
|
332
|
+
|
|
333
|
+
if run_dirs:
|
|
334
|
+
latest_dir = max(run_dirs, key=lambda d: d.stat().st_mtime)
|
|
335
|
+
tool_info["latest_run"] = {
|
|
336
|
+
"timestamp": latest_dir.stat().st_mtime,
|
|
337
|
+
"path": str(latest_dir),
|
|
338
|
+
"artifacts": [str(f) for f in latest_dir.iterdir() if f.is_file()],
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# Collect all artifacts
|
|
342
|
+
for artifact in tool_dir.rglob("*"):
|
|
343
|
+
if artifact.is_file():
|
|
344
|
+
tool_info["artifacts"].append(
|
|
345
|
+
{
|
|
346
|
+
"path": str(artifact),
|
|
347
|
+
"size": artifact.stat().st_size,
|
|
348
|
+
"modified": artifact.stat().st_mtime,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
index_data["tools"][tool_dir.name] = tool_info
|
|
353
|
+
|
|
354
|
+
# Write index
|
|
355
|
+
with open(index_path, "w") as f:
|
|
356
|
+
import json
|
|
357
|
+
|
|
358
|
+
json.dump(index_data, f, indent=2)
|
|
359
|
+
|
|
360
|
+
return index_path
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Base classes and protocols for quality analysis tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class QualityResult:
|
|
13
|
+
"""Result from a quality analysis tool.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
tool: Name of the tool that generated this result
|
|
17
|
+
passed: Whether the quality check passed
|
|
18
|
+
score: Numeric score (0-100) if applicable
|
|
19
|
+
details: Tool-specific details and metrics
|
|
20
|
+
artifacts: List of artifact files created
|
|
21
|
+
execution_time: Time taken to run the analysis in seconds
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
tool: str
|
|
25
|
+
passed: bool
|
|
26
|
+
score: float | None = None
|
|
27
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
artifacts: list[Path] = field(default_factory=list)
|
|
29
|
+
execution_time: float | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def summary(self) -> str:
|
|
33
|
+
"""Human-readable summary of the result."""
|
|
34
|
+
status = "✅ PASSED" if self.passed else "❌ FAILED"
|
|
35
|
+
score_text = f" ({self.score}%)" if self.score is not None else ""
|
|
36
|
+
return f"{self.tool}: {status}{score_text}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class QualityTool(Protocol):
|
|
41
|
+
"""Protocol for quality analysis tools."""
|
|
42
|
+
|
|
43
|
+
def analyze(self, path: Path, **kwargs: Any) -> QualityResult:
|
|
44
|
+
"""Run analysis on the given path.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to analyze (file or directory)
|
|
48
|
+
**kwargs: Tool-specific options
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
QualityResult containing analysis results
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def report(self, result: QualityResult, format: str = "terminal") -> str:
|
|
56
|
+
"""Generate a report from analysis result.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
result: Result to generate report for
|
|
60
|
+
format: Output format (terminal, json, html, markdown)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Formatted report string
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BaseQualityFixture(ABC):
|
|
69
|
+
"""Base class for pytest quality fixtures.
|
|
70
|
+
|
|
71
|
+
Provides common functionality for quality analysis fixtures including
|
|
72
|
+
configuration management, artifact handling, and result tracking.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config: dict[str, Any] | None = None, artifact_dir: Path | None = None):
|
|
76
|
+
"""Initialize the fixture.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Tool-specific configuration
|
|
80
|
+
artifact_dir: Directory to store artifacts
|
|
81
|
+
"""
|
|
82
|
+
self.config = config or {}
|
|
83
|
+
self.artifact_dir = artifact_dir or Path(".quality")
|
|
84
|
+
self.results: list[QualityResult] = []
|
|
85
|
+
self._setup_complete = False
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def setup(self) -> None:
|
|
89
|
+
"""Setup the quality tool."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def teardown(self) -> None:
|
|
94
|
+
"""Cleanup after quality check."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def add_result(self, result: QualityResult) -> None:
|
|
98
|
+
"""Add a result to the tracked results."""
|
|
99
|
+
self.results.append(result)
|
|
100
|
+
|
|
101
|
+
def get_results(self) -> list[QualityResult]:
|
|
102
|
+
"""Get all tracked results."""
|
|
103
|
+
return self.results.copy()
|
|
104
|
+
|
|
105
|
+
def get_results_by_tool(self) -> dict[str, QualityResult]:
|
|
106
|
+
"""Get results indexed by tool name."""
|
|
107
|
+
return {result.tool: result for result in self.results}
|
|
108
|
+
|
|
109
|
+
def ensure_setup(self) -> None:
|
|
110
|
+
"""Ensure setup has been called."""
|
|
111
|
+
if not self._setup_complete:
|
|
112
|
+
self.setup()
|
|
113
|
+
self._setup_complete = True
|
|
114
|
+
|
|
115
|
+
def create_artifact_dir(self, subdir: str | None = None) -> Path:
|
|
116
|
+
"""Create and return artifact directory.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
subdir: Optional subdirectory name
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Path to the artifact directory
|
|
123
|
+
"""
|
|
124
|
+
if subdir:
|
|
125
|
+
artifact_path = self.artifact_dir / subdir
|
|
126
|
+
else:
|
|
127
|
+
artifact_path = self.artifact_dir
|
|
128
|
+
|
|
129
|
+
artifact_path.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
return artifact_path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class QualityError(Exception):
|
|
134
|
+
"""Base exception for quality analysis errors."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, message: str, tool: str | None = None, details: dict[str, Any] | None = None):
|
|
137
|
+
"""Initialize quality error.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
message: Error message
|
|
141
|
+
tool: Name of tool that caused the error
|
|
142
|
+
details: Additional error details
|
|
143
|
+
"""
|
|
144
|
+
super().__init__(message)
|
|
145
|
+
self.tool = tool
|
|
146
|
+
self.details = details or {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class QualityConfigError(QualityError):
|
|
150
|
+
"""Exception for configuration errors."""
|
|
151
|
+
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class QualityToolError(QualityError):
|
|
156
|
+
"""Exception for tool execution errors."""
|
|
157
|
+
|
|
158
|
+
pass
|