codeshift 0.2.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 +8 -0
- codeshift/analyzer/__init__.py +5 -0
- codeshift/analyzer/risk_assessor.py +388 -0
- codeshift/api/__init__.py +1 -0
- codeshift/api/auth.py +182 -0
- codeshift/api/config.py +73 -0
- codeshift/api/database.py +215 -0
- codeshift/api/main.py +103 -0
- codeshift/api/models/__init__.py +55 -0
- codeshift/api/models/auth.py +108 -0
- codeshift/api/models/billing.py +92 -0
- codeshift/api/models/migrate.py +42 -0
- codeshift/api/models/usage.py +116 -0
- codeshift/api/routers/__init__.py +5 -0
- codeshift/api/routers/auth.py +440 -0
- codeshift/api/routers/billing.py +395 -0
- codeshift/api/routers/migrate.py +304 -0
- codeshift/api/routers/usage.py +291 -0
- codeshift/api/routers/webhooks.py +289 -0
- codeshift/cli/__init__.py +5 -0
- codeshift/cli/commands/__init__.py +7 -0
- codeshift/cli/commands/apply.py +352 -0
- codeshift/cli/commands/auth.py +842 -0
- codeshift/cli/commands/diff.py +221 -0
- codeshift/cli/commands/scan.py +368 -0
- codeshift/cli/commands/upgrade.py +436 -0
- codeshift/cli/commands/upgrade_all.py +518 -0
- codeshift/cli/main.py +221 -0
- codeshift/cli/quota.py +210 -0
- codeshift/knowledge/__init__.py +50 -0
- codeshift/knowledge/cache.py +167 -0
- codeshift/knowledge/generator.py +231 -0
- codeshift/knowledge/models.py +151 -0
- codeshift/knowledge/parser.py +270 -0
- codeshift/knowledge/sources.py +388 -0
- codeshift/knowledge_base/__init__.py +17 -0
- codeshift/knowledge_base/loader.py +102 -0
- codeshift/knowledge_base/models.py +110 -0
- codeshift/migrator/__init__.py +23 -0
- codeshift/migrator/ast_transforms.py +256 -0
- codeshift/migrator/engine.py +395 -0
- codeshift/migrator/llm_migrator.py +320 -0
- codeshift/migrator/transforms/__init__.py +19 -0
- codeshift/migrator/transforms/fastapi_transformer.py +174 -0
- codeshift/migrator/transforms/pandas_transformer.py +236 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +637 -0
- codeshift/migrator/transforms/requests_transformer.py +218 -0
- codeshift/migrator/transforms/sqlalchemy_transformer.py +175 -0
- codeshift/scanner/__init__.py +6 -0
- codeshift/scanner/code_scanner.py +352 -0
- codeshift/scanner/dependency_parser.py +473 -0
- codeshift/utils/__init__.py +5 -0
- codeshift/utils/api_client.py +266 -0
- codeshift/utils/cache.py +318 -0
- codeshift/utils/config.py +71 -0
- codeshift/utils/llm_client.py +221 -0
- codeshift/validator/__init__.py +6 -0
- codeshift/validator/syntax_checker.py +183 -0
- codeshift/validator/test_runner.py +224 -0
- codeshift-0.2.0.dist-info/METADATA +326 -0
- codeshift-0.2.0.dist-info/RECORD +65 -0
- codeshift-0.2.0.dist-info/WHEEL +5 -0
- codeshift-0.2.0.dist-info/entry_points.txt +2 -0
- codeshift-0.2.0.dist-info/licenses/LICENSE +21 -0
- codeshift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Source fetchers for changelog and migration guide discovery."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from codeshift.knowledge.models import ChangelogSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PackageInfo:
|
|
14
|
+
"""Package information from PyPI."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
version: str
|
|
18
|
+
home_page: str | None = None
|
|
19
|
+
project_url: str | None = None
|
|
20
|
+
repository_url: str | None = None
|
|
21
|
+
documentation_url: str | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def github_url(self) -> str | None:
|
|
25
|
+
"""Extract GitHub repository URL."""
|
|
26
|
+
for url in [self.repository_url, self.project_url, self.home_page]:
|
|
27
|
+
if url and "github.com" in url:
|
|
28
|
+
# Normalize to https://github.com/owner/repo format
|
|
29
|
+
parsed = urlparse(url)
|
|
30
|
+
if parsed.netloc == "github.com":
|
|
31
|
+
path_parts = parsed.path.strip("/").split("/")
|
|
32
|
+
if len(path_parts) >= 2:
|
|
33
|
+
return f"https://github.com/{path_parts[0]}/{path_parts[1]}"
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SourceFetcher:
|
|
38
|
+
"""Fetches changelog and migration guide sources from various locations."""
|
|
39
|
+
|
|
40
|
+
CHANGELOG_FILENAMES = [
|
|
41
|
+
"CHANGELOG.md",
|
|
42
|
+
"CHANGELOG.rst",
|
|
43
|
+
"CHANGELOG.txt",
|
|
44
|
+
"CHANGELOG",
|
|
45
|
+
"CHANGES.md",
|
|
46
|
+
"CHANGES.rst",
|
|
47
|
+
"CHANGES.txt",
|
|
48
|
+
"CHANGES",
|
|
49
|
+
"HISTORY.md",
|
|
50
|
+
"HISTORY.rst",
|
|
51
|
+
"NEWS.md",
|
|
52
|
+
"NEWS.rst",
|
|
53
|
+
"NEWS",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
MIGRATION_GUIDE_PATTERNS = [
|
|
57
|
+
"docs/migration",
|
|
58
|
+
"docs/upgrading",
|
|
59
|
+
"docs/upgrade",
|
|
60
|
+
"migration",
|
|
61
|
+
"MIGRATION.md",
|
|
62
|
+
"UPGRADING.md",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def __init__(self, timeout: float = 30.0):
|
|
66
|
+
"""Initialize the fetcher.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
timeout: HTTP request timeout in seconds.
|
|
70
|
+
"""
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
self._client: httpx.Client | None = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def client(self) -> httpx.Client:
|
|
76
|
+
"""Get or create HTTP client."""
|
|
77
|
+
if self._client is None:
|
|
78
|
+
self._client = httpx.Client(
|
|
79
|
+
timeout=self.timeout,
|
|
80
|
+
follow_redirects=True,
|
|
81
|
+
headers={"Accept": "application/vnd.github.v3.raw"},
|
|
82
|
+
)
|
|
83
|
+
return self._client
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
"""Close the HTTP client."""
|
|
87
|
+
if self._client:
|
|
88
|
+
self._client.close()
|
|
89
|
+
self._client = None
|
|
90
|
+
|
|
91
|
+
def get_package_info(self, package: str) -> PackageInfo | None:
|
|
92
|
+
"""Fetch package information from PyPI.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
package: Package name.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
PackageInfo or None if not found.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
response = self.client.get(f"https://pypi.org/pypi/{package}/json")
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
data = response.json()
|
|
104
|
+
|
|
105
|
+
info = data.get("info", {})
|
|
106
|
+
project_urls = info.get("project_urls") or {}
|
|
107
|
+
|
|
108
|
+
return PackageInfo(
|
|
109
|
+
name=info.get("name", package),
|
|
110
|
+
version=info.get("version", ""),
|
|
111
|
+
home_page=info.get("home_page"),
|
|
112
|
+
project_url=info.get("project_url"),
|
|
113
|
+
repository_url=project_urls.get("Repository")
|
|
114
|
+
or project_urls.get("Source")
|
|
115
|
+
or project_urls.get("GitHub"),
|
|
116
|
+
documentation_url=project_urls.get("Documentation") or project_urls.get("Docs"),
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def fetch_github_file(self, repo_url: str, file_path: str, branch: str = "main") -> str | None:
|
|
122
|
+
"""Fetch a file from a GitHub repository.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
repo_url: GitHub repository URL (https://github.com/owner/repo).
|
|
126
|
+
file_path: Path to the file within the repo.
|
|
127
|
+
branch: Branch to fetch from.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
File content or None if not found.
|
|
131
|
+
"""
|
|
132
|
+
# Extract owner/repo from URL
|
|
133
|
+
parsed = urlparse(repo_url)
|
|
134
|
+
path_parts = parsed.path.strip("/").split("/")
|
|
135
|
+
if len(path_parts) < 2:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
owner, repo = path_parts[0], path_parts[1]
|
|
139
|
+
|
|
140
|
+
# Try raw GitHub content URL
|
|
141
|
+
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{file_path}"
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
response = self.client.get(raw_url)
|
|
145
|
+
if response.status_code == 200:
|
|
146
|
+
return str(response.text)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# Try master branch if main failed
|
|
151
|
+
if branch == "main":
|
|
152
|
+
return self.fetch_github_file(repo_url, file_path, branch="master")
|
|
153
|
+
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def fetch_changelog(self, repo_url: str) -> ChangelogSource | None:
|
|
157
|
+
"""Fetch changelog from a GitHub repository.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
repo_url: GitHub repository URL.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
ChangelogSource or None if not found.
|
|
164
|
+
"""
|
|
165
|
+
for filename in self.CHANGELOG_FILENAMES:
|
|
166
|
+
content = self.fetch_github_file(repo_url, filename)
|
|
167
|
+
if content:
|
|
168
|
+
return ChangelogSource(
|
|
169
|
+
url=f"{repo_url}/blob/main/{filename}",
|
|
170
|
+
source_type="changelog",
|
|
171
|
+
content=content,
|
|
172
|
+
)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def fetch_migration_guide(self, repo_url: str) -> ChangelogSource | None:
|
|
176
|
+
"""Fetch migration guide from a GitHub repository.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
repo_url: GitHub repository URL.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
ChangelogSource or None if not found.
|
|
183
|
+
"""
|
|
184
|
+
for pattern in self.MIGRATION_GUIDE_PATTERNS:
|
|
185
|
+
# Try common file extensions
|
|
186
|
+
for ext in [".md", ".rst", ".txt", ""]:
|
|
187
|
+
path = (
|
|
188
|
+
f"{pattern}{ext}" if not pattern.endswith((".md", ".rst", ".txt")) else pattern
|
|
189
|
+
)
|
|
190
|
+
content = self.fetch_github_file(repo_url, path)
|
|
191
|
+
if content:
|
|
192
|
+
return ChangelogSource(
|
|
193
|
+
url=f"{repo_url}/blob/main/{path}",
|
|
194
|
+
source_type="migration_guide",
|
|
195
|
+
content=content,
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def fetch_release_notes(self, repo_url: str, version: str) -> ChangelogSource | None:
|
|
200
|
+
"""Fetch release notes for a specific version from GitHub releases.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
repo_url: GitHub repository URL.
|
|
204
|
+
version: Version to fetch release notes for.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
ChangelogSource or None if not found.
|
|
208
|
+
"""
|
|
209
|
+
parsed = urlparse(repo_url)
|
|
210
|
+
path_parts = parsed.path.strip("/").split("/")
|
|
211
|
+
if len(path_parts) < 2:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
owner, repo = path_parts[0], path_parts[1]
|
|
215
|
+
|
|
216
|
+
# Try GitHub API for releases
|
|
217
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/releases"
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = self.client.get(api_url)
|
|
221
|
+
if response.status_code != 200:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
releases = response.json()
|
|
225
|
+
|
|
226
|
+
# Find matching release
|
|
227
|
+
version_patterns = [
|
|
228
|
+
f"v{version}",
|
|
229
|
+
version,
|
|
230
|
+
f"release-{version}",
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
for release in releases:
|
|
234
|
+
tag = release.get("tag_name", "")
|
|
235
|
+
for pattern in version_patterns:
|
|
236
|
+
if tag == pattern or tag.startswith(pattern):
|
|
237
|
+
body = release.get("body", "")
|
|
238
|
+
if body:
|
|
239
|
+
return ChangelogSource(
|
|
240
|
+
url=release.get("html_url", repo_url),
|
|
241
|
+
source_type="release_notes",
|
|
242
|
+
content=body,
|
|
243
|
+
version_range=(version, version),
|
|
244
|
+
)
|
|
245
|
+
break
|
|
246
|
+
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
async def discover_sources(
|
|
253
|
+
self, package: str, target_version: str | None = None
|
|
254
|
+
) -> list[ChangelogSource]:
|
|
255
|
+
"""Discover all available changelog sources for a package.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
package: Package name.
|
|
259
|
+
target_version: Optional target version for release notes.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
List of discovered ChangelogSources.
|
|
263
|
+
"""
|
|
264
|
+
# This is a sync wrapper - the async signature matches the architecture doc
|
|
265
|
+
return self.discover_sources_sync(package, target_version)
|
|
266
|
+
|
|
267
|
+
def discover_sources_sync(
|
|
268
|
+
self, package: str, target_version: str | None = None
|
|
269
|
+
) -> list[ChangelogSource]:
|
|
270
|
+
"""Synchronously discover all available changelog sources for a package.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
package: Package name.
|
|
274
|
+
target_version: Optional target version for release notes.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List of discovered ChangelogSources.
|
|
278
|
+
"""
|
|
279
|
+
sources: list[ChangelogSource] = []
|
|
280
|
+
|
|
281
|
+
# Get package info from PyPI
|
|
282
|
+
pkg_info = self.get_package_info(package)
|
|
283
|
+
if not pkg_info:
|
|
284
|
+
return sources
|
|
285
|
+
|
|
286
|
+
github_url = pkg_info.github_url
|
|
287
|
+
if not github_url:
|
|
288
|
+
return sources
|
|
289
|
+
|
|
290
|
+
# Fetch changelog
|
|
291
|
+
changelog = self.fetch_changelog(github_url)
|
|
292
|
+
if changelog:
|
|
293
|
+
sources.append(changelog)
|
|
294
|
+
|
|
295
|
+
# Fetch migration guide
|
|
296
|
+
migration_guide = self.fetch_migration_guide(github_url)
|
|
297
|
+
if migration_guide:
|
|
298
|
+
sources.append(migration_guide)
|
|
299
|
+
|
|
300
|
+
# Fetch release notes for target version
|
|
301
|
+
if target_version:
|
|
302
|
+
release_notes = self.fetch_release_notes(github_url, target_version)
|
|
303
|
+
if release_notes:
|
|
304
|
+
sources.append(release_notes)
|
|
305
|
+
|
|
306
|
+
return sources
|
|
307
|
+
|
|
308
|
+
def extract_version_changelog(
|
|
309
|
+
self,
|
|
310
|
+
changelog_content: str,
|
|
311
|
+
from_version: str,
|
|
312
|
+
to_version: str,
|
|
313
|
+
) -> str:
|
|
314
|
+
"""Extract the relevant portion of a changelog between two versions.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
changelog_content: Full changelog content.
|
|
318
|
+
from_version: Starting version.
|
|
319
|
+
to_version: Target version.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Extracted changelog content for the version range.
|
|
323
|
+
"""
|
|
324
|
+
lines = changelog_content.split("\n")
|
|
325
|
+
result_lines = []
|
|
326
|
+
in_range = False
|
|
327
|
+
found_start = False
|
|
328
|
+
|
|
329
|
+
# Common version header patterns
|
|
330
|
+
version_pattern = re.compile(
|
|
331
|
+
r"^#+\s*\[?v?(\d+\.\d+(?:\.\d+)?)\]?|" # ## [1.0.0] or ## v1.0.0
|
|
332
|
+
r"^v?(\d+\.\d+(?:\.\d+)?)\s*[-–—]|" # 1.0.0 - or v1.0.0 -
|
|
333
|
+
r"^v?(\d+\.\d+(?:\.\d+)?)\s*\(", # 1.0.0 (date)
|
|
334
|
+
re.IGNORECASE,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
for line in lines:
|
|
338
|
+
match = version_pattern.match(line)
|
|
339
|
+
if match:
|
|
340
|
+
# Extract version number
|
|
341
|
+
version = match.group(1) or match.group(2) or match.group(3)
|
|
342
|
+
if version:
|
|
343
|
+
# Check if this is our target version or later
|
|
344
|
+
if self._compare_versions(version, to_version) <= 0:
|
|
345
|
+
if not found_start:
|
|
346
|
+
in_range = True
|
|
347
|
+
found_start = True
|
|
348
|
+
|
|
349
|
+
# Check if we've gone past the from_version
|
|
350
|
+
if self._compare_versions(version, from_version) < 0:
|
|
351
|
+
in_range = False
|
|
352
|
+
|
|
353
|
+
if in_range:
|
|
354
|
+
result_lines.append(line)
|
|
355
|
+
|
|
356
|
+
return "\n".join(result_lines)
|
|
357
|
+
|
|
358
|
+
def _compare_versions(self, v1: str, v2: str) -> int:
|
|
359
|
+
"""Compare two version strings.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
-1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
|
|
363
|
+
"""
|
|
364
|
+
from packaging.version import Version
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
ver1 = Version(v1)
|
|
368
|
+
ver2 = Version(v2)
|
|
369
|
+
if ver1 < ver2:
|
|
370
|
+
return -1
|
|
371
|
+
elif ver1 > ver2:
|
|
372
|
+
return 1
|
|
373
|
+
return 0
|
|
374
|
+
except Exception:
|
|
375
|
+
# Fallback to string comparison
|
|
376
|
+
return (v1 > v2) - (v1 < v2)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Singleton instance
|
|
380
|
+
_default_fetcher: SourceFetcher | None = None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_source_fetcher() -> SourceFetcher:
|
|
384
|
+
"""Get the default source fetcher instance."""
|
|
385
|
+
global _default_fetcher
|
|
386
|
+
if _default_fetcher is None:
|
|
387
|
+
_default_fetcher = SourceFetcher()
|
|
388
|
+
return _default_fetcher
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Knowledge base module for breaking change definitions."""
|
|
2
|
+
|
|
3
|
+
from codeshift.knowledge_base.loader import KnowledgeBaseLoader
|
|
4
|
+
from codeshift.knowledge_base.models import (
|
|
5
|
+
BreakingChange,
|
|
6
|
+
ChangeType,
|
|
7
|
+
LibraryKnowledge,
|
|
8
|
+
Severity,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"KnowledgeBaseLoader",
|
|
13
|
+
"BreakingChange",
|
|
14
|
+
"ChangeType",
|
|
15
|
+
"Severity",
|
|
16
|
+
"LibraryKnowledge",
|
|
17
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Loader for the knowledge base YAML files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from codeshift.knowledge_base.models import LibraryKnowledge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KnowledgeBaseLoader:
|
|
11
|
+
"""Loads and manages library knowledge bases."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, knowledge_base_dir: Path | None = None):
|
|
14
|
+
"""Initialize the loader.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
knowledge_base_dir: Directory containing the YAML files.
|
|
18
|
+
Defaults to the 'libraries' subdirectory.
|
|
19
|
+
"""
|
|
20
|
+
if knowledge_base_dir is None:
|
|
21
|
+
knowledge_base_dir = Path(__file__).parent / "libraries"
|
|
22
|
+
self.knowledge_base_dir = knowledge_base_dir
|
|
23
|
+
self._cache: dict[str, LibraryKnowledge] = {}
|
|
24
|
+
|
|
25
|
+
def get_supported_libraries(self) -> list[str]:
|
|
26
|
+
"""Get a list of all supported library names."""
|
|
27
|
+
libraries = []
|
|
28
|
+
if self.knowledge_base_dir.exists():
|
|
29
|
+
for yaml_file in self.knowledge_base_dir.glob("*.yaml"):
|
|
30
|
+
libraries.append(yaml_file.stem)
|
|
31
|
+
return sorted(libraries)
|
|
32
|
+
|
|
33
|
+
def load(self, library_name: str) -> LibraryKnowledge:
|
|
34
|
+
"""Load the knowledge base for a specific library.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
library_name: Name of the library (e.g., "pydantic")
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
LibraryKnowledge object containing all breaking change info
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
FileNotFoundError: If the knowledge base file doesn't exist
|
|
44
|
+
ValueError: If the YAML file is invalid
|
|
45
|
+
"""
|
|
46
|
+
if library_name in self._cache:
|
|
47
|
+
return self._cache[library_name]
|
|
48
|
+
|
|
49
|
+
yaml_path = self.knowledge_base_dir / f"{library_name}.yaml"
|
|
50
|
+
if not yaml_path.exists():
|
|
51
|
+
raise FileNotFoundError(
|
|
52
|
+
f"No knowledge base found for library '{library_name}'. "
|
|
53
|
+
f"Available libraries: {', '.join(self.get_supported_libraries())}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with open(yaml_path) as f:
|
|
58
|
+
data = yaml.safe_load(f)
|
|
59
|
+
except yaml.YAMLError as e:
|
|
60
|
+
raise ValueError(f"Invalid YAML in knowledge base for '{library_name}': {e}") from e
|
|
61
|
+
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise ValueError(f"Knowledge base for '{library_name}' must be a dictionary")
|
|
64
|
+
|
|
65
|
+
knowledge = LibraryKnowledge.from_dict(data)
|
|
66
|
+
self._cache[library_name] = knowledge
|
|
67
|
+
return knowledge
|
|
68
|
+
|
|
69
|
+
def is_migration_supported(self, library_name: str, from_version: str, to_version: str) -> bool:
|
|
70
|
+
"""Check if a specific migration path is supported.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
library_name: Name of the library
|
|
74
|
+
from_version: Starting version
|
|
75
|
+
to_version: Target version
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if the migration path is supported
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
knowledge = self.load(library_name)
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
from packaging.version import Version
|
|
86
|
+
|
|
87
|
+
from_v = Version(from_version)
|
|
88
|
+
to_v = Version(to_version)
|
|
89
|
+
|
|
90
|
+
for supported_from, supported_to in knowledge.supported_migrations:
|
|
91
|
+
supported_from_v = Version(supported_from)
|
|
92
|
+
supported_to_v = Version(supported_to)
|
|
93
|
+
|
|
94
|
+
# Check if requested migration falls within a supported range
|
|
95
|
+
if from_v >= supported_from_v and to_v <= supported_to_v:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def clear_cache(self) -> None:
|
|
101
|
+
"""Clear the cached knowledge bases."""
|
|
102
|
+
self._cache.clear()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Data models for the knowledge base."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChangeType(Enum):
|
|
8
|
+
"""Types of breaking changes."""
|
|
9
|
+
|
|
10
|
+
RENAMED = "renamed"
|
|
11
|
+
REMOVED = "removed"
|
|
12
|
+
SIGNATURE_CHANGED = "signature_changed"
|
|
13
|
+
BEHAVIOR_CHANGED = "behavior_changed"
|
|
14
|
+
DEPRECATED = "deprecated"
|
|
15
|
+
TYPE_CHANGED = "type_changed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Severity(Enum):
|
|
19
|
+
"""Severity levels for breaking changes."""
|
|
20
|
+
|
|
21
|
+
LOW = "low"
|
|
22
|
+
MEDIUM = "medium"
|
|
23
|
+
HIGH = "high"
|
|
24
|
+
CRITICAL = "critical"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BreakingChange:
|
|
29
|
+
"""Represents a single breaking change in a library."""
|
|
30
|
+
|
|
31
|
+
symbol: str # e.g., "BaseModel.Config", "@validator", ".dict()"
|
|
32
|
+
change_type: ChangeType
|
|
33
|
+
severity: Severity
|
|
34
|
+
from_version: str
|
|
35
|
+
to_version: str
|
|
36
|
+
description: str
|
|
37
|
+
replacement: str | None = None # e.g., "model_config = ConfigDict(...)"
|
|
38
|
+
has_deterministic_transform: bool = False
|
|
39
|
+
transform_name: str | None = None # e.g., "config_to_configdict"
|
|
40
|
+
migration_guide_url: str | None = None
|
|
41
|
+
notes: str | None = None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict) -> "BreakingChange":
|
|
45
|
+
"""Create a BreakingChange from a dictionary."""
|
|
46
|
+
return cls(
|
|
47
|
+
symbol=data["symbol"],
|
|
48
|
+
change_type=ChangeType(data["change_type"]),
|
|
49
|
+
severity=Severity(data["severity"]),
|
|
50
|
+
from_version=data["from_version"],
|
|
51
|
+
to_version=data["to_version"],
|
|
52
|
+
description=data["description"],
|
|
53
|
+
replacement=data.get("replacement"),
|
|
54
|
+
has_deterministic_transform=data.get("has_deterministic_transform", False),
|
|
55
|
+
transform_name=data.get("transform_name"),
|
|
56
|
+
migration_guide_url=data.get("migration_guide_url"),
|
|
57
|
+
notes=data.get("notes"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class LibraryKnowledge:
|
|
63
|
+
"""Knowledge about a library's breaking changes."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
display_name: str
|
|
67
|
+
description: str
|
|
68
|
+
migration_guide_url: str | None
|
|
69
|
+
supported_migrations: list[tuple[str, str]] # List of (from_version, to_version)
|
|
70
|
+
breaking_changes: list[BreakingChange] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: dict) -> "LibraryKnowledge":
|
|
74
|
+
"""Create a LibraryKnowledge from a dictionary."""
|
|
75
|
+
breaking_changes = [BreakingChange.from_dict(bc) for bc in data.get("breaking_changes", [])]
|
|
76
|
+
supported_migrations = [(m["from"], m["to"]) for m in data.get("supported_migrations", [])]
|
|
77
|
+
|
|
78
|
+
return cls(
|
|
79
|
+
name=data["name"],
|
|
80
|
+
display_name=data["display_name"],
|
|
81
|
+
description=data.get("description", ""),
|
|
82
|
+
migration_guide_url=data.get("migration_guide_url"),
|
|
83
|
+
supported_migrations=supported_migrations,
|
|
84
|
+
breaking_changes=breaking_changes,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def get_changes_for_migration(self, from_version: str, to_version: str) -> list[BreakingChange]:
|
|
88
|
+
"""Get all breaking changes relevant to a specific migration."""
|
|
89
|
+
from packaging.version import Version
|
|
90
|
+
|
|
91
|
+
from_v = Version(from_version)
|
|
92
|
+
to_v = Version(to_version)
|
|
93
|
+
|
|
94
|
+
relevant = []
|
|
95
|
+
for change in self.breaking_changes:
|
|
96
|
+
change_from = Version(change.from_version)
|
|
97
|
+
change_to = Version(change.to_version)
|
|
98
|
+
|
|
99
|
+
# Include if the change affects versions between from and to
|
|
100
|
+
if change_from >= from_v and change_to <= to_v:
|
|
101
|
+
relevant.append(change)
|
|
102
|
+
|
|
103
|
+
return relevant
|
|
104
|
+
|
|
105
|
+
def get_deterministic_transforms(
|
|
106
|
+
self, from_version: str, to_version: str
|
|
107
|
+
) -> list[BreakingChange]:
|
|
108
|
+
"""Get all breaking changes that have deterministic transforms."""
|
|
109
|
+
changes = self.get_changes_for_migration(from_version, to_version)
|
|
110
|
+
return [c for c in changes if c.has_deterministic_transform]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Migrator module for transforming code."""
|
|
2
|
+
|
|
3
|
+
from codeshift.migrator.ast_transforms import (
|
|
4
|
+
BaseTransformer,
|
|
5
|
+
TransformChange,
|
|
6
|
+
TransformResult,
|
|
7
|
+
TransformStatus,
|
|
8
|
+
)
|
|
9
|
+
from codeshift.migrator.engine import (
|
|
10
|
+
MigrationEngine,
|
|
11
|
+
get_migration_engine,
|
|
12
|
+
run_migration,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseTransformer",
|
|
17
|
+
"TransformChange",
|
|
18
|
+
"TransformResult",
|
|
19
|
+
"TransformStatus",
|
|
20
|
+
"MigrationEngine",
|
|
21
|
+
"get_migration_engine",
|
|
22
|
+
"run_migration",
|
|
23
|
+
]
|