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
codeshift/cli/main.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Main CLI entry point for PyResolve."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from codeshift import __version__
|
|
7
|
+
from codeshift.cli.commands.apply import apply
|
|
8
|
+
from codeshift.cli.commands.auth import (
|
|
9
|
+
billing,
|
|
10
|
+
login,
|
|
11
|
+
logout,
|
|
12
|
+
quota,
|
|
13
|
+
register,
|
|
14
|
+
upgrade_plan,
|
|
15
|
+
whoami,
|
|
16
|
+
)
|
|
17
|
+
from codeshift.cli.commands.diff import diff
|
|
18
|
+
from codeshift.cli.commands.scan import scan
|
|
19
|
+
from codeshift.cli.commands.upgrade import upgrade
|
|
20
|
+
from codeshift.cli.commands.upgrade_all import upgrade_all
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(version=__version__, prog_name="codeshift")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def cli(ctx: click.Context) -> None:
|
|
29
|
+
"""PyResolve - AI-powered Python dependency migration tool.
|
|
30
|
+
|
|
31
|
+
Don't just flag the update. Fix the break.
|
|
32
|
+
|
|
33
|
+
\b
|
|
34
|
+
Examples:
|
|
35
|
+
codeshift upgrade pydantic --target 2.5.0
|
|
36
|
+
codeshift diff
|
|
37
|
+
codeshift apply
|
|
38
|
+
"""
|
|
39
|
+
# Ensure context object exists
|
|
40
|
+
ctx.ensure_object(dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Register commands
|
|
44
|
+
cli.add_command(scan)
|
|
45
|
+
cli.add_command(upgrade)
|
|
46
|
+
cli.add_command(upgrade_all)
|
|
47
|
+
cli.add_command(diff)
|
|
48
|
+
cli.add_command(apply)
|
|
49
|
+
|
|
50
|
+
# Auth commands
|
|
51
|
+
cli.add_command(register)
|
|
52
|
+
cli.add_command(login)
|
|
53
|
+
cli.add_command(logout)
|
|
54
|
+
cli.add_command(whoami)
|
|
55
|
+
cli.add_command(quota)
|
|
56
|
+
cli.add_command(upgrade_plan)
|
|
57
|
+
cli.add_command(billing)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command()
|
|
61
|
+
def libraries() -> None:
|
|
62
|
+
"""List supported libraries and their migration paths."""
|
|
63
|
+
from rich.table import Table
|
|
64
|
+
|
|
65
|
+
from codeshift.knowledge_base import KnowledgeBaseLoader
|
|
66
|
+
|
|
67
|
+
loader = KnowledgeBaseLoader()
|
|
68
|
+
supported = loader.get_supported_libraries()
|
|
69
|
+
|
|
70
|
+
table = Table(title="Supported Libraries")
|
|
71
|
+
table.add_column("Library", style="cyan")
|
|
72
|
+
table.add_column("Migration Path", style="green")
|
|
73
|
+
table.add_column("Description", style="dim")
|
|
74
|
+
|
|
75
|
+
for lib_name in supported:
|
|
76
|
+
try:
|
|
77
|
+
knowledge = loader.load(lib_name)
|
|
78
|
+
for from_v, to_v in knowledge.supported_migrations:
|
|
79
|
+
table.add_row(
|
|
80
|
+
knowledge.display_name,
|
|
81
|
+
f"v{from_v} → v{to_v}",
|
|
82
|
+
(
|
|
83
|
+
knowledge.description[:50] + "..."
|
|
84
|
+
if len(knowledge.description) > 50
|
|
85
|
+
else knowledge.description
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@cli.command()
|
|
95
|
+
@click.option("--path", "-p", type=click.Path(exists=True), default=".", help="Project path")
|
|
96
|
+
def status(path: str) -> None:
|
|
97
|
+
"""Show current migration status, pending changes, and quota info."""
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
|
|
100
|
+
import httpx
|
|
101
|
+
from rich.panel import Panel
|
|
102
|
+
from rich.table import Table
|
|
103
|
+
|
|
104
|
+
from codeshift.cli.commands.auth import get_api_key, get_api_url, load_credentials
|
|
105
|
+
from codeshift.cli.commands.upgrade import load_state
|
|
106
|
+
|
|
107
|
+
project_path = Path(path).resolve()
|
|
108
|
+
state = load_state(project_path)
|
|
109
|
+
|
|
110
|
+
# Show migration status
|
|
111
|
+
if state is None:
|
|
112
|
+
console.print(
|
|
113
|
+
Panel(
|
|
114
|
+
"[yellow]No pending migration found.[/]\n\n"
|
|
115
|
+
"Run [cyan]codeshift upgrade <library> --target <version>[/] to start a migration.",
|
|
116
|
+
title="Migration Status",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
console.print(
|
|
121
|
+
Panel(
|
|
122
|
+
f"[green]Migration in progress[/]\n\n"
|
|
123
|
+
f"Library: [cyan]{state.get('library', 'unknown')}[/]\n"
|
|
124
|
+
f"Target version: [cyan]{state.get('target_version', 'unknown')}[/]\n"
|
|
125
|
+
f"Files to modify: [cyan]{len(state.get('results', []))}[/]\n"
|
|
126
|
+
f"Total changes: [cyan]{sum(r.get('change_count', 0) for r in state.get('results', []))}[/]\n\n"
|
|
127
|
+
"Use [cyan]codeshift diff[/] to view changes\n"
|
|
128
|
+
"Use [cyan]codeshift apply[/] to apply changes",
|
|
129
|
+
title="Migration Status",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Show authentication and quota status
|
|
134
|
+
console.print()
|
|
135
|
+
|
|
136
|
+
creds = load_credentials()
|
|
137
|
+
api_key = get_api_key()
|
|
138
|
+
|
|
139
|
+
if not creds and not api_key:
|
|
140
|
+
console.print(
|
|
141
|
+
Panel(
|
|
142
|
+
"[yellow]Not logged in[/]\n\n"
|
|
143
|
+
"Run [cyan]codeshift login[/] to authenticate and unlock cloud features.\n"
|
|
144
|
+
"[dim]Free tier: 100 files/month, 50 LLM calls/month[/]",
|
|
145
|
+
title="Account Status",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Try to fetch quota from API
|
|
151
|
+
try:
|
|
152
|
+
api_url = get_api_url()
|
|
153
|
+
headers: dict[str, str] = {}
|
|
154
|
+
if api_key:
|
|
155
|
+
headers["X-API-Key"] = api_key
|
|
156
|
+
response = httpx.get(
|
|
157
|
+
f"{api_url}/usage/quota",
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=10,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if response.status_code == 200:
|
|
163
|
+
data = response.json()
|
|
164
|
+
|
|
165
|
+
# Build quota table
|
|
166
|
+
table = Table(show_header=False, box=None)
|
|
167
|
+
table.add_column("Label", style="dim")
|
|
168
|
+
table.add_column("Value")
|
|
169
|
+
|
|
170
|
+
table.add_row("Tier", f"[cyan]{data['tier'].title()}[/]")
|
|
171
|
+
table.add_row("Billing Period", data["billing_period"])
|
|
172
|
+
table.add_row(
|
|
173
|
+
"File Migrations",
|
|
174
|
+
f"{data['files_migrated']}/{data['files_limit']} ({data['files_remaining']} remaining)",
|
|
175
|
+
)
|
|
176
|
+
table.add_row(
|
|
177
|
+
"LLM Calls",
|
|
178
|
+
f"{data['llm_calls']}/{data['llm_calls_limit']} ({data['llm_calls_remaining']} remaining)",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
email_display = creds.get("email", "Authenticated") if creds else "Authenticated"
|
|
182
|
+
console.print(
|
|
183
|
+
Panel(
|
|
184
|
+
table,
|
|
185
|
+
title=f"Account Status - {email_display}",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Show warning if near limit
|
|
190
|
+
if data["files_percentage"] > 80 or data["llm_calls_percentage"] > 80:
|
|
191
|
+
console.print(
|
|
192
|
+
"[yellow]Running low on quota![/] "
|
|
193
|
+
"Run [cyan]codeshift upgrade-plan[/] to see upgrade options."
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# Fall back to cached info
|
|
197
|
+
cached_email = creds.get("email", "unknown") if creds else "unknown"
|
|
198
|
+
cached_tier = creds.get("tier", "free") if creds else "free"
|
|
199
|
+
console.print(
|
|
200
|
+
Panel(
|
|
201
|
+
f"[green]Logged in[/] [dim](offline)[/]\n"
|
|
202
|
+
f"Email: [cyan]{cached_email}[/]\n"
|
|
203
|
+
f"Tier: [cyan]{cached_tier}[/]",
|
|
204
|
+
title="Account Status",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
except httpx.RequestError:
|
|
208
|
+
# Offline - show cached info
|
|
209
|
+
if creds:
|
|
210
|
+
console.print(
|
|
211
|
+
Panel(
|
|
212
|
+
f"[green]Logged in[/] [dim](offline)[/]\n"
|
|
213
|
+
f"Email: [cyan]{creds.get('email', 'unknown')}[/]\n"
|
|
214
|
+
f"Tier: [cyan]{creds.get('tier', 'free')}[/]",
|
|
215
|
+
title="Account Status",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
cli()
|
codeshift/cli/quota.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Quota checking and usage logging utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from codeshift.cli.commands.auth import get_api_key, get_api_url, load_credentials
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuotaError(Exception):
|
|
15
|
+
"""Exception raised when quota is exceeded."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, current: int, limit: int, remaining: int):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.current = current
|
|
20
|
+
self.limit = limit
|
|
21
|
+
self.remaining = remaining
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_quota(
|
|
25
|
+
event_type: str,
|
|
26
|
+
quantity: int = 1,
|
|
27
|
+
allow_offline: bool = True,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Check if the user has quota for an operation.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
event_type: Type of event ('file_migrated', 'llm_call', 'scan', 'apply')
|
|
33
|
+
quantity: Number of events to check
|
|
34
|
+
allow_offline: If True, allow operation when offline (default True)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if operation is allowed, raises QuotaError otherwise
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
QuotaError: If quota would be exceeded
|
|
41
|
+
"""
|
|
42
|
+
api_key = get_api_key()
|
|
43
|
+
|
|
44
|
+
# If no API key, use default free tier limits (offline mode)
|
|
45
|
+
if not api_key:
|
|
46
|
+
if allow_offline:
|
|
47
|
+
# No quota enforcement without authentication
|
|
48
|
+
return True
|
|
49
|
+
else:
|
|
50
|
+
console.print(
|
|
51
|
+
Panel(
|
|
52
|
+
"[yellow]Authentication required for this operation.[/]\n\n"
|
|
53
|
+
"Run [cyan]codeshift login[/] to authenticate.",
|
|
54
|
+
title="Authentication Required",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
api_url = get_api_url()
|
|
61
|
+
response = httpx.post(
|
|
62
|
+
f"{api_url}/usage/check",
|
|
63
|
+
headers={"X-API-Key": api_key},
|
|
64
|
+
json={"event_type": event_type, "quantity": quantity},
|
|
65
|
+
timeout=10,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if response.status_code == 200:
|
|
69
|
+
data = response.json()
|
|
70
|
+
|
|
71
|
+
if not data["allowed"]:
|
|
72
|
+
raise QuotaError(
|
|
73
|
+
data.get("message", "Quota exceeded"),
|
|
74
|
+
data["current_usage"],
|
|
75
|
+
data["limit"],
|
|
76
|
+
data["remaining"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
elif response.status_code == 401:
|
|
82
|
+
# Invalid credentials
|
|
83
|
+
console.print(
|
|
84
|
+
"[yellow]Invalid credentials. Run [cyan]codeshift login[/] to re-authenticate.[/]"
|
|
85
|
+
)
|
|
86
|
+
if allow_offline:
|
|
87
|
+
return True
|
|
88
|
+
raise SystemExit(1)
|
|
89
|
+
|
|
90
|
+
else:
|
|
91
|
+
# API error, allow operation in offline mode
|
|
92
|
+
if allow_offline:
|
|
93
|
+
return True
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
|
|
96
|
+
except httpx.RequestError:
|
|
97
|
+
# Network error, allow operation in offline mode
|
|
98
|
+
if allow_offline:
|
|
99
|
+
return True
|
|
100
|
+
console.print("[yellow]Cannot connect to API. Working in offline mode.[/]")
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def record_usage(
|
|
105
|
+
event_type: str,
|
|
106
|
+
library: str | None = None,
|
|
107
|
+
quantity: int = 1,
|
|
108
|
+
metadata: dict | None = None,
|
|
109
|
+
) -> bool:
|
|
110
|
+
"""Record a usage event after an operation completes.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
event_type: Type of event ('file_migrated', 'llm_call', 'scan', 'apply')
|
|
114
|
+
library: Library being migrated (optional)
|
|
115
|
+
quantity: Number of events
|
|
116
|
+
metadata: Additional metadata (optional)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if recording succeeded, False otherwise
|
|
120
|
+
"""
|
|
121
|
+
api_key = get_api_key()
|
|
122
|
+
|
|
123
|
+
if not api_key:
|
|
124
|
+
# Can't record without authentication
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
api_url = get_api_url()
|
|
129
|
+
response = httpx.post(
|
|
130
|
+
f"{api_url}/usage/",
|
|
131
|
+
headers={"X-API-Key": api_key},
|
|
132
|
+
json={
|
|
133
|
+
"event_type": event_type,
|
|
134
|
+
"library": library,
|
|
135
|
+
"quantity": quantity,
|
|
136
|
+
"metadata": metadata or {},
|
|
137
|
+
},
|
|
138
|
+
timeout=10,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return bool(response.status_code == 200)
|
|
142
|
+
|
|
143
|
+
except httpx.RequestError:
|
|
144
|
+
# Network error, don't fail the operation
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def show_quota_exceeded_message(error: QuotaError) -> None:
|
|
149
|
+
"""Display a helpful message when quota is exceeded."""
|
|
150
|
+
creds = load_credentials()
|
|
151
|
+
tier = creds.get("tier", "free") if creds else "free"
|
|
152
|
+
|
|
153
|
+
console.print(
|
|
154
|
+
Panel(
|
|
155
|
+
f"[red]Quota exceeded![/]\n\n"
|
|
156
|
+
f"You have used [cyan]{error.current}[/] of your [cyan]{error.limit}[/] "
|
|
157
|
+
f"monthly allowance.\n\n"
|
|
158
|
+
f"Current tier: [cyan]{tier.title()}[/]\n\n"
|
|
159
|
+
"Options:\n"
|
|
160
|
+
" • Upgrade your plan: [cyan]codeshift upgrade-plan[/]\n"
|
|
161
|
+
" • Wait until next billing period\n"
|
|
162
|
+
" • Contact support for enterprise options",
|
|
163
|
+
title="Quota Exceeded",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_remaining_quota(event_type: str) -> int | None:
|
|
169
|
+
"""Get remaining quota for an event type.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Number of remaining events, or None if offline/unauthenticated
|
|
173
|
+
"""
|
|
174
|
+
api_key = get_api_key()
|
|
175
|
+
|
|
176
|
+
if not api_key:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
api_url = get_api_url()
|
|
181
|
+
response = httpx.get(
|
|
182
|
+
f"{api_url}/usage/quota",
|
|
183
|
+
headers={"X-API-Key": api_key},
|
|
184
|
+
timeout=10,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if response.status_code == 200:
|
|
188
|
+
data = response.json()
|
|
189
|
+
|
|
190
|
+
if event_type == "file_migrated":
|
|
191
|
+
return cast(int, data.get("files_remaining", 0))
|
|
192
|
+
elif event_type == "llm_call":
|
|
193
|
+
return cast(int, data.get("llm_calls_remaining", 0))
|
|
194
|
+
else:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
except httpx.RequestError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_tier1_migration(library: str) -> bool:
|
|
204
|
+
"""Check if this is a Tier 1 (free) migration.
|
|
205
|
+
|
|
206
|
+
Tier 1 libraries have AST-based transforms and don't require LLM calls.
|
|
207
|
+
"""
|
|
208
|
+
from codeshift.knowledge import is_tier_1_library
|
|
209
|
+
|
|
210
|
+
return is_tier_1_library(library)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Knowledge acquisition pipeline for auto-generated knowledge bases."""
|
|
2
|
+
|
|
3
|
+
from codeshift.knowledge.cache import KnowledgeCache, get_knowledge_cache
|
|
4
|
+
from codeshift.knowledge.generator import (
|
|
5
|
+
TIER_1_LIBRARIES,
|
|
6
|
+
KnowledgeGenerator,
|
|
7
|
+
generate_knowledge_base,
|
|
8
|
+
generate_knowledge_base_sync,
|
|
9
|
+
get_knowledge_generator,
|
|
10
|
+
is_tier_1_library,
|
|
11
|
+
)
|
|
12
|
+
from codeshift.knowledge.models import (
|
|
13
|
+
BreakingChange,
|
|
14
|
+
ChangeCategory,
|
|
15
|
+
ChangelogSource,
|
|
16
|
+
Confidence,
|
|
17
|
+
GeneratedKnowledgeBase,
|
|
18
|
+
)
|
|
19
|
+
from codeshift.knowledge.parser import ChangelogParser, get_changelog_parser
|
|
20
|
+
from codeshift.knowledge.sources import (
|
|
21
|
+
PackageInfo,
|
|
22
|
+
SourceFetcher,
|
|
23
|
+
get_source_fetcher,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Models
|
|
28
|
+
"BreakingChange",
|
|
29
|
+
"ChangeCategory",
|
|
30
|
+
"ChangelogSource",
|
|
31
|
+
"Confidence",
|
|
32
|
+
"GeneratedKnowledgeBase",
|
|
33
|
+
# Sources
|
|
34
|
+
"PackageInfo",
|
|
35
|
+
"SourceFetcher",
|
|
36
|
+
"get_source_fetcher",
|
|
37
|
+
# Parser
|
|
38
|
+
"ChangelogParser",
|
|
39
|
+
"get_changelog_parser",
|
|
40
|
+
# Cache
|
|
41
|
+
"KnowledgeCache",
|
|
42
|
+
"get_knowledge_cache",
|
|
43
|
+
# Generator
|
|
44
|
+
"KnowledgeGenerator",
|
|
45
|
+
"generate_knowledge_base",
|
|
46
|
+
"generate_knowledge_base_sync",
|
|
47
|
+
"get_knowledge_generator",
|
|
48
|
+
"is_tier_1_library",
|
|
49
|
+
"TIER_1_LIBRARIES",
|
|
50
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Cache for generated knowledge bases."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codeshift.knowledge.models import GeneratedKnowledgeBase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KnowledgeCache:
|
|
11
|
+
"""Cache for storing generated knowledge bases."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_TTL = 86400 * 7 # 7 days
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
cache_dir: Path | None = None,
|
|
18
|
+
ttl: int = DEFAULT_TTL,
|
|
19
|
+
):
|
|
20
|
+
"""Initialize the cache.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
cache_dir: Directory to store cache files.
|
|
24
|
+
ttl: Time-to-live in seconds.
|
|
25
|
+
"""
|
|
26
|
+
if cache_dir is None:
|
|
27
|
+
cache_dir = Path.home() / ".codeshift" / "cache" / "knowledge"
|
|
28
|
+
self.cache_dir = cache_dir
|
|
29
|
+
self.ttl = ttl
|
|
30
|
+
|
|
31
|
+
def _ensure_dir(self) -> None:
|
|
32
|
+
"""Ensure cache directory exists."""
|
|
33
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
def _get_cache_key(self, package: str, old_version: str, new_version: str) -> str:
|
|
36
|
+
"""Generate cache key for a knowledge base."""
|
|
37
|
+
return f"{package}_{old_version}_to_{new_version}".replace(".", "_")
|
|
38
|
+
|
|
39
|
+
def _get_cache_path(self, key: str) -> Path:
|
|
40
|
+
"""Get file path for a cache key."""
|
|
41
|
+
return self.cache_dir / f"{key}.json"
|
|
42
|
+
|
|
43
|
+
def get(
|
|
44
|
+
self,
|
|
45
|
+
package: str,
|
|
46
|
+
old_version: str,
|
|
47
|
+
new_version: str,
|
|
48
|
+
) -> GeneratedKnowledgeBase | None:
|
|
49
|
+
"""Get a cached knowledge base.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
package: Package name.
|
|
53
|
+
old_version: Starting version.
|
|
54
|
+
new_version: Target version.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Cached GeneratedKnowledgeBase or None if not found/expired.
|
|
58
|
+
"""
|
|
59
|
+
key = self._get_cache_key(package, old_version, new_version)
|
|
60
|
+
cache_path = self._get_cache_path(key)
|
|
61
|
+
|
|
62
|
+
if not cache_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(cache_path.read_text())
|
|
67
|
+
|
|
68
|
+
# Check expiration
|
|
69
|
+
created_at = data.get("_created_at", 0)
|
|
70
|
+
if time.time() - created_at > self.ttl:
|
|
71
|
+
cache_path.unlink()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
return GeneratedKnowledgeBase.from_dict(data["knowledge_base"])
|
|
75
|
+
|
|
76
|
+
except (json.JSONDecodeError, KeyError):
|
|
77
|
+
# Invalid cache, remove it
|
|
78
|
+
cache_path.unlink(missing_ok=True)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def set(self, kb: GeneratedKnowledgeBase) -> None:
|
|
82
|
+
"""Store a knowledge base in cache.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
kb: GeneratedKnowledgeBase to cache.
|
|
86
|
+
"""
|
|
87
|
+
self._ensure_dir()
|
|
88
|
+
|
|
89
|
+
key = self._get_cache_key(kb.package, kb.old_version, kb.new_version)
|
|
90
|
+
cache_path = self._get_cache_path(key)
|
|
91
|
+
|
|
92
|
+
data = {
|
|
93
|
+
"_created_at": time.time(),
|
|
94
|
+
"knowledge_base": kb.to_dict(),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cache_path.write_text(json.dumps(data, indent=2))
|
|
98
|
+
|
|
99
|
+
def delete(self, package: str, old_version: str, new_version: str) -> bool:
|
|
100
|
+
"""Delete a cached knowledge base.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
package: Package name.
|
|
104
|
+
old_version: Starting version.
|
|
105
|
+
new_version: Target version.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if deleted, False if not found.
|
|
109
|
+
"""
|
|
110
|
+
key = self._get_cache_key(package, old_version, new_version)
|
|
111
|
+
cache_path = self._get_cache_path(key)
|
|
112
|
+
|
|
113
|
+
if cache_path.exists():
|
|
114
|
+
cache_path.unlink()
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def clear(self) -> int:
|
|
119
|
+
"""Clear all cached knowledge bases.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Number of entries cleared.
|
|
123
|
+
"""
|
|
124
|
+
count = 0
|
|
125
|
+
if self.cache_dir.exists():
|
|
126
|
+
for cache_file in self.cache_dir.glob("*.json"):
|
|
127
|
+
cache_file.unlink()
|
|
128
|
+
count += 1
|
|
129
|
+
return count
|
|
130
|
+
|
|
131
|
+
def list_cached(self) -> list[tuple[str, str, str]]:
|
|
132
|
+
"""List all cached knowledge bases.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of (package, old_version, new_version) tuples.
|
|
136
|
+
"""
|
|
137
|
+
cached: list[tuple[str, str, str]] = []
|
|
138
|
+
if not self.cache_dir.exists():
|
|
139
|
+
return cached
|
|
140
|
+
|
|
141
|
+
for cache_file in self.cache_dir.glob("*.json"):
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(cache_file.read_text())
|
|
144
|
+
kb_data = data.get("knowledge_base", {})
|
|
145
|
+
cached.append(
|
|
146
|
+
(
|
|
147
|
+
kb_data.get("package", ""),
|
|
148
|
+
kb_data.get("old_version", ""),
|
|
149
|
+
kb_data.get("new_version", ""),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
return cached
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Singleton instance
|
|
159
|
+
_default_cache: KnowledgeCache | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_knowledge_cache() -> KnowledgeCache:
|
|
163
|
+
"""Get the default knowledge cache instance."""
|
|
164
|
+
global _default_cache
|
|
165
|
+
if _default_cache is None:
|
|
166
|
+
_default_cache = KnowledgeCache()
|
|
167
|
+
return _default_cache
|