codeshift 0.4.0__py3-none-any.whl → 0.5.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.
@@ -1,11 +1,10 @@
1
1
  """Authentication commands for Codeshift CLI."""
2
2
 
3
- import json
4
3
  import os
5
4
  import time
6
5
  import webbrowser
7
6
  from pathlib import Path
8
- from typing import Any, cast
7
+ from typing import Any
9
8
 
10
9
  import click
11
10
  import httpx
@@ -15,11 +14,16 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
15
14
  from rich.prompt import Confirm, Prompt
16
15
  from rich.table import Table
17
16
 
17
+ from codeshift.utils.credential_store import (
18
+ CredentialDecryptionError,
19
+ get_credential_store,
20
+ )
21
+
18
22
  console = Console()
19
23
 
20
- # Config directory for storing credentials
24
+ # Config directory for storing credentials (kept for backward compatibility reference)
21
25
  CONFIG_DIR = Path.home() / ".config" / "codeshift"
22
- CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
26
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json" # Legacy path
23
27
 
24
28
 
25
29
  def get_api_url() -> str:
@@ -28,28 +32,38 @@ def get_api_url() -> str:
28
32
 
29
33
 
30
34
  def load_credentials() -> dict[str, Any] | None:
31
- """Load saved credentials from disk."""
32
- if not CREDENTIALS_FILE.exists():
33
- return None
35
+ """Load saved credentials from secure storage.
36
+
37
+ Automatically handles migration from plaintext to encrypted storage.
38
+
39
+ Returns:
40
+ Dictionary of credentials, or None if not found.
41
+ """
42
+ store = get_credential_store()
34
43
  try:
35
- return cast(dict[str, Any], json.loads(CREDENTIALS_FILE.read_text()))
36
- except (OSError, json.JSONDecodeError):
44
+ return store.load()
45
+ except CredentialDecryptionError as e:
46
+ console.print(
47
+ Panel(
48
+ f"[red]Could not decrypt credentials:[/] {e}\n\n"
49
+ "This may happen if credentials were created on a different machine.\n"
50
+ "Please run [cyan]codeshift login[/] to re-authenticate.",
51
+ title="Credential Error",
52
+ )
53
+ )
37
54
  return None
38
55
 
39
56
 
40
57
  def save_credentials(credentials: dict) -> None:
41
- """Save credentials to disk."""
42
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
-
44
- # Set restrictive permissions
45
- CREDENTIALS_FILE.write_text(json.dumps(credentials, indent=2))
46
- os.chmod(CREDENTIALS_FILE, 0o600)
58
+ """Save credentials to secure encrypted storage."""
59
+ store = get_credential_store()
60
+ store.save(credentials)
47
61
 
48
62
 
49
63
  def delete_credentials() -> None:
50
- """Delete saved credentials."""
51
- if CREDENTIALS_FILE.exists():
52
- CREDENTIALS_FILE.unlink()
64
+ """Delete saved credentials securely."""
65
+ store = get_credential_store()
66
+ store.delete()
53
67
 
54
68
 
55
69
  def get_api_key() -> str | None:
