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.
Files changed (65) hide show
  1. codeshift/__init__.py +8 -0
  2. codeshift/analyzer/__init__.py +5 -0
  3. codeshift/analyzer/risk_assessor.py +388 -0
  4. codeshift/api/__init__.py +1 -0
  5. codeshift/api/auth.py +182 -0
  6. codeshift/api/config.py +73 -0
  7. codeshift/api/database.py +215 -0
  8. codeshift/api/main.py +103 -0
  9. codeshift/api/models/__init__.py +55 -0
  10. codeshift/api/models/auth.py +108 -0
  11. codeshift/api/models/billing.py +92 -0
  12. codeshift/api/models/migrate.py +42 -0
  13. codeshift/api/models/usage.py +116 -0
  14. codeshift/api/routers/__init__.py +5 -0
  15. codeshift/api/routers/auth.py +440 -0
  16. codeshift/api/routers/billing.py +395 -0
  17. codeshift/api/routers/migrate.py +304 -0
  18. codeshift/api/routers/usage.py +291 -0
  19. codeshift/api/routers/webhooks.py +289 -0
  20. codeshift/cli/__init__.py +5 -0
  21. codeshift/cli/commands/__init__.py +7 -0
  22. codeshift/cli/commands/apply.py +352 -0
  23. codeshift/cli/commands/auth.py +842 -0
  24. codeshift/cli/commands/diff.py +221 -0
  25. codeshift/cli/commands/scan.py +368 -0
  26. codeshift/cli/commands/upgrade.py +436 -0
  27. codeshift/cli/commands/upgrade_all.py +518 -0
  28. codeshift/cli/main.py +221 -0
  29. codeshift/cli/quota.py +210 -0
  30. codeshift/knowledge/__init__.py +50 -0
  31. codeshift/knowledge/cache.py +167 -0
  32. codeshift/knowledge/generator.py +231 -0
  33. codeshift/knowledge/models.py +151 -0
  34. codeshift/knowledge/parser.py +270 -0
  35. codeshift/knowledge/sources.py +388 -0
  36. codeshift/knowledge_base/__init__.py +17 -0
  37. codeshift/knowledge_base/loader.py +102 -0
  38. codeshift/knowledge_base/models.py +110 -0
  39. codeshift/migrator/__init__.py +23 -0
  40. codeshift/migrator/ast_transforms.py +256 -0
  41. codeshift/migrator/engine.py +395 -0
  42. codeshift/migrator/llm_migrator.py +320 -0
  43. codeshift/migrator/transforms/__init__.py +19 -0
  44. codeshift/migrator/transforms/fastapi_transformer.py +174 -0
  45. codeshift/migrator/transforms/pandas_transformer.py +236 -0
  46. codeshift/migrator/transforms/pydantic_v1_to_v2.py +637 -0
  47. codeshift/migrator/transforms/requests_transformer.py +218 -0
  48. codeshift/migrator/transforms/sqlalchemy_transformer.py +175 -0
  49. codeshift/scanner/__init__.py +6 -0
  50. codeshift/scanner/code_scanner.py +352 -0
  51. codeshift/scanner/dependency_parser.py +473 -0
  52. codeshift/utils/__init__.py +5 -0
  53. codeshift/utils/api_client.py +266 -0
  54. codeshift/utils/cache.py +318 -0
  55. codeshift/utils/config.py +71 -0
  56. codeshift/utils/llm_client.py +221 -0
  57. codeshift/validator/__init__.py +6 -0
  58. codeshift/validator/syntax_checker.py +183 -0
  59. codeshift/validator/test_runner.py +224 -0
  60. codeshift-0.2.0.dist-info/METADATA +326 -0
  61. codeshift-0.2.0.dist-info/RECORD +65 -0
  62. codeshift-0.2.0.dist-info/WHEEL +5 -0
  63. codeshift-0.2.0.dist-info/entry_points.txt +2 -0
  64. codeshift-0.2.0.dist-info/licenses/LICENSE +21 -0
  65. 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