deepctl-core 0.1.0__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.
@@ -0,0 +1,49 @@
1
+ """Core components for deepctl."""
2
+
3
+ from .auth import AuthenticationError, AuthManager
4
+ from .base_command import BaseCommand
5
+ from .base_group_command import BaseGroupCommand
6
+ from .client import DeepgramClient
7
+ from .config import Config
8
+ from .models import (
9
+ BaseResult,
10
+ ErrorResult,
11
+ PluginInfo,
12
+ ProfileInfo,
13
+ ProfilesResult,
14
+ )
15
+ from .output import (
16
+ OutputFormatter,
17
+ get_console,
18
+ print_error,
19
+ print_info,
20
+ print_output,
21
+ print_success,
22
+ print_warning,
23
+ setup_output,
24
+ )
25
+ from .plugin_manager import PluginManager
26
+
27
+ __all__ = [
28
+ "AuthManager",
29
+ "AuthenticationError",
30
+ "BaseCommand",
31
+ "BaseGroupCommand",
32
+ "BaseResult",
33
+ "Config",
34
+ "DeepgramClient",
35
+ "ErrorResult",
36
+ "OutputFormatter",
37
+ "PluginInfo",
38
+ "PluginManager",
39
+ "ProfileInfo",
40
+ "ProfilesResult",
41
+ "get_console",
42
+ "print_error",
43
+ "print_info",
44
+ "print_output",
45
+ "print_success",
46
+ "print_warning",
47
+ # Output utilities
48
+ "setup_output",
49
+ ]
deepctl_core/auth.py ADDED
@@ -0,0 +1,642 @@
1
+ """Cross-platform authentication system for deepctl based on the Go CLI
2
+ implementation."""
3
+
4
+ import os
5
+ import random
6
+ import string
7
+ import time
8
+ import webbrowser
9
+ from urllib.parse import urlencode
10
+
11
+ import httpx
12
+ import keyring
13
+ from pydantic import BaseModel
14
+ from rich.console import Console
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+
17
+ from .config import Config
18
+ from .models import ProfileInfo, ProfilesResult
19
+
20
+ console = Console()
21
+
22
+ # Constants from Go implementation
23
+ COMMUNITY_BASE_URL = os.getenv(
24
+ "DEEPGRAM_CLI_BASE_URL", "https://community.deepgram.com"
25
+ )
26
+ DEVICE_CODE_URL = f"{COMMUNITY_BASE_URL}/api/auth/device/code"
27
+ TOKEN_POLL_URL = f"{COMMUNITY_BASE_URL}/api/auth/device/token"
28
+
29
+ # Keyring service identifier using reverse domain notation
30
+ KEYRING_SERVICE = "com.deepgram.dx.deepctl"
31
+
32
+
33
+ class DeviceCodeResponse(BaseModel):
34
+ """Response from device code request."""
35
+
36
+ device_code: str
37
+ user_code: str | None = None # Not used in current implementation
38
+ verification_uri: str
39
+ expires_in: int
40
+ interval: int
41
+
42
+
43
+ class TokenResponse(BaseModel):
44
+ """Response from token request."""
45
+
46
+ access_token: str
47
+ project_id: str
48
+ token_type: str | None = None
49
+ expires_in: int | None = None
50
+ scope: str | None = None
51
+
52
+ # The access_token returned is the actual Deepgram API key
53
+ @property
54
+ def api_key(self) -> str:
55
+ """Get the API key from the response."""
56
+ # The community server returns the actual API key in the
57
+ # access_token field
58
+ return self.access_token
59
+
60
+
61
+ class AuthenticationError(Exception):
62
+ """Authentication related errors."""
63
+
64
+ pass
65
+
66
+
67
+ class AuthManager:
68
+ """Cross-platform authentication manager."""
69
+
70
+ def __init__(self, config: Config):
71
+ """Initialize authentication manager.
72
+
73
+ Args:
74
+ config: Configuration manager instance
75
+ """
76
+ self.config = config
77
+ # Disable SSL verification for local development
78
+ verify = not COMMUNITY_BASE_URL.startswith("https://community-local")
79
+ self.client = httpx.Client(timeout=30.0, verify=verify)
80
+
81
+ def is_authenticated(self) -> bool:
82
+ """Check if user is authenticated."""
83
+ # Check for API key in environment
84
+ if os.getenv("DEEPGRAM_API_KEY"):
85
+ return True
86
+
87
+ # Check keyring
88
+ try:
89
+ profile_name = self.config.profile or "default"
90
+ api_key = keyring.get_password(
91
+ KEYRING_SERVICE, f"api-key.{profile_name}"
92
+ )
93
+ if api_key:
94
+ return True
95
+ except Exception:
96
+ pass
97
+
98
+ # Check for API key in config (backward compatibility)
99
+ current_profile = self.config.get_profile()
100
+ return bool(current_profile.api_key)
101
+
102
+ def is_ci_mode(self) -> bool:
103
+ """Check if running in CI mode (credentials from environment)."""
104
+ # If both API key and project ID are provided via environment,
105
+ # we're in CI mode
106
+ return bool(
107
+ os.getenv("DEEPGRAM_API_KEY") and os.getenv("DEEPGRAM_PROJECT_ID")
108
+ )
109
+
110
+ def get_api_key(self) -> str | None:
111
+ """Get API key from keyring, then environment, then config."""
112
+ # Environment variable takes precedence (CI-friendly)
113
+ api_key = os.getenv("DEEPGRAM_API_KEY")
114
+ if api_key:
115
+ return api_key
116
+
117
+ # Check keyring next
118
+ try:
119
+ api_key = keyring.get_password(
120
+ KEYRING_SERVICE, f"api-key.{self.config.profile or 'default'}"
121
+ )
122
+ if api_key:
123
+ return api_key
124
+ except Exception:
125
+ pass # Keyring not available or error
126
+
127
+ # Fall back to config file (for backward compatibility)
128
+ current_profile = self.config.get_profile()
129
+ if current_profile.api_key:
130
+ return current_profile.api_key
131
+
132
+ return None
133
+
134
+ def get_project_id(self) -> str | None:
135
+ """Get project ID from environment or config."""
136
+ # Environment variable takes precedence (CI-friendly)
137
+ project_id = os.getenv("DEEPGRAM_PROJECT_ID")
138
+ if project_id:
139
+ return project_id
140
+
141
+ # Check keyring for project ID (if stored there)
142
+ try:
143
+ project_id = keyring.get_password(
144
+ KEYRING_SERVICE,
145
+ f"project-id.{self.config.profile or 'default'}",
146
+ )
147
+ if project_id:
148
+ return project_id
149
+ except Exception:
150
+ pass
151
+
152
+ # Check current profile config
153
+ current_profile = self.config.get_profile()
154
+ if current_profile.project_id:
155
+ return current_profile.project_id
156
+
157
+ return None
158
+
159
+ def verify_credentials(
160
+ self, api_key: str | None = None, project_id: str | None = None
161
+ ) -> tuple[bool, str, str | None]:
162
+ """Verify API key and project ID by making a request to the
163
+ Deepgram API.
164
+
165
+ Args:
166
+ api_key: API key to verify (uses stored key if not provided)
167
+ project_id: Project ID to verify (uses stored ID if not provided)
168
+
169
+ Returns:
170
+ Tuple of (success, message, error_type)
171
+ - success: True if credentials are valid
172
+ - message: Human-readable message about the result
173
+ - error_type: 'auth' for API key issues, 'project' for project ID
174
+ issues, None if successful
175
+ """
176
+ # Use provided credentials or get from storage
177
+ if not api_key:
178
+ api_key = self.get_api_key()
179
+ if not project_id:
180
+ project_id = self.get_project_id()
181
+
182
+ # Check if we have required credentials
183
+ if not api_key:
184
+ return False, "No API key provided or stored", "auth"
185
+
186
+ if not project_id:
187
+ return False, "No project ID provided or stored", "project"
188
+
189
+ # Make API request to verify credentials
190
+ try:
191
+ headers = {
192
+ "Authorization": f"Token {api_key}",
193
+ "Content-Type": "application/json",
194
+ }
195
+
196
+ # Make request to get project details
197
+ response = self.client.get(
198
+ f"https://api.deepgram.com/v1/projects/{project_id}",
199
+ headers=headers,
200
+ )
201
+
202
+ if response.status_code == 200:
203
+ return True, "Credentials verified successfully", None
204
+ elif response.status_code == 401:
205
+ return False, "Invalid API key - authentication failed", "auth"
206
+ elif response.status_code == 403:
207
+ return (
208
+ False,
209
+ "API key is valid but lacks permission for this project",
210
+ "auth",
211
+ )
212
+ elif response.status_code == 404:
213
+ return False, f"Project ID '{project_id}' not found", "project"
214
+ else:
215
+ return (
216
+ False,
217
+ f"Unexpected error: HTTP {response.status_code}",
218
+ "unknown",
219
+ )
220
+
221
+ except httpx.RequestError as e:
222
+ return False, f"Network error during verification: {e}", "network"
223
+ except Exception as e:
224
+ return (
225
+ False,
226
+ f"Unexpected error during verification: {e}",
227
+ "unknown",
228
+ )
229
+
230
+ def guard(self) -> None:
231
+ """Guard function to ensure authentication (replicated from Go
232
+ implementation)."""
233
+ api_key = self.get_api_key()
234
+
235
+ if not api_key:
236
+ console.print(
237
+ "[red]Error:[/red] DEEPGRAM_API_KEY is not set in the "
238
+ "configuration file "
239
+ f"({self.config.config_path}) or environment variable.\n"
240
+ )
241
+ console.print(
242
+ "[yellow]Run[/yellow] [bold]deepctl login[/bold] "
243
+ "[yellow]to configure the CLI with your Deepgram "
244
+ "account.[/yellow]\n"
245
+ )
246
+ raise AuthenticationError("DEEPGRAM_API_KEY is not set")
247
+
248
+ # Verify credentials before proceeding
249
+ success, message, error_type = self.verify_credentials()
250
+ if not success:
251
+ console.print(f"[red]Error:[/red] {message}")
252
+
253
+ if error_type == "auth":
254
+ console.print(
255
+ "[yellow]Your API key may have expired or been "
256
+ "revoked.[/yellow]\n"
257
+ "[yellow]Run[/yellow] [bold]deepctl login[/bold] "
258
+ "[yellow]to re-authenticate.[/yellow]"
259
+ )
260
+ raise AuthenticationError(message)
261
+ elif error_type == "project":
262
+ console.print(
263
+ "[yellow]The project may have been deleted or you may "
264
+ "need to specify a different project.[/yellow]\n"
265
+ "[yellow]Run[/yellow] [bold]deepctl login --project-id "
266
+ "<project_id>[/bold] [yellow]to set a valid project "
267
+ "ID.[/yellow]"
268
+ )
269
+ raise AuthenticationError(message)
270
+ else:
271
+ raise AuthenticationError(message)
272
+
273
+ def login_with_api_key(
274
+ self, api_key: str, project_id: str, _force_write: bool = False
275
+ ) -> None:
276
+ """Login with API key directly (CI-friendly method).
277
+
278
+ Args:
279
+ api_key: Deepgram API key
280
+ project_id: Deepgram project ID
281
+ force_write: Skip confirmation prompts
282
+ """
283
+ # Validate API key format (basic check)
284
+ if not api_key.startswith(("sk-", "pk-")):
285
+ console.print(
286
+ "[red]Warning:[/red] API key format doesn't match expected "
287
+ "pattern"
288
+ )
289
+
290
+ # Verify credentials before storing
291
+ console.print("[dim]Verifying credentials...[/dim]")
292
+ success, message, error_type = self.verify_credentials(
293
+ api_key, project_id
294
+ )
295
+
296
+ if not success:
297
+ console.print(f"[red]Error:[/red] {message}")
298
+ if error_type == "auth":
299
+ raise AuthenticationError(
300
+ f"API key verification failed: {message}"
301
+ )
302
+ elif error_type == "project":
303
+ raise AuthenticationError(
304
+ f"Project ID verification failed: {message}"
305
+ )
306
+ else:
307
+ raise AuthenticationError(
308
+ f"Credential verification failed: {message}"
309
+ )
310
+
311
+ console.print(f"[green]✓[/green] {message}")
312
+
313
+ # Store API key in keyring for security
314
+ profile_name = self.config.profile or "default"
315
+ keyring_available = False
316
+
317
+ try:
318
+ keyring.set_password(
319
+ KEYRING_SERVICE, f"api-key.{profile_name}", api_key
320
+ )
321
+ if project_id:
322
+ keyring.set_password(
323
+ KEYRING_SERVICE, f"project-id.{profile_name}", project_id
324
+ )
325
+ console.print(
326
+ "[green]✓[/green] Credentials stored securely in system "
327
+ "keyring"
328
+ )
329
+ keyring_available = True
330
+ except Exception as e:
331
+ console.print(
332
+ f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
333
+ )
334
+ console.print("Credentials will be stored in config file instead")
335
+
336
+ # Update config with non-sensitive data
337
+ # Only store API key in config if keyring is not available
338
+ self.config.create_profile(
339
+ profile_name,
340
+ api_key=api_key if not keyring_available else None,
341
+ project_id=project_id,
342
+ base_url=self.config.get_profile(profile_name).base_url,
343
+ )
344
+
345
+ console.print("[green]✓[/green] Successfully logged in with API key")
346
+ console.print(f"[dim]Profile:[/dim] {profile_name}")
347
+
348
+ if project_id:
349
+ console.print(f"[dim]Project ID:[/dim] {project_id}")
350
+
351
+ def _generate_client_id(self, length: int = 40) -> str:
352
+ """Generate a random client ID for device flow.
353
+
354
+ Args:
355
+ length: Length of the client ID
356
+
357
+ Returns:
358
+ Random URL-safe string
359
+ """
360
+ # URL-friendly characters matching Go implementation
361
+ url_friendly_chars = string.ascii_letters + string.digits + "-._~"
362
+ return "".join(
363
+ random.choice(url_friendly_chars) for _ in range(length)
364
+ )
365
+
366
+ def login_with_device_flow(self) -> None:
367
+ """Login using device flow (interactive method)."""
368
+ console.print("[blue]Starting device flow authentication...[/blue]")
369
+
370
+ # Check if already authenticated
371
+ if self.is_authenticated():
372
+ console.print("[yellow]You're already logged in.[/yellow]")
373
+ if (
374
+ not console.input("Do you want to login again? [y/N]: ")
375
+ .lower()
376
+ .startswith("y")
377
+ ):
378
+ return
379
+
380
+ try:
381
+ # Get hostname for device identification
382
+ hostname = (
383
+ os.uname().nodename if hasattr(os, "uname") else "unknown"
384
+ )
385
+
386
+ # Request device code (returns device code response and client_id)
387
+ device_response, client_id = self._request_device_code()
388
+
389
+ # Build verification URI with query parameters like Go
390
+ # implementation
391
+ query_params = {
392
+ "device_code": device_response.device_code,
393
+ "client_id": client_id,
394
+ "hostname": hostname,
395
+ }
396
+ verification_uri = (
397
+ f"{device_response.verification_uri}?{urlencode(query_params)}"
398
+ )
399
+
400
+ # Display prompt message like Go implementation
401
+ console.print(
402
+ "\n[bold]Hello from Deepgram![/bold] Press Enter to open "
403
+ "browser and login automatically."
404
+ )
405
+ console.print(
406
+ f"[dim]Here is your login link in case browser did not "
407
+ f"open:[/dim] [dim]{verification_uri}[/dim]\n"
408
+ )
409
+
410
+ # Wait for Enter key
411
+ console.input()
412
+
413
+ # Open browser
414
+ try:
415
+ webbrowser.open(verification_uri)
416
+ console.print(
417
+ "[green]✓[/green] Opened browser for authentication"
418
+ )
419
+ except Exception as e:
420
+ console.print(
421
+ f"[yellow]Warning:[/yellow] Could not open browser: {e}"
422
+ )
423
+ console.print(
424
+ "Please manually navigate to the verification URL above"
425
+ )
426
+
427
+ # Poll for token
428
+ token_response = self._poll_for_token(
429
+ device_response, client_id, hostname
430
+ )
431
+
432
+ # Store token and get user info
433
+ self._store_token(token_response)
434
+
435
+ console.print(
436
+ "\n[green]Key created and stored successfully.[/green]"
437
+ )
438
+ console.print("\nYou are now logged in. Happy coding!")
439
+
440
+ except Exception as e:
441
+ console.print(f"[red]Error during device flow:[/red] {e}")
442
+ raise AuthenticationError(f"Device flow failed: {e}")
443
+
444
+ def _request_device_code(self) -> tuple[DeviceCodeResponse, str]:
445
+ """Request device code from community site.
446
+
447
+ Returns:
448
+ Tuple of (DeviceCodeResponse, client_id)
449
+ """
450
+ # Get hostname info (like Go implementation)
451
+ hostname = os.uname().nodename if hasattr(os, "uname") else "unknown"
452
+
453
+ # Generate random client ID like Go implementation
454
+ client_id = self._generate_client_id(40)
455
+
456
+ payload = {
457
+ "client_id": client_id,
458
+ "hostname": hostname,
459
+ # Full scopes needed for CLI
460
+ "scopes": ["admin"],
461
+ }
462
+
463
+ try:
464
+ response = self.client.post(
465
+ DEVICE_CODE_URL,
466
+ json=payload,
467
+ headers={"Content-Type": "application/json"},
468
+ )
469
+
470
+ if response.status_code == 201:
471
+ return DeviceCodeResponse(**response.json()), client_id
472
+ else:
473
+ raise AuthenticationError(
474
+ f"Device code request failed: {response.status_code}"
475
+ )
476
+
477
+ except httpx.RequestError as e:
478
+ raise AuthenticationError(
479
+ f"Network error during device code request: {e}"
480
+ )
481
+
482
+ def _poll_for_token(
483
+ self,
484
+ device_response: DeviceCodeResponse,
485
+ client_id: str,
486
+ hostname: str,
487
+ ) -> TokenResponse:
488
+ """Poll for token using device code."""
489
+ console.print("\n[blue]Waiting for authentication...[/blue]")
490
+
491
+ start_time = time.time()
492
+
493
+ # Build query parameters like Go implementation
494
+ query_params = {
495
+ "device_code": device_response.device_code,
496
+ "client_id": client_id,
497
+ "hostname": hostname,
498
+ }
499
+
500
+ poll_url = f"{TOKEN_POLL_URL}?{urlencode(query_params)}"
501
+
502
+ with Progress(
503
+ SpinnerColumn(),
504
+ TextColumn("[progress.description]{task.description}"),
505
+ console=console,
506
+ transient=True,
507
+ ) as progress:
508
+ progress.add_task("Waiting for authentication...", total=None)
509
+
510
+ while time.time() - start_time < device_response.expires_in:
511
+ try:
512
+ # Use GET request like Go implementation
513
+ response = self.client.get(poll_url)
514
+
515
+ if response.status_code == 201:
516
+ response_data = response.json()
517
+ return TokenResponse(**response_data)
518
+ elif response.status_code == 404:
519
+ # Still pending - this is the expected status code from
520
+ # Go implementation
521
+ time.sleep(device_response.interval)
522
+ continue
523
+ else:
524
+ error_data = response.json()
525
+ raise AuthenticationError(
526
+ f"Token request failed: "
527
+ f"{error_data.get('error', 'Unknown error')}"
528
+ )
529
+
530
+ except httpx.RequestError as e:
531
+ console.print(f"[red]Network error:[/red] {e}")
532
+ time.sleep(device_response.interval)
533
+ continue
534
+
535
+ raise AuthenticationError("Authentication timed out")
536
+
537
+ def _store_token(self, token_response: TokenResponse) -> None:
538
+ """Store authentication token."""
539
+ # The access_token from community site is already a Deepgram API key
540
+ api_key = token_response.access_token
541
+ project_id = token_response.project_id
542
+
543
+ profile_name = self.config.profile or "default"
544
+ keyring_available = False
545
+
546
+ try:
547
+ keyring.set_password(
548
+ KEYRING_SERVICE, f"api-key.{profile_name}", api_key
549
+ )
550
+ if project_id:
551
+ keyring.set_password(
552
+ KEYRING_SERVICE, f"project-id.{profile_name}", project_id
553
+ )
554
+ console.print(
555
+ "[green]✓[/green] Credentials stored securely in system "
556
+ "keyring"
557
+ )
558
+ keyring_available = True
559
+ except Exception as e:
560
+ console.print(
561
+ f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
562
+ )
563
+ console.print("Credentials will be stored in config file instead")
564
+
565
+ # Update config - only store API key if keyring is not available
566
+ self.config.create_profile(
567
+ profile_name,
568
+ api_key=api_key if not keyring_available else None,
569
+ project_id=project_id,
570
+ )
571
+
572
+ def logout(self) -> None:
573
+ """Logout user and clear credentials."""
574
+ profile_name = self.config.profile or "default"
575
+
576
+ # Clear keyring
577
+ try:
578
+ keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}")
579
+ keyring.delete_password(
580
+ KEYRING_SERVICE, f"project-id.{profile_name}"
581
+ )
582
+ console.print("[dim]Cleared credentials from system keyring[/dim]")
583
+ except Exception:
584
+ pass # Ignore errors if not stored
585
+
586
+ # Clear sensitive data from config but keep profile
587
+ if profile_name in self.config.list_profiles():
588
+ profile = self.config.get_profile(profile_name)
589
+ self.config.create_profile(
590
+ profile_name,
591
+ api_key=None, # Clear API key
592
+ project_id=profile.project_id, # Keep project ID
593
+ base_url=profile.base_url, # Keep base URL
594
+ )
595
+
596
+ console.print("[green]✓[/green] Successfully logged out")
597
+
598
+ def list_profiles(self) -> ProfilesResult:
599
+ """Return all profiles wrapped in ProfilesResult model."""
600
+ profiles: dict[str, ProfileInfo] = {}
601
+
602
+ for profile_name in self.config.list_profiles():
603
+ profile = self.config.get_profile(profile_name)
604
+
605
+ # Try to get API key from keyring first
606
+ api_key = None
607
+ project_id = profile.project_id
608
+
609
+ try:
610
+ api_key = keyring.get_password(
611
+ KEYRING_SERVICE, f"api-key.{profile_name}"
612
+ )
613
+ # Also check if project_id is in keyring
614
+ keyring_project_id = keyring.get_password(
615
+ KEYRING_SERVICE, f"project-id.{profile_name}"
616
+ )
617
+ if keyring_project_id:
618
+ project_id = keyring_project_id
619
+ except Exception:
620
+ # Fall back to config
621
+ api_key = profile.api_key
622
+
623
+ masked_key = None
624
+ if api_key:
625
+ masked_key = "****" + api_key[-4:]
626
+
627
+ profiles[profile_name] = ProfileInfo(
628
+ api_key=masked_key,
629
+ project_id=project_id,
630
+ base_url=profile.base_url,
631
+ )
632
+
633
+ return ProfilesResult(
634
+ profiles=profiles,
635
+ current_profile=self.config.profile
636
+ or self.config._config.default_profile,
637
+ )
638
+
639
+ def __del__(self) -> None:
640
+ """Cleanup HTTP client."""
641
+ if hasattr(self, "client"):
642
+ self.client.close()