@@ -107,7 +121,8 @@ def login(
107
121
  2. API key: codeshift login -k pyr_xxxxx
108
122
  3. Device flow: codeshift login --device
109
123
 
110
- Your credentials are stored in ~/.config/codeshift/credentials.json
124
+ Your credentials are stored securely in ~/.config/codeshift/credentials.enc
125
+ using AES encryption.
111
126
 
112
127
  Don't have an account? Run: codeshift register
113
128
  """
@@ -154,7 +169,8 @@ def register(
154
169
  Example:
155
170
  codeshift register -e user@example.com -p yourpassword
156
171
 
157
- Your credentials are stored in ~/.config/codeshift/credentials.json
172
+ Your credentials are stored securely in ~/.config/codeshift/credentials.enc
173
+ using AES encryption.
158
174
  """
159
175
  # Check if already logged in
160
176
  existing = load_credentials()
@@ -211,7 +227,7 @@ def _register_account(email: str, password: str, full_name: str | None) -> None:
211
227
  if response.status_code == 200:
212
228
  data = response.json()
213
229
 
214
- # Save credentials
230
+ # Save credentials securely
215
231
  save_credentials(
216
232
  {
217
233
  "api_key": data["api_key"],
@@ -275,7 +291,7 @@ def _login_with_api_key(api_key: str) -> None:
275
291
  if response.status_code == 200:
276
292
  user = response.json()
277
293
 
278
- # Save credentials
294
+ # Save credentials securely
279
295
  save_credentials(
280
296
  {
281
297
  "api_key": api_key,
@@ -327,7 +343,7 @@ def _login_with_password(email: str, password: str) -> None:
327
343
  if response.status_code == 200:
328
344
  data = response.json()
329
345
 
330
- # Save credentials
346
+ # Save credentials securely
331
347
  save_credentials(
332
348
  {
333
349
  "api_key": data["api_key"],
@@ -422,7 +438,7 @@ def _login_with_device_code() -> None:
422
438
  if response.status_code == 200:
423
439
  data = response.json()
424
440
 
425
- # Save credentials
441
+ # Save credentials securely
426
442
  save_credentials(
427
443
  {
428
444
  "api_key": data["api_key"],
@@ -487,7 +503,7 @@ def logout() -> None:
487
503
  except httpx.RequestError:
488
504
  pass
489
505
 
490
- # Delete local credentials
506
+ # Delete local credentials securely
491
507
  delete_credentials()
492
508
 
493
509
  console.print("[green]Successfully logged out[/]")
@@ -18,12 +18,8 @@ from codeshift.knowledge import (
18
18
  is_tier_1_library,
19
19
  )
20
20
  from codeshift.knowledge_base import KnowledgeBaseLoader
21
- from codeshift.migrator.ast_transforms import TransformChange, TransformResult, TransformStatus
22
- from codeshift.migrator.transforms.fastapi_transformer import transform_fastapi
23
- from codeshift.migrator.transforms.pandas_transformer import transform_pandas
24
- from codeshift.migrator.transforms.pydantic_v1_to_v2 import transform_pydantic_v1_to_v2
25
- from codeshift.migrator.transforms.requests_transformer import transform_requests
26
- from codeshift.migrator.transforms.sqlalchemy_transformer import transform_sqlalchemy
21
+ from codeshift.knowledge_base.models import LibraryKnowledge
22
+ from codeshift.migrator.ast_transforms import TransformResult, TransformStatus
27
23
  from codeshift.scanner import CodeScanner, DependencyParser
28
24
  from codeshift.utils.config import ProjectConfig
29
25
 
@@ -81,6 +77,11 @@ def save_state(project_path: Path, state: dict) -> None:
81
77
  is_flag=True,
82
78
  help="Show detailed output",
83
79
  )
80
+ @click.option(
81
+ "--force-llm",
82
+ is_flag=True,
83
+ help="Force LLM migration even for libraries with AST transforms",
84
+ )
84
85
  def upgrade(
85
86
  library: str,
86
87
  target: str,
@@ -88,6 +89,7 @@ def upgrade(
88
89
  file: str | None,
89
90
  dry_run: bool,
90
91
  verbose: bool,
92
+ force_llm: bool,
91
93
  ) -> None:
92
94
  """Analyze your codebase and propose changes for a library upgrade.
93
95
 
@@ -100,34 +102,44 @@ def upgrade(
100
102
  project_path = Path(path).resolve()
101
103
  project_config = ProjectConfig.from_pyproject(project_path)
102
104
 
103
- # Check quota before starting (allow offline for Tier 1 libraries)
105
+ # Check quota before starting (allow offline for Tier 1 libraries unless force-llm)
104
106
  is_tier1 = is_tier_1_library(library)
105
107
  try:
106
- check_quota("file_migrated", quantity=1, allow_offline=is_tier1)
108
+ check_quota("file_migrated", quantity=1, allow_offline=is_tier1 and not force_llm)
107
109
  except QuotaError as e:
108
110
  show_quota_exceeded_message(e)
109
111
  raise SystemExit(1) from e
110
112
 
111
- # Load knowledge base
113
+ # Load knowledge base (optional - YAML may not exist for all libraries)
112
114
  loader = KnowledgeBaseLoader()
115
+ knowledge: LibraryKnowledge | None = None
113
116
 
114
117
  try:
115
118
  knowledge = loader.load(library)
116
- except FileNotFoundError as e:
117
- console.print(f"[red]Error:[/] {e}")
118
- console.print(f"\nSupported libraries: {', '.join(loader.get_supported_libraries())}")
119
- raise SystemExit(1) from e
119
+ except FileNotFoundError:
120
+ if verbose:
121
+ console.print(
122
+ f"[dim]No knowledge base YAML for {library} - using generated knowledge[/]"
123
+ )
120
124
 
121
- # Check if migration is supported
122
- # For now, we'll allow any version since we're doing a general migration
123
- console.print(
124
- Panel(
125
- f"[bold]Upgrading {knowledge.display_name}[/] to version [cyan]{target}[/]\n\n"
126
- f"{knowledge.description}\n"
127
- f"Migration guide: {knowledge.migration_guide_url or 'N/A'}",
128
- title="Codeshift Migration",
125
+ # Display migration info with fallback for missing YAML
126
+ if knowledge:
127
+ console.print(
128
+ Panel(
129
+ f"[bold]Upgrading {knowledge.display_name}[/] to version [cyan]{target}[/]\n\n"
130
+ f"{knowledge.description}\n"
131
+ f"Migration guide: {knowledge.migration_guide_url or 'N/A'}",
132
+ title="Codeshift Migration",
133
+ )
134
+ )
135
+ else:
136
+ console.print(
137
+ Panel(
138
+ f"[bold]Upgrading {library}[/] to version [cyan]{target}[/]\n\n"
139
+ "Using AI-powered migration (no static knowledge base available)",
140
+ title="Codeshift Migration",
141
+ )
129
142
  )
130
- )
131
143
 
132
144
  # Step 1: Parse dependencies
133
145
  with Progress(
@@ -271,7 +283,25 @@ def upgrade(
271
283
  console.print(f"\n[yellow]No {library} imports found in the codebase.[/]")
272
284
  return
273
285
 
274
- # Step 4: Apply transforms
286
+ # Step 4: Apply transforms using MigrationEngine
287
+ # Import here to avoid circular dependency (upgrade.py -> migrator -> llm_migrator -> api_client -> auth -> cli -> upgrade.py)
288
+ from codeshift.migrator import get_migration_engine
289
+
290
+ engine = get_migration_engine()
291
+
292
+ # Check auth for non-Tier1 libraries or force-llm mode
293
+ llm_required = force_llm or not is_tier1
294
+ if llm_required and not engine.llm_migrator.is_available:
295
+ console.print(
296
+ Panel(
297
+ f"[yellow]LLM migration required for {library}[/]\n\n"
298
+ "Run [cyan]codeshift login[/] and upgrade to Pro tier for LLM features.",
299
+ title="Authentication Required",
300
+ )
301
+ )
302
+ if not is_tier1:
303
+ raise SystemExit(1)
304
+
275
305
  with Progress(
276
306
  SpinnerColumn(),
277
307
  TextColumn("[progress.description]{task.description}"),
@@ -286,45 +316,28 @@ def upgrade(
286
316
 
287
317
  results: list[TransformResult] = []
288
318
 
319
+ def migration_progress(msg: str) -> None:
320
+ progress.update(task, description=msg)
321
+
289
322
  for file_path in files_to_transform:
290
323
  try:
291
324
  source_code = file_path.read_text()
292
325
 
293
- # Select transformer based on library
294
- transform_func = {
295
- "pydantic": transform_pydantic_v1_to_v2,
296
- "fastapi": transform_fastapi,
297
- "sqlalchemy": transform_sqlalchemy,
298
- "pandas": transform_pandas,
299
- "requests": transform_requests,
300
- }.get(library)
301
-
302
- if transform_func:
303
- transformed_code, changes = transform_func(source_code)
304
- # Create TransformResult from the function output
305
- result = TransformResult(
306
- file_path=file_path,
307
- status=TransformStatus.SUCCESS if changes else TransformStatus.NO_CHANGES,
308
- original_code=source_code,
309
- transformed_code=transformed_code,
310
- changes=[
311
- TransformChange(
312
- description=c.description,
313
- line_number=c.line_number,
314
- original=c.original,
315
- replacement=c.replacement,
316
- transform_name=c.transform_name,
317
- confidence=getattr(c, "confidence", 1.0),
318
- )
319
- for c in changes
320
- ],
321
- )
322
- else:
323
- console.print(f"[yellow]Warning:[/] No transformer available for {library}")
324
- continue
326
+ result = engine.run_migration(
327
+ code=source_code,
328
+ file_path=file_path,
329
+ library=library,
330
+ old_version=current_version or "1.0",
331
+ new_version=target,
332
+ knowledge_base=generated_kb,
333
+ progress_callback=migration_progress if verbose else None,
334
+ )
325
335
 
326
336
  if result.has_changes:
327
337
  results.append(result)
338
+ elif result.errors:
339
+ for error in result.errors:
340
+ console.print(f"[yellow]Warning ({file_path.name}):[/] {error}")
328
341
 
329
342
  except Exception as e:
330
343
  console.print(f"[red]Error processing {file_path}:[/] {e}")
@@ -172,6 +172,12 @@ TIER_1_LIBRARIES = {
172
172
  "pytest",
173
173
  "marshmallow",
174
174
  "flask",
175
+ "celery",
176
+ "httpx",
177
+ "aiohttp",
178
+ "click",
179
+ "attrs",
180
+ "django",
175
181
  }
176
182
 
177
183
 
@@ -69,7 +69,7 @@ breaking_changes:
69
69
 
70
70
  # connector_owner default change
71
71
  - symbol: "ClientSession(connector=..., connector_owner=None)"
72
- change_type: behavior_change
72
+ change_type: behavior_changed
73
73
  severity: medium
74
74
  from_version: "3.7"
75
75
  to_version: "3.9"
@@ -133,7 +133,7 @@ breaking_changes:
133
133
 
134
134
  # Middleware signature changes (old-style to new-style)
135
135
  - symbol: "@middleware"
136
- change_type: signature_change
136
+ change_type: signature_changed
137
137
  severity: high
138
138
  from_version: "3.7"
139
139
  to_version: "3.9"
@@ -154,7 +154,7 @@ breaking_changes:
154
154
  transform_name: ws_connect_timeout_rename
155
155
 
156
156
  - symbol: "ws_connect(receive_timeout=...)"
157
- change_type: behavior_change
157
+ change_type: behavior_changed
158
158
  severity: low
159
159
  from_version: "3.7"
160
160
  to_version: "3.9"
@@ -119,7 +119,7 @@ breaking_changes:
119
119
 
120
120
  # Response.iter_lines() behavior change
121
121
  - symbol: "Response.iter_lines()"
122
- change_type: behavior_change
122
+ change_type: behavior_changed
123
123
  severity: medium
124
124
  from_version: "0.23"
125
125
  to_version: "0.24"
@@ -130,7 +130,7 @@ breaking_changes:
130
130
 
131
131
  # NetRC authentication change
132
132
  - symbol: "trust_env=True"
133
- change_type: behavior_change
133
+ change_type: behavior_changed
134
134
  severity: medium
135
135
  from_version: "0.23"
136
136
  to_version: "0.24"
@@ -141,7 +141,7 @@ breaking_changes:
141
141
 
142
142
  # Query parameter encoding change
143
143
  - symbol: "params encoding"
144
- change_type: behavior_change
144
+ change_type: behavior_changed
145
145
  severity: low
146
146
  from_version: "0.23"
147
147
  to_version: "0.24"
@@ -173,7 +173,7 @@ breaking_changes:
173
173
 
174
174
  # Follow redirects default change
175
175
  - symbol: "follow_redirects"
176
- change_type: behavior_change
176
+ change_type: behavior_changed
177
177
  severity: high
178
178
  from_version: "0.19"
179
179
  to_version: "0.20"
@@ -182,7 +182,7 @@ breaking_changes:
182
182
 
183
183
  # pytest.importorskip exc_type
184
184
  - symbol: "pytest.importorskip()"
185
- change_type: behavior_change
185
+ change_type: behavior_changed
186
186
  severity: low
187
187
  from_version: "8.0"
188
188
  to_version: "8.2"
@@ -9,6 +9,7 @@ class ChangeType(Enum):
9
9
 
10
10
  RENAMED = "renamed"
11
11
  REMOVED = "removed"
12
+ MOVED = "moved"
12
13
  SIGNATURE_CHANGED = "signature_changed"
13
14
  BEHAVIOR_CHANGED = "behavior_changed"
14
15
  DEPRECATED = "deprecated"
@@ -191,6 +191,10 @@ class MarshmallowTransformer(BaseTransformer):
191
191
  - default -> dump_default
192
192
  - load_from -> data_key
193
193
  - dump_to -> data_key
194
+
195
+ Special handling: When both load_from and dump_to are present, only one data_key
196
+ is kept (preferring load_from) and a warning comment is added about the removed
197
+ dump_to value.
194
198
  """
195
199
  # Check if this is a fields.* call or a Field-like call
196
200
  func_name = self._get_call_func_name(node.func)
@@ -232,6 +236,27 @@ class MarshmallowTransformer(BaseTransformer):
232
236
  if func_name not in field_types:
233
237
  return node
234
238
 
239
+ # First pass: detect if both load_from and dump_to are present
240
+ load_from_arg = None
241
+ dump_to_arg = None
242
+ load_from_value = None
243
+ dump_to_value = None
244
+
245
+ for arg in node.args:
246
+ if isinstance(arg.keyword, cst.Name):
247
+ if arg.keyword.value == "load_from":
248
+ load_from_arg = arg
249
+ # Extract the value for comparison/warning
250
+ if isinstance(arg.value, cst.SimpleString):
251
+ load_from_value = arg.value.value
252
+ elif arg.keyword.value == "dump_to":
253
+ dump_to_arg = arg
254
+ # Extract the value for comparison/warning
255
+ if isinstance(arg.value, cst.SimpleString):
256
+ dump_to_value = arg.value.value
257
+
258
+ has_both_load_from_and_dump_to = load_from_arg is not None and dump_to_arg is not None
259
+
235
260
  new_args = []
236
261
  changed = False
237
262
  param_mappings = {
@@ -245,6 +270,31 @@ class MarshmallowTransformer(BaseTransformer):
245
270
  if isinstance(arg.keyword, cst.Name) and arg.keyword.value in param_mappings:
246
271
  old_name = arg.keyword.value
247
272
  new_name = param_mappings[old_name]
273
+
274
+ # Special case: skip dump_to when both load_from and dump_to exist
275
+ if old_name == "dump_to" and has_both_load_from_and_dump_to:
276
+ changed = True
277
+ # Record that dump_to was removed due to conflict
278
+ self.record_change(
279
+ description=(
280
+ f"Remove '{old_name}' parameter - Marshmallow 3.x uses single "
281
+ f"data_key for both load/dump. load_from value kept, dump_to="
282
+ f"{dump_to_value} removed. Manual review may be needed if "
283
+ f"load_from ({load_from_value}) != dump_to ({dump_to_value})."
284
+ ),
285
+ line_number=1,
286
+ original=f"{func_name}(load_from=..., dump_to=...)",
287
+ replacement=f"{func_name}(data_key=...)",
288
+ transform_name="remove_dump_to_conflict",
289
+ notes=(
290
+ f"dump_to={dump_to_value} was removed because load_from="
291
+ f"{load_from_value} was also present. In Marshmallow 3.x, "
292
+ "data_key serves both purposes."
293
+ ),
294
+ )
295
+ # Skip adding this arg
296
+ continue
297
+
248
298
  new_arg = arg.with_changes(keyword=cst.Name(new_name))
249
299
  new_args.append(new_arg)
250
300
  changed = True