codeshift 0.3.6__py3-none-any.whl → 0.4.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 (34) hide show
  1. codeshift/__init__.py +2 -2
  2. codeshift/cli/__init__.py +1 -1
  3. codeshift/cli/commands/__init__.py +1 -1
  4. codeshift/cli/commands/auth.py +5 -5
  5. codeshift/cli/commands/scan.py +2 -5
  6. codeshift/cli/commands/upgrade.py +2 -7
  7. codeshift/cli/commands/upgrade_all.py +1 -1
  8. codeshift/cli/main.py +2 -2
  9. codeshift/migrator/llm_migrator.py +8 -12
  10. codeshift/utils/__init__.py +1 -1
  11. codeshift/utils/api_client.py +11 -11
  12. codeshift/utils/cache.py +1 -1
  13. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/METADATA +2 -18
  14. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/RECORD +18 -34
  15. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/licenses/LICENSE +1 -1
  16. codeshift/api/__init__.py +0 -1
  17. codeshift/api/auth.py +0 -182
  18. codeshift/api/config.py +0 -73
  19. codeshift/api/database.py +0 -215
  20. codeshift/api/main.py +0 -103
  21. codeshift/api/models/__init__.py +0 -55
  22. codeshift/api/models/auth.py +0 -108
  23. codeshift/api/models/billing.py +0 -92
  24. codeshift/api/models/migrate.py +0 -42
  25. codeshift/api/models/usage.py +0 -116
  26. codeshift/api/routers/__init__.py +0 -5
  27. codeshift/api/routers/auth.py +0 -440
  28. codeshift/api/routers/billing.py +0 -395
  29. codeshift/api/routers/migrate.py +0 -304
  30. codeshift/api/routers/usage.py +0 -291
  31. codeshift/api/routers/webhooks.py +0 -289
  32. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/WHEEL +0 -0
  33. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/entry_points.txt +0 -0
  34. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/top_level.txt +0 -0
