codeshift 0.4.0__py3-none-any.whl → 0.7.0__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.
- codeshift/__init__.py +1 -1
- codeshift/cli/commands/auth.py +41 -25
- codeshift/cli/commands/health.py +244 -0
- codeshift/cli/commands/upgrade.py +68 -55
- codeshift/cli/main.py +2 -0
- codeshift/health/__init__.py +50 -0
- codeshift/health/calculator.py +217 -0
- codeshift/health/metrics/__init__.py +63 -0
- codeshift/health/metrics/documentation.py +209 -0
- codeshift/health/metrics/freshness.py +180 -0
- codeshift/health/metrics/migration_readiness.py +142 -0
- codeshift/health/metrics/security.py +225 -0
- codeshift/health/metrics/test_coverage.py +191 -0
- codeshift/health/models.py +284 -0
- codeshift/health/report.py +310 -0
- codeshift/knowledge/generator.py +6 -0
- codeshift/knowledge_base/libraries/aiohttp.yaml +3 -3
- codeshift/knowledge_base/libraries/httpx.yaml +4 -4
- codeshift/knowledge_base/libraries/pytest.yaml +1 -1
- codeshift/knowledge_base/models.py +1 -0
- codeshift/migrator/transforms/marshmallow_transformer.py +50 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +191 -22
- codeshift/scanner/code_scanner.py +22 -2
- codeshift/utils/api_client.py +144 -4
- codeshift/utils/credential_store.py +393 -0
- codeshift/utils/llm_client.py +111 -9
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/METADATA +4 -1
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/RECORD +32 -20
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/WHEEL +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/entry_points.txt +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Migration readiness metric calculator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
8
|
+
from codeshift.health.models import DependencyHealth, MetricCategory, MetricResult
|
|
9
|
+
from codeshift.knowledge_base import KnowledgeBaseLoader
|
|
10
|
+
from codeshift.scanner.dependency_parser import DependencyParser
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MigrationReadinessCalculator(BaseMetricCalculator):
|
|
16
|
+
"""Calculates migration readiness score (20% weight).
|
|
17
|
+
|
|
18
|
+
Score based on Tier 1/2 support coverage:
|
|
19
|
+
- Tier 1 (deterministic AST): 100% score contribution
|
|
20
|
+
- Tier 2 (knowledge base + LLM): 50% score contribution
|
|
21
|
+
- No support: 0% score contribution
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def category(self) -> MetricCategory:
|
|
26
|
+
return MetricCategory.MIGRATION_READINESS
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def weight(self) -> float:
|
|
30
|
+
return 0.20
|
|
31
|
+
|
|
32
|
+
def calculate(
|
|
33
|
+
self,
|
|
34
|
+
project_path: Path,
|
|
35
|
+
dependencies: list[DependencyHealth] | None = None,
|
|
36
|
+
**kwargs: Any,
|
|
37
|
+
) -> MetricResult:
|
|
38
|
+
"""Calculate the migration readiness score.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_path: Path to the project
|
|
42
|
+
dependencies: Pre-populated dependency health list (optional)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
MetricResult with migration readiness score
|
|
46
|
+
"""
|
|
47
|
+
if dependencies is None:
|
|
48
|
+
dependencies = self._analyze_dependencies(project_path)
|
|
49
|
+
|
|
50
|
+
if not dependencies:
|
|
51
|
+
return self._create_result(
|
|
52
|
+
score=100,
|
|
53
|
+
description="No dependencies to analyze",
|
|
54
|
+
details={"dependency_count": 0},
|
|
55
|
+
recommendations=[],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
tier1_count = sum(1 for d in dependencies if d.has_tier1_support)
|
|
59
|
+
tier2_count = sum(
|
|
60
|
+
1 for d in dependencies if d.has_tier2_support and not d.has_tier1_support
|
|
61
|
+
)
|
|
62
|
+
no_support_count = len(dependencies) - tier1_count - tier2_count
|
|
63
|
+
|
|
64
|
+
total = len(dependencies)
|
|
65
|
+
# Score: Tier 1 gets full points, Tier 2 gets half points
|
|
66
|
+
score = ((tier1_count * 100) + (tier2_count * 50)) / total if total > 0 else 100
|
|
67
|
+
|
|
68
|
+
# Build recommendations
|
|
69
|
+
recommendations: list[str] = []
|
|
70
|
+
|
|
71
|
+
if no_support_count > 0:
|
|
72
|
+
unsupported = [
|
|
73
|
+
d.name for d in dependencies if not d.has_tier1_support and not d.has_tier2_support
|
|
74
|
+
]
|
|
75
|
+
recommendations.append(
|
|
76
|
+
f"Consider requesting Tier 1 support for: {', '.join(unsupported[:3])}"
|
|
77
|
+
+ (f" (+{len(unsupported) - 3} more)" if len(unsupported) > 3 else "")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if tier2_count > 0:
|
|
81
|
+
tier2_deps = [
|
|
82
|
+
d.name for d in dependencies if d.has_tier2_support and not d.has_tier1_support
|
|
83
|
+
]
|
|
84
|
+
recommendations.append(
|
|
85
|
+
f"Libraries with Tier 2 (LLM) support: {', '.join(tier2_deps[:3])}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return self._create_result(
|
|
89
|
+
score=score,
|
|
90
|
+
description=f"{tier1_count} Tier 1, {tier2_count} Tier 2, {no_support_count} unsupported",
|
|
91
|
+
details={
|
|
92
|
+
"total_dependencies": total,
|
|
93
|
+
"tier1_count": tier1_count,
|
|
94
|
+
"tier2_count": tier2_count,
|
|
95
|
+
"unsupported_count": no_support_count,
|
|
96
|
+
"tier1_ratio": tier1_count / total if total > 0 else 0,
|
|
97
|
+
"tier2_ratio": tier2_count / total if total > 0 else 0,
|
|
98
|
+
},
|
|
99
|
+
recommendations=recommendations,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
|
|
103
|
+
"""Analyze project dependencies for migration support.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
project_path: Path to the project
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of DependencyHealth objects with tier support info
|
|
110
|
+
"""
|
|
111
|
+
parser = DependencyParser(project_path)
|
|
112
|
+
dependencies = parser.parse_all()
|
|
113
|
+
|
|
114
|
+
loader = KnowledgeBaseLoader()
|
|
115
|
+
supported_libraries = loader.get_supported_libraries()
|
|
116
|
+
|
|
117
|
+
results: list[DependencyHealth] = []
|
|
118
|
+
|
|
119
|
+
# Tier 1 supported libraries (have AST transformers)
|
|
120
|
+
tier1_libraries = {"pydantic", "fastapi", "sqlalchemy", "pandas", "requests"}
|
|
121
|
+
|
|
122
|
+
for dep in dependencies:
|
|
123
|
+
dep_name_lower = dep.name.lower()
|
|
124
|
+
|
|
125
|
+
# Check Tier 1 support (deterministic AST transforms)
|
|
126
|
+
has_tier1 = dep_name_lower in tier1_libraries
|
|
127
|
+
|
|
128
|
+
# Check Tier 2 support (knowledge base exists)
|
|
129
|
+
has_tier2 = dep_name_lower in [lib.lower() for lib in supported_libraries]
|
|
130
|
+
|
|
131
|
+
results.append(
|
|
132
|
+
DependencyHealth(
|
|
133
|
+
name=dep.name,
|
|
134
|
+
current_version=str(dep.min_version) if dep.min_version else None,
|
|
135
|
+
latest_version=None,
|
|
136
|
+
is_outdated=False,
|
|
137
|
+
has_tier1_support=has_tier1,
|
|
138
|
+
has_tier2_support=has_tier2,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return results
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Security vulnerabilities metric calculator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
10
|
+
from codeshift.health.models import (
|
|
11
|
+
DependencyHealth,
|
|
12
|
+
MetricCategory,
|
|
13
|
+
MetricResult,
|
|
14
|
+
SecurityVulnerability,
|
|
15
|
+
VulnerabilitySeverity,
|
|
16
|
+
)
|
|
17
|
+
from codeshift.scanner.dependency_parser import DependencyParser
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# PyPI API timeout
|
|
22
|
+
PYPI_TIMEOUT = 5.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityCalculator(BaseMetricCalculator):
|
|
26
|
+
"""Calculates security score based on known vulnerabilities (25% weight).
|
|
27
|
+
|
|
28
|
+
Penalties:
|
|
29
|
+
- Critical: -25 points
|
|
30
|
+
- High: -15 points
|
|
31
|
+
- Medium: -8 points
|
|
32
|
+
- Low: -3 points
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def category(self) -> MetricCategory:
|
|
37
|
+
return MetricCategory.SECURITY
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def weight(self) -> float:
|
|
41
|
+
return 0.25
|
|
42
|
+
|
|
43
|
+
def calculate(
|
|
44
|
+
self,
|
|
45
|
+
project_path: Path,
|
|
46
|
+
dependencies: list[DependencyHealth] | None = None,
|
|
47
|
+
**kwargs: Any,
|
|
48
|
+
) -> MetricResult:
|
|
49
|
+
"""Calculate the security score.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
project_path: Path to the project
|
|
53
|
+
dependencies: Pre-populated dependency health list (optional)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
MetricResult with security score
|
|
57
|
+
"""
|
|
58
|
+
if dependencies is None:
|
|
59
|
+
dependencies = self._analyze_dependencies(project_path)
|
|
60
|
+
|
|
61
|
+
if not dependencies:
|
|
62
|
+
return self._create_result(
|
|
63
|
+
score=100,
|
|
64
|
+
description="No dependencies to analyze",
|
|
65
|
+
details={"dependency_count": 0, "vulnerability_count": 0},
|
|
66
|
+
recommendations=[],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Collect all vulnerabilities
|
|
70
|
+
all_vulns: list[SecurityVulnerability] = []
|
|
71
|
+
vuln_counts = {
|
|
72
|
+
VulnerabilitySeverity.CRITICAL: 0,
|
|
73
|
+
VulnerabilitySeverity.HIGH: 0,
|
|
74
|
+
VulnerabilitySeverity.MEDIUM: 0,
|
|
75
|
+
VulnerabilitySeverity.LOW: 0,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for dep in dependencies:
|
|
79
|
+
for vuln in dep.vulnerabilities:
|
|
80
|
+
all_vulns.append(vuln)
|
|
81
|
+
vuln_counts[vuln.severity] += 1
|
|
82
|
+
|
|
83
|
+
# Calculate penalty
|
|
84
|
+
total_penalty = sum(count * severity.penalty for severity, count in vuln_counts.items())
|
|
85
|
+
score = max(0, 100 - total_penalty)
|
|
86
|
+
|
|
87
|
+
# Build recommendations
|
|
88
|
+
recommendations: list[str] = []
|
|
89
|
+
if vuln_counts[VulnerabilitySeverity.CRITICAL] > 0:
|
|
90
|
+
critical_pkgs = list(
|
|
91
|
+
{v.package for v in all_vulns if v.severity == VulnerabilitySeverity.CRITICAL}
|
|
92
|
+
)
|
|
93
|
+
recommendations.append(
|
|
94
|
+
f"URGENT: Fix critical vulnerabilities in: {', '.join(critical_pkgs)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if vuln_counts[VulnerabilitySeverity.HIGH] > 0:
|
|
98
|
+
high_pkgs = list(
|
|
99
|
+
{v.package for v in all_vulns if v.severity == VulnerabilitySeverity.HIGH}
|
|
100
|
+
)
|
|
101
|
+
recommendations.append(
|
|
102
|
+
f"Address high severity vulnerabilities in: {', '.join(high_pkgs)}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if vuln_counts[VulnerabilitySeverity.MEDIUM] > 0:
|
|
106
|
+
recommendations.append(
|
|
107
|
+
f"Review {vuln_counts[VulnerabilitySeverity.MEDIUM]} medium severity vulnerabilities"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return self._create_result(
|
|
111
|
+
score=score,
|
|
112
|
+
description=(
|
|
113
|
+
f"{len(all_vulns)} vulnerabilities found"
|
|
114
|
+
if all_vulns
|
|
115
|
+
else "No known vulnerabilities"
|
|
116
|
+
),
|
|
117
|
+
details={
|
|
118
|
+
"total_vulnerabilities": len(all_vulns),
|
|
119
|
+
"critical": vuln_counts[VulnerabilitySeverity.CRITICAL],
|
|
120
|
+
"high": vuln_counts[VulnerabilitySeverity.HIGH],
|
|
121
|
+
"medium": vuln_counts[VulnerabilitySeverity.MEDIUM],
|
|
122
|
+
"low": vuln_counts[VulnerabilitySeverity.LOW],
|
|
123
|
+
"total_penalty": total_penalty,
|
|
124
|
+
},
|
|
125
|
+
recommendations=recommendations,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
|
|
129
|
+
"""Analyze project dependencies for security vulnerabilities.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
project_path: Path to the project
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of DependencyHealth objects with vulnerability data
|
|
136
|
+
"""
|
|
137
|
+
parser = DependencyParser(project_path)
|
|
138
|
+
dependencies = parser.parse_all()
|
|
139
|
+
|
|
140
|
+
results: list[DependencyHealth] = []
|
|
141
|
+
|
|
142
|
+
for dep in dependencies:
|
|
143
|
+
vulns = self._get_vulnerabilities(dep.name)
|
|
144
|
+
results.append(
|
|
145
|
+
DependencyHealth(
|
|
146
|
+
name=dep.name,
|
|
147
|
+
current_version=str(dep.min_version) if dep.min_version else None,
|
|
148
|
+
latest_version=None,
|
|
149
|
+
is_outdated=False,
|
|
150
|
+
vulnerabilities=vulns,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
def _get_vulnerabilities(self, package_name: str) -> list[SecurityVulnerability]:
|
|
157
|
+
"""Get known vulnerabilities for a package from PyPI.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
package_name: Name of the package
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of SecurityVulnerability objects
|
|
164
|
+
"""
|
|
165
|
+
vulns: list[SecurityVulnerability] = []
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
response = httpx.get(
|
|
169
|
+
f"https://pypi.org/pypi/{package_name}/json",
|
|
170
|
+
timeout=PYPI_TIMEOUT,
|
|
171
|
+
)
|
|
172
|
+
if response.status_code == 200:
|
|
173
|
+
data = response.json()
|
|
174
|
+
vulnerabilities = data.get("vulnerabilities", [])
|
|
175
|
+
|
|
176
|
+
for vuln_data in vulnerabilities:
|
|
177
|
+
severity_str = self._parse_severity(vuln_data)
|
|
178
|
+
try:
|
|
179
|
+
severity = VulnerabilitySeverity(severity_str.lower())
|
|
180
|
+
except ValueError:
|
|
181
|
+
severity = VulnerabilitySeverity.MEDIUM
|
|
182
|
+
|
|
183
|
+
fixed_in = None
|
|
184
|
+
if vuln_data.get("fixed_in"):
|
|
185
|
+
fixed_versions = vuln_data.get("fixed_in", [])
|
|
186
|
+
if fixed_versions:
|
|
187
|
+
fixed_in = fixed_versions[0]
|
|
188
|
+
|
|
189
|
+
vulns.append(
|
|
190
|
+
SecurityVulnerability(
|
|
191
|
+
package=package_name,
|
|
192
|
+
vulnerability_id=vuln_data.get("id", "unknown"),
|
|
193
|
+
severity=severity,
|
|
194
|
+
description=vuln_data.get("summary", vuln_data.get("details", ""))[
|
|
195
|
+
:200
|
|
196
|
+
],
|
|
197
|
+
fixed_in=fixed_in,
|
|
198
|
+
url=vuln_data.get("link"),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.debug(f"Failed to get vulnerabilities for {package_name}: {e}")
|
|
204
|
+
|
|
205
|
+
return vulns
|
|
206
|
+
|
|
207
|
+
def _parse_severity(self, vuln_data: dict) -> str:
|
|
208
|
+
"""Parse severity from vulnerability data.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
vuln_data: Vulnerability data dictionary
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Severity string (critical, high, medium, low)
|
|
215
|
+
"""
|
|
216
|
+
# Try to get severity from aliases (e.g., CVE data)
|
|
217
|
+
aliases = vuln_data.get("aliases", [])
|
|
218
|
+
for alias in aliases:
|
|
219
|
+
if "CRITICAL" in alias.upper():
|
|
220
|
+
return "critical"
|
|
221
|
+
elif "HIGH" in alias.upper():
|
|
222
|
+
return "high"
|
|
223
|
+
|
|
224
|
+
# Default to medium if not specified
|
|
225
|
+
return "medium"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Test coverage metric calculator."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
9
|
+
from codeshift.health.models import MetricCategory, MetricResult
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCoverageCalculator(BaseMetricCalculator):
|
|
15
|
+
"""Calculates test coverage score (15% weight).
|
|
16
|
+
|
|
17
|
+
Score is directly mapped from coverage percentage.
|
|
18
|
+
Returns 50 (neutral) if no coverage data is found.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def category(self) -> MetricCategory:
|
|
23
|
+
return MetricCategory.TEST_COVERAGE
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def weight(self) -> float:
|
|
27
|
+
return 0.15
|
|
28
|
+
|
|
29
|
+
def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
|
|
30
|
+
"""Calculate the test coverage score.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
project_path: Path to the project
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
MetricResult with test coverage score
|
|
37
|
+
"""
|
|
38
|
+
coverage, source = self._get_coverage(project_path)
|
|
39
|
+
|
|
40
|
+
if coverage is None:
|
|
41
|
+
return self._create_result(
|
|
42
|
+
score=50, # Neutral score when no data
|
|
43
|
+
description="No coverage data found",
|
|
44
|
+
details={"coverage_found": False},
|
|
45
|
+
recommendations=[
|
|
46
|
+
"Run tests with coverage: pytest --cov",
|
|
47
|
+
"Generate coverage report: coverage run -m pytest && coverage report",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Direct mapping: coverage % = score
|
|
52
|
+
score = coverage * 100
|
|
53
|
+
|
|
54
|
+
recommendations: list[str] = []
|
|
55
|
+
if coverage < 0.5:
|
|
56
|
+
recommendations.append("Increase test coverage to at least 50%")
|
|
57
|
+
elif coverage < 0.8:
|
|
58
|
+
recommendations.append("Consider increasing test coverage to 80% or higher")
|
|
59
|
+
|
|
60
|
+
return self._create_result(
|
|
61
|
+
score=score,
|
|
62
|
+
description=f"{coverage:.0%} test coverage",
|
|
63
|
+
details={
|
|
64
|
+
"coverage_found": True,
|
|
65
|
+
"coverage_percentage": coverage * 100,
|
|
66
|
+
"source": source,
|
|
67
|
+
},
|
|
68
|
+
recommendations=recommendations,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _get_coverage(self, project_path: Path) -> tuple[float | None, str]:
|
|
72
|
+
"""Get test coverage from available sources.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
project_path: Path to the project
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (coverage percentage as 0-1 or None, source description)
|
|
79
|
+
"""
|
|
80
|
+
# Try coverage.json first (pytest-cov JSON output)
|
|
81
|
+
coverage_json = project_path / "coverage.json"
|
|
82
|
+
if coverage_json.exists():
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(coverage_json.read_text())
|
|
85
|
+
totals = data.get("totals", {})
|
|
86
|
+
percent = totals.get("percent_covered", 0)
|
|
87
|
+
return percent / 100, "coverage.json"
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.debug(f"Failed to parse coverage.json: {e}")
|
|
90
|
+
|
|
91
|
+
# Try .coverage SQLite database
|
|
92
|
+
coverage_db = project_path / ".coverage"
|
|
93
|
+
if coverage_db.exists():
|
|
94
|
+
coverage = self._read_coverage_db(coverage_db)
|
|
95
|
+
if coverage is not None:
|
|
96
|
+
return coverage, ".coverage database"
|
|
97
|
+
|
|
98
|
+
# Try htmlcov/index.html for percentage
|
|
99
|
+
htmlcov_index = project_path / "htmlcov" / "index.html"
|
|
100
|
+
if htmlcov_index.exists():
|
|
101
|
+
coverage = self._parse_htmlcov(htmlcov_index)
|
|
102
|
+
if coverage is not None:
|
|
103
|
+
return coverage, "htmlcov"
|
|
104
|
+
|
|
105
|
+
# Try pytest-cov XML format
|
|
106
|
+
coverage_xml = project_path / "coverage.xml"
|
|
107
|
+
if coverage_xml.exists():
|
|
108
|
+
coverage = self._parse_coverage_xml(coverage_xml)
|
|
109
|
+
if coverage is not None:
|
|
110
|
+
return coverage, "coverage.xml"
|
|
111
|
+
|
|
112
|
+
return None, ""
|
|
113
|
+
|
|
114
|
+
def _read_coverage_db(self, db_path: Path) -> float | None:
|
|
115
|
+
"""Read coverage from SQLite database.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
db_path: Path to .coverage database
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Coverage percentage as 0-1 or None
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
import sqlite3
|
|
125
|
+
|
|
126
|
+
conn = sqlite3.connect(db_path)
|
|
127
|
+
cursor = conn.cursor()
|
|
128
|
+
|
|
129
|
+
# Get total lines and covered lines
|
|
130
|
+
cursor.execute(
|
|
131
|
+
"""
|
|
132
|
+
SELECT SUM(num_lines), SUM(num_hits)
|
|
133
|
+
FROM line_counts
|
|
134
|
+
"""
|
|
135
|
+
)
|
|
136
|
+
row = cursor.fetchone()
|
|
137
|
+
conn.close()
|
|
138
|
+
|
|
139
|
+
if row and row[0] and row[0] > 0:
|
|
140
|
+
total_lines = row[0]
|
|
141
|
+
covered_lines = row[1] or 0
|
|
142
|
+
return float(covered_lines / total_lines) if total_lines > 0 else None
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.debug(f"Failed to read .coverage database: {e}")
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _parse_htmlcov(self, index_path: Path) -> float | None:
|
|
150
|
+
"""Parse coverage percentage from htmlcov index.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
index_path: Path to htmlcov/index.html
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Coverage percentage as 0-1 or None
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
import re
|
|
160
|
+
|
|
161
|
+
content = index_path.read_text()
|
|
162
|
+
# Look for patterns like "85%" or "coverage: 85"
|
|
163
|
+
match = re.search(r"(\d+(?:\.\d+)?)\s*%", content)
|
|
164
|
+
if match:
|
|
165
|
+
return float(match.group(1)) / 100
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.debug(f"Failed to parse htmlcov: {e}")
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def _parse_coverage_xml(self, xml_path: Path) -> float | None:
|
|
172
|
+
"""Parse coverage from Cobertura XML format.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
xml_path: Path to coverage.xml
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Coverage percentage as 0-1 or None
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
import re
|
|
182
|
+
|
|
183
|
+
content = xml_path.read_text()
|
|
184
|
+
# Look for line-rate="0.85" attribute
|
|
185
|
+
match = re.search(r'line-rate="(\d+(?:\.\d+)?)"', content)
|
|
186
|
+
if match:
|
|
187
|
+
return float(match.group(1))
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.debug(f"Failed to parse coverage.xml: {e}")
|
|
190
|
+
|
|
191
|
+
return None
|