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
alphai/client.py
CHANGED
|
@@ -9,6 +9,10 @@ from rich.panel import Panel
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
|
|
11
11
|
from .config import Config
|
|
12
|
+
from .utils import get_logger
|
|
13
|
+
from . import exceptions
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class TunnelData:
|
|
@@ -41,31 +45,52 @@ class AlphAIClient:
|
|
|
41
45
|
"""Get the SDK instance, creating it if necessary."""
|
|
42
46
|
if self._sdk is None:
|
|
43
47
|
if not self.config.bearer_token:
|
|
48
|
+
logger.error("SDK initialization failed: No authentication token")
|
|
44
49
|
self.console.print("[red]Error: No authentication token found. Please run 'alphai login' first.[/red]")
|
|
45
|
-
|
|
50
|
+
raise exceptions.AuthenticationError("No authentication token found")
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
try:
|
|
53
|
+
self._sdk = AlphSDK(**self.config.to_sdk_config())
|
|
54
|
+
logger.debug("SDK initialized successfully")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to initialize SDK: {e}")
|
|
57
|
+
raise exceptions.APIError(f"Failed to initialize SDK: {e}")
|
|
48
58
|
return self._sdk
|
|
49
59
|
|
|
50
60
|
def test_connection(self) -> bool:
|
|
51
61
|
"""Test the connection to the API."""
|
|
52
62
|
try:
|
|
63
|
+
logger.info("Testing API connection")
|
|
53
64
|
# Try to get organizations as a connection test
|
|
54
65
|
response = self.sdk.orgs.get()
|
|
55
|
-
|
|
66
|
+
success = response.result.status == "success" if response.result.status else True
|
|
67
|
+
if success:
|
|
68
|
+
logger.info("API connection test successful")
|
|
69
|
+
else:
|
|
70
|
+
logger.warning("API connection test failed: unexpected response")
|
|
71
|
+
return success
|
|
72
|
+
except exceptions.AuthenticationError:
|
|
73
|
+
raise
|
|
56
74
|
except Exception as e:
|
|
75
|
+
logger.error(f"Connection test failed: {e}", exc_info=True)
|
|
57
76
|
self.console.print(f"[red]Connection test failed: {e}[/red]")
|
|
58
|
-
|
|
77
|
+
raise exceptions.NetworkError(f"Connection test failed: {e}")
|
|
59
78
|
|
|
60
79
|
def get_organizations(self) -> List[Dict[str, Any]]:
|
|
61
80
|
"""Get all organizations."""
|
|
62
81
|
try:
|
|
82
|
+
logger.debug("Fetching organizations from API")
|
|
63
83
|
response = self.sdk.orgs.get()
|
|
64
84
|
# Access organizations from response.result.organizations
|
|
65
|
-
|
|
85
|
+
orgs = response.result.organizations or []
|
|
86
|
+
logger.info(f"Successfully fetched {len(orgs)} organization(s)")
|
|
87
|
+
return orgs
|
|
88
|
+
except exceptions.AuthenticationError:
|
|
89
|
+
raise
|
|
66
90
|
except Exception as e:
|
|
91
|
+
logger.error(f"Error getting organizations: {e}", exc_info=True)
|
|
67
92
|
self.console.print(f"[red]Error getting organizations: {e}[/red]")
|
|
68
|
-
|
|
93
|
+
raise exceptions.APIError(f"Failed to get organizations: {e}")
|
|
69
94
|
|
|
70
95
|
def create_organization(self, name: str, description: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
71
96
|
"""Create a new organization."""
|
|
@@ -109,15 +134,13 @@ class AlphAIClient:
|
|
|
109
134
|
table.add_column("Name", style="green")
|
|
110
135
|
table.add_column("Role", style="blue")
|
|
111
136
|
table.add_column("Slug", style="dim")
|
|
112
|
-
table.add_column("Subscription", style="yellow")
|
|
113
137
|
|
|
114
138
|
for org in orgs:
|
|
115
139
|
table.add_row(
|
|
116
140
|
org.id or "",
|
|
117
141
|
org.name or "",
|
|
118
142
|
org.role or "",
|
|
119
|
-
org.slug or ""
|
|
120
|
-
org.subscription_level or ""
|
|
143
|
+
org.slug or ""
|
|
121
144
|
)
|
|
122
145
|
|
|
123
146
|
self.console.print(table)
|
|
@@ -155,27 +178,53 @@ class AlphAIClient:
|
|
|
155
178
|
"""Display current configuration status."""
|
|
156
179
|
status_info = []
|
|
157
180
|
|
|
158
|
-
# API URL
|
|
159
|
-
|
|
181
|
+
# Only show API URL if it's not the default (for developers using custom endpoints)
|
|
182
|
+
default_api_url = "https://www.runalph.ai/api"
|
|
183
|
+
if self.config.api_url != default_api_url:
|
|
184
|
+
status_info.append(f"[bold]API:[/bold] {self.config.api_url} [dim](custom)[/dim]")
|
|
160
185
|
|
|
161
|
-
# Authentication status
|
|
186
|
+
# Authentication status and user info
|
|
162
187
|
if self.config.bearer_token:
|
|
163
188
|
status_info.append("[bold]Authentication:[/bold] [green]✓ Logged in[/green]")
|
|
189
|
+
|
|
190
|
+
# Try to fetch organizations to show helpful context
|
|
191
|
+
try:
|
|
192
|
+
orgs = self.get_organizations()
|
|
193
|
+
if orgs:
|
|
194
|
+
# Show current/default organization
|
|
195
|
+
if self.config.current_org:
|
|
196
|
+
# Find the org name for the current org slug
|
|
197
|
+
current_org_name = None
|
|
198
|
+
for org in orgs:
|
|
199
|
+
if hasattr(org, 'slug') and org.slug == self.config.current_org:
|
|
200
|
+
current_org_name = org.name
|
|
201
|
+
break
|
|
202
|
+
if current_org_name:
|
|
203
|
+
status_info.append(f"[bold]Organization:[/bold] {current_org_name} [dim]({self.config.current_org})[/dim]")
|
|
204
|
+
else:
|
|
205
|
+
status_info.append(f"[bold]Organization:[/bold] {self.config.current_org}")
|
|
206
|
+
elif len(orgs) == 1:
|
|
207
|
+
# If only one org, show it as the default
|
|
208
|
+
org = orgs[0]
|
|
209
|
+
status_info.append(f"[bold]Organization:[/bold] {org.name} [dim]({org.slug})[/dim]")
|
|
210
|
+
else:
|
|
211
|
+
# Multiple orgs, show count and list them
|
|
212
|
+
status_info.append(f"[bold]Organizations:[/bold] {len(orgs)} available")
|
|
213
|
+
for org in orgs[:3]: # Show up to 3
|
|
214
|
+
# Handle role which may be an enum or string
|
|
215
|
+
role_str = ""
|
|
216
|
+
if hasattr(org, 'role') and org.role:
|
|
217
|
+
role_val = org.role.value if hasattr(org.role, 'value') else str(org.role)
|
|
218
|
+
role_str = f"[cyan]{role_val}[/cyan]"
|
|
219
|
+
status_info.append(f" • {org.name} [dim]({org.slug})[/dim] {role_str}")
|
|
220
|
+
if len(orgs) > 3:
|
|
221
|
+
status_info.append(f" [dim]... and {len(orgs) - 3} more[/dim]")
|
|
222
|
+
except Exception:
|
|
223
|
+
# Silently fail if we can't fetch orgs - just don't show org info
|
|
224
|
+
pass
|
|
164
225
|
else:
|
|
165
226
|
status_info.append("[bold]Authentication:[/bold] [red]✗ Not logged in[/red]")
|
|
166
227
|
|
|
167
|
-
# Current organization
|
|
168
|
-
if self.config.current_org:
|
|
169
|
-
status_info.append(f"[bold]Current Organization:[/bold] {self.config.current_org}")
|
|
170
|
-
else:
|
|
171
|
-
status_info.append("[bold]Current Organization:[/bold] [dim]None selected[/dim]")
|
|
172
|
-
|
|
173
|
-
# Current project
|
|
174
|
-
if self.config.current_project:
|
|
175
|
-
status_info.append(f"[bold]Current Project:[/bold] {self.config.current_project}")
|
|
176
|
-
else:
|
|
177
|
-
status_info.append("[bold]Current Project:[/bold] [dim]None selected[/dim]")
|
|
178
|
-
|
|
179
228
|
# Debug mode
|
|
180
229
|
if self.config.debug:
|
|
181
230
|
status_info.append("[bold]Debug Mode:[/bold] [yellow]Enabled[/yellow]")
|
|
@@ -186,6 +235,18 @@ class AlphAIClient:
|
|
|
186
235
|
title_align="left"
|
|
187
236
|
)
|
|
188
237
|
self.console.print(panel)
|
|
238
|
+
|
|
239
|
+
# Show helpful tips based on state
|
|
240
|
+
if not self.config.bearer_token:
|
|
241
|
+
self.console.print("\n[bold yellow]Quick Start:[/bold yellow]")
|
|
242
|
+
self.console.print(" [cyan]alphai login[/cyan] Log in to runalph.ai")
|
|
243
|
+
self.console.print(" [cyan]alphai login --browser[/cyan] Log in via browser (recommended)")
|
|
244
|
+
else:
|
|
245
|
+
self.console.print("\n[bold yellow]Quick Start:[/bold yellow]")
|
|
246
|
+
self.console.print(" [cyan]alphai jupyter lab[/cyan] Start Jupyter Lab with cloud sync")
|
|
247
|
+
self.console.print(" [cyan]alphai nb[/cyan] List your notebooks")
|
|
248
|
+
self.console.print(" [cyan]alphai orgs[/cyan] List your organizations")
|
|
249
|
+
self.console.print(" [cyan]alphai --help[/cyan] Show all commands")
|
|
189
250
|
|
|
190
251
|
def create_tunnel(
|
|
191
252
|
self,
|
|
@@ -205,40 +266,38 @@ class AlphAIClient:
|
|
|
205
266
|
|
|
206
267
|
if response.result.status == "success":
|
|
207
268
|
tunnel_data = response.result.data
|
|
208
|
-
|
|
209
|
-
self.console.print(f"[blue]Tunnel ID: {tunnel_data.id}[/blue]")
|
|
210
|
-
self.console.print(f"[blue]App URL: {tunnel_data.app_url}[/blue]")
|
|
211
|
-
self.console.print(f"[blue]Jupyter URL: {tunnel_data.jupyter_url}[/blue]")
|
|
269
|
+
logger.info(f"Connection created: {tunnel_data.id}")
|
|
212
270
|
return tunnel_data
|
|
213
271
|
else:
|
|
214
|
-
self.console.print(f"[red]Failed to
|
|
272
|
+
self.console.print(f"[red]Failed to connect: {response.result.status}[/red]")
|
|
215
273
|
return None
|
|
216
274
|
except Exception as e:
|
|
217
|
-
self.console.print(f"[red]Error
|
|
275
|
+
self.console.print(f"[red]Error connecting: {e}[/red]")
|
|
218
276
|
return None
|
|
219
277
|
|
|
220
278
|
def get_tunnel(self, tunnel_id: str) -> Optional[Dict[str, Any]]:
|
|
221
|
-
"""Get
|
|
279
|
+
"""Get connection information by ID."""
|
|
222
280
|
try:
|
|
223
281
|
response = self.sdk.tunnels.get(tunnel_id=tunnel_id)
|
|
224
282
|
return response.result.data if response.result.status == "success" else None
|
|
225
283
|
except Exception as e:
|
|
226
|
-
|
|
284
|
+
logger.error(f"Error getting connection: {e}")
|
|
227
285
|
return None
|
|
228
286
|
|
|
229
287
|
def delete_tunnel(self, tunnel_id: str) -> bool:
|
|
230
|
-
"""Delete a
|
|
288
|
+
"""Delete a connection by ID."""
|
|
231
289
|
try:
|
|
290
|
+
logger.info(f"Cleaning up connection: {tunnel_id}")
|
|
232
291
|
response = self.sdk.tunnels.delete(tunnel_id=tunnel_id)
|
|
233
292
|
if response.result.status == "success":
|
|
234
|
-
|
|
293
|
+
logger.info(f"Connection {tunnel_id} cleaned up successfully")
|
|
235
294
|
return True
|
|
236
295
|
else:
|
|
237
|
-
|
|
296
|
+
logger.warning(f"Failed to clean up connection {tunnel_id}: {response.result.status}")
|
|
238
297
|
return False
|
|
239
298
|
except Exception as e:
|
|
240
|
-
|
|
241
|
-
|
|
299
|
+
logger.error(f"Error cleaning up connection {tunnel_id}: {e}", exc_info=True)
|
|
300
|
+
raise exceptions.TunnelError(f"Failed to clean up connection: {e}")
|
|
242
301
|
|
|
243
302
|
def create_project(
|
|
244
303
|
self,
|
|
@@ -275,14 +334,21 @@ class AlphAIClient:
|
|
|
275
334
|
def get_organization_by_slug(self, slug: str) -> Optional[Dict[str, Any]]:
|
|
276
335
|
"""Get organization by slug."""
|
|
277
336
|
try:
|
|
337
|
+
logger.debug(f"Looking up organization by slug: {slug}")
|
|
278
338
|
orgs = self.get_organizations()
|
|
279
339
|
for org in orgs:
|
|
280
340
|
if hasattr(org, 'slug') and org.slug == slug:
|
|
341
|
+
logger.info(f"Found organization: {slug}")
|
|
281
342
|
return org
|
|
282
|
-
|
|
343
|
+
logger.warning(f"Organization not found: {slug}")
|
|
344
|
+
raise exceptions.ResourceNotFoundError("Organization", slug)
|
|
345
|
+
except exceptions.ResourceNotFoundError:
|
|
346
|
+
self.console.print(f"[red]Organization '{slug}' not found[/red]")
|
|
347
|
+
raise
|
|
283
348
|
except Exception as e:
|
|
349
|
+
logger.error(f"Error getting organization by slug: {e}", exc_info=True)
|
|
284
350
|
self.console.print(f"[red]Error getting organization by slug: {e}[/red]")
|
|
285
|
-
|
|
351
|
+
raise
|
|
286
352
|
|
|
287
353
|
def create_tunnel_with_project(
|
|
288
354
|
self,
|
|
@@ -325,24 +391,8 @@ class AlphAIClient:
|
|
|
325
391
|
# Store project data in wrapper
|
|
326
392
|
wrapped_tunnel.project_data = project_data
|
|
327
393
|
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
self.console.print(f"[bold]Tunnel ID:[/bold] {wrapped_tunnel.id}")
|
|
331
|
-
if project_data:
|
|
332
|
-
self.console.print(f"[bold]Project ID:[/bold] {project_data.id if hasattr(project_data, 'id') else 'Created'}")
|
|
333
|
-
|
|
334
|
-
# Token information
|
|
335
|
-
self.console.print(f"\n[bold]Cloudflared Token:[/bold]")
|
|
336
|
-
self.console.print(f"[dim]For tunnel setup: cloudflared service install {wrapped_tunnel.cloudflared_token}[/dim]")
|
|
337
|
-
|
|
338
|
-
if jupyter_token:
|
|
339
|
-
self.console.print(f"\n[bold]Jupyter Token:[/bold]")
|
|
340
|
-
self.console.print(f"[dim]For Jupyter access: {jupyter_token}[/dim]")
|
|
341
|
-
|
|
342
|
-
# URLs
|
|
343
|
-
self.console.print(f"\n[bold]Access URLs:[/bold]")
|
|
344
|
-
self.console.print(f"[blue]• App URL: {wrapped_tunnel.app_url}[/blue]")
|
|
345
|
-
self.console.print(f"[blue]• Jupyter URL: {wrapped_tunnel.jupyter_url}[/blue]")
|
|
394
|
+
# Log success (user-facing details shown by caller)
|
|
395
|
+
logger.info(f"Project setup complete: {project_name}")
|
|
346
396
|
|
|
347
397
|
return wrapped_tunnel
|
|
348
398
|
|
|
@@ -358,13 +408,13 @@ class AlphAIClient:
|
|
|
358
408
|
try:
|
|
359
409
|
response = self.sdk.projects.delete(project_id=project_id)
|
|
360
410
|
if response.result.status == "success":
|
|
361
|
-
|
|
411
|
+
logger.info(f"Project {project_id} deleted successfully")
|
|
362
412
|
return True
|
|
363
413
|
else:
|
|
364
|
-
|
|
414
|
+
logger.warning(f"Failed to delete project: {response.result.status}")
|
|
365
415
|
return False
|
|
366
416
|
except Exception as e:
|
|
367
|
-
|
|
417
|
+
logger.error(f"Error deleting project: {e}")
|
|
368
418
|
return False
|
|
369
419
|
|
|
370
420
|
def cleanup_tunnel_and_project(
|
|
@@ -373,28 +423,23 @@ class AlphAIClient:
|
|
|
373
423
|
project_id: Optional[str] = None,
|
|
374
424
|
force: bool = False
|
|
375
425
|
) -> bool:
|
|
376
|
-
"""Comprehensive cleanup of
|
|
426
|
+
"""Comprehensive cleanup of connection and project resources."""
|
|
377
427
|
success = True
|
|
378
428
|
|
|
379
429
|
if not tunnel_id and not project_id:
|
|
380
|
-
|
|
430
|
+
logger.debug("No resources to clean up")
|
|
381
431
|
return True
|
|
382
432
|
|
|
383
|
-
#
|
|
433
|
+
# Clean up connection first
|
|
384
434
|
if tunnel_id:
|
|
385
|
-
|
|
435
|
+
logger.info(f"Cleaning up connection...")
|
|
386
436
|
if not self.delete_tunnel(tunnel_id):
|
|
387
437
|
success = False
|
|
388
438
|
|
|
389
439
|
# Delete project second
|
|
390
440
|
if project_id:
|
|
391
|
-
|
|
441
|
+
logger.info(f"Cleaning up project...")
|
|
392
442
|
if not self.delete_project(project_id):
|
|
393
443
|
success = False
|
|
394
444
|
|
|
395
|
-
if success:
|
|
396
|
-
self.console.print("[green]✓ Tunnel and project cleanup completed successfully[/green]")
|
|
397
|
-
else:
|
|
398
|
-
self.console.print("[yellow]⚠ Tunnel and project cleanup completed with errors[/yellow]")
|
|
399
|
-
|
|
400
445
|
return success
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Command modules for alphai CLI.
|
|
2
|
+
|
|
3
|
+
This package contains the CLI commands organized by domain:
|
|
4
|
+
- jupyter: Jupyter Lab/Notebook commands
|
|
5
|
+
- docker: Docker container management (run, cleanup)
|
|
6
|
+
- orgs: Organization listing
|
|
7
|
+
- projects: Project listing
|
|
8
|
+
- config: Configuration management
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .jupyter import jupyter
|
|
12
|
+
from .docker import run, cleanup
|
|
13
|
+
from .orgs import orgs
|
|
14
|
+
from .projects import projects
|
|
15
|
+
from .config import config
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'jupyter',
|
|
19
|
+
'run',
|
|
20
|
+
'cleanup',
|
|
21
|
+
'orgs',
|
|
22
|
+
'projects',
|
|
23
|
+
'config',
|
|
24
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Configuration commands for alphai CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.prompt import Confirm
|
|
8
|
+
|
|
9
|
+
from ..client import AlphAIClient
|
|
10
|
+
from ..config import Config as ConfigModel
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def config(ctx: click.Context) -> None:
|
|
18
|
+
"""Manage configuration settings."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@config.command('show')
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def config_show(ctx: click.Context) -> None:
|
|
25
|
+
"""Show current configuration."""
|
|
26
|
+
client: AlphAIClient = ctx.obj['client']
|
|
27
|
+
client.display_status()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@config.command('set')
|
|
31
|
+
@click.argument('key')
|
|
32
|
+
@click.argument('value')
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def config_set(ctx: click.Context, key: str, value: str) -> None:
|
|
35
|
+
"""Set a configuration value."""
|
|
36
|
+
cfg: ConfigModel = ctx.obj['config']
|
|
37
|
+
|
|
38
|
+
valid_keys = {'api_url', 'debug', 'current_org'}
|
|
39
|
+
|
|
40
|
+
if key not in valid_keys:
|
|
41
|
+
console.print(f"[red]Invalid configuration key. Valid keys: {', '.join(valid_keys)}[/red]")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
# Convert string values to appropriate types
|
|
45
|
+
if key == 'debug':
|
|
46
|
+
value = value.lower() in ('true', '1', 'yes', 'on')
|
|
47
|
+
|
|
48
|
+
setattr(cfg, key, value)
|
|
49
|
+
cfg.save()
|
|
50
|
+
console.print(f"[green]✓ Set {key} = {value}[/green]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@config.command('reset')
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def config_reset(ctx: click.Context) -> None:
|
|
56
|
+
"""Reset configuration to defaults."""
|
|
57
|
+
if Confirm.ask("Are you sure you want to reset all configuration to defaults?"):
|
|
58
|
+
config_file = ConfigModel.get_config_file()
|
|
59
|
+
if config_file.exists():
|
|
60
|
+
config_file.unlink()
|
|
61
|
+
|
|
62
|
+
# Clear keyring
|
|
63
|
+
cfg = ConfigModel()
|
|
64
|
+
cfg.clear_bearer_token()
|
|
65
|
+
|
|
66
|
+
console.print("[green]✓ Configuration reset to defaults[/green]")
|
|
67
|
+
|