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.
Files changed (57) hide show
  1. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/PKG-INFO +1 -1
  2. lyceum_cli-1.0.24/lyceum/external/__init__.py +1 -0
  3. lyceum_cli-1.0.24/lyceum/external/auth/__init__.py +1 -0
  4. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/auth/login.py +65 -60
  5. lyceum_cli-1.0.24/lyceum/external/compute/__init__.py +1 -0
  6. lyceum_cli-1.0.24/lyceum/external/compute/execution/__init__.py +1 -0
  7. lyceum_cli-1.0.24/lyceum/external/compute/execution/config.py +258 -0
  8. lyceum_cli-1.0.24/lyceum/external/compute/execution/docker.py +272 -0
  9. lyceum_cli-1.0.24/lyceum/external/compute/execution/python.py +371 -0
  10. lyceum_cli-1.0.24/lyceum/external/compute/execution/workloads.py +184 -0
  11. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/batch.py +73 -64
  12. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/chat.py +36 -41
  13. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/models.py +20 -14
  14. lyceum_cli-1.0.24/lyceum/external/general/__init__.py +1 -0
  15. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/main.py +5 -10
  16. lyceum_cli-1.0.24/lyceum/shared/__init__.py +1 -0
  17. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/shared/config.py +41 -36
  18. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/shared/display.py +3 -5
  19. lyceum_cli-1.0.24/lyceum/shared/imports.py +312 -0
  20. lyceum_cli-1.0.24/lyceum/shared/streaming.py +229 -0
  21. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/PKG-INFO +1 -1
  22. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/SOURCES.txt +18 -1
  23. lyceum_cli-1.0.24/lyceum_cli.egg-info/dependency_links.txt +1 -0
  24. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/top_level.txt +1 -0
  25. lyceum_cli-1.0.24/lyceum_cloud_execution_api_client/__init__.py +1 -0
  26. lyceum_cli-1.0.24/lyceum_cloud_execution_api_client/api/__init__.py +1 -0
  27. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/setup.py +4 -5
  28. lyceum_cli-1.0.24/tests/__init__.py +1 -0
  29. lyceum_cli-1.0.24/tests/conftest.py +200 -0
  30. lyceum_cli-1.0.24/tests/unit/__init__.py +1 -0
  31. lyceum_cli-1.0.24/tests/unit/external/__init__.py +1 -0
  32. lyceum_cli-1.0.24/tests/unit/external/compute/__init__.py +1 -0
  33. lyceum_cli-1.0.24/tests/unit/external/compute/execution/__init__.py +1 -0
  34. lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_data.py +33 -0
  35. lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_dependency_resolver.py +257 -0
  36. lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_python_helpers.py +406 -0
  37. lyceum_cli-1.0.24/tests/unit/external/compute/execution/test_python_run.py +289 -0
  38. lyceum_cli-1.0.24/tests/unit/shared/__init__.py +1 -0
  39. lyceum_cli-1.0.24/tests/unit/shared/test_config.py +341 -0
  40. lyceum_cli-1.0.24/tests/unit/shared/test_streaming.py +259 -0
  41. lyceum_cli-1.0.22/lyceum/__init__.py +0 -0
  42. lyceum_cli-1.0.22/lyceum/external/__init__.py +0 -0
  43. lyceum_cli-1.0.22/lyceum/external/auth/__init__.py +0 -0
  44. lyceum_cli-1.0.22/lyceum/external/compute/__init__.py +0 -0
  45. lyceum_cli-1.0.22/lyceum/external/compute/execution/__init__.py +0 -0
  46. lyceum_cli-1.0.22/lyceum/external/compute/execution/python.py +0 -99
  47. lyceum_cli-1.0.22/lyceum/external/general/__init__.py +0 -0
  48. lyceum_cli-1.0.22/lyceum/shared/__init__.py +0 -0
  49. lyceum_cli-1.0.22/lyceum/shared/streaming.py +0 -134
  50. lyceum_cli-1.0.22/lyceum_cloud_execution_api_client/__init__.py +0 -0
  51. lyceum_cli-1.0.22/lyceum_cloud_execution_api_client/api/__init__.py +0 -0
  52. /lyceum_cli-1.0.22/lyceum_cli.egg-info/dependency_links.txt → /lyceum_cli-1.0.24/lyceum/__init__.py +0 -0
  53. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum/external/compute/inference/__init__.py +0 -0
  54. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/entry_points.txt +0 -0
  55. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cli.egg-info/requires.txt +0 -0
  56. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/lyceum_cloud_execution_api_client/models/__init__.py +0 -0
  57. {lyceum_cli-1.0.22 → lyceum_cli-1.0.24}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lyceum-cli
3
- Version: 1.0.22
3
+ Version: 1.0.24
4
4
  Summary: Command-line interface for Lyceum Cloud Execution API
5
5
  Home-page: https://lyceum.technology
6
6
  Author: Lyceum Team
@@ -1,16 +1,14 @@
1
- """
2
- Authentication commands: login, logout, status
3
- """
1
+ """Authentication commands: login, logout, status"""
4
2
 
5
- from typing import Optional
6
- import typer
7
- from rich.console import Console
8
- import webbrowser
3
+ import socket
9
4
  import threading
10
5
  import time
11
- from http.server import HTTPServer, BaseHTTPRequestHandler
12
- from urllib.parse import urlparse, parse_qs
13
- import socket
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
- # Suppress HTTP server logs
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: Optional[str] = typer.Option(None, "--url", help="API base URL (for development)"),
313
- dashboard_url: Optional[str] = typer.Option("https://dashboard.lyceum.technology", "--dashboard", help="Dashboard URL"),
314
- manual: bool = typer.Option(False, "--manual", help="Use manual API key login instead of browser"),
315
- api_key: Optional[str] = typer.Option(None, "--api-key", "-k", help="API key for manual login"),
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(f"[green]✅ Authenticated[/green]")
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,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)