lyceum-cli 1.0.22__tar.gz → 1.0.24__tar.gz
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.
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/PKG-INFO +1 -1
- lyceum_cli-1.0.24/lyceum/external/__init__.py +1 -0
- lyceum_cli-1.0.24/lyceum/external/auth/__init__.py +1 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/auth/login.py +65 -60
- lyceum_cli-1.0.24/lyceum/external/compute/__init__.py +1 -0
- lyceum_cli-1.0.24/lyceum/external/compute/execution/__init__.py +1 -0
- lyceum_cli-1.0.24/lyceum/external/compute/execution/config.py +258 -0
- lyceum_cli-1.0.24/lyceum/external/compute/execution/docker.py +272 -0
- lyceum_cli-1.0.24/lyceum/external/compute/execution/python.py +371 -0
- lyceum_cli-1.0.24/lyceum/external/compute/execution/workloads.py +184 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/batch.py +73 -64
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/chat.py +36 -41
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/models.py +20 -14
- lyceum_cli-1.0.24/lyceum/external/general/__init__.py +1 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/main.py +5 -10
- lyceum_cli-1.0.24/lyceum/shared/__init__.py +1 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/shared/config.py +41 -36
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/shared/display.py +3 -5
- lyceum_cli-1.0.24/lyceum/shared/imports.py +312 -0
- lyceum_cli-1.0.24/lyceum/shared/streaming.py +229 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/PKG-INFO +1 -1
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/SOURCES.txt +18 -1
- lyceum_cli-1.0.24/lyceum_cli.egg-info/dependency_links.txt +1 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/top_level.txt +1 -0
- lyceum_cli-1.0.24/lyceum_cloud_execution_api_client/__init__.py +1 -0
- lyceum_cli-1.0.24/lyceum_cloud_execution_api_client/api/__init__.py +1 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/setup.py +4 -5
- lyceum_cli-1.0.24/tests/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/conftest.py +200 -0
- lyceum_cli-1.0.24/tests/unit/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/unit/external/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/execution/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_data.py +33 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_dependency_resolver.py +257 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_python_helpers.py +406 -0
- lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_python_run.py +289 -0
- lyceum_cli-1.0.24/tests/unit/shared/__init__.py +1 -0
- lyceum_cli-1.0.24/tests/unit/shared/test_config.py +341 -0
- lyceum_cli-1.0.24/tests/unit/shared/test_streaming.py +259 -0
- lyceum_cli-1.0.22/lyceum/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/external/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/external/auth/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/external/compute/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/external/compute/execution/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/external/compute/execution/python.py +0 -99
- lyceum_cli-1.0.22/lyceum/external/general/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/shared/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum/shared/streaming.py +0 -134
- lyceum_cli-1.0.22/lyceum_cloud_execution_api_client/__init__.py +0 -0
- lyceum_cli-1.0.22/lyceum_cloud_execution_api_client/api/__init__.py +0 -0
- /lyceum_cli-1.0.22/lyceum_cli.egg-info/dependency_links.txt → /lyceum_cli-1.0.24/lyceum/__init__.py +0 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/__init__.py +0 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/entry_points.txt +0 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/requires.txt +0 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cloud_execution_api_client/models/__init__.py +0 -0
- {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Authentication commands: login, logout, status
|
|
3
|
-
"""
|
|
1
|
+
"""Authentication commands: login, logout, status"""
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
import typer
|
|
7
|
-
from rich.console import Console
|
|
8
|
-
import webbrowser
|
|
3
|
+
import socket
|
|
9
4
|
import threading
|
|
10
5
|
import time
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
import
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
14
12
|
|
|
15
13
|
from ...shared.config import config
|
|
16
14
|
|
|
@@ -24,37 +22,38 @@ callback_result = {"token": None, "error": None, "received": False}
|
|
|
24
22
|
|
|
25
23
|
class CallbackHandler(BaseHTTPRequestHandler):
|
|
26
24
|
"""HTTP handler for OAuth callback"""
|
|
27
|
-
|
|
25
|
+
|
|
28
26
|
def log_message(self, format, *args):
|
|
29
|
-
|
|
27
|
+
"""Suppress HTTP server logs"""
|
|
30
28
|
pass
|
|
31
|
-
|
|
29
|
+
|
|
32
30
|
def do_GET(self):
|
|
31
|
+
"""Handle GET request for OAuth callback"""
|
|
33
32
|
global callback_result
|
|
34
|
-
|
|
33
|
+
|
|
35
34
|
try:
|
|
36
35
|
# Parse the callback URL
|
|
37
36
|
parsed_url = urlparse(self.path)
|
|
38
37
|
query_params = parse_qs(parsed_url.query)
|
|
39
|
-
|
|
38
|
+
|
|
40
39
|
if parsed_url.path == "/callback":
|
|
41
40
|
# Extract token from query parameters
|
|
42
41
|
if "token" in query_params:
|
|
43
42
|
token = query_params["token"][0]
|
|
44
43
|
user_info = query_params.get("user", [None])[0]
|
|
45
44
|
refresh_token = query_params.get("refresh_token", [None])[0]
|
|
46
|
-
|
|
45
|
+
|
|
47
46
|
callback_result["token"] = token
|
|
48
47
|
callback_result["user"] = user_info
|
|
49
48
|
if refresh_token:
|
|
50
49
|
callback_result["refresh_token"] = refresh_token
|
|
51
50
|
callback_result["received"] = True
|
|
52
|
-
|
|
51
|
+
|
|
53
52
|
# Send success response
|
|
54
53
|
self.send_response(200)
|
|
55
54
|
self.send_header("Content-type", "text/html")
|
|
56
55
|
self.end_headers()
|
|
57
|
-
|
|
56
|
+
|
|
58
57
|
success_html = """
|
|
59
58
|
<!DOCTYPE html>
|
|
60
59
|
<html lang="en">
|
|
@@ -73,31 +72,31 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
73
72
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
|
74
73
|
</svg>
|
|
75
74
|
</div>
|
|
76
|
-
|
|
75
|
+
|
|
77
76
|
<!-- Header -->
|
|
78
77
|
<div class="mb-6">
|
|
79
78
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Authentication Successful!</h1>
|
|
80
79
|
<p class="text-gray-600 text-lg">Welcome to Lyceum</p>
|
|
81
80
|
</div>
|
|
82
|
-
|
|
81
|
+
|
|
83
82
|
<!-- Instructions -->
|
|
84
83
|
<div class="space-y-3 mb-8">
|
|
85
84
|
<p class="text-gray-700">You can now close this browser tab and return to the CLI.</p>
|
|
86
85
|
<p class="text-sm text-gray-500">Your Lyceum CLI has been authenticated successfully and is ready to use.</p>
|
|
87
86
|
</div>
|
|
88
|
-
|
|
87
|
+
|
|
89
88
|
<!-- Close Message -->
|
|
90
89
|
<div class="w-full py-3 px-4 bg-gray-50 text-gray-700 rounded-md border border-gray-200">
|
|
91
90
|
You can close this window now
|
|
92
91
|
</div>
|
|
93
|
-
|
|
92
|
+
|
|
94
93
|
<!-- Lyceum Branding -->
|
|
95
94
|
<div class="mt-8 pt-6 border-t border-gray-200">
|
|
96
95
|
<p class="text-xs text-gray-400">Powered by Lyceum Technology</p>
|
|
97
96
|
</div>
|
|
98
97
|
</div>
|
|
99
98
|
</div>
|
|
100
|
-
|
|
99
|
+
|
|
101
100
|
<!-- Auto-close script -->
|
|
102
101
|
<script>
|
|
103
102
|
// Auto-close after 10 seconds
|
|
@@ -113,17 +112,17 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
113
112
|
except (BrokenPipeError, ConnectionResetError):
|
|
114
113
|
# Browser closed connection (expected when tab auto-closes)
|
|
115
114
|
pass
|
|
116
|
-
|
|
115
|
+
|
|
117
116
|
elif "error" in query_params:
|
|
118
117
|
error = query_params["error"][0]
|
|
119
118
|
callback_result["error"] = error
|
|
120
119
|
callback_result["received"] = True
|
|
121
|
-
|
|
120
|
+
|
|
122
121
|
# Send error response
|
|
123
122
|
self.send_response(400)
|
|
124
123
|
self.send_header("Content-type", "text/html")
|
|
125
124
|
self.end_headers()
|
|
126
|
-
|
|
125
|
+
|
|
127
126
|
error_html = f"""
|
|
128
127
|
<!DOCTYPE html>
|
|
129
128
|
<html lang="en">
|
|
@@ -142,13 +141,13 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
142
141
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
143
142
|
</svg>
|
|
144
143
|
</div>
|
|
145
|
-
|
|
144
|
+
|
|
146
145
|
<!-- Header -->
|
|
147
146
|
<div class="mb-6">
|
|
148
147
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Authentication Failed</h1>
|
|
149
148
|
<p class="text-red-600 text-lg">Something went wrong</p>
|
|
150
149
|
</div>
|
|
151
|
-
|
|
150
|
+
|
|
152
151
|
<!-- Error Details -->
|
|
153
152
|
<div class="space-y-3 mb-8">
|
|
154
153
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
|
@@ -156,12 +155,12 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
156
155
|
</div>
|
|
157
156
|
<p class="text-gray-600">Please try again or contact support if the issue persists.</p>
|
|
158
157
|
</div>
|
|
159
|
-
|
|
158
|
+
|
|
160
159
|
<!-- Close Message -->
|
|
161
160
|
<div class="w-full py-3 px-4 bg-gray-50 text-gray-700 rounded-md border border-gray-200">
|
|
162
161
|
You can close this window now
|
|
163
162
|
</div>
|
|
164
|
-
|
|
163
|
+
|
|
165
164
|
<!-- Lyceum Branding -->
|
|
166
165
|
<div class="mt-8 pt-6 border-t border-gray-200">
|
|
167
166
|
<p class="text-xs text-gray-400">Powered by Lyceum Technology</p>
|
|
@@ -180,7 +179,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
180
179
|
# Missing parameters
|
|
181
180
|
callback_result["error"] = "Missing token or error parameter"
|
|
182
181
|
callback_result["received"] = True
|
|
183
|
-
|
|
182
|
+
|
|
184
183
|
self.send_response(400)
|
|
185
184
|
self.send_header("Content-type", "text/html")
|
|
186
185
|
self.end_headers()
|
|
@@ -253,11 +252,11 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
253
252
|
except (BrokenPipeError, ConnectionResetError):
|
|
254
253
|
# Browser closed connection
|
|
255
254
|
pass
|
|
256
|
-
|
|
255
|
+
|
|
257
256
|
except Exception as e:
|
|
258
257
|
callback_result["error"] = str(e)
|
|
259
258
|
callback_result["received"] = True
|
|
260
|
-
|
|
259
|
+
|
|
261
260
|
self.send_response(500)
|
|
262
261
|
self.send_header("Content-type", "text/html")
|
|
263
262
|
self.end_headers()
|
|
@@ -309,24 +308,30 @@ def get_available_port():
|
|
|
309
308
|
|
|
310
309
|
@auth_app.command("login")
|
|
311
310
|
def login(
|
|
312
|
-
base_url:
|
|
313
|
-
dashboard_url:
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
base_url: str | None = typer.Option(None, "--url", help="API base URL (for development)"),
|
|
312
|
+
dashboard_url: str | None = typer.Option(
|
|
313
|
+
"https://dashboard.lyceum.technology", "--dashboard", help="Dashboard URL"
|
|
314
|
+
),
|
|
315
|
+
manual: bool = typer.Option(
|
|
316
|
+
False, "--manual", help="Use manual API key login instead of browser"
|
|
317
|
+
),
|
|
318
|
+
api_key: str | None = typer.Option(
|
|
319
|
+
None, "--api-key", "-k", help="API key for manual login"
|
|
320
|
+
),
|
|
316
321
|
):
|
|
317
322
|
"""Login to Lyceum via browser authentication"""
|
|
318
323
|
global callback_result
|
|
319
|
-
|
|
324
|
+
|
|
320
325
|
if manual:
|
|
321
326
|
# Legacy manual login
|
|
322
327
|
if not api_key:
|
|
323
328
|
api_key = typer.prompt("Enter your Lyceum API key", hide_input=True)
|
|
324
|
-
|
|
329
|
+
|
|
325
330
|
config.api_key = api_key
|
|
326
331
|
if base_url:
|
|
327
332
|
config.base_url = base_url
|
|
328
333
|
config.save()
|
|
329
|
-
|
|
334
|
+
|
|
330
335
|
# Test the connection
|
|
331
336
|
try:
|
|
332
337
|
import httpx
|
|
@@ -341,7 +346,7 @@ def login(
|
|
|
341
346
|
console.print(f"[red]❌ Authentication failed: {e}[/red]")
|
|
342
347
|
raise typer.Exit(1)
|
|
343
348
|
return
|
|
344
|
-
|
|
349
|
+
|
|
345
350
|
# OAuth-style browser login
|
|
346
351
|
try:
|
|
347
352
|
if base_url:
|
|
@@ -349,45 +354,45 @@ def login(
|
|
|
349
354
|
else:
|
|
350
355
|
# Reset to production URL if no custom URL specified
|
|
351
356
|
config.base_url = "https://api.lyceum.technology"
|
|
352
|
-
|
|
357
|
+
|
|
353
358
|
# Reset callback result
|
|
354
359
|
callback_result = {"token": None, "error": None, "received": False}
|
|
355
|
-
|
|
360
|
+
|
|
356
361
|
# Start callback server
|
|
357
362
|
callback_port = get_available_port()
|
|
358
363
|
callback_server = HTTPServer(('localhost', callback_port), CallbackHandler)
|
|
359
|
-
|
|
364
|
+
|
|
360
365
|
console.print(f"[dim]Starting callback server on port {callback_port}...[/dim]")
|
|
361
|
-
|
|
366
|
+
|
|
362
367
|
# Start server in background thread
|
|
363
368
|
server_thread = threading.Thread(target=callback_server.serve_forever, daemon=True)
|
|
364
369
|
server_thread.start()
|
|
365
|
-
|
|
370
|
+
|
|
366
371
|
# Construct login URL
|
|
367
372
|
callback_url = f"http://localhost:{callback_port}/callback"
|
|
368
373
|
login_url = f"{dashboard_url}/cli-login?callback={callback_url}"
|
|
369
|
-
|
|
374
|
+
|
|
370
375
|
console.print("[cyan]🌐 Opening browser for authentication...[/cyan]")
|
|
371
376
|
console.print(f"[dim]If browser doesn't open, visit: {login_url}[/dim]")
|
|
372
|
-
|
|
377
|
+
|
|
373
378
|
# Open browser
|
|
374
379
|
if not webbrowser.open(login_url):
|
|
375
380
|
console.print("[yellow]⚠️ Could not open browser automatically[/yellow]")
|
|
376
381
|
console.print(f"[yellow]Please manually open: {login_url}[/yellow]")
|
|
377
|
-
|
|
382
|
+
|
|
378
383
|
console.print("[dim]Waiting for authentication... (timeout: 120 seconds)[/dim]")
|
|
379
|
-
|
|
384
|
+
|
|
380
385
|
# Wait for callback with timeout
|
|
381
386
|
timeout = 120 # 2 minutes
|
|
382
387
|
start_time = time.time()
|
|
383
|
-
|
|
388
|
+
|
|
384
389
|
while not callback_result["received"] and (time.time() - start_time) < timeout:
|
|
385
390
|
time.sleep(0.5)
|
|
386
|
-
|
|
391
|
+
|
|
387
392
|
# Stop server
|
|
388
393
|
callback_server.shutdown()
|
|
389
394
|
callback_server.server_close()
|
|
390
|
-
|
|
395
|
+
|
|
391
396
|
if callback_result["received"]:
|
|
392
397
|
if callback_result["token"]:
|
|
393
398
|
# Save token and test connection
|
|
@@ -396,7 +401,7 @@ def login(
|
|
|
396
401
|
if callback_result.get("refresh_token"):
|
|
397
402
|
config.refresh_token = callback_result["refresh_token"]
|
|
398
403
|
config.save()
|
|
399
|
-
|
|
404
|
+
|
|
400
405
|
console.print("[green]✅ Authentication token received![/green]")
|
|
401
406
|
|
|
402
407
|
# Test the connection using health endpoint
|
|
@@ -430,14 +435,14 @@ def login(
|
|
|
430
435
|
console.print(f"[red]❌ Token validation failed: {e}[/red]")
|
|
431
436
|
console.print(f"[dim]Token saved but couldn't verify. Error type: {type(e).__name__}[/dim]")
|
|
432
437
|
raise typer.Exit(1)
|
|
433
|
-
|
|
438
|
+
|
|
434
439
|
elif callback_result["error"]:
|
|
435
440
|
console.print(f"[red]❌ Authentication failed: {callback_result['error']}[/red]")
|
|
436
441
|
raise typer.Exit(1)
|
|
437
442
|
else:
|
|
438
443
|
console.print("[red]❌ Authentication timed out. Please try again.[/red]")
|
|
439
444
|
raise typer.Exit(1)
|
|
440
|
-
|
|
445
|
+
|
|
441
446
|
except KeyboardInterrupt:
|
|
442
447
|
console.print("\n[yellow]Authentication cancelled by user.[/yellow]")
|
|
443
448
|
raise typer.Exit(1)
|
|
@@ -461,11 +466,11 @@ def status():
|
|
|
461
466
|
from ...shared.config import CONFIG_FILE
|
|
462
467
|
console.print(f"[dim]Config file: {CONFIG_FILE}[/dim]")
|
|
463
468
|
console.print(f"[dim]Base URL: {config.base_url}[/dim]")
|
|
464
|
-
|
|
469
|
+
|
|
465
470
|
if config.api_key:
|
|
466
|
-
console.print(
|
|
471
|
+
console.print("[green]✅ Authenticated[/green]")
|
|
467
472
|
console.print(f"[dim]API Key: {config.api_key[:8]}...[/dim]")
|
|
468
|
-
|
|
473
|
+
|
|
469
474
|
# Test connection
|
|
470
475
|
try:
|
|
471
476
|
import httpx
|
|
@@ -478,4 +483,4 @@ def status():
|
|
|
478
483
|
except Exception as e:
|
|
479
484
|
console.print(f"[red]❌ API connection failed: {e}[/red]")
|
|
480
485
|
else:
|
|
481
|
-
console.print("[red]❌ Not authenticated. Run 'lyceum login' first.[/red]")
|
|
486
|
+
console.print("[red]❌ Not authenticated. Run 'lyceum login' first.[/red]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Workspace configuration commands for Python execution"""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata as im
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from ....shared.imports import (
|
|
12
|
+
is_stdlib_module_by_name,
|
|
13
|
+
should_skip_path,
|
|
14
|
+
find_imports_in_file,
|
|
15
|
+
SKIP_DIRS,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
config_app = typer.Typer(name="config", help="Workspace configuration commands")
|
|
21
|
+
|
|
22
|
+
# Known package name mismatches (import name -> pip name)
|
|
23
|
+
PACKAGE_NAME_MAP = {
|
|
24
|
+
"sklearn": "scikit-learn",
|
|
25
|
+
"cv2": "opencv-python",
|
|
26
|
+
"PIL": "Pillow",
|
|
27
|
+
"bs4": "beautifulsoup4",
|
|
28
|
+
"yaml": "PyYAML",
|
|
29
|
+
"Crypto": "pycryptodome",
|
|
30
|
+
"OpenSSL": "pyOpenSSL",
|
|
31
|
+
"cudf": "cudf-cu12",
|
|
32
|
+
"cpuinfo": "py-cpuinfo",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_package_name(module_name: str) -> str:
|
|
37
|
+
"""Convert import name to pip package name."""
|
|
38
|
+
top_level = module_name.split(".")[0]
|
|
39
|
+
return PACKAGE_NAME_MAP.get(top_level, top_level)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_installed_version(package_name: str) -> Optional[str]:
|
|
43
|
+
"""Get installed version of a package."""
|
|
44
|
+
try:
|
|
45
|
+
return im.version(package_name)
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_local_packages(workspace: Path) -> dict[str, Path]:
|
|
51
|
+
"""Find all local Python packages in the workspace recursively."""
|
|
52
|
+
packages = {}
|
|
53
|
+
|
|
54
|
+
for init_file in workspace.rglob("__init__.py"):
|
|
55
|
+
if should_skip_path(init_file):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
package_dir = init_file.parent
|
|
59
|
+
parent_init = package_dir.parent / "__init__.py"
|
|
60
|
+
if parent_init.exists() and not should_skip_path(parent_init):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
rel_path = package_dir.relative_to(workspace)
|
|
65
|
+
packages[str(rel_path)] = package_dir
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
for item in workspace.iterdir():
|
|
70
|
+
if should_skip_path(item):
|
|
71
|
+
continue
|
|
72
|
+
if item.is_file() and item.suffix == ".py" and item.stem != "__init__":
|
|
73
|
+
packages[item.stem] = item
|
|
74
|
+
|
|
75
|
+
return packages
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def collect_all_python_files(workspace: Path) -> list[Path]:
|
|
79
|
+
"""Collect all Python files in the workspace."""
|
|
80
|
+
py_files = []
|
|
81
|
+
for py_file in workspace.rglob("*.py"):
|
|
82
|
+
if not should_skip_path(py_file):
|
|
83
|
+
py_files.append(py_file)
|
|
84
|
+
return py_files
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_requirements_file(req_file: Path) -> list[str]:
|
|
88
|
+
"""Parse a requirements.txt file into a list of dependencies."""
|
|
89
|
+
deps = []
|
|
90
|
+
try:
|
|
91
|
+
with open(req_file) as f:
|
|
92
|
+
for line in f:
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if line and not line.startswith("#") and not line.startswith("-"):
|
|
95
|
+
deps.append(line)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return deps
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def extract_package_name(dep: str) -> str:
|
|
102
|
+
"""Extract package name from a dependency string."""
|
|
103
|
+
return dep.split("==")[0].split(">=")[0].split("<=")[0].split("<")[0].split(">")[0].split("[")[0].split("~=")[0].lower()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@config_app.command("init")
|
|
107
|
+
def init_config(
|
|
108
|
+
workspace: Path = typer.Argument(".", help="Workspace directory to analyze"),
|
|
109
|
+
requirements: Path | None = typer.Option(
|
|
110
|
+
None, "--requirements", "-r", help="Path to requirements.txt"
|
|
111
|
+
),
|
|
112
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
|
|
113
|
+
):
|
|
114
|
+
"""Initialize workspace configuration for Lyceum Cloud execution.
|
|
115
|
+
|
|
116
|
+
Scans the workspace to detect:
|
|
117
|
+
- Local Python packages (for import resolution at runtime)
|
|
118
|
+
- External dependencies (from imports and requirements.txt)
|
|
119
|
+
|
|
120
|
+
Creates .lyceum/config.json with workspace metadata.
|
|
121
|
+
Local file contents are resolved at runtime based on what each script imports.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
lyceum python config init
|
|
125
|
+
lyceum python config init ./my-project
|
|
126
|
+
lyceum python config init -r requirements.txt
|
|
127
|
+
"""
|
|
128
|
+
workspace = Path(workspace).resolve()
|
|
129
|
+
|
|
130
|
+
if not workspace.exists():
|
|
131
|
+
console.print(f"[red]Error: Workspace directory does not exist: {workspace}[/red]")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
config_dir = workspace / ".lyceum"
|
|
135
|
+
config_file = config_dir / "config.json"
|
|
136
|
+
|
|
137
|
+
if config_file.exists() and not force:
|
|
138
|
+
console.print(f"[yellow]Config already exists: {config_file}[/yellow]")
|
|
139
|
+
console.print("[dim]Use --force to overwrite[/dim]")
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
console.print(f"[dim]Scanning workspace: {workspace}[/dim]")
|
|
143
|
+
|
|
144
|
+
# Find local packages
|
|
145
|
+
local_packages = find_local_packages(workspace)
|
|
146
|
+
console.print(f"[dim]Found {len(local_packages)} local packages/modules[/dim]")
|
|
147
|
+
|
|
148
|
+
if local_packages:
|
|
149
|
+
for name in sorted(local_packages.keys())[:10]:
|
|
150
|
+
console.print(f"[dim] - {name}[/dim]")
|
|
151
|
+
if len(local_packages) > 10:
|
|
152
|
+
console.print(f"[dim] ... and {len(local_packages) - 10} more[/dim]")
|
|
153
|
+
|
|
154
|
+
# Collect all Python files for import scanning
|
|
155
|
+
py_files = collect_all_python_files(workspace)
|
|
156
|
+
console.print(f"[dim]Found {len(py_files)} Python files[/dim]")
|
|
157
|
+
|
|
158
|
+
# Scan all files for imports to detect external dependencies
|
|
159
|
+
all_imports = set()
|
|
160
|
+
for py_file in py_files:
|
|
161
|
+
all_imports.update(find_imports_in_file(py_file))
|
|
162
|
+
|
|
163
|
+
# Filter to external dependencies
|
|
164
|
+
local_package_names = {Path(p).parts[0] if "/" in p else p for p in local_packages.keys()}
|
|
165
|
+
external_deps = {
|
|
166
|
+
imp for imp in all_imports
|
|
167
|
+
if not is_stdlib_module_by_name(imp) and imp not in local_package_names
|
|
168
|
+
}
|
|
169
|
+
console.print(f"[dim]Found {len(external_deps)} external dependencies from imports[/dim]")
|
|
170
|
+
|
|
171
|
+
# Parse requirements.txt
|
|
172
|
+
requirements_deps = []
|
|
173
|
+
req_file = requirements or workspace / "requirements.txt"
|
|
174
|
+
if req_file.exists():
|
|
175
|
+
console.print(f"[dim]Reading requirements from: {req_file}[/dim]")
|
|
176
|
+
requirements_deps = parse_requirements_file(req_file)
|
|
177
|
+
|
|
178
|
+
# Build merged dependencies with versions
|
|
179
|
+
merged_deps = []
|
|
180
|
+
req_names = {extract_package_name(dep) for dep in requirements_deps}
|
|
181
|
+
|
|
182
|
+
for dep in requirements_deps:
|
|
183
|
+
merged_deps.append(dep)
|
|
184
|
+
|
|
185
|
+
for imp in sorted(external_deps):
|
|
186
|
+
pkg_name = get_package_name(imp)
|
|
187
|
+
if pkg_name.lower() not in req_names:
|
|
188
|
+
version = get_installed_version(pkg_name)
|
|
189
|
+
if version:
|
|
190
|
+
merged_deps.append(f"{pkg_name}=={version}")
|
|
191
|
+
else:
|
|
192
|
+
merged_deps.append(pkg_name)
|
|
193
|
+
|
|
194
|
+
# Build config (no local_imports - resolved at runtime)
|
|
195
|
+
config_data = {
|
|
196
|
+
"workspace": str(workspace),
|
|
197
|
+
"local_packages": {name: str(path) for name, path in sorted(local_packages.items())},
|
|
198
|
+
"dependencies": {
|
|
199
|
+
"from_imports": sorted(external_deps),
|
|
200
|
+
"from_requirements": requirements_deps,
|
|
201
|
+
"merged": sorted(set(merged_deps), key=str.lower),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Write config
|
|
206
|
+
config_dir.mkdir(exist_ok=True)
|
|
207
|
+
with open(config_file, "w") as f:
|
|
208
|
+
json.dump(config_data, f, indent=2)
|
|
209
|
+
|
|
210
|
+
console.print(f"[green]Created config: {config_file}[/green]")
|
|
211
|
+
console.print(f"[dim] - {len(local_packages)} local packages[/dim]")
|
|
212
|
+
console.print(f"[dim] - {len(config_data['dependencies']['merged'])} dependencies[/dim]")
|
|
213
|
+
console.print("[dim] - Local imports resolved at runtime per-file[/dim]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@config_app.command("show")
|
|
217
|
+
def show_config(
|
|
218
|
+
workspace: Path = typer.Argument(".", help="Workspace directory"),
|
|
219
|
+
):
|
|
220
|
+
"""Show current workspace configuration."""
|
|
221
|
+
workspace = Path(workspace).resolve()
|
|
222
|
+
config_file = workspace / ".lyceum" / "config.json"
|
|
223
|
+
|
|
224
|
+
if not config_file.exists():
|
|
225
|
+
console.print(f"[yellow]No config found at: {config_file}[/yellow]")
|
|
226
|
+
console.print("[dim]Run 'lyceum python config init' to create one[/dim]")
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
|
|
229
|
+
with open(config_file) as f:
|
|
230
|
+
config_data = json.load(f)
|
|
231
|
+
|
|
232
|
+
console.print(f"[bold]Workspace:[/bold] {config_data.get('workspace', 'unknown')}")
|
|
233
|
+
|
|
234
|
+
local_packages = config_data.get("local_packages", {})
|
|
235
|
+
console.print(f"\n[bold]Local packages ({len(local_packages)}):[/bold]")
|
|
236
|
+
for pkg in list(local_packages.keys())[:15]:
|
|
237
|
+
console.print(f" - {pkg}")
|
|
238
|
+
if len(local_packages) > 15:
|
|
239
|
+
console.print(f" ... and {len(local_packages) - 15} more")
|
|
240
|
+
|
|
241
|
+
deps = config_data.get("dependencies", {})
|
|
242
|
+
merged = deps.get("merged", [])
|
|
243
|
+
console.print(f"\n[bold]Dependencies ({len(merged)}):[/bold]")
|
|
244
|
+
for dep in merged[:20]:
|
|
245
|
+
console.print(f" - {dep}")
|
|
246
|
+
if len(merged) > 20:
|
|
247
|
+
console.print(f" ... and {len(merged) - 20} more")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@config_app.command("refresh")
|
|
251
|
+
def refresh_config(
|
|
252
|
+
workspace: Path = typer.Argument(".", help="Workspace directory"),
|
|
253
|
+
requirements: Path | None = typer.Option(
|
|
254
|
+
None, "--requirements", "-r", help="Path to requirements.txt"
|
|
255
|
+
),
|
|
256
|
+
):
|
|
257
|
+
"""Refresh workspace configuration (re-scans the workspace)."""
|
|
258
|
+
init_config(workspace=workspace, requirements=requirements, force=True)
|