@@ -1,116 +0,0 @@
1
- """Usage tracking models for the PyResolve API."""
2
-
3
- from datetime import datetime
4
- from typing import Any
5
-
6
- from pydantic import BaseModel, Field
7
-
8
-
9
- class UsageEventCreate(BaseModel):
10
- """Request to record a usage event."""
11
-
12
- event_type: str = Field(..., pattern="^(file_migrated|llm_call|scan|apply)$")
13
- library: str | None = None
14
- quantity: int = Field(default=1, ge=1)
15
- metadata: dict[str, Any] = Field(default_factory=dict)
16
-
17
-
18
- class UsageEvent(BaseModel):
19
- """A recorded usage event."""
20
-
21
- id: str
22
- user_id: str
23
- event_type: str
24
- library: str | None = None
25
- quantity: int
26
- metadata: dict[str, Any]
27
- billing_period: str
28
- created_at: datetime
29
-
30
- class Config:
31
- from_attributes = True
32
-
33
-
34
- class UsageSummary(BaseModel):
35
- """Summary of usage for a billing period."""
36
-
37
- billing_period: str
38
- files_migrated: int = 0
39
- llm_calls: int = 0
40
- scans: int = 0
41
- applies: int = 0
42
- total_events: int = 0
43
-
44
-
45
- class QuotaInfo(BaseModel):
46
- """Current quota information for a user."""
47
-
48
- tier: str
49
- billing_period: str
50
-
51
- # Current usage
52
- files_migrated: int = 0
53
- llm_calls: int = 0
54
-
55
- # Limits
56
- files_limit: int
57
- llm_calls_limit: int
58
-
59
- # Calculated fields
60
- files_remaining: int
61
- llm_calls_remaining: int
62
- files_percentage: float
63
- llm_calls_percentage: float
64
-
65
- @classmethod
66
- def from_usage(
67
- cls,
68
- tier: str,
69
- billing_period: str,
70
- files_migrated: int,
71
- llm_calls: int,
72
- files_limit: int,
73
- llm_calls_limit: int,
74
- ) -> "QuotaInfo":
75
- """Create QuotaInfo from usage data."""
76
- files_remaining = max(0, files_limit - files_migrated)
77
- llm_calls_remaining = max(0, llm_calls_limit - llm_calls)
78
-
79
- return cls(
80
- tier=tier,
81
- billing_period=billing_period,
82
- files_migrated=files_migrated,
83
- llm_calls=llm_calls,
84
- files_limit=files_limit,
85
- llm_calls_limit=llm_calls_limit,
86
- files_remaining=files_remaining,
87
- llm_calls_remaining=llm_calls_remaining,
88
- files_percentage=round(files_migrated / files_limit * 100, 1) if files_limit > 0 else 0,
89
- llm_calls_percentage=(
90
- round(llm_calls / llm_calls_limit * 100, 1) if llm_calls_limit > 0 else 0
91
- ),
92
- )
93
-
94
-
95
- class UsageResponse(BaseModel):
96
- """Response for usage queries."""
97
-
98
- quota: QuotaInfo
99
- recent_events: list[UsageEvent] = Field(default_factory=list)
100
-
101
-
102
- class QuotaCheckRequest(BaseModel):
103
- """Request to check if an operation is within quota."""
104
-
105
- event_type: str = Field(..., pattern="^(file_migrated|llm_call|scan|apply)$")
106
- quantity: int = Field(default=1, ge=1)
107
-
108
-
109
- class QuotaCheckResponse(BaseModel):
110
- """Response for quota check."""
111
-
112
- allowed: bool
113
- current_usage: int
114
- limit: int
115
- remaining: int
116
- message: str | None = None
@@ -1,5 +0,0 @@
1
- """API routers for PyResolve."""
2
-
3
- from codeshift.api.routers import auth, billing, migrate, usage, webhooks
4
-
5
- __all__ = ["auth", "billing", "migrate", "usage", "webhooks"]
@@ -1,440 +0,0 @@
1
- """Authentication router for the PyResolve API."""
2
-
3
- import secrets
4
- from datetime import datetime, timedelta, timezone
5
-
6
- from fastapi import APIRouter, HTTPException, status
7
-
8
- from codeshift.api.auth import (
9
- CurrentUser,
10
- generate_api_key,
11
- )
12
- from codeshift.api.database import get_database
13
- from codeshift.api.models.auth import (
14
- APIKey,
15
- APIKeyCreate,
16
- APIKeyResponse,
17
- DeviceCodeRequest,
18
- DeviceCodeResponse,
19
- DeviceTokenRequest,
20
- LoginRequest,
21
- LoginResponse,
22
- RegisterRequest,
23
- UserInfo,
24
- )
25
-
26
- router = APIRouter()
27
-
28
- # In-memory storage for device codes (in production, use Redis)
29
- _device_codes: dict[str, dict] = {}
30
-
31
-
32
- @router.get("/me", response_model=UserInfo)
33
- async def get_current_user_info(user: CurrentUser) -> UserInfo:
34
- """Get current authenticated user information."""
35
- db = get_database()
36
- profile = db.get_profile_by_id(user.user_id)
37
-
38
- if not profile:
39
- raise HTTPException(
40
- status_code=status.HTTP_404_NOT_FOUND,
41
- detail="User profile not found",
42
- )
43
-
44
- return UserInfo(
45
- id=profile["id"],
46
- email=profile["email"],
47
- full_name=profile.get("full_name"),
48
- tier=profile.get("tier", "free"),
49
- stripe_customer_id=profile.get("stripe_customer_id"),
50
- created_at=profile["created_at"],
51
- )
52
-
53
-
54
- @router.post("/login", response_model=LoginResponse)
55
- async def login(request: LoginRequest) -> LoginResponse:
56
- """Login with email and password, returns an API key.
57
-
58
- This endpoint authenticates against Supabase Auth and creates
59
- a new API key for CLI usage.
60
- """
61
- from codeshift.api.database import get_supabase_anon_client
62
-
63
- client = get_supabase_anon_client()
64
-
65
- try:
66
- # Authenticate with Supabase
67
- auth_response = client.auth.sign_in_with_password(
68
- {"email": request.email, "password": request.password}
69
- )
70
- except Exception as e:
71
- raise HTTPException(
72
- status_code=status.HTTP_401_UNAUTHORIZED,
73
- detail="Invalid email or password",
74
- ) from e
75
-
76
- if not auth_response.user:
77
- raise HTTPException(
78
- status_code=status.HTTP_401_UNAUTHORIZED,
79
- detail="Invalid email or password",
80
- )
81
-
82
- user_id = auth_response.user.id
83
-
84
- # Get or create profile
85
- db = get_database()
86
- profile = db.get_profile_by_id(user_id)
87
-
88
- if not profile:
89
- raise HTTPException(
90
- status_code=status.HTTP_404_NOT_FOUND,
91
- detail="User profile not found",
92
- )
93
-
94
- # Generate a new API key
95
- full_key, key_prefix, key_hash = generate_api_key()
96
-
97
- # Store the API key
98
- db.create_api_key(
99
- user_id=user_id,
100
- key_prefix=key_prefix,
101
- key_hash=key_hash,
102
- name="CLI Login Key",
103
- )
104
-
105
- return LoginResponse(
106
- api_key=full_key,
107
- user=UserInfo(
108
- id=profile["id"],
109
- email=profile["email"],
110
- full_name=profile.get("full_name"),
111
- tier=profile.get("tier", "free"),
112
- stripe_customer_id=profile.get("stripe_customer_id"),
113
- created_at=profile["created_at"],
114
- ),
115
- )
116
-
117
-
118
- @router.post("/register", response_model=LoginResponse)
119
- async def register(request: RegisterRequest) -> LoginResponse:
120
- """Register a new user account.
121
-
122
- Creates a new user in Supabase Auth and returns an API key for CLI usage.
123
- The profile is automatically created by a database trigger.
124
- """
125
- from codeshift.api.database import get_supabase_anon_client
126
-
127
- client = get_supabase_anon_client()
128
-
129
- try:
130
- # Create user in Supabase Auth
131
- auth_response = client.auth.sign_up(
132
- {
133
- "email": request.email,
134
- "password": request.password,
135
- "options": {"data": {"full_name": request.full_name} if request.full_name else {}},
136
- }
137
- )
138
- except Exception as e:
139
- error_msg = str(e).lower()
140
- if "already registered" in error_msg or "already exists" in error_msg:
141
- raise HTTPException(
142
- status_code=status.HTTP_409_CONFLICT,
143
- detail="An account with this email already exists",
144
- ) from e
145
- raise HTTPException(
146
- status_code=status.HTTP_400_BAD_REQUEST,
147
- detail="Registration failed. Please try again.",
148
- ) from e
149
-
150
- if not auth_response.user:
151
- raise HTTPException(
152
- status_code=status.HTTP_400_BAD_REQUEST,
153
- detail="Registration failed. Please try again.",
154
- )
155
-
156
- user_id = auth_response.user.id
157
-
158
- # Wait briefly for the trigger to create the profile
159
- import asyncio
160
-
161
- await asyncio.sleep(0.5)
162
-
163
- # Get the profile (created by database trigger)
164
- db = get_database()
165
- profile = db.get_profile_by_id(user_id)
166
-
167
- if not profile:
168
- # Profile wasn't created by trigger - create it manually
169
- try:
170
- db.client.table("profiles").insert(
171
- {
172
- "id": user_id,
173
- "email": request.email,
174
- "full_name": request.full_name or "",
175
- "tier": "free",
176
- }
177
- ).execute()
178
- profile = db.get_profile_by_id(user_id)
179
- except Exception:
180
- pass
181
-
182
- if not profile:
183
- raise HTTPException(
184
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
185
- detail="Account created but profile setup failed. Please try logging in.",
186
- )
187
-
188
- # Generate a new API key
189
- full_key, key_prefix, key_hash = generate_api_key()
190
-
191
- # Store the API key
192
- db.create_api_key(
193
- user_id=user_id,
194
- key_prefix=key_prefix,
195
- key_hash=key_hash,
196
- name="CLI Registration Key",
197
- )
198
-
199
- return LoginResponse(
200
- api_key=full_key,
201
- user=UserInfo(
202
- id=profile["id"],
203
- email=profile["email"],
204
- full_name=profile.get("full_name"),
205
- tier=profile.get("tier", "free"),
206
- stripe_customer_id=profile.get("stripe_customer_id"),
207
- created_at=profile["created_at"],
208
- ),
209
- message="Registration successful",
210
- )
211
-
212
-
213
- @router.post("/device/code", response_model=DeviceCodeResponse)
214
- async def request_device_code(request: DeviceCodeRequest) -> DeviceCodeResponse:
215
- """Request a device code for CLI authentication.
216
-
217
- This initiates the device code flow for CLI authentication.
218
- The user will receive a code to enter in their browser.
219
- """
220
- # Generate codes
221
- device_code = secrets.token_urlsafe(32)
222
- user_code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(8))
223
- user_code = f"{user_code[:4]}-{user_code[4:]}" # Format: XXXX-XXXX
224
-
225
- # Store for later verification
226
- _device_codes[device_code] = {
227
- "user_code": user_code,
228
- "client_id": request.client_id,
229
- "expires_at": datetime.now(timezone.utc) + timedelta(minutes=15),
230
- "user_id": None, # Will be set when user authenticates
231
- "status": "pending",
232
- }
233
-
234
- return DeviceCodeResponse(
235
- device_code=device_code,
236
- user_code=user_code,
237
- verification_uri="https://codeshift.dev/device",
238
- expires_in=900,
239
- interval=5,
240
- )
241
-
242
-
243
- @router.post("/device/token", response_model=LoginResponse)
244
- async def exchange_device_code(request: DeviceTokenRequest) -> LoginResponse:
245
- """Exchange a device code for an API key.
246
-
247
- The CLI polls this endpoint until the user completes authentication.
248
- """
249
- device_data = _device_codes.get(request.device_code)
250
-
251
- if not device_data:
252
- raise HTTPException(
253
- status_code=status.HTTP_400_BAD_REQUEST,
254
- detail="Invalid device code",
255
- )
256
-
257
- # Check expiration
258
- if datetime.now(timezone.utc) > device_data["expires_at"]:
259
- del _device_codes[request.device_code]
260
- raise HTTPException(
261
- status_code=status.HTTP_400_BAD_REQUEST,
262
- detail="Device code expired",
263
- )
264
-
265
- # Check status
266
- if device_data["status"] == "pending":
267
- raise HTTPException(
268
- status_code=status.HTTP_428_PRECONDITION_REQUIRED,
269
- detail="authorization_pending",
270
- )
271
-
272
- if device_data["status"] == "denied":
273
- del _device_codes[request.device_code]
274
- raise HTTPException(
275
- status_code=status.HTTP_403_FORBIDDEN,
276
- detail="User denied the request",
277
- )
278
-
279
- if device_data["status"] != "approved":
280
- raise HTTPException(
281
- status_code=status.HTTP_400_BAD_REQUEST,
282
- detail="Invalid device code status",
283
- )
284
-
285
- # Get user info
286
- user_id = device_data["user_id"]
287
- db = get_database()
288
- profile = db.get_profile_by_id(user_id)
289
-
290
- if not profile:
291
- raise HTTPException(
292
- status_code=status.HTTP_404_NOT_FOUND,
293
- detail="User profile not found",
294
- )
295
-
296
- # Generate API key
297
- full_key, key_prefix, key_hash = generate_api_key()
298
-
299
- db.create_api_key(
300
- user_id=user_id,
301
- key_prefix=key_prefix,
302
- key_hash=key_hash,
303
- name="CLI Device Key",
304
- )
305
-
306
- # Clean up device code
307
- del _device_codes[request.device_code]
308
-
309
- return LoginResponse(
310
- api_key=full_key,
311
- user=UserInfo(
312
- id=profile["id"],
313
- email=profile["email"],
314
- full_name=profile.get("full_name"),
315
- tier=profile.get("tier", "free"),
316
- stripe_customer_id=profile.get("stripe_customer_id"),
317
- created_at=profile["created_at"],
318
- ),
319
- )
320
-
321
-
322
- @router.post("/device/approve")
323
- async def approve_device_code(user_code: str, user: CurrentUser) -> dict:
324
- """Approve a device code (called from web UI after user logs in)."""
325
- # Find the device code by user code
326
- for device_code, data in _device_codes.items():
327
- if data["user_code"] == user_code.upper().replace("-", "").strip():
328
- if datetime.now(timezone.utc) > data["expires_at"]:
329
- del _device_codes[device_code]
330
- raise HTTPException(
331
- status_code=status.HTTP_400_BAD_REQUEST,
332
- detail="Device code expired",
333
- )
334
-
335
- data["user_id"] = user.user_id
336
- data["status"] = "approved"
337
- return {"message": "Device approved successfully"}
338
-
339
- raise HTTPException(
340
- status_code=status.HTTP_404_NOT_FOUND,
341
- detail="Invalid user code",
342
- )
343
-
344
-
345
- @router.get("/keys", response_model=list[APIKey])
346
- async def list_api_keys(user: CurrentUser) -> list[APIKey]:
347
- """List all API keys for the current user."""
348
- db = get_database()
349
-
350
- result = (
351
- db.client.table("api_keys")
352
- .select("*")
353
- .eq("user_id", user.user_id)
354
- .eq("revoked", False)
355
- .order("created_at", desc=True)
356
- .execute()
357
- )
358
-
359
- return [
360
- APIKey(
361
- id=key["id"],
362
- name=key["name"],
363
- key_prefix=key["key_prefix"],
364
- scopes=key.get("scopes", []),
365
- revoked=key["revoked"],
366
- last_used_at=key.get("last_used_at"),
367
- expires_at=key.get("expires_at"),
368
- created_at=key["created_at"],
369
- )
370
- for key in result.data
371
- ]
372
-
373
-
374
- @router.post("/keys", response_model=APIKeyResponse)
375
- async def create_api_key(request: APIKeyCreate, user: CurrentUser) -> APIKeyResponse:
376
- """Create a new API key."""
377
- db = get_database()
378
-
379
- # Generate the key
380
- full_key, key_prefix, key_hash = generate_api_key()
381
-
382
- # Store it
383
- result = db.create_api_key(
384
- user_id=user.user_id,
385
- key_prefix=key_prefix,
386
- key_hash=key_hash,
387
- name=request.name,
388
- scopes=request.scopes,
389
- )
390
-
391
- return APIKeyResponse(
392
- id=result["id"],
393
- name=request.name,
394
- key=full_key, # Only time the full key is returned
395
- key_prefix=key_prefix,
396
- scopes=request.scopes,
397
- created_at=result["created_at"],
398
- )
399
-
400
-
401
- @router.delete("/keys/{key_id}")
402
- async def revoke_api_key(key_id: str, user: CurrentUser) -> dict:
403
- """Revoke an API key."""
404
- db = get_database()
405
-
406
- # Verify ownership
407
- result = db.client.table("api_keys").select("user_id").eq("id", key_id).execute()
408
-
409
- if not result.data:
410
- raise HTTPException(
411
- status_code=status.HTTP_404_NOT_FOUND,
412
- detail="API key not found",
413
- )
414
-
415
- if result.data[0]["user_id"] != user.user_id:
416
- raise HTTPException(
417
- status_code=status.HTTP_403_FORBIDDEN,
418
- detail="Not authorized to revoke this key",
419
- )
420
-
421
- # Revoke
422
- success = db.revoke_api_key(key_id)
423
-
424
- if not success:
425
- raise HTTPException(
426
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
427
- detail="Failed to revoke key",
428
- )
429
-
430
- return {"message": "API key revoked successfully"}
431
-
432
-
433
- @router.post("/logout")
434
- async def logout(user: CurrentUser) -> dict:
435
- """Logout by revoking the current API key."""
436
- if user.api_key_id:
437
- db = get_database()
438
- db.revoke_api_key(user.api_key_id)
439
-
440
- return {"message": "Logged out successfully"}