alphai 0.1.2__py3-none-any.whl → 0.2.1__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.
- alphai/__init__.py +40 -2
- alphai/auth.py +31 -11
- alphai/cleanup.py +351 -0
- alphai/cli.py +45 -910
- alphai/client.py +115 -70
- alphai/commands/__init__.py +24 -0
- alphai/commands/config.py +67 -0
- alphai/commands/docker.py +615 -0
- alphai/commands/jupyter.py +350 -0
- alphai/commands/notebooks.py +1173 -0
- alphai/commands/orgs.py +27 -0
- alphai/commands/projects.py +35 -0
- alphai/config.py +15 -5
- alphai/docker.py +80 -45
- alphai/exceptions.py +122 -0
- alphai/jupyter_manager.py +577 -0
- alphai/notebook_renderer.py +473 -0
- alphai/utils.py +67 -0
- {alphai-0.1.2.dist-info → alphai-0.2.1.dist-info}/METADATA +8 -9
- alphai-0.2.1.dist-info/RECORD +23 -0
- alphai-0.1.2.dist-info/RECORD +0 -12
- {alphai-0.1.2.dist-info → alphai-0.2.1.dist-info}/WHEEL +0 -0
- {alphai-0.1.2.dist-info → alphai-0.2.1.dist-info}/entry_points.txt +0 -0
- {alphai-0.1.2.dist-info → alphai-0.2.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Jupyter commands for alphai CLI.
|
|
2
|
+
|
|
3
|
+
Contains `jupyter lab` and `jupyter notebook` commands with shared implementation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import signal
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from ..client import AlphAIClient
|
|
14
|
+
from ..config import Config
|
|
15
|
+
from ..jupyter_manager import JupyterManager
|
|
16
|
+
from ..cleanup import JupyterCleanupManager
|
|
17
|
+
from ..utils import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_frontend_url(api_url: str) -> str:
|
|
24
|
+
"""Convert API URL to frontend URL for browser opening."""
|
|
25
|
+
if api_url.startswith("http://localhost") or api_url.startswith("https://localhost"):
|
|
26
|
+
return api_url.replace("/api", "").rstrip("/")
|
|
27
|
+
elif "runalph.ai" in api_url:
|
|
28
|
+
if "/api" in api_url:
|
|
29
|
+
return api_url.replace("runalph.ai/api", "runalph.ai").rstrip("/")
|
|
30
|
+
else:
|
|
31
|
+
return api_url.replace("runalph.ai", "runalph.ai").rstrip("/")
|
|
32
|
+
else:
|
|
33
|
+
return api_url.replace("/api", "").rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _select_organization(client: AlphAIClient) -> str:
|
|
37
|
+
"""Interactively select an organization."""
|
|
38
|
+
import questionary
|
|
39
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
40
|
+
|
|
41
|
+
logger.debug("Prompting user to select organization")
|
|
42
|
+
console.print("[yellow]No organization specified. Please select one:[/yellow]")
|
|
43
|
+
|
|
44
|
+
with Progress(
|
|
45
|
+
SpinnerColumn(),
|
|
46
|
+
TextColumn("[progress.description]{task.description}"),
|
|
47
|
+
console=console
|
|
48
|
+
) as progress:
|
|
49
|
+
task = progress.add_task("Fetching organizations...", total=None)
|
|
50
|
+
orgs_data = client.get_organizations()
|
|
51
|
+
progress.update(task, completed=1)
|
|
52
|
+
|
|
53
|
+
if not orgs_data or len(orgs_data) == 0:
|
|
54
|
+
logger.error("No organizations found for user")
|
|
55
|
+
console.print("[red]No organizations found. Please create one first.[/red]")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
org_choices = []
|
|
59
|
+
for org_data in orgs_data:
|
|
60
|
+
display_name = f"{org_data.name} ({org_data.slug})"
|
|
61
|
+
org_choices.append(questionary.Choice(title=display_name, value=org_data.slug))
|
|
62
|
+
|
|
63
|
+
selected_org_slug = questionary.select(
|
|
64
|
+
"Select organization (use ↑↓ arrows and press Enter):",
|
|
65
|
+
choices=org_choices,
|
|
66
|
+
style=questionary.Style([
|
|
67
|
+
('question', 'bold'),
|
|
68
|
+
('pointer', 'fg:#673ab7 bold'),
|
|
69
|
+
('highlighted', 'fg:#673ab7 bold'),
|
|
70
|
+
('selected', 'fg:#cc5454'),
|
|
71
|
+
('instruction', 'fg:#888888 italic')
|
|
72
|
+
])
|
|
73
|
+
).ask()
|
|
74
|
+
|
|
75
|
+
if not selected_org_slug:
|
|
76
|
+
logger.warning("User cancelled organization selection")
|
|
77
|
+
console.print("[red]No organization selected. Exiting.[/red]")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
selected_org_name = next((o.name for o in orgs_data if o.slug == selected_org_slug), selected_org_slug)
|
|
81
|
+
console.print(f"[green]✓ Selected organization: {selected_org_name} ({selected_org_slug})[/green]")
|
|
82
|
+
logger.info(f"Organization selected: {selected_org_slug}")
|
|
83
|
+
|
|
84
|
+
return selected_org_slug
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_project_name() -> str:
|
|
88
|
+
"""Interactively get project name from user."""
|
|
89
|
+
from rich.prompt import Prompt
|
|
90
|
+
|
|
91
|
+
logger.debug("Prompting user to enter project name")
|
|
92
|
+
console.print("[yellow]No project specified. Please enter a project name:[/yellow]")
|
|
93
|
+
|
|
94
|
+
while True:
|
|
95
|
+
project = Prompt.ask("Enter project name")
|
|
96
|
+
if project and project.strip():
|
|
97
|
+
project = project.strip()
|
|
98
|
+
console.print(f"[green]✓ Will create project: {project}[/green]")
|
|
99
|
+
logger.info(f"Project name entered: {project}")
|
|
100
|
+
return project
|
|
101
|
+
else:
|
|
102
|
+
console.print("[red]Project name cannot be empty[/red]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_jupyter_session(
|
|
106
|
+
ctx: click.Context,
|
|
107
|
+
jupyter_command: List[str],
|
|
108
|
+
port: int,
|
|
109
|
+
app_port: Optional[int],
|
|
110
|
+
org: Optional[str],
|
|
111
|
+
project: Optional[str],
|
|
112
|
+
local_only: bool,
|
|
113
|
+
quiet: bool,
|
|
114
|
+
extra_args: List[str]
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Shared implementation for jupyter lab and jupyter notebook commands.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
ctx: Click context
|
|
120
|
+
jupyter_command: Base command (e.g., ['jupyter', 'lab'] or ['jupyter', 'notebook'])
|
|
121
|
+
port: Jupyter port
|
|
122
|
+
app_port: Optional app port for cloud connection
|
|
123
|
+
org: Organization slug (interactive if not provided)
|
|
124
|
+
project: Project name (interactive if not provided)
|
|
125
|
+
local_only: Skip cloud connection
|
|
126
|
+
quiet: Suppress Jupyter log output
|
|
127
|
+
extra_args: Additional arguments to pass to Jupyter
|
|
128
|
+
"""
|
|
129
|
+
command_name = ' '.join(jupyter_command)
|
|
130
|
+
logger.info(f"Starting {command_name} (jupyter_port={port}, app_port={app_port}, local_only={local_only}, quiet={quiet})")
|
|
131
|
+
|
|
132
|
+
config: Config = ctx.obj['config']
|
|
133
|
+
client: AlphAIClient = ctx.obj['client']
|
|
134
|
+
jupyter_manager = JupyterManager(console)
|
|
135
|
+
|
|
136
|
+
# Set up cleanup manager
|
|
137
|
+
cleanup_mgr = JupyterCleanupManager(
|
|
138
|
+
console=console,
|
|
139
|
+
jupyter_manager=jupyter_manager,
|
|
140
|
+
client=client
|
|
141
|
+
)
|
|
142
|
+
cleanup_mgr.install_signal_handlers()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Check Jupyter is installed
|
|
146
|
+
jupyter_manager.check_jupyter_or_exit("jupyter")
|
|
147
|
+
|
|
148
|
+
# Find an available port (auto-increment if specified port is in use)
|
|
149
|
+
actual_port = jupyter_manager.find_available_port(port)
|
|
150
|
+
|
|
151
|
+
# Generate token
|
|
152
|
+
jupyter_token = jupyter_manager.generate_jupyter_token()
|
|
153
|
+
console.print(f"[cyan]Generated Jupyter token: {jupyter_token[:12]}...[/cyan]")
|
|
154
|
+
|
|
155
|
+
# Start Jupyter (allow remote access if connecting to cloud)
|
|
156
|
+
jupyter_process = jupyter_manager.start_jupyter(
|
|
157
|
+
command=jupyter_command,
|
|
158
|
+
port=actual_port,
|
|
159
|
+
token=jupyter_token,
|
|
160
|
+
extra_args=extra_args,
|
|
161
|
+
allow_remote=not local_only
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Wait for Jupyter to be ready
|
|
165
|
+
if not jupyter_manager.wait_for_jupyter_ready(actual_port, timeout=30):
|
|
166
|
+
console.print("[red]Failed to start Jupyter[/red]")
|
|
167
|
+
jupyter_process.kill()
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
console.print("[green]✓ Jupyter started successfully[/green]")
|
|
171
|
+
|
|
172
|
+
# Connect to cloud if not local-only
|
|
173
|
+
connection_data = None
|
|
174
|
+
if not local_only:
|
|
175
|
+
if not config.bearer_token:
|
|
176
|
+
logger.info("Not authenticated, skipping cloud connection")
|
|
177
|
+
console.print("[yellow]⚠ Not authenticated - running locally only[/yellow]")
|
|
178
|
+
console.print("[dim]Run 'alphai login' to enable cloud access[/dim]")
|
|
179
|
+
else:
|
|
180
|
+
# Interactive org/project selection
|
|
181
|
+
if not org:
|
|
182
|
+
org = _select_organization(client)
|
|
183
|
+
if not project:
|
|
184
|
+
project = _get_project_name()
|
|
185
|
+
|
|
186
|
+
# Ensure connector is available
|
|
187
|
+
if not jupyter_manager.ensure_cloudflared():
|
|
188
|
+
logger.info("Connector not available, running locally only")
|
|
189
|
+
console.print("[yellow]⚠ Connector not available - running locally only[/yellow]")
|
|
190
|
+
else:
|
|
191
|
+
# Connect to cloud
|
|
192
|
+
logger.info(f"Connecting {org}/{project} to cloud")
|
|
193
|
+
console.print("[yellow]Connecting to cloud...[/yellow]")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
connect_kwargs = {
|
|
197
|
+
'org_slug': org,
|
|
198
|
+
'project_name': project,
|
|
199
|
+
'jupyter_port': actual_port,
|
|
200
|
+
'jupyter_token': jupyter_token
|
|
201
|
+
}
|
|
202
|
+
if app_port is not None:
|
|
203
|
+
connect_kwargs['app_port'] = app_port
|
|
204
|
+
|
|
205
|
+
connection_data = client.create_tunnel_with_project(**connect_kwargs)
|
|
206
|
+
|
|
207
|
+
if connection_data:
|
|
208
|
+
# Setup connector
|
|
209
|
+
if jupyter_manager.setup_cloudflared_tunnel(connection_data.cloudflared_token):
|
|
210
|
+
console.print("[green]✓ Connected to cloud[/green]")
|
|
211
|
+
jupyter_manager.set_tunnel_data(connection_data)
|
|
212
|
+
|
|
213
|
+
# Store IDs for cleanup
|
|
214
|
+
cleanup_mgr.set_tunnel(connection_data.id)
|
|
215
|
+
if connection_data.project_data and hasattr(connection_data.project_data, 'id'):
|
|
216
|
+
cleanup_mgr.set_project(connection_data.project_data.id)
|
|
217
|
+
else:
|
|
218
|
+
console.print("[yellow]⚠ Connection setup failed - running locally only[/yellow]")
|
|
219
|
+
connection_data = None
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error(f"Failed to connect to cloud: {e}", exc_info=True)
|
|
223
|
+
console.print("[yellow]⚠ Cloud connection failed - running locally only[/yellow]")
|
|
224
|
+
connection_data = None
|
|
225
|
+
|
|
226
|
+
# Display access information
|
|
227
|
+
jupyter_manager.display_jupyter_info(
|
|
228
|
+
jupyter_port=actual_port,
|
|
229
|
+
token=jupyter_token,
|
|
230
|
+
tunnel_data=connection_data,
|
|
231
|
+
org=org,
|
|
232
|
+
project=project,
|
|
233
|
+
api_url=config.api_url,
|
|
234
|
+
app_port=app_port
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Monitor and wait for interrupt
|
|
238
|
+
try:
|
|
239
|
+
jupyter_manager.monitor_jupyter(show_logs=not quiet)
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
finally:
|
|
244
|
+
# Cleanup
|
|
245
|
+
cleanup_mgr.cleanup()
|
|
246
|
+
cleanup_mgr.restore_signal_handlers()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@click.group()
|
|
250
|
+
@click.pass_context
|
|
251
|
+
def jupyter(ctx: click.Context) -> None:
|
|
252
|
+
"""Run Jupyter with automatic cloud sync to runalph.ai."""
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@jupyter.command(
|
|
257
|
+
name='lab',
|
|
258
|
+
context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)
|
|
259
|
+
)
|
|
260
|
+
@click.option('--port', default=8888, help='Port for Jupyter Lab (default: 8888)')
|
|
261
|
+
@click.option('--app-port', default=None, type=int, help='Additional port for your app (optional)')
|
|
262
|
+
@click.option('--org', help='Organization slug (interactive if not provided)')
|
|
263
|
+
@click.option('--project', help='Project name (interactive if not provided)')
|
|
264
|
+
@click.option('--local-only', is_flag=True, help='Run locally only, skip cloud connection')
|
|
265
|
+
@click.option('--quiet', is_flag=True, help='Suppress Jupyter log output')
|
|
266
|
+
@click.pass_context
|
|
267
|
+
def jupyter_lab(
|
|
268
|
+
ctx: click.Context,
|
|
269
|
+
port: int,
|
|
270
|
+
app_port: Optional[int],
|
|
271
|
+
org: Optional[str],
|
|
272
|
+
project: Optional[str],
|
|
273
|
+
local_only: bool,
|
|
274
|
+
quiet: bool
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Start Jupyter Lab with automatic cloud sync.
|
|
277
|
+
|
|
278
|
+
This command starts Jupyter Lab locally and connects it to your
|
|
279
|
+
cloud workspace, making it accessible from anywhere.
|
|
280
|
+
|
|
281
|
+
All standard Jupyter Lab arguments are supported and passed through.
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
alphai jupyter lab
|
|
285
|
+
alphai jupyter lab --port 9999
|
|
286
|
+
alphai jupyter lab --port 8888 --app-port 5000
|
|
287
|
+
alphai jupyter lab --org my-org --project my-project
|
|
288
|
+
alphai jupyter lab --local-only
|
|
289
|
+
alphai jupyter lab --quiet # Suppress Jupyter logs
|
|
290
|
+
alphai jupyter lab --ServerApp.root_dir=/path/to/notebooks
|
|
291
|
+
"""
|
|
292
|
+
_run_jupyter_session(
|
|
293
|
+
ctx=ctx,
|
|
294
|
+
jupyter_command=['jupyter', 'lab'],
|
|
295
|
+
port=port,
|
|
296
|
+
app_port=app_port,
|
|
297
|
+
org=org,
|
|
298
|
+
project=project,
|
|
299
|
+
local_only=local_only,
|
|
300
|
+
quiet=quiet,
|
|
301
|
+
extra_args=ctx.args
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@jupyter.command(
|
|
306
|
+
name='notebook',
|
|
307
|
+
context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)
|
|
308
|
+
)
|
|
309
|
+
@click.option('--port', default=8888, help='Port for Jupyter Notebook (default: 8888)')
|
|
310
|
+
@click.option('--app-port', default=None, type=int, help='Additional port for your app (optional)')
|
|
311
|
+
@click.option('--org', help='Organization slug (interactive if not provided)')
|
|
312
|
+
@click.option('--project', help='Project name (interactive if not provided)')
|
|
313
|
+
@click.option('--local-only', is_flag=True, help='Run locally only, skip cloud connection')
|
|
314
|
+
@click.option('--quiet', is_flag=True, help='Suppress Jupyter log output')
|
|
315
|
+
@click.pass_context
|
|
316
|
+
def jupyter_notebook(
|
|
317
|
+
ctx: click.Context,
|
|
318
|
+
port: int,
|
|
319
|
+
app_port: Optional[int],
|
|
320
|
+
org: Optional[str],
|
|
321
|
+
project: Optional[str],
|
|
322
|
+
local_only: bool,
|
|
323
|
+
quiet: bool
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Start Jupyter Notebook with automatic cloud sync.
|
|
326
|
+
|
|
327
|
+
This command starts Jupyter Notebook locally and connects it to your
|
|
328
|
+
cloud workspace, making it accessible from anywhere.
|
|
329
|
+
|
|
330
|
+
All standard Jupyter Notebook arguments are supported and passed through.
|
|
331
|
+
|
|
332
|
+
Examples:
|
|
333
|
+
alphai jupyter notebook
|
|
334
|
+
alphai jupyter notebook --port 9999
|
|
335
|
+
alphai jupyter notebook --port 8888 --app-port 5000
|
|
336
|
+
alphai jupyter notebook --org my-org --project my-project
|
|
337
|
+
alphai jupyter notebook --local-only
|
|
338
|
+
alphai jupyter notebook --quiet # Suppress Jupyter logs
|
|
339
|
+
"""
|
|
340
|
+
_run_jupyter_session(
|
|
341
|
+
ctx=ctx,
|
|
342
|
+
jupyter_command=['jupyter', 'notebook'],
|
|
343
|
+
port=port,
|
|
344
|
+
app_port=app_port,
|
|
345
|
+
org=org,
|
|
346
|
+
project=project,
|
|
347
|
+
local_only=local_only,
|
|
348
|
+
quiet=quiet,
|
|
349
|
+
extra_args=ctx.args
|
|
350
|
+
)
|