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.
Files changed (32) hide show
  1. codeshift/__init__.py +1 -1
  2. codeshift/cli/commands/auth.py +41 -25
  3. codeshift/cli/commands/health.py +244 -0
  4. codeshift/cli/commands/upgrade.py +68 -55
  5. codeshift/cli/main.py +2 -0
  6. codeshift/health/__init__.py +50 -0
  7. codeshift/health/calculator.py +217 -0
  8. codeshift/health/metrics/__init__.py +63 -0
  9. codeshift/health/metrics/documentation.py +209 -0
  10. codeshift/health/metrics/freshness.py +180 -0
  11. codeshift/health/metrics/migration_readiness.py +142 -0
  12. codeshift/health/metrics/security.py +225 -0
  13. codeshift/health/metrics/test_coverage.py +191 -0
  14. codeshift/health/models.py +284 -0
  15. codeshift/health/report.py +310 -0
  16. codeshift/knowledge/generator.py +6 -0
  17. codeshift/knowledge_base/libraries/aiohttp.yaml +3 -3
  18. codeshift/knowledge_base/libraries/httpx.yaml +4 -4
  19. codeshift/knowledge_base/libraries/pytest.yaml +1 -1
  20. codeshift/knowledge_base/models.py +1 -0
  21. codeshift/migrator/transforms/marshmallow_transformer.py +50 -0
  22. codeshift/migrator/transforms/pydantic_v1_to_v2.py +191 -22
  23. codeshift/scanner/code_scanner.py +22 -2
  24. codeshift/utils/api_client.py +144 -4
  25. codeshift/utils/credential_store.py +393 -0
  26. codeshift/utils/llm_client.py +111 -9
  27. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/METADATA +4 -1
  28. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/RECORD +32 -20
  29. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/WHEEL +0 -0
  30. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/entry_points.txt +0 -0
  31. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/licenses/LICENSE +0 -0
  32. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,217 @@
