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,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