lyceum-cli 1.0.23__tar.gz → 1.0.25__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 (55) hide show
  1. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/PKG-INFO +1 -1
  2. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/auth/login.py +1 -1
  3. lyceum_cli-1.0.25/lyceum/external/compute/execution/config.py +258 -0
  4. lyceum_cli-1.0.25/lyceum/external/compute/execution/docker.py +272 -0
  5. lyceum_cli-1.0.25/lyceum/external/compute/execution/docker_config.py +123 -0
  6. lyceum_cli-1.0.25/lyceum/external/compute/execution/notebook.py +242 -0
  7. lyceum_cli-1.0.25/lyceum/external/compute/execution/python.py +371 -0
  8. lyceum_cli-1.0.25/lyceum/external/compute/execution/workloads.py +184 -0
  9. lyceum_cli-1.0.25/lyceum/external/storage/files.py +273 -0
  10. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/main.py +6 -11
  11. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/shared/config.py +7 -7
  12. lyceum_cli-1.0.25/lyceum/shared/imports.py +312 -0
  13. lyceum_cli-1.0.25/lyceum/shared/streaming.py +229 -0
  14. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cli.egg-info/PKG-INFO +1 -1
  15. lyceum_cli-1.0.25/lyceum_cli.egg-info/SOURCES.txt +48 -0
  16. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cli.egg-info/top_level.txt +1 -0
  17. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/setup.py +1 -1
  18. lyceum_cli-1.0.25/tests/__init__.py +1 -0
  19. lyceum_cli-1.0.25/tests/conftest.py +200 -0
  20. lyceum_cli-1.0.25/tests/unit/__init__.py +1 -0
  21. lyceum_cli-1.0.25/tests/unit/external/__init__.py +1 -0
  22. lyceum_cli-1.0.25/tests/unit/external/compute/__init__.py +1 -0
  23. lyceum_cli-1.0.25/tests/unit/external/compute/execution/__init__.py +1 -0
  24. lyceum_cli-1.0.25/tests/unit/external/compute/execution/test_data.py +33 -0
  25. lyceum_cli-1.0.25/tests/unit/external/compute/execution/test_dependency_resolver.py +257 -0
  26. lyceum_cli-1.0.25/tests/unit/external/compute/execution/test_python_helpers.py +406 -0
  27. lyceum_cli-1.0.25/tests/unit/external/compute/execution/test_python_run.py +289 -0
  28. lyceum_cli-1.0.25/tests/unit/shared/__init__.py +1 -0
  29. lyceum_cli-1.0.25/tests/unit/shared/test_config.py +341 -0
  30. lyceum_cli-1.0.25/tests/unit/shared/test_streaming.py +259 -0
  31. lyceum_cli-1.0.23/lyceum/external/compute/execution/python.py +0 -103
  32. lyceum_cli-1.0.23/lyceum/external/vms/instances.py +0 -303
  33. lyceum_cli-1.0.23/lyceum/external/vms/management.py +0 -253
  34. lyceum_cli-1.0.23/lyceum/shared/streaming.py +0 -150
  35. lyceum_cli-1.0.23/lyceum_cli.egg-info/SOURCES.txt +0 -30
  36. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/__init__.py +0 -0
  37. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/__init__.py +0 -0
  38. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/auth/__init__.py +0 -0
  39. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/__init__.py +0 -0
  40. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/execution/__init__.py +0 -0
  41. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/inference/__init__.py +0 -0
  42. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/inference/batch.py +0 -0
  43. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/inference/chat.py +0 -0
  44. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/compute/inference/models.py +0 -0
  45. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/external/general/__init__.py +0 -0
  46. {lyceum_cli-1.0.23/lyceum/external/vms → lyceum_cli-1.0.25/lyceum/external/storage}/__init__.py +0 -0
  47. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/shared/__init__.py +0 -0
  48. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum/shared/display.py +0 -0
  49. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cli.egg-info/dependency_links.txt +0 -0
  50. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cli.egg-info/entry_points.txt +0 -0
  51. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cli.egg-info/requires.txt +0 -0
  52. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cloud_execution_api_client/__init__.py +0 -0
  53. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cloud_execution_api_client/api/__init__.py +0 -0
  54. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/lyceum_cloud_execution_api_client/models/__init__.py +0 -0
  55. {lyceum_cli-1.0.23 → lyceum_cli-1.0.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lyceum-cli
3
- Version: 1.0.23
3
+ Version: 1.0.25
4
4
  Summary: Command-line interface for Lyceum Cloud Execution API
5
5
  Home-page: https://lyceum.technology
6
6
  Author: Lyceum Team
@@ -483,4 +483,4 @@ def status():
483
483
  except Exception as e:
484
484
  console.print(f"[red]❌ API connection failed: {e}[/red]")
485
485
  else:
486
- console.print("[red]❌ Not authenticated. Run 'lyceum auth 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)
@@ -0,0 +1,272 @@
1
+ """
2
+ Docker execution commands
3
+ """
4
+
5
+ import json
6
+ import shlex
7
+ from typing import Optional
8
+
9
+ import httpx
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from ....shared.config import config
14
+ from ....shared.streaming import StatusLine, stream_execution_output
15
+
16
+ console = Console()
17
+
18
+
19
+ docker_app = typer.Typer(name="docker", help="Docker execution commands")
20
+
21
+
22
+ @docker_app.command("run")
23
+ def run_docker(
24
+ image: str = typer.Argument(..., help="Docker image to run"),
25
+ machine_type: str = typer.Option(
26
+ "cpu", "--machine", "-m", help="Machine type (cpu, a100, h100, etc.)"
27
+ ),
28
+ timeout: int = typer.Option(
29
+ 300, "--timeout", "-t", help="Execution timeout in seconds"
30
+ ),
31
+ file_name: Optional[str] = typer.Option(
32
+ None, "--file-name", "-f", help="Name for the execution"
33
+ ),
34
+ command: Optional[str] = typer.Option(
35
+ None,
36
+ "--command",
37
+ "-c",
38
+ help="Command to run in container (e.g., 'python app.py')",
39
+ ),
40
+ env: Optional[list[str]] = typer.Option(
41
+ None, "--env", "-e", help="Environment variables (e.g., KEY=value)"
42
+ ),
43
+ detach: bool = typer.Option(
44
+ False, "--detach", "-d", help="Run container in background and print execution ID"
45
+ ),
46
+ callback_url: Optional[str] = typer.Option(
47
+ None, "--callback", help="Webhook URL for completion notification"
48
+ ),
49
+ registry_creds: Optional[str] = typer.Option(
50
+ None, "--registry-creds", help="Docker registry credentials as JSON string"
51
+ ),
52
+ registry_type: Optional[str] = typer.Option(
53
+ None, "--registry-type", help="Registry credential type: basic, aws, etc."
54
+ ),
55
+ ):
56
+ """Execute a Docker container on Lyceum Cloud.
57
+
58
+ By default, streams container output in real-time.
59
+ Use --detach to run in background and return immediately.
60
+
61
+ Examples:
62
+ lyceum docker run python:3.11 -c "python -c 'print(1+1)'"
63
+ lyceum docker run myapp:latest -e "DEBUG=true"
64
+ lyceum docker run nvidia/cuda:12.0-base -m a100 -d
65
+ """
66
+ status = StatusLine()
67
+
68
+ try:
69
+ config.get_client()
70
+
71
+ status.start()
72
+ status.update("Validating configuration...")
73
+
74
+ # Parse environment variables
75
+ docker_env = {}
76
+ if env:
77
+ for env_var in env:
78
+ if "=" in env_var:
79
+ key, value = env_var.split("=", 1)
80
+ docker_env[key] = value
81
+ else:
82
+ status.stop()
83
+ console.print(
84
+ f"[yellow]Warning: Ignoring invalid env var format: {env_var}[/yellow]"
85
+ )
86
+ status.start()
87
+
88
+ # Parse registry credentials
89
+ registry_credentials = None
90
+ if registry_creds:
91
+ try:
92
+ registry_credentials = json.loads(registry_creds)
93
+ except json.JSONDecodeError:
94
+ status.stop()
95
+ console.print(
96
+ "[red]Error: Invalid JSON format for registry credentials[/red]"
97
+ )
98
+ raise typer.Exit(1)
99
+
100
+ # Validate registry credentials and type
101
+ if (registry_creds and not registry_type) or (
102
+ registry_type and not registry_creds
103
+ ):
104
+ status.stop()
105
+ console.print(
106
+ "[red]Error: Both --registry-creds and --registry-type must be provided together[/red]"
107
+ )
108
+ raise typer.Exit(1)
109
+
110
+ status.update(f"Starting container {image}...")
111
+
112
+ # Build request for v2 image API
113
+ image_request = {
114
+ "docker_image_ref": image,
115
+ "timeout": timeout,
116
+ "execution_type": machine_type,
117
+ }
118
+
119
+ if command:
120
+ image_request["docker_run_cmd"] = shlex.split(command)
121
+ if file_name:
122
+ image_request["file_name"] = file_name
123
+ if env:
124
+ # Join with newlines - the execlet parses env vars by splitting on \n
125
+ image_request["docker_run_env"] = "\n".join(env)
126
+
127
+ # Handle registry credentials
128
+ if registry_type and registry_credentials:
129
+ image_request["docker_registry_credential_type"] = registry_type
130
+
131
+ if registry_type == "aws":
132
+ creds = registry_credentials
133
+ image_request.update(
134
+ {
135
+ "aws_access_key_id": creds.get("aws_access_key_id"),
136
+ "aws_secret_access_key": creds.get("aws_secret_access_key"),
137
+ "aws_session_token": creds.get("aws_session_token"),
138
+ "aws_region": creds.get("region", "us-east-1"),
139
+ }
140
+ )
141
+ elif registry_type == "basic":
142
+ creds = registry_credentials
143
+ image_request.update(
144
+ {
145
+ "docker_username": creds.get("username"),
146
+ "docker_password": creds.get("password"),
147
+ }
148
+ )
149
+
150
+ # Call the v2 image endpoint
151
+ status.update("Submitting execution...")
152
+
153
+ response = httpx.post(
154
+ f"{config.base_url}/api/v2/external/execution/image/start",
155
+ json=image_request,
156
+ headers={"Authorization": f"Bearer {config.api_key}"},
157
+ timeout=30.0,
158
+ )
159
+
160
+ if response.status_code != 200:
161
+ status.stop()
162
+ console.print(f"[red]Error: HTTP {response.status_code}[/red]")
163
+ if response.status_code == 401:
164
+ console.print(
165
+ "[red]Authentication failed. Your session may have expired.[/red]"
166
+ )
167
+ console.print("[yellow]Run 'lyceum auth login' to re-authenticate.[/yellow]")
168
+ elif response.content:
169
+ console.print(f"[red]{response.content.decode()}[/red]")
170
+ raise typer.Exit(1)
171
+
172
+ result = response.json()
173
+ execution_id = result.get("execution_id")
174
+ streaming_url = result.get("streaming_url")
175
+
176
+ if detach:
177
+ # Detached mode - just return the execution info
178
+ status.stop()
179
+ console.print(f"[dim]Image: {image}[/dim]")
180
+ console.print(f"[dim]Machine: {machine_type}[/dim]")
181
+ console.print(f"[dim]Execution ID: {execution_id}[/dim]")
182
+ console.print("")
183
+ console.print(f"[dim]To stream logs:[/dim] lyceum docker logs {execution_id}")
184
+ else:
185
+ # Default: stream output in real-time (like docker run)
186
+ success = stream_execution_output(execution_id, streaming_url, status)
187
+
188
+ # Show execution ID at the end
189
+ console.print(f"[dim]Execution ID: {execution_id}[/dim]")
190
+
191
+ if not success:
192
+ raise typer.Exit(1)
193
+
194
+ except typer.Exit:
195
+ status.stop()
196
+ raise
197
+ except Exception as e:
198
+ status.stop()
199
+ console.print(f"[red]Error: {e}[/red]")
200
+ raise typer.Exit(1)
201
+
202
+
203
+ @docker_app.command("logs")
204
+ def docker_logs(
205
+ execution_id: str = typer.Argument(..., help="Execution ID to stream logs from"),
206
+ ):
207
+ """Stream logs from a running or completed Docker execution.
208
+
209
+ Examples:
210
+ lyceum docker logs 9d73319c-6f1c-4b4c-90e4-044244353ce4
211
+ """
212
+ status = StatusLine()
213
+
214
+ try:
215
+ config.get_client()
216
+
217
+ status.start()
218
+ status.update("Connecting to execution...")
219
+
220
+ # Pass None for streaming_url to use the default v2 endpoint
221
+ success = stream_execution_output(execution_id, None, status)
222
+
223
+ console.print(f"[dim]Execution ID: {execution_id}[/dim]")
224
+
225
+ if not success:
226
+ raise typer.Exit(1)
227
+
228
+ except typer.Exit:
229
+ status.stop()
230
+ raise
231
+ except Exception as e:
232
+ status.stop()
233
+ console.print(f"[red]Error: {e}[/red]")
234
+ raise typer.Exit(1)
235
+
236
+
237
+ @docker_app.command("registry-examples")
238
+ def show_registry_examples():
239
+ """Show examples of Docker registry credential formats"""
240
+ console.print("[bold cyan]Docker Registry Credential Examples[/bold cyan]\n")
241
+
242
+ console.print("[bold]1. Docker Hub (basic)[/bold]")
243
+ console.print("Type: [green]basic[/green]")
244
+ console.print(
245
+ 'Credentials: [yellow]\'{"username": "myuser", "password": "mypassword"}\'[/yellow]\n'
246
+ )
247
+
248
+ console.print("[bold]2. AWS ECR (aws)[/bold]")
249
+ console.print("Type: [green]aws[/green]")
250
+ console.print(
251
+ 'Credentials: [yellow]\'{"region": "us-west-2", "aws_access_key_id": "AKIAI...", "aws_secret_access_key": "wJalrX...", "session_token": "optional..."}\'[/yellow]\n'
252
+ )
253
+
254
+ console.print("[bold]3. Private Registry (basic)[/bold]")
255
+ console.print("Type: [green]basic[/green]")
256
+ console.print(
257
+ 'Credentials: [yellow]\'{"username": "admin", "password": "secret"}\'[/yellow]\n'
258
+ )
259
+
260
+ console.print("[bold]Example Commands:[/bold]")
261
+ console.print("# Docker Hub:")
262
+ console.print(
263
+ "[dim]lyceum docker run myuser/myapp:latest --registry-type basic --registry-creds '{\"username\": \"myuser\", \"password\": \"mytoken\"}'[/dim]"
264
+ )
265
+ console.print("\n# AWS ECR:")
266
+ console.print(
267
+ "[dim]lyceum docker run 123456789012.dkr.ecr.us-west-2.amazonaws.com/myapp:latest --registry-type aws --registry-creds '{\"region\": \"us-west-2\", \"aws_access_key_id\": \"AKIAI...\", \"aws_secret_access_key\": \"wJalrX...\"}'[/dim]"
268
+ )
269
+ console.print("\n# Private Registry:")
270
+ console.print(
271
+ "[dim]lyceum docker run myregistry.com/myapp:latest --registry-type basic --registry-creds '{\"username\": \"admin\", \"password\": \"secret\"}'[/dim]"
272
+ )
@@ -0,0 +1,123 @@
1
+ """Docker registry configuration commands"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ console = Console()
11
+
12
+ docker_config_app = typer.Typer(name="config", help="Docker registry configuration")
13
+
14
+ CONFIG_DIR = Path.home() / ".lyceum"
15
+ DOCKER_CONFIG_FILE = CONFIG_DIR / "docker-registries.json"
16
+
17
+
18
+ def load_docker_config() -> dict:
19
+ """Load docker registry configuration."""
20
+ if DOCKER_CONFIG_FILE.exists():
21
+ try:
22
+ with open(DOCKER_CONFIG_FILE) as f:
23
+ return json.load(f)
24
+ except Exception:
25
+ pass
26
+ return {"registries": {}}
27
+
28
+
29
+ def save_docker_config(config: dict) -> None:
30
+ """Save docker registry configuration."""
31
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
32
+ with open(DOCKER_CONFIG_FILE, "w") as f:
33
+ json.dump(config, f, indent=2)
34
+ # Set restrictive permissions since this contains credentials
35
+ DOCKER_CONFIG_FILE.chmod(0o600)
36
+
37
+
38
+ def get_hub_credentials(image: str) -> dict | None:
39
+ """Get Docker Hub credentials if the image is from Docker Hub.
40
+
41
+ Docker Hub images either have no registry prefix or use docker.io.
42
+ Returns None if no credentials found or image is not from Docker Hub.
43
+ """
44
+ config = load_docker_config()
45
+ registries = config.get("registries", {})
46
+
47
+ if "hub" not in registries:
48
+ return None
49
+
50
+ # Check if this looks like a Docker Hub image
51
+ # Docker Hub: no dots in the first part, or explicitly docker.io
52
+ if "/" not in image:
53
+ # Single name like "python:3.9" - it's Docker Hub
54
+ return registries["hub"]
55
+
56
+ parts = image.split("/")
57
+ first_part = parts[0]
58
+
59
+ # If first part has a dot, it's a custom registry (not Docker Hub)
60
+ # Exception: docker.io is Docker Hub
61
+ if "." in first_part and first_part != "docker.io":
62
+ return None
63
+
64
+ # Otherwise it's Docker Hub (e.g., "myuser/myimage" or "docker.io/myuser/myimage")
65
+ return registries["hub"]
66
+
67
+
68
+ @docker_config_app.command("hub")
69
+ def configure_hub():
70
+ """Configure Docker Hub credentials interactively."""
71
+ console.print("\n[bold]Docker Hub Configuration[/bold]")
72
+ console.print("[dim]These credentials will be used for private Docker Hub images.[/dim]\n")
73
+
74
+ username = typer.prompt("Docker Hub Username")
75
+ password = typer.prompt("Docker Hub Password/Token", hide_input=True)
76
+
77
+ config = load_docker_config()
78
+ config["registries"]["hub"] = {
79
+ "username": username,
80
+ "password": password,
81
+ }
82
+ save_docker_config(config)
83
+
84
+ console.print("\n[green]Docker Hub credentials saved![/green]")
85
+ console.print("[dim]Credentials stored in ~/.lyceum/docker-registries.json[/dim]")
86
+
87
+
88
+ @docker_config_app.command("show")
89
+ def show_config():
90
+ """Show configured docker registries."""
91
+ config = load_docker_config()
92
+ registries = config.get("registries", {})
93
+
94
+ if not registries:
95
+ console.print("[dim]No docker registries configured.[/dim]")
96
+ console.print("[dim]Run 'lyceum docker config hub' to configure Docker Hub.[/dim]")
97
+ console.print("[dim]For AWS ECR, use 'lyceum docker run <image> --aws' to auto-detect credentials.[/dim]")
98
+ return
99
+
100
+ table = Table(title="Configured Docker Registries")
101
+ table.add_column("Registry", style="cyan")
102
+ table.add_column("Details", style="dim")
103
+
104
+ if "hub" in registries:
105
+ hub = registries["hub"]
106
+ username = hub.get("username", "")
107
+ table.add_row("Docker Hub", f"User: {username}")
108
+
109
+ console.print(table)
110
+ console.print("\n[dim]For AWS ECR, use 'lyceum docker run <image> --aws' to auto-detect credentials.[/dim]")
111
+
112
+
113
+ @docker_config_app.command("clear")
114
+ def clear_config():
115
+ """Clear saved Docker Hub credentials."""
116
+ config = load_docker_config()
117
+
118
+ if "hub" in config.get("registries", {}):
119
+ del config["registries"]["hub"]
120
+ save_docker_config(config)
121
+ console.print("[green]Cleared Docker Hub credentials.[/green]")
122
+ else:
123
+ console.print("[yellow]No Docker Hub credentials found.[/yellow]")