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/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
- sys.exit(1)
50
+ raise exceptions.AuthenticationError("No authentication token found")
46
51
 
47
- self._sdk = AlphSDK(**self.config.to_sdk_config())
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
- return response.result.status == "success" if response.result.status else True
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
- return False
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
- return response.result.organizations or []
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
- return []
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
- status_info.append(f"[bold]API URL:[/bold] {self.config.api_url}")
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
- self.console.print(f"[green]✓ Tunnel created successfully[/green]")
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 create tunnel: {response.result.status}[/red]")
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 creating tunnel: {e}[/red]")
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 tunnel information by ID."""
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
- self.console.print(f"[red]Error getting tunnel: {e}[/red]")
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 tunnel by ID."""
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
- self.console.print(f"[green]✓ Tunnel {tunnel_id} deleted successfully[/green]")
293
+ logger.info(f"Connection {tunnel_id} cleaned up successfully")
235
294
  return True
236
295
  else:
237
- self.console.print(f"[red]Failed to delete tunnel: {response.result.status}[/red]")
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
- self.console.print(f"[red]Error deleting tunnel: {e}[/red]")
241
- return False
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
- return None
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
- return None
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
- # Enhanced logging output
329
- self.console.print("\n[bold green]🎉 Tunnel and Project Setup Complete![/bold green]")
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
- self.console.print(f"[green]✓ Project {project_id} deleted successfully[/green]")
411
+ logger.info(f"Project {project_id} deleted successfully")
362
412
  return True
363
413
  else:
364
- self.console.print(f"[red]Failed to delete project: {response.result.status}[/red]")
414
+ logger.warning(f"Failed to delete project: {response.result.status}")
365
415
  return False
366
416
  except Exception as e:
367
- self.console.print(f"[red]Error deleting project: {e}[/red]")
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 tunnel and project resources."""
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
- self.console.print("[yellow]No tunnel or project ID provided for cleanup[/yellow]")
430
+ logger.debug("No resources to clean up")
381
431
  return True
382
432
 
383
- # Delete tunnel first
433
+ # Clean up connection first
384
434
  if tunnel_id:
385
- self.console.print(f"[yellow]Deleting tunnel {tunnel_id}...[/yellow]")
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
- self.console.print(f"[yellow]Deleting project {project_id}...[/yellow]")
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
+