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,236 @@
|
|
|
1
|
+
"""Pytest fixtures for coverage tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from ..base import BaseQualityFixture
|
|
12
|
+
from .tracker import COVERAGE_AVAILABLE, CoverageTracker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CoverageFixture(BaseQualityFixture):
|
|
16
|
+
"""Pytest fixture for coverage tracking integration."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: dict[str, Any] | None = None, artifact_dir: Path | None = None):
|
|
19
|
+
"""Initialize coverage fixture.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: Coverage configuration
|
|
23
|
+
artifact_dir: Directory for artifacts
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(config, artifact_dir)
|
|
26
|
+
self.tracker: CoverageTracker | None = None
|
|
27
|
+
|
|
28
|
+
def setup(self) -> None:
|
|
29
|
+
"""Setup coverage tracking."""
|
|
30
|
+
if not COVERAGE_AVAILABLE:
|
|
31
|
+
pytest.skip("Coverage.py not available")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
self.tracker = CoverageTracker(self.config)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
pytest.skip(f"Failed to initialize coverage: {e}")
|
|
37
|
+
|
|
38
|
+
def teardown(self) -> None:
|
|
39
|
+
"""Stop coverage and generate reports."""
|
|
40
|
+
if self.tracker and self.tracker.is_running:
|
|
41
|
+
self.tracker.stop()
|
|
42
|
+
|
|
43
|
+
def start_tracking(self) -> None:
|
|
44
|
+
"""Start coverage tracking."""
|
|
45
|
+
self.ensure_setup()
|
|
46
|
+
if self.tracker:
|
|
47
|
+
self.tracker.start()
|
|
48
|
+
|
|
49
|
+
def stop_tracking(self) -> None:
|
|
50
|
+
"""Stop coverage tracking."""
|
|
51
|
+
if self.tracker:
|
|
52
|
+
self.tracker.stop()
|
|
53
|
+
|
|
54
|
+
def get_coverage(self) -> float:
|
|
55
|
+
"""Get current coverage percentage."""
|
|
56
|
+
if not self.tracker:
|
|
57
|
+
return 0.0
|
|
58
|
+
return self.tracker.get_coverage()
|
|
59
|
+
|
|
60
|
+
def generate_report(self, format: str = "terminal") -> str:
|
|
61
|
+
"""Generate coverage report."""
|
|
62
|
+
if not self.tracker:
|
|
63
|
+
return "No coverage data"
|
|
64
|
+
return self.tracker.generate_report(format)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def coverage_tracker(request, tmp_path) -> Generator[CoverageFixture, None, None]:
|
|
69
|
+
"""Pytest fixture for coverage tracking.
|
|
70
|
+
|
|
71
|
+
Provides a CoverageFixture instance that automatically starts and stops
|
|
72
|
+
coverage tracking around individual tests.
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
def test_with_coverage(coverage_tracker):
|
|
76
|
+
coverage_tracker.start_tracking()
|
|
77
|
+
# ... test code
|
|
78
|
+
coverage_tracker.stop_tracking()
|
|
79
|
+
assert coverage_tracker.get_coverage() > 80
|
|
80
|
+
"""
|
|
81
|
+
# Get configuration from pytest request
|
|
82
|
+
config = getattr(request, "param", {})
|
|
83
|
+
|
|
84
|
+
# Create artifact directory for this test
|
|
85
|
+
artifact_dir = tmp_path / "coverage"
|
|
86
|
+
|
|
87
|
+
# Initialize fixture
|
|
88
|
+
fixture = CoverageFixture(config=config, artifact_dir=artifact_dir)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
fixture.setup()
|
|
92
|
+
yield fixture
|
|
93
|
+
finally:
|
|
94
|
+
fixture.teardown()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def auto_coverage(coverage_tracker) -> Generator[CoverageFixture, None, None]:
|
|
99
|
+
"""Automatic coverage tracking fixture.
|
|
100
|
+
|
|
101
|
+
Automatically starts coverage at the beginning of the test and stops
|
|
102
|
+
at the end. Ideal for tests that want zero-configuration coverage.
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
def test_automatic_coverage(auto_coverage):
|
|
106
|
+
# Coverage automatically tracked
|
|
107
|
+
result = some_function()
|
|
108
|
+
assert result is not None
|
|
109
|
+
# Coverage automatically stopped and saved
|
|
110
|
+
"""
|
|
111
|
+
coverage_tracker.start_tracking()
|
|
112
|
+
try:
|
|
113
|
+
yield coverage_tracker
|
|
114
|
+
finally:
|
|
115
|
+
coverage_tracker.stop_tracking()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.fixture(scope="session")
|
|
119
|
+
def session_coverage(tmp_path_factory) -> Generator[CoverageFixture, None, None]:
|
|
120
|
+
"""Session-wide coverage tracking.
|
|
121
|
+
|
|
122
|
+
Tracks coverage across all tests in the session. Useful for getting
|
|
123
|
+
overall coverage metrics for the entire test suite.
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
def test_part_one(session_coverage):
|
|
127
|
+
# Coverage tracked across all tests
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def test_part_two(session_coverage):
|
|
131
|
+
# Same coverage instance
|
|
132
|
+
pass
|
|
133
|
+
"""
|
|
134
|
+
# Create session-wide artifact directory
|
|
135
|
+
artifact_dir = tmp_path_factory.mktemp("session_coverage")
|
|
136
|
+
|
|
137
|
+
# Initialize session fixture
|
|
138
|
+
fixture = CoverageFixture(artifact_dir=artifact_dir)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
fixture.setup()
|
|
142
|
+
fixture.start_tracking()
|
|
143
|
+
yield fixture
|
|
144
|
+
finally:
|
|
145
|
+
fixture.stop_tracking()
|
|
146
|
+
fixture.teardown()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.fixture
|
|
150
|
+
def coverage_config():
|
|
151
|
+
"""Default coverage configuration fixture.
|
|
152
|
+
|
|
153
|
+
Returns standard coverage configuration that can be customized
|
|
154
|
+
per test or project.
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
def test_with_custom_coverage(coverage_config):
|
|
158
|
+
coverage_config["fail_under"] = 95
|
|
159
|
+
# Use with parametrized coverage_tracker
|
|
160
|
+
"""
|
|
161
|
+
return {
|
|
162
|
+
"branch": True,
|
|
163
|
+
"source": ["src"],
|
|
164
|
+
"omit": [
|
|
165
|
+
"*/tests/*",
|
|
166
|
+
"*/test_*",
|
|
167
|
+
"*/.venv/*",
|
|
168
|
+
"*/venv/*",
|
|
169
|
+
],
|
|
170
|
+
"fail_under": 80,
|
|
171
|
+
"show_missing": True,
|
|
172
|
+
"skip_covered": False,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Parametrized fixtures for different coverage configurations
|
|
177
|
+
@pytest.fixture(
|
|
178
|
+
params=[
|
|
179
|
+
{"fail_under": 80, "branch": True},
|
|
180
|
+
{"fail_under": 90, "branch": False},
|
|
181
|
+
{"fail_under": 95, "branch": True, "show_missing": True},
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
def parametrized_coverage(request, tmp_path) -> Generator[CoverageFixture, None, None]:
|
|
185
|
+
"""Parametrized coverage fixture for testing different configurations.
|
|
186
|
+
|
|
187
|
+
Automatically runs tests with different coverage configurations
|
|
188
|
+
to validate behavior under various settings.
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
def test_coverage_configs(parametrized_coverage):
|
|
192
|
+
# Test runs multiple times with different configs
|
|
193
|
+
pass
|
|
194
|
+
"""
|
|
195
|
+
config = request.param
|
|
196
|
+
artifact_dir = tmp_path / f"coverage_{id(config)}"
|
|
197
|
+
|
|
198
|
+
fixture = CoverageFixture(config=config, artifact_dir=artifact_dir)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
fixture.setup()
|
|
202
|
+
yield fixture
|
|
203
|
+
finally:
|
|
204
|
+
fixture.teardown()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# Pytest hooks for automatic coverage integration
|
|
208
|
+
def pytest_configure(config):
|
|
209
|
+
"""Configure pytest with coverage markers."""
|
|
210
|
+
config.addinivalue_line("markers", "coverage: mark test to run with coverage tracking")
|
|
211
|
+
config.addinivalue_line("markers", "no_coverage: mark test to skip coverage tracking")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.fixture(autouse=True)
|
|
215
|
+
def auto_coverage_marker(request):
|
|
216
|
+
"""Automatically apply coverage to marked tests.
|
|
217
|
+
|
|
218
|
+
Tests marked with @pytest.mark.coverage will automatically
|
|
219
|
+
get coverage tracking without needing to explicitly use fixtures.
|
|
220
|
+
"""
|
|
221
|
+
if request.node.get_closest_marker("coverage"):
|
|
222
|
+
# Test is marked for coverage - enable automatic tracking
|
|
223
|
+
if not request.node.get_closest_marker("no_coverage"):
|
|
224
|
+
# Create temporary coverage fixture
|
|
225
|
+
coverage_fixture = CoverageFixture()
|
|
226
|
+
try:
|
|
227
|
+
coverage_fixture.setup()
|
|
228
|
+
coverage_fixture.start_tracking()
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
coverage_fixture.stop_tracking()
|
|
232
|
+
coverage_fixture.teardown()
|
|
233
|
+
else:
|
|
234
|
+
yield
|
|
235
|
+
else:
|
|
236
|
+
yield
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Coverage reporting utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..base import QualityResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CoverageReporter:
|
|
11
|
+
"""Specialized reporter for coverage results."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
14
|
+
"""Initialize coverage reporter.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config: Reporter configuration
|
|
18
|
+
"""
|
|
19
|
+
self.config = config or {}
|
|
20
|
+
|
|
21
|
+
def format_terminal_report(self, result: QualityResult) -> str:
|
|
22
|
+
"""Format coverage result for terminal output.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
result: Coverage result to format
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Formatted terminal report
|
|
29
|
+
"""
|
|
30
|
+
lines = [
|
|
31
|
+
f"Coverage Report - {result.tool}",
|
|
32
|
+
"=" * 40,
|
|
33
|
+
f"Status: {'✅ PASSED' if result.passed else '❌ FAILED'}",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if result.score is not None:
|
|
37
|
+
lines.append(f"Coverage: {result.score}%")
|
|
38
|
+
|
|
39
|
+
details = result.details
|
|
40
|
+
if "total_statements" in details:
|
|
41
|
+
lines.extend(
|
|
42
|
+
[
|
|
43
|
+
f"Total Statements: {details.get('total_statements', 0)}",
|
|
44
|
+
f"Missing Statements: {details.get('missing_statements', 0)}",
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if "branch_coverage" in details and details["branch_coverage"] is not None:
|
|
49
|
+
lines.append(f"Branch Coverage: {details['branch_coverage']}%")
|
|
50
|
+
|
|
51
|
+
if "threshold" in details:
|
|
52
|
+
lines.append(f"Threshold: {details['threshold']}%")
|
|
53
|
+
|
|
54
|
+
if result.execution_time:
|
|
55
|
+
lines.append(f"Execution Time: {result.execution_time:.2f}s")
|
|
56
|
+
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
def format_json_report(self, result: QualityResult) -> dict[str, Any]:
|
|
60
|
+
"""Format coverage result as JSON data.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
result: Coverage result to format
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
JSON-serializable report data
|
|
67
|
+
"""
|
|
68
|
+
return {
|
|
69
|
+
"tool": result.tool,
|
|
70
|
+
"passed": result.passed,
|
|
71
|
+
"score": result.score,
|
|
72
|
+
"details": result.details,
|
|
73
|
+
"execution_time": result.execution_time,
|
|
74
|
+
"artifacts": [str(p) for p in result.artifacts],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def format_html_summary(self, result: QualityResult) -> str:
|
|
78
|
+
"""Format coverage result as HTML summary.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
result: Coverage result to format
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
HTML summary
|
|
85
|
+
"""
|
|
86
|
+
status_color = "green" if result.passed else "red"
|
|
87
|
+
status_text = "PASSED" if result.passed else "FAILED"
|
|
88
|
+
|
|
89
|
+
html_parts = [
|
|
90
|
+
'<div class="coverage-summary">',
|
|
91
|
+
"<h3>Coverage Report</h3>",
|
|
92
|
+
f'<p><span style="color: {status_color}">Status: {status_text}</span></p>',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
if result.score is not None:
|
|
96
|
+
html_parts.append(f"<p>Coverage: <strong>{result.score}%</strong></p>")
|
|
97
|
+
|
|
98
|
+
details = result.details
|
|
99
|
+
if "total_statements" in details:
|
|
100
|
+
html_parts.extend(
|
|
101
|
+
[
|
|
102
|
+
f"<p>Total Statements: {details.get('total_statements', 0)}</p>",
|
|
103
|
+
f"<p>Missing Statements: {details.get('missing_statements', 0)}</p>",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
html_parts.append("</div>")
|
|
108
|
+
return "\n".join(html_parts)
|
|
109
|
+
|
|
110
|
+
def generate_dashboard_data(self, result: QualityResult) -> dict[str, Any]:
|
|
111
|
+
"""Generate data for coverage dashboard.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
result: Coverage result
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dashboard data structure
|
|
118
|
+
"""
|
|
119
|
+
dashboard_data: dict[str, Any] = {
|
|
120
|
+
"title": "Code Coverage",
|
|
121
|
+
"status": "passed" if result.passed else "failed",
|
|
122
|
+
"primary_metric": {
|
|
123
|
+
"label": "Coverage",
|
|
124
|
+
"value": result.score,
|
|
125
|
+
"unit": "%",
|
|
126
|
+
"threshold": result.details.get("threshold", 0),
|
|
127
|
+
},
|
|
128
|
+
"secondary_metrics": [],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
details = result.details
|
|
132
|
+
secondary_metrics = dashboard_data["secondary_metrics"]
|
|
133
|
+
if "total_statements" in details and isinstance(secondary_metrics, list):
|
|
134
|
+
secondary_metrics.extend(
|
|
135
|
+
[
|
|
136
|
+
{"label": "Total Statements", "value": details.get("total_statements", 0)},
|
|
137
|
+
{"label": "Missing Statements", "value": details.get("missing_statements", 0)},
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
"branch_coverage" in details
|
|
143
|
+
and details["branch_coverage"] is not None
|
|
144
|
+
and isinstance(secondary_metrics, list)
|
|
145
|
+
):
|
|
146
|
+
secondary_metrics.append(
|
|
147
|
+
{"label": "Branch Coverage", "value": details["branch_coverage"], "unit": "%"}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return dashboard_data
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Coverage tracking implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from coverage import Coverage
|
|
11
|
+
|
|
12
|
+
COVERAGE_AVAILABLE = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
COVERAGE_AVAILABLE = False
|
|
15
|
+
Coverage = None
|
|
16
|
+
|
|
17
|
+
from ..base import QualityResult, QualityToolError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CoverageTracker:
|
|
21
|
+
"""Wrapper for coverage.py library with testkit integration.
|
|
22
|
+
|
|
23
|
+
Provides high-level interface for coverage tracking with automatic
|
|
24
|
+
artifact management and integration with the quality framework.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
28
|
+
"""Initialize coverage tracker.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Coverage configuration options
|
|
32
|
+
"""
|
|
33
|
+
if not COVERAGE_AVAILABLE:
|
|
34
|
+
raise QualityToolError(
|
|
35
|
+
"Coverage.py not available. Install with: pip install coverage", tool="coverage"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.config = config or {}
|
|
39
|
+
self.coverage: Coverage | None = None
|
|
40
|
+
self.is_running = False
|
|
41
|
+
self.artifact_dir: Path | None = None
|
|
42
|
+
|
|
43
|
+
def analyze(self, path: Path, **kwargs: Any) -> QualityResult:
|
|
44
|
+
"""Run coverage analysis on the given path.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to analyze
|
|
48
|
+
**kwargs: Additional options including artifact_dir
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
QualityResult with coverage data
|
|
52
|
+
"""
|
|
53
|
+
self.artifact_dir = kwargs.get("artifact_dir", Path(".coverage"))
|
|
54
|
+
start_time = time.time()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# If coverage is already running, get current data
|
|
58
|
+
if self.is_running and self.coverage:
|
|
59
|
+
self.stop()
|
|
60
|
+
|
|
61
|
+
# Start fresh coverage analysis
|
|
62
|
+
self.start()
|
|
63
|
+
|
|
64
|
+
# For analysis mode, we need to combine existing coverage data
|
|
65
|
+
# This is typically used when coverage was collected during test runs
|
|
66
|
+
self._load_existing_data()
|
|
67
|
+
|
|
68
|
+
# Generate report
|
|
69
|
+
result = self._create_result()
|
|
70
|
+
result.execution_time = time.time() - start_time
|
|
71
|
+
|
|
72
|
+
# Generate artifacts
|
|
73
|
+
self._generate_artifacts(result)
|
|
74
|
+
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return QualityResult(
|
|
79
|
+
tool="coverage",
|
|
80
|
+
passed=False,
|
|
81
|
+
details={"error": str(e), "error_type": type(e).__name__},
|
|
82
|
+
execution_time=time.time() - start_time,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def start(self) -> None:
|
|
86
|
+
"""Start coverage tracking."""
|
|
87
|
+
if self.is_running:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
coverage_config = self._build_coverage_config()
|
|
91
|
+
self.coverage = Coverage(**coverage_config)
|
|
92
|
+
self.coverage.start()
|
|
93
|
+
self.is_running = True
|
|
94
|
+
|
|
95
|
+
def stop(self) -> None:
|
|
96
|
+
"""Stop coverage tracking and save data."""
|
|
97
|
+
if not self.is_running or not self.coverage:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
self.coverage.stop()
|
|
101
|
+
self.coverage.save()
|
|
102
|
+
self.is_running = False
|
|
103
|
+
|
|
104
|
+
def get_coverage(self) -> float:
|
|
105
|
+
"""Get current coverage percentage.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Coverage percentage (0-100)
|
|
109
|
+
"""
|
|
110
|
+
if not self.coverage:
|
|
111
|
+
return 0.0
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# Get coverage data
|
|
115
|
+
total = self.coverage.report(file=None, show_missing=False)
|
|
116
|
+
return round(total, 2)
|
|
117
|
+
except Exception:
|
|
118
|
+
return 0.0
|
|
119
|
+
|
|
120
|
+
def generate_report(self, format: str = "terminal") -> str:
|
|
121
|
+
"""Generate coverage report.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
format: Report format (terminal, html, xml, json)
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Report content (for terminal/json) or path (for html/xml)
|
|
128
|
+
"""
|
|
129
|
+
if not self.coverage:
|
|
130
|
+
return "No coverage data available"
|
|
131
|
+
|
|
132
|
+
if format == "terminal":
|
|
133
|
+
return self._generate_terminal_report()
|
|
134
|
+
elif format == "html" and self.artifact_dir:
|
|
135
|
+
html_dir = self.artifact_dir / "htmlcov"
|
|
136
|
+
self.coverage.html_report(directory=str(html_dir))
|
|
137
|
+
return str(html_dir / "index.html")
|
|
138
|
+
elif format == "xml" and self.artifact_dir:
|
|
139
|
+
xml_file = self.artifact_dir / "coverage.xml"
|
|
140
|
+
self.coverage.xml_report(outfile=str(xml_file))
|
|
141
|
+
return str(xml_file)
|
|
142
|
+
elif format == "json" and self.artifact_dir:
|
|
143
|
+
json_file = self.artifact_dir / "coverage.json"
|
|
144
|
+
self.coverage.json_report(outfile=str(json_file))
|
|
145
|
+
return json_file.read_text()
|
|
146
|
+
else:
|
|
147
|
+
return f"Unsupported format: {format}"
|
|
148
|
+
|
|
149
|
+
def report(self, result: QualityResult, format: str = "terminal") -> str:
|
|
150
|
+
"""Generate report from QualityResult (implements QualityTool protocol).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
result: Coverage result
|
|
154
|
+
format: Report format
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Formatted report
|
|
158
|
+
"""
|
|
159
|
+
if format == "terminal":
|
|
160
|
+
lines = [
|
|
161
|
+
f"Coverage Report - {result.tool}",
|
|
162
|
+
"=" * 40,
|
|
163
|
+
f"Status: {'✅ PASSED' if result.passed else '❌ FAILED'}",
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
if result.score is not None:
|
|
167
|
+
lines.append(f"Coverage: {result.score}%")
|
|
168
|
+
|
|
169
|
+
if "total_statements" in result.details:
|
|
170
|
+
details = result.details
|
|
171
|
+
lines.extend(
|
|
172
|
+
[
|
|
173
|
+
f"Total Statements: {details.get('total_statements', 0)}",
|
|
174
|
+
f"Missing Statements: {details.get('missing_statements', 0)}",
|
|
175
|
+
f"Branch Coverage: {details.get('branch_coverage', 'N/A')}%",
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
return str(result.details)
|
|
182
|
+
|
|
183
|
+
def _build_coverage_config(self) -> dict[str, Any]:
|
|
184
|
+
"""Build coverage.py configuration."""
|
|
185
|
+
config = {
|
|
186
|
+
"branch": self.config.get("branch", True),
|
|
187
|
+
"source": self.config.get("source", ["src"]),
|
|
188
|
+
"omit": self.config.get(
|
|
189
|
+
"omit", ["*/tests/*", "*/test_*", "*/.venv/*", "*/venv/*", "*/__pycache__/*"]
|
|
190
|
+
),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Add data file configuration if artifact directory is set
|
|
194
|
+
if self.artifact_dir:
|
|
195
|
+
config["data_file"] = str(self.artifact_dir / ".coverage")
|
|
196
|
+
|
|
197
|
+
return config
|
|
198
|
+
|
|
199
|
+
def _load_existing_data(self) -> None:
|
|
200
|
+
"""Load existing coverage data if available."""
|
|
201
|
+
if not self.coverage or not self.artifact_dir:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
data_file = self.artifact_dir / ".coverage"
|
|
205
|
+
if data_file.exists():
|
|
206
|
+
try:
|
|
207
|
+
self.coverage.load()
|
|
208
|
+
except Exception:
|
|
209
|
+
# If loading fails, continue with fresh data
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def _create_result(self) -> QualityResult:
|
|
213
|
+
"""Create QualityResult from current coverage data."""
|
|
214
|
+
if not self.coverage:
|
|
215
|
+
return QualityResult(tool="coverage", passed=False, details={"error": "No coverage instance"})
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Get coverage percentage
|
|
219
|
+
coverage_percent = self.get_coverage()
|
|
220
|
+
|
|
221
|
+
# Get detailed metrics
|
|
222
|
+
total_statements = 0
|
|
223
|
+
missing_statements = 0
|
|
224
|
+
branch_coverage = None
|
|
225
|
+
|
|
226
|
+
# Access coverage data for detailed metrics
|
|
227
|
+
data = self.coverage.get_data()
|
|
228
|
+
if data:
|
|
229
|
+
try:
|
|
230
|
+
# Count statements across all files
|
|
231
|
+
for filename in data.measured_files():
|
|
232
|
+
file_data = data.lines(filename)
|
|
233
|
+
if file_data:
|
|
234
|
+
total_statements += len(file_data)
|
|
235
|
+
|
|
236
|
+
# Get missing statements
|
|
237
|
+
if hasattr(self.coverage, "_analyze"):
|
|
238
|
+
for filename in data.measured_files():
|
|
239
|
+
try:
|
|
240
|
+
analysis = self.coverage._analyze(filename)
|
|
241
|
+
missing_statements += len(analysis.missing)
|
|
242
|
+
except Exception:
|
|
243
|
+
continue
|
|
244
|
+
except Exception:
|
|
245
|
+
# Handle mock objects or other issues gracefully
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# Calculate pass/fail based on configured threshold
|
|
249
|
+
threshold = self.config.get("fail_under", 0)
|
|
250
|
+
passed = coverage_percent >= threshold
|
|
251
|
+
|
|
252
|
+
return QualityResult(
|
|
253
|
+
tool="coverage",
|
|
254
|
+
passed=passed,
|
|
255
|
+
score=coverage_percent,
|
|
256
|
+
details={
|
|
257
|
+
"total_statements": total_statements,
|
|
258
|
+
"missing_statements": missing_statements,
|
|
259
|
+
"branch_coverage": branch_coverage,
|
|
260
|
+
"threshold": threshold,
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return QualityResult(tool="coverage", passed=False, details={"error": str(e)})
|
|
266
|
+
|
|
267
|
+
def _generate_terminal_report(self) -> str:
|
|
268
|
+
"""Generate terminal coverage report."""
|
|
269
|
+
if not self.coverage:
|
|
270
|
+
return "No coverage data available"
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
from io import StringIO
|
|
274
|
+
|
|
275
|
+
output = StringIO()
|
|
276
|
+
self.coverage.report(file=output, show_missing=True)
|
|
277
|
+
return output.getvalue()
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return f"Error generating report: {e}"
|
|
280
|
+
|
|
281
|
+
def _generate_artifacts(self, result: QualityResult) -> None:
|
|
282
|
+
"""Generate coverage artifacts.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
result: Result to add artifacts to
|
|
286
|
+
"""
|
|
287
|
+
if not self.artifact_dir:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
self.artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
# Generate HTML report
|
|
294
|
+
html_dir = self.artifact_dir / "htmlcov"
|
|
295
|
+
self.coverage.html_report(directory=str(html_dir))
|
|
296
|
+
if html_dir.exists():
|
|
297
|
+
result.artifacts.append(html_dir / "index.html")
|
|
298
|
+
|
|
299
|
+
# Generate XML report
|
|
300
|
+
xml_file = self.artifact_dir / "coverage.xml"
|
|
301
|
+
self.coverage.xml_report(outfile=str(xml_file))
|
|
302
|
+
if xml_file.exists():
|
|
303
|
+
result.artifacts.append(xml_file)
|
|
304
|
+
|
|
305
|
+
# Generate terminal report
|
|
306
|
+
terminal_file = self.artifact_dir / "coverage.txt"
|
|
307
|
+
terminal_report = self._generate_terminal_report()
|
|
308
|
+
terminal_file.write_text(terminal_report)
|
|
309
|
+
result.artifacts.append(terminal_file)
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
# Add error to result details but don't fail
|
|
313
|
+
result.details["artifact_error"] = str(e)
|