1
+ """Main health score calculator orchestrator."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from codeshift.health.metrics import BaseMetricCalculator
7
+ from codeshift.health.metrics.documentation import DocumentationCalculator
8
+ from codeshift.health.metrics.freshness import FreshnessCalculator
9
+ from codeshift.health.metrics.migration_readiness import MigrationReadinessCalculator
10
+ from codeshift.health.metrics.security import SecurityCalculator
11
+ from codeshift.health.metrics.test_coverage import TestCoverageCalculator
12
+ from codeshift.health.models import (
13
+ DependencyHealth,
14
+ HealthGrade,
15
+ HealthReport,
16
+ HealthScore,
17
+ MetricResult,
18
+ SecurityVulnerability,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class HealthCalculator:
25
+ """Orchestrates health score calculation across all metrics."""
26
+
27
+ def __init__(self) -> None:
28
+ """Initialize the calculator with all metric calculators."""
29
+ self.calculators: list[BaseMetricCalculator] = [
30
+ FreshnessCalculator(),
31
+ SecurityCalculator(),
32
+ MigrationReadinessCalculator(),
33
+ TestCoverageCalculator(),
34
+ DocumentationCalculator(),
35
+ ]
36
+
37
+ def calculate(self, project_path: Path) -> HealthScore:
38
+ """Calculate the complete health score for a project.
39
+
40
+ Args:
41
+ project_path: Path to the project root
42
+
43
+ Returns:
44
+ HealthScore with all metrics and overall score
45
+ """
46
+ project_path = project_path.resolve()
47
+
48
+ # First, analyze dependencies once to share across calculators
49
+ dependencies = self._analyze_dependencies(project_path)
50
+
51
+ # Calculate each metric
52
+ metrics: list[MetricResult] = []
53
+ for calculator in self.calculators:
54
+ try:
55
+ result = calculator.calculate(
56
+ project_path,
57
+ dependencies=dependencies,
58
+ )
59
+ metrics.append(result)
60
+ except Exception as e:
61
+ logger.warning(f"Failed to calculate {calculator.category.value}: {e}")
62
+ # Add a neutral result on failure
63
+ metrics.append(
64
+ MetricResult(
65
+ category=calculator.category,
66
+ score=50,
67
+ weight=calculator.weight,
68
+ description=f"Error: {str(e)[:50]}",
69
+ details={"error": str(e)},
70
+ recommendations=["Fix metric calculation error"],
71
+ )
72
+ )
73
+
74
+ # Calculate overall weighted score
75
+ total_weight = sum(m.weight for m in metrics)
76
+ if total_weight > 0:
77
+ overall_score = sum(m.weighted_score for m in metrics) / total_weight
78
+ else:
79
+ overall_score = 0
80
+
81
+ # Collect all vulnerabilities
82
+ all_vulns: list[SecurityVulnerability] = []
83
+ for dep in dependencies:
84
+ all_vulns.extend(dep.vulnerabilities)
85
+
86
+ return HealthScore(
87
+ overall_score=overall_score,
88
+ grade=HealthGrade.from_score(overall_score),
89
+ metrics=metrics,
90
+ dependencies=dependencies,
91
+ vulnerabilities=all_vulns,
92
+ project_path=project_path,
93
+ )
94
+
95
+ def calculate_report(
96
+ self,
97
+ project_path: Path,
98
+ previous: HealthScore | None = None,
99
+ ) -> HealthReport:
100
+ """Calculate a health report with trend information.
101
+
102
+ Args:
103
+ project_path: Path to the project root
104
+ previous: Optional previous health score for comparison
105
+
106
+ Returns:
107
+ HealthReport with current score and trend
108
+ """
109
+ current = self.calculate(project_path)
110
+ return HealthReport(current=current, previous=previous)
111
+
112
+ def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
113
+ """Analyze all dependencies for shared data.
114
+
115
+ This method runs once and provides data for multiple calculators
116
+ to avoid redundant API calls.
117
+
118
+ Args:
119
+ project_path: Path to the project
120
+
121
+ Returns:
122
+ List of DependencyHealth with all analyzable data
123
+ """
124
+ from codeshift.scanner.dependency_parser import DependencyParser
125
+
126
+ parser = DependencyParser(project_path)
127
+ raw_deps = parser.parse_all()
128
+
129
+ # Get knowledge base info for tier support
130
+ from codeshift.knowledge_base import KnowledgeBaseLoader
131
+
132
+ loader = KnowledgeBaseLoader()
133
+ supported_libraries = loader.get_supported_libraries()
134
+ tier1_libraries = {"pydantic", "fastapi", "sqlalchemy", "pandas", "requests"}
135
+
136
+ dependencies: list[DependencyHealth] = []
137
+
138
+ for dep in raw_deps:
139
+ dep_name_lower = dep.name.lower()
140
+
141
+ # Get latest version and vulnerabilities from PyPI
142
+ latest_version = None
143
+ vulnerabilities: list[SecurityVulnerability] = []
144
+
145
+ try:
146
+ import httpx
147
+ from packaging.version import Version
148
+
149
+ response = httpx.get(
150
+ f"https://pypi.org/pypi/{dep.name}/json",
151
+ timeout=5.0,
152
+ )
153
+ if response.status_code == 200:
154
+ data = response.json()
155
+
156
+ # Get latest version
157
+ version_str = data.get("info", {}).get("version")
158
+ if version_str:
159
+ latest_version = Version(version_str)
160
+
161
+ # Get vulnerabilities
162
+ from codeshift.health.models import VulnerabilitySeverity
163
+
164
+ for vuln_data in data.get("vulnerabilities", []):
165
+ try:
166
+ severity = VulnerabilitySeverity.MEDIUM
167
+ vulnerabilities.append(
168
+ SecurityVulnerability(
169
+ package=dep.name,
170
+ vulnerability_id=vuln_data.get("id", "unknown"),
171
+ severity=severity,
172
+ description=vuln_data.get("summary", "")[:200],
173
+ fixed_in=(
174
+ vuln_data.get("fixed_in", [None])[0]
175
+ if vuln_data.get("fixed_in")
176
+ else None
177
+ ),
178
+ url=vuln_data.get("link"),
179
+ )
180
+ )
181
+ except Exception:
182
+ pass
183
+
184
+ except Exception as e:
185
+ logger.debug(f"Failed to fetch PyPI data for {dep.name}: {e}")
186
+
187
+ # Calculate version lag
188
+ current = dep.min_version
189
+ is_outdated = False
190
+ major_behind = 0
191
+ minor_behind = 0
192
+
193
+ if current and latest_version:
194
+ is_outdated = current < latest_version
195
+ major_behind = max(0, latest_version.major - current.major)
196
+ if major_behind == 0:
197
+ minor_behind = max(0, latest_version.minor - current.minor)
198
+
199
+ # Check tier support
200
+ has_tier1 = dep_name_lower in tier1_libraries
201
+ has_tier2 = dep_name_lower in [lib.lower() for lib in supported_libraries]
202
+
203
+ dependencies.append(
204
+ DependencyHealth(
205
+ name=dep.name,
206
+ current_version=str(current) if current else None,
207
+ latest_version=str(latest_version) if latest_version else None,
208
+ is_outdated=is_outdated,
209
+ major_versions_behind=major_behind,
210
+ minor_versions_behind=minor_behind,
211
+ has_tier1_support=has_tier1,
212
+ has_tier2_support=has_tier2,
213
+ vulnerabilities=vulnerabilities,
214
+ )
215
+ )
216
+
217
+ return dependencies
@@ -0,0 +1,63 @@
1
+ """Base class and utilities for health metric calculators."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from codeshift.health.models import MetricCategory, MetricResult
8
+
9
+
10
+ class BaseMetricCalculator(ABC):
11
+ """Abstract base class for health metric calculators."""
12
+
13
+ @property
14
+ @abstractmethod
15
+ def category(self) -> MetricCategory:
16
+ """Return the metric category."""
17
+ ...
18
+
19
+ @property
20
+ @abstractmethod
21
+ def weight(self) -> float:
22
+ """Return the weight for this metric (0.0 to 1.0)."""
23
+ ...
24
+
25
+ @abstractmethod
26
+ def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
27
+ """Calculate the metric score.
28
+
29
+ Args:
30
+ project_path: Path to the project root
31
+ **kwargs: Additional arguments specific to the metric
32
+
33
+ Returns:
34
+ MetricResult with score and details
35
+ """
36
+ ...
37
+
38
+ def _create_result(
39
+ self,
40
+ score: float,
41
+ description: str,
42
+ details: dict | None = None,
43
+ recommendations: list[str] | None = None,
44
+ ) -> MetricResult:
45
+ """Helper to create a MetricResult.
46
+
47
+ Args:
48
+ score: Score from 0-100
49
+ description: Human-readable description
50
+ details: Optional details dictionary
51
+ recommendations: Optional list of recommendations
52
+
53
+ Returns:
54
+ MetricResult instance
55
+ """
56
+ return MetricResult(
57
+ category=self.category,
58
+ score=max(0, min(100, score)), # Clamp to 0-100
59
+ weight=self.weight,
60
+ description=description,
61
+ details=details or {},
62
+ recommendations=recommendations or [],
63
+ )
@@ -0,0 +1,209 @@
1
+ """Documentation quality metric calculator."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import libcst as cst
8
+
9
+ from codeshift.health.metrics import BaseMetricCalculator
10
+ from codeshift.health.models import MetricCategory, MetricResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DocumentationCalculator(BaseMetricCalculator):
16
+ """Calculates documentation score (10% weight).
17
+
18
+ Score based on:
19
+ - Type hints coverage: 70% of score
20
+ - Docstring coverage: 30% of score
21
+ """
22
+
23
+ @property
24
+ def category(self) -> MetricCategory:
25
+ return MetricCategory.DOCUMENTATION
26
+
27
+ @property
28
+ def weight(self) -> float:
29
+ return 0.10
30
+
31
+ def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
32
+ """Calculate the documentation score.
33
+
34
+ Args:
35
+ project_path: Path to the project
36
+
37
+ Returns:
38
+ MetricResult with documentation score
39
+ """
40
+ # Find all Python files
41
+ python_files = list(project_path.rglob("*.py"))
42
+
43
+ # Exclude common non-source directories
44
+ excluded_patterns = [
45
+ ".venv",
46
+ "venv",
47
+ ".git",
48
+ "__pycache__",
49
+ ".tox",
50
+ ".eggs",
51
+ "build",
52
+ "dist",
53
+ ".mypy_cache",
54
+ ".pytest_cache",
55
+ ]
56
+
57
+ python_files = [
58
+ f for f in python_files if not any(pattern in str(f) for pattern in excluded_patterns)
59
+ ]
60
+
61
+ if not python_files:
62
+ return self._create_result(
63
+ score=100,
64
+ description="No Python files to analyze",
65
+ details={"file_count": 0},
66
+ recommendations=[],
67
+ )
68
+
69
+ # Analyze files
70
+ total_functions = 0
71
+ typed_functions = 0
72
+ documented_functions = 0
73
+
74
+ for file_path in python_files:
75
+ try:
76
+ source = file_path.read_text()
77
+ tree = cst.parse_module(source)
78
+ stats = self._analyze_file(tree)
79
+
80
+ total_functions += stats["total"]
81
+ typed_functions += stats["typed"]
82
+ documented_functions += stats["documented"]
83
+ except Exception as e:
84
+ logger.debug(f"Failed to analyze {file_path}: {e}")
85
+
86
+ if total_functions == 0:
87
+ return self._create_result(
88
+ score=100,
89
+ description="No functions found to analyze",
90
+ details={"file_count": len(python_files), "function_count": 0},
91
+ recommendations=[],
92
+ )
93
+
94
+ typed_ratio = typed_functions / total_functions
95
+ documented_ratio = documented_functions / total_functions
96
+
97
+ # Score = (typed_ratio * 70) + (documented_ratio * 30)
98
+ score = (typed_ratio * 70) + (documented_ratio * 30)
99
+
100
+ recommendations: list[str] = []
101
+ if typed_ratio < 0.5:
102
+ recommendations.append(
103
+ f"Add type hints to functions ({typed_functions}/{total_functions} typed)"
104
+ )
105
+ if documented_ratio < 0.3:
106
+ recommendations.append(
107
+ f"Add docstrings to functions ({documented_functions}/{total_functions} documented)"
108
+ )
109
+
110
+ return self._create_result(
111
+ score=score,
112
+ description=f"{typed_ratio:.0%} typed, {documented_ratio:.0%} documented",
113
+ details={
114
+ "file_count": len(python_files),
115
+ "function_count": total_functions,
116
+ "typed_count": typed_functions,
117
+ "documented_count": documented_functions,
118
+ "typed_ratio": typed_ratio,
119
+ "documented_ratio": documented_ratio,
120
+ },
121
+ recommendations=recommendations,
122
+ )
123
+
124
+ def _analyze_file(self, tree: cst.Module) -> dict:
125
+ """Analyze a file for type hints and docstrings.
126
+
127
+ Args:
128
+ tree: Parsed CST module
129
+
130
+ Returns:
131
+ Dict with total, typed, and documented counts
132
+ """
133
+ visitor = FunctionAnalyzer()
134
+ # Use MetadataWrapper to walk the tree with the visitor
135
+ wrapper = cst.MetadataWrapper(tree)
136
+ wrapper.visit(visitor)
137
+
138
+ return {
139
+ "total": visitor.total_functions,
140
+ "typed": visitor.typed_functions,
141
+ "documented": visitor.documented_functions,
142
+ }
143
+
144
+
145
+ class FunctionAnalyzer(cst.CSTVisitor):
146
+ """CST visitor to analyze functions for type hints and docstrings."""
147
+
148
+ def __init__(self) -> None:
149
+ self.total_functions = 0
150
+ self.typed_functions = 0
151
+ self.documented_functions = 0
152
+
153
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
154
+ self.total_functions += 1
155
+
156
+ # Check for type hints
157
+ if self._has_type_hints(node):
158
+ self.typed_functions += 1
159
+
160
+ # Check for docstring
161
+ if self._has_docstring(node):
162
+ self.documented_functions += 1
163
+
164
+ return True # Continue visiting nested functions
165
+
166
+ def _has_type_hints(self, node: cst.FunctionDef) -> bool:
167
+ """Check if a function has type hints.
168
+
169
+ Args:
170
+ node: Function definition node
171
+
172
+ Returns:
173
+ True if function has return type or any parameter types
174
+ """
175
+ # Check return type
176
+ if node.returns is not None:
177
+ return True
178
+
179
+ # Check parameter types
180
+ for param in node.params.params:
181
+ if param.annotation is not None:
182
+ return True
183
+
184
+ return False
185
+
186
+ def _has_docstring(self, node: cst.FunctionDef) -> bool:
187
+ """Check if a function has a docstring.
188
+
189
+ Args:
190
+ node: Function definition node
191
+
192
+ Returns:
193
+ True if function has a docstring
194
+ """
195
+ if not node.body.body:
196
+ return False
197
+
198
+ first_stmt = node.body.body[0]
199
+
200
+ # Check if first statement is an expression statement with a string
201
+ if isinstance(first_stmt, cst.SimpleStatementLine):
202
+ if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr):
203
+ expr = first_stmt.body[0].value
204
+ if isinstance(expr, (cst.SimpleString, cst.ConcatenatedString)):
205
+ return True
206
+ if isinstance(expr, cst.FormattedString):
207
+ return True
208
+
209
+ return False
@@ -0,0 +1,180 @@
1
+ """Dependency freshness metric calculator."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from packaging.version import Version
9
+
10
+ from codeshift.health.metrics import BaseMetricCalculator
11
+ from codeshift.health.models import DependencyHealth, MetricCategory, MetricResult
12
+ from codeshift.scanner.dependency_parser import DependencyParser
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # PyPI API timeout
17
+ PYPI_TIMEOUT = 5.0
18
+
19
+
20
+ class FreshnessCalculator(BaseMetricCalculator):
21
+ """Calculates dependency freshness score (30% weight).
22
+
23
+ Score is based on how up-to-date dependencies are:
24
+ - Major version behind: -15 points per dependency
25
+ - Minor version behind: -5 points each (up to 3 per dependency)
26
+ """
27
+
28
+ @property
29
+ def category(self) -> MetricCategory:
30
+ return MetricCategory.FRESHNESS
31
+
32
+ @property
33
+ def weight(self) -> float:
34
+ return 0.30
35
+
36
+ def calculate(
37
+ self,
38
+ project_path: Path,
39
+ dependencies: list[DependencyHealth] | None = None,
40
+ **kwargs: Any,
41
+ ) -> MetricResult:
42
+ """Calculate the freshness score.
43
+
44
+ Args:
45
+ project_path: Path to the project
46
+ dependencies: Pre-populated dependency health list (optional)
47
+
48
+ Returns:
49
+ MetricResult with freshness score
50
+ """
51
+ if dependencies is None:
52
+ dependencies = self._analyze_dependencies(project_path)
53
+
54
+ if not dependencies:
55
+ return self._create_result(
56
+ score=100,
57
+ description="No dependencies to analyze",
58
+ details={"dependency_count": 0},
59
+ recommendations=[],
60
+ )
61
+
62
+ # Calculate penalty
63
+ total_penalty = 0
64
+ outdated_deps: list[str] = []
65
+ major_outdated: list[str] = []
66
+
67
+ for dep in dependencies:
68
+ if dep.is_outdated:
69
+ outdated_deps.append(dep.name)
70
+ penalty = dep.version_lag_penalty
71
+ total_penalty += penalty
72
+
73
+ if dep.major_versions_behind > 0:
74
+ major_outdated.append(
75
+ f"{dep.name} ({dep.current_version} -> {dep.latest_version})"
76
+ )
77
+
78
+ # Score starts at 100, subtract penalties (min 0)
79
+ score = max(0, 100 - total_penalty)
80
+
81
+ # Build recommendations
82
+ recommendations: list[str] = []
83
+ if major_outdated:
84
+ recommendations.append(
85
+ f"Update major versions: {', '.join(major_outdated[:3])}"
86
+ + (f" (+{len(major_outdated) - 3} more)" if len(major_outdated) > 3 else "")
87
+ )
88
+ if len(outdated_deps) > len(major_outdated):
89
+ minor_count = len(outdated_deps) - len(major_outdated)
90
+ recommendations.append(f"Update {minor_count} dependencies with minor version updates")
91
+
92
+ return self._create_result(
93
+ score=score,
94
+ description=f"{len(outdated_deps)}/{len(dependencies)} dependencies outdated",
95
+ details={
96
+ "total_dependencies": len(dependencies),
97
+ "outdated_count": len(outdated_deps),
98
+ "major_outdated_count": len(major_outdated),
99
+ "total_penalty": total_penalty,
100
+ },
101
+ recommendations=recommendations,
102
+ )
103
+
104
+ def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
105
+ """Analyze project dependencies for freshness.
106
+
107
+ Args:
108
+ project_path: Path to the project
109
+
110
+ Returns:
111
+ List of DependencyHealth objects
112
+ """
113
+ parser = DependencyParser(project_path)
114
+ dependencies = parser.parse_all()
115
+
116
+ results: list[DependencyHealth] = []
117
+
118
+ for dep in dependencies:
119
+ try:
120
+ latest = self._get_latest_version(dep.name)
121
+ current = dep.min_version
122
+
123
+ if current and latest:
124
+ is_outdated = current < latest
125
+ major_behind = max(0, latest.major - current.major)
126
+ minor_behind = 0
127
+ if major_behind == 0:
128
+ minor_behind = max(0, latest.minor - current.minor)
129
+ else:
130
+ is_outdated = False
131
+ major_behind = 0
132
+ minor_behind = 0
133
+
134
+ results.append(
135
+ DependencyHealth(
136
+ name=dep.name,
137
+ current_version=str(current) if current else None,
138
+ latest_version=str(latest) if latest else None,
139
+ is_outdated=is_outdated,
140
+ major_versions_behind=major_behind,
141
+ minor_versions_behind=minor_behind,
142
+ )
143
+ )
144
+ except Exception as e:
145
+ logger.debug(f"Error analyzing {dep.name}: {e}")
146
+ # Add with unknown status
147
+ results.append(
148
+ DependencyHealth(
149
+ name=dep.name,
150
+ current_version=str(dep.min_version) if dep.min_version else None,
151
+ latest_version=None,
152
+ is_outdated=False,
153
+ )
154
+ )
155
+
156
+ return results
157
+
158
+ def _get_latest_version(self, package_name: str) -> Version | None:
159
+ """Get the latest version of a package from PyPI.
160
+
161
+ Args:
162
+ package_name: Name of the package
163
+
164
+ Returns:
165
+ Latest Version or None if not found
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
+ version_str = data.get("info", {}).get("version")
175
+ if version_str:
176
+ return Version(version_str)
177
+ except Exception as e:
178
+ logger.debug(f"Failed to get latest version for {package_name}: {e}")
179
+
180
+ return None