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 CHANGED
@@ -1,10 +1,48 @@
1
1
  """alphai - A CLI tool and Python package for the runalph.ai platform."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.1"
4
4
  __author__ = "American Data Science"
5
5
  __email__ = "support@americandatascience.com"
6
6
 
7
7
  from .client import AlphAIClient
8
8
  from .config import Config
9
+ from .jupyter_manager import JupyterManager
10
+ from .exceptions import (
11
+ AlphAIException,
12
+ AuthenticationError,
13
+ AuthorizationError,
14
+ APIError,
15
+ DockerError,
16
+ DockerNotAvailableError,
17
+ ContainerError,
18
+ TunnelError,
19
+ CloudflaredError,
20
+ ConfigurationError,
21
+ ValidationError,
22
+ NetworkError,
23
+ TimeoutError,
24
+ ResourceNotFoundError,
25
+ JupyterError,
26
+ )
9
27
 
10
- __all__ = ["AlphAIClient", "Config", "__version__"]
28
+ __all__ = [
29
+ "AlphAIClient",
30
+ "Config",
31
+ "JupyterManager",
32
+ "__version__",
33
+ "AlphAIException",
34
+ "AuthenticationError",
35
+ "AuthorizationError",
36
+ "APIError",
37
+ "DockerError",
38
+ "DockerNotAvailableError",
39
+ "ContainerError",
40
+ "TunnelError",
41
+ "CloudflaredError",
42
+ "ConfigurationError",
43
+ "ValidationError",
44
+ "NetworkError",
45
+ "TimeoutError",
46
+ "ResourceNotFoundError",
47
+ "JupyterError",
48
+ ]
alphai/auth.py CHANGED
@@ -16,6 +16,10 @@ from rich.prompt import Prompt
16
16
  from rich.panel import Panel
17
17
 
18
18
  from .config import Config
19
+ from .utils import get_logger
20
+ from . import exceptions
21
+
22
+ logger = get_logger(__name__)
19
23
 
20
24
 
21
25
  class CallbackHandler(BaseHTTPRequestHandler):
@@ -47,7 +51,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
47
51
  else:
48
52
  # No success page, redirect directly to dashboard
49
53
  redirect_url = api_url
50
- except:
54
+ except Exception:
51
55
  # Network error or timeout, redirect directly to dashboard
52
56
  redirect_url = api_url
53
57
 
@@ -99,15 +103,19 @@ class AuthManager:
99
103
 
100
104
  def login_with_token(self, token: str) -> bool:
101
105
  """Login with a provided bearer token."""
106
+ logger.info("Attempting login with provided token")
102
107
  if not token.strip():
108
+ logger.error("Empty token provided")
103
109
  self.console.print("[red]Error: Empty token provided[/red]")
104
- return False
110
+ raise exceptions.ValidationError("Token cannot be empty")
105
111
 
106
112
  # Validate the token
107
113
  if self.validate_token(token):
108
114
  self.config.set_bearer_token(token)
115
+ logger.info("Login successful with token")
109
116
  return True
110
117
  else:
118
+ logger.error("Invalid token provided")
111
119
  self.console.print("[red]Error: Invalid token[/red]")
112
120
  return False
113
121
 
@@ -171,6 +179,7 @@ class AuthManager:
171
179
 
172
180
  def browser_login(self, port: int = 8080) -> bool:
173
181
  """Perform browser-based login using OAuth flow."""
182
+ logger.info("Starting browser-based authentication")
174
183
  # Try to find an available port
175
184
  for attempt_port in range(port, port + 10):
176
185
  try:
@@ -179,18 +188,21 @@ class AuthManager:
179
188
  httpd.timeout = 60 # 1 minute timeout
180
189
  httpd.token = None
181
190
  httpd.error = None
182
- httpd.api_url = self.config.api_url # Pass api_url to server
191
+ httpd.api_url = self.config.base_url # Pass base_url for redirects (not /api)
192
+ logger.debug(f"Callback server started on port {attempt_port}")
183
193
  break
184
194
  except OSError:
195
+ logger.debug(f"Port {attempt_port} not available, trying next")
185
196
  continue
186
197
  else:
198
+ logger.error("Could not find available port for OAuth callback")
187
199
  self.console.print("[red]Error: Could not find an available port for callback[/red]")
188
200
  return False
189
201
 
190
- redirect_uri = f"http://localhost:{attempt_port}"
202
+ redirect_uri = f"http://127.0.0.1:{attempt_port}"
191
203
 
192
- # Construct the authentication URL
193
- auth_url = f"{self.config.api_url}/auth/cli"
204
+ # Construct the authentication URL (auth is at base URL, not /api)
205
+ auth_url = f"{self.config.base_url}/auth/cli"
194
206
  auth_params = {
195
207
  "redirect_uri": redirect_uri,
196
208
  "response_type": "token",
@@ -242,6 +254,7 @@ class AuthManager:
242
254
 
243
255
  def validate_token(self, token: str) -> bool:
244
256
  """Validate a bearer token by making a test API call."""
257
+ logger.debug("Validating bearer token")
245
258
  try:
246
259
  with httpx.Client() as client:
247
260
  headers = {
@@ -251,31 +264,38 @@ class AuthManager:
251
264
 
252
265
  # Test the token by trying to get organizations
253
266
  response = client.get(
254
- f"{self.config.api_url}/api/orgs",
267
+ f"{self.config.api_url}/orgs",
255
268
  headers=headers,
256
269
  timeout=10.0
257
270
  )
258
271
 
259
272
  # Check if the response is successful
260
273
  if response.status_code in (200, 201):
274
+ logger.info("Token validation successful")
261
275
  return True
262
276
  elif response.status_code == 401:
277
+ logger.error("Token validation failed: Invalid or expired token")
263
278
  self.console.print("[red]Error: Invalid or expired token[/red]")
264
279
  return False
265
280
  elif response.status_code == 403:
281
+ logger.error("Token validation failed: Access forbidden")
266
282
  self.console.print("[red]Error: Access forbidden - check your permissions[/red]")
267
283
  return False
268
284
  else:
285
+ logger.error(f"Token validation failed: API returned status {response.status_code}")
269
286
  self.console.print(f"[red]Error: API returned status {response.status_code}[/red]")
270
287
  return False
271
288
 
272
- except httpx.ConnectError:
289
+ except httpx.ConnectError as e:
290
+ logger.error(f"Connection error during token validation: {e}")
273
291
  self.console.print(f"[red]Error: Could not connect to API at {self.config.api_url}[/red]")
274
292
  return False
275
- except httpx.TimeoutException:
293
+ except httpx.TimeoutException as e:
294
+ logger.error(f"Timeout during token validation: {e}")
276
295
  self.console.print("[red]Error: Request timed out[/red]")
277
296
  return False
278
297
  except Exception as e:
298
+ logger.error(f"Unexpected error validating token: {e}", exc_info=True)
279
299
  self.console.print(f"[red]Error validating token: {e}[/red]")
280
300
  return False
281
301
 
@@ -301,7 +321,7 @@ class AuthManager:
301
321
 
302
322
  # Try to get user info (this endpoint may not exist in the actual API)
303
323
  response = client.get(
304
- f"{self.config.api_url}/api/user",
324
+ f"{self.config.api_url}/user",
305
325
  headers=headers,
306
326
  timeout=10.0
307
327
  )
@@ -328,7 +348,7 @@ class AuthManager:
328
348
  }
329
349
 
330
350
  response = client.get(
331
- f"{self.config.api_url}/api/orgs",
351
+ f"{self.config.api_url}/orgs",
332
352
  headers=headers,
333
353
  timeout=10.0
334
354
  )
alphai/cleanup.py ADDED
@@ -0,0 +1,351 @@
1
+ """Cleanup management for alphai CLI.
2
+
3
+ Provides a class-based approach to resource cleanup that replaces
4
+ global mutable state and handles signal registration safely.
5
+ """
6
+
7
+ import signal
8
+ import sys
9
+ from typing import Optional, Callable, Dict, Any
10
+ from rich.console import Console
11
+
12
+ from .utils import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class CleanupManager:
18
+ """Manages cleanup of resources with proper signal handling.
19
+
20
+ This class replaces the global _cleanup_state and _jupyter_cleanup_state
21
+ dictionaries with a proper encapsulated approach that:
22
+ - Avoids global mutable state
23
+ - Allows safe signal handler installation/restoration
24
+ - Can be tested in isolation
25
+ """
26
+
27
+ def __init__(self, console: Optional[Console] = None):
28
+ """Initialize the cleanup manager.
29
+
30
+ Args:
31
+ console: Rich console for output. Creates one if not provided.
32
+ """
33
+ self.console = console or Console()
34
+ self._resources: Dict[str, Dict[str, Any]] = {}
35
+ self._cleanup_done = False
36
+ self._original_handlers: Dict[int, Any] = {}
37
+ self._cleanup_callbacks: list[Callable[[], None]] = []
38
+
39
+ def register_resource(
40
+ self,
41
+ resource_type: str,
42
+ resource_id: str,
43
+ cleanup_fn: Optional[Callable[[], bool]] = None,
44
+ **metadata
45
+ ) -> None:
46
+ """Register a resource for cleanup.
47
+
48
+ Args:
49
+ resource_type: Type of resource (e.g., 'container', 'tunnel', 'process')
50
+ resource_id: Unique identifier for the resource
51
+ cleanup_fn: Optional function to call for cleanup (returns success bool)
52
+ **metadata: Additional metadata to store with the resource
53
+ """
54
+ key = f"{resource_type}:{resource_id}"
55
+ self._resources[key] = {
56
+ 'type': resource_type,
57
+ 'id': resource_id,
58
+ 'cleanup_fn': cleanup_fn,
59
+ **metadata
60
+ }
61
+ logger.debug(f"Registered resource for cleanup: {key}")
62
+
63
+ def unregister_resource(self, resource_type: str, resource_id: str) -> None:
64
+ """Unregister a resource (e.g., after successful manual cleanup).
65
+
66
+ Args:
67
+ resource_type: Type of resource
68
+ resource_id: Unique identifier for the resource
69
+ """
70
+ key = f"{resource_type}:{resource_id}"
71
+ if key in self._resources:
72
+ del self._resources[key]
73
+ logger.debug(f"Unregistered resource: {key}")
74
+
75
+ def add_cleanup_callback(self, callback: Callable[[], None]) -> None:
76
+ """Add a cleanup callback to be called during cleanup.
77
+
78
+ Args:
79
+ callback: Function to call during cleanup (no arguments, no return)
80
+ """
81
+ self._cleanup_callbacks.append(callback)
82
+
83
+ def get_resource(self, resource_type: str, resource_id: str) -> Optional[Dict[str, Any]]:
84
+ """Get a registered resource by type and ID.
85
+
86
+ Args:
87
+ resource_type: Type of resource
88
+ resource_id: Unique identifier for the resource
89
+
90
+ Returns:
91
+ Resource metadata dict or None if not found
92
+ """
93
+ key = f"{resource_type}:{resource_id}"
94
+ return self._resources.get(key)
95
+
96
+ def has_resources(self) -> bool:
97
+ """Check if there are any resources registered for cleanup."""
98
+ return len(self._resources) > 0 or len(self._cleanup_callbacks) > 0
99
+
100
+ def install_signal_handlers(self) -> None:
101
+ """Install signal handlers for cleanup on interrupt.
102
+
103
+ Stores original handlers so they can be restored later.
104
+ """
105
+ if self._original_handlers:
106
+ logger.debug("Signal handlers already installed")
107
+ return
108
+
109
+ # Store original handlers
110
+ self._original_handlers[signal.SIGINT] = signal.signal(
111
+ signal.SIGINT, self._signal_handler
112
+ )
113
+ self._original_handlers[signal.SIGTERM] = signal.signal(
114
+ signal.SIGTERM, self._signal_handler
115
+ )
116
+ logger.debug("Signal handlers installed")
117
+
118
+ def restore_signal_handlers(self) -> None:
119
+ """Restore original signal handlers."""
120
+ for sig, handler in self._original_handlers.items():
121
+ signal.signal(sig, handler)
122
+ self._original_handlers.clear()
123
+ logger.debug("Signal handlers restored")
124
+
125
+ def _signal_handler(self, signum: int, frame) -> None:
126
+ """Handle interrupt signals by running cleanup.
127
+
128
+ Args:
129
+ signum: Signal number received
130
+ frame: Current stack frame
131
+ """
132
+ if self._cleanup_done:
133
+ return
134
+
135
+ if not self.has_resources():
136
+ sys.exit(0)
137
+
138
+ self.cleanup()
139
+ sys.exit(0)
140
+
141
+ def cleanup(self) -> bool:
142
+ """Run cleanup for all registered resources.
143
+
144
+ Returns:
145
+ True if all cleanup succeeded, False if any failed
146
+ """
147
+ if self._cleanup_done:
148
+ logger.debug("Cleanup already done, skipping")
149
+ return True
150
+
151
+ if not self.has_resources():
152
+ logger.debug("No resources to clean up")
153
+ return True
154
+
155
+ self.console.print("\n[yellow]🔄 Cleaning up resources...[/yellow]")
156
+ success = True
157
+
158
+ # Run cleanup callbacks first
159
+ for callback in self._cleanup_callbacks:
160
+ try:
161
+ callback()
162
+ except Exception as e:
163
+ logger.error(f"Cleanup callback failed: {e}", exc_info=True)
164
+ self.console.print(f"[red]Error in cleanup callback: {e}[/red]")
165
+ success = False
166
+
167
+ # Clean up registered resources
168
+ for key, resource in list(self._resources.items()):
169
+ cleanup_fn = resource.get('cleanup_fn')
170
+ if cleanup_fn:
171
+ try:
172
+ logger.debug(f"Running cleanup for {key}")
173
+ if not cleanup_fn():
174
+ success = False
175
+ except Exception as e:
176
+ logger.error(f"Cleanup failed for {key}: {e}", exc_info=True)
177
+ self.console.print(f"[red]Error cleaning up {resource['type']}: {e}[/red]")
178
+ success = False
179
+
180
+ if success:
181
+ self.console.print("[green]✓ Cleanup completed[/green]")
182
+ else:
183
+ self.console.print("[yellow]⚠ Cleanup completed with warnings[/yellow]")
184
+
185
+ self._cleanup_done = True
186
+ self._resources.clear()
187
+ self._cleanup_callbacks.clear()
188
+
189
+ return success
190
+
191
+ def reset(self) -> None:
192
+ """Reset the cleanup manager state (useful for testing)."""
193
+ self._resources.clear()
194
+ self._cleanup_callbacks.clear()
195
+ self._cleanup_done = False
196
+ self.restore_signal_handlers()
197
+
198
+
199
+ class DockerCleanupManager(CleanupManager):
200
+ """Cleanup manager specialized for Docker container cleanup."""
201
+
202
+ def __init__(
203
+ self,
204
+ console: Optional[Console] = None,
205
+ docker_manager: Any = None,
206
+ client: Any = None
207
+ ):
208
+ """Initialize Docker cleanup manager.
209
+
210
+ Args:
211
+ console: Rich console for output
212
+ docker_manager: DockerManager instance for container operations
213
+ client: AlphAIClient instance for API operations
214
+ """
215
+ super().__init__(console)
216
+ self.docker_manager = docker_manager
217
+ self.client = client
218
+ self._container_id: Optional[str] = None
219
+ self._tunnel_id: Optional[str] = None
220
+ self._project_id: Optional[str] = None
221
+
222
+ def set_container(self, container_id: str) -> None:
223
+ """Set the container ID for cleanup."""
224
+ self._container_id = container_id
225
+
226
+ def set_tunnel(self, tunnel_id: str) -> None:
227
+ """Set the tunnel ID for cleanup."""
228
+ self._tunnel_id = tunnel_id
229
+
230
+ def set_project(self, project_id: str) -> None:
231
+ """Set the project ID for cleanup."""
232
+ self._project_id = project_id
233
+
234
+ def cleanup(self) -> bool:
235
+ """Clean up Docker resources (container, tunnel, project)."""
236
+ if self._cleanup_done:
237
+ return True
238
+
239
+ if not any([self._container_id, self._tunnel_id, self._project_id]):
240
+ return True
241
+
242
+ self.console.print("\n[yellow]🔄 Cleaning up resources...[/yellow]")
243
+ success = True
244
+
245
+ try:
246
+ # Clean up container and cloudflared service
247
+ if self._container_id and self.docker_manager:
248
+ if not self.docker_manager.cleanup_container_and_tunnel(
249
+ container_id=self._container_id,
250
+ tunnel_id=self._tunnel_id,
251
+ project_id=self._project_id,
252
+ force=True
253
+ ):
254
+ success = False
255
+
256
+ # Clean up tunnel and project via API
257
+ if self.client and (self._tunnel_id or self._project_id):
258
+ if not self.client.cleanup_tunnel_and_project(
259
+ tunnel_id=self._tunnel_id,
260
+ project_id=self._project_id,
261
+ force=True
262
+ ):
263
+ success = False
264
+
265
+ if success:
266
+ self.console.print("[green]✓ Cleanup completed[/green]")
267
+ else:
268
+ self.console.print("[yellow]⚠ Cleanup completed with warnings[/yellow]")
269
+
270
+ except Exception as e:
271
+ logger.error(f"Error during cleanup: {e}", exc_info=True)
272
+ self.console.print(f"[red]Error during cleanup: {e}[/red]")
273
+ success = False
274
+
275
+ # Reset state
276
+ self._container_id = None
277
+ self._tunnel_id = None
278
+ self._project_id = None
279
+ self._cleanup_done = True
280
+
281
+ return success
282
+
283
+
284
+ class JupyterCleanupManager(CleanupManager):
285
+ """Cleanup manager specialized for Jupyter process cleanup."""
286
+
287
+ def __init__(
288
+ self,
289
+ console: Optional[Console] = None,
290
+ jupyter_manager: Any = None,
291
+ client: Any = None
292
+ ):
293
+ """Initialize Jupyter cleanup manager.
294
+
295
+ Args:
296
+ console: Rich console for output
297
+ jupyter_manager: JupyterManager instance for process operations
298
+ client: AlphAIClient instance for API operations
299
+ """
300
+ super().__init__(console)
301
+ self.jupyter_manager = jupyter_manager
302
+ self.client = client
303
+ self._tunnel_id: Optional[str] = None
304
+ self._project_id: Optional[str] = None
305
+
306
+ def set_tunnel(self, tunnel_id: str) -> None:
307
+ """Set the tunnel ID for cleanup."""
308
+ self._tunnel_id = tunnel_id
309
+
310
+ def set_project(self, project_id: str) -> None:
311
+ """Set the project ID for cleanup."""
312
+ self._project_id = project_id
313
+
314
+ def cleanup(self) -> bool:
315
+ """Clean up Jupyter resources (process, tunnel, project)."""
316
+ if self._cleanup_done:
317
+ return True
318
+
319
+ if not self.jupyter_manager:
320
+ return True
321
+
322
+ self.console.print("\n[yellow]🔄 Cleaning up Jupyter resources...[/yellow]")
323
+ success = True
324
+
325
+ try:
326
+ # Comprehensive cleanup via JupyterManager
327
+ if not self.jupyter_manager.cleanup(
328
+ client=self.client,
329
+ tunnel_id=self._tunnel_id,
330
+ project_id=self._project_id,
331
+ force=True
332
+ ):
333
+ success = False
334
+
335
+ if success:
336
+ self.console.print("[green]✓ Cleanup completed[/green]")
337
+ else:
338
+ self.console.print("[yellow]⚠ Cleanup completed with warnings[/yellow]")
339
+
340
+ except Exception as e:
341
+ logger.error(f"Error during Jupyter cleanup: {e}", exc_info=True)
342
+ self.console.print(f"[red]Error during cleanup: {e}[/red]")
343
+ success = False
344
+
345
+ # Reset state
346
+ self._tunnel_id = None
347
+ self._project_id = None
348
+ self._cleanup_done = True
349
+
350
+ return success
